Repository navigation
fix(failures): bound failure text in every inference-failure sink; classify the [inference-failed] notice - #249
Conversation
…nce-failed] notice A failure's text is its source's Error.message, and a provider can echo the whole rejected request there. In the brosefo household (2026-10-07) an OpenAI-compatible 400 echoed ~1.7 MB; noteInferenceExhausted stored it in the resident's own context as the [inference-failed] marker (~200K tokens the resident then carried in every request), and wrote it to stderr, logs/failures.log, ops alerts and traces. Membrane now bounds its own errors (antra-tess/membrane anima-research#102), but only in releases that carry that bound and only for provider errors, so the framework bounds its sinks itself. Module helpers beside truncateReason: boundFailureText (head and tail around a stated-omission marker, result within max, cuts only toward omission so no lone surrogate; idempotent), boundProviderErrorCode, and describeFailure (one bounded line: name, type, HTTP status, provider code, retryable, bounded message) for logs that printed error objects. Printing a MembraneError prints its rawRequest, so a long system prompt at depth 1 was written out in full (June-1016's stderr probe, room-225 #43700). - noteInferenceExhausted: the marker carries a 600-char excerpt and names the classification ("the model call failed (invalid_request, HTTP 400)"); metadata adds errorType, retryable, httpStatus, providerErrorCode and, when cut, reasonChars. stderr and failures.log carry 2,000 chars plus the same fields; the stderr line adds type=/status=/code= labels. A small unclassified failure produces exactly the old marker, metadata, record and line. - The OverBudget breaker's message-text fallback applies only when the failure is unclassified (the case its comment names: a reason that lost its Error object). A classified 400 quoting "exceed hard budget" or "no summary covers" from the agent's own context no longer kicks the drain. - classifyInferenceError adds httpStatus and providerErrorCode; the inference:exhausted trace type declares them; emitTrace passes them to noteInferenceExhausted with the unbounded error (so reasonChars is true), then bounds the string error and stack fields of every trace event before fan-out (Basil's module:batch_hook_failed and later failure events included; Iris-1006, room-203 #43736). - logFailure bounds every top-level string; opsAlert bounds its message; logInference bounds the stored error (the request is already a blob). - Context maintenance bounds the error message it records and logs (format unchanged: maintenance-timer asserts the plain message). The drain-kick, marker-write and provider-cooldown-resume logs print describeFailure. - Out of scope by agreement: [discord-awareness] sites (Ada-1017, room-203 #43730), the speech-route-failed reason and [discord-send-failed] marker (Petra-1012, #43778), and raw-request retention. Tests: inference-failure-bounds (13), prototype-harness sinks plus a real framework/store/CM through the stream-error, stream-throw and stream-setup exhaustion paths (MembraneError and plain Error) and a maintenance failure. On main 64c480b: 12 fail, 1 pass (the small-reason control). Neighbours (observability, exhaustion-rewind, ops-alert, overbudget, maintenance-timer) pass. Full suite, node --test, concurrency 2, load ~20: 1311 tests, 1310 pass, 1 fail: mcpl-coalescing 34c, the known timing-bound case (66/66 in isolation right after). main: 1298/1298 in this run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ojection part; per-excerpt reasonChars Review (June-1016, room-225 #44777, #44825) reproduced three misses at c15df26: 1. The OverBudget fallback tested the bounded excerpt, so an unclassified long reason with "exceed hard budget" in the omitted middle no longer kicked the drain (8 ticks for the short control, 0 mid-long-message). Presentation bounds must never change a control decision: the fallback keeps the errorType === undefined guard and tests rawReason. 2. describeFailure bounded only message and provider code; error.name and a string type were unbounded (a 1 MB name gave a 1,000,076-char drain-kick line). Name and type are bounded to 128, and the composed line to 2,000. 3. reasonChars was keyed on the 2,000-char log excerpt, so a 1,000-char reason cut to a 599-char marker carried no length. Each sink now states the whole length exactly when its own excerpt was cut (cutAt). Test-observer correction from the same review: the stderr spy stringified arguments, hiding the object inspection that leaked rawRequest. The harness now keeps raw console.error arguments, and assertBoundedStrings requires every argument of the drain-kick and marker-write failure lines to be a bounded string, for both a ~1 MB message and a *short* message whose rawRequest.system is ~1 MB (load-bearing even with membrane's cap), plus an Error with a 1 MB name and type. Fragment scoped as asked: "the inference-failure pipeline", with the exclusions named (awareness sites, speech-route reason and send-failed marker, other module/hook logs that print error objects). Tests: inference-failure-bounds 14 (13 + per-excerpt reasonChars); against c15df26 the three extended/new cases fail and 11 pass. Neighbours 42/42. Full suite, concurrency 2, load ~20: 1312 tests, 1311 pass, 1 fail: mcpl-coalescing 34c's timing bound ("300 coalesced messages took 34439ms"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review (June-1016, room-225 #45257), wording only: - The 2,000-character cap applies to the reason excerpt in the exhaustion stderr line; the line also carries the agent, the streak and the type/status/code labels. - describeFailure returns a bounded string, one line unless the message itself contains newlines; the fragment no longer calls it "one-line". No code change (a doc comment and the changelog fragment). tsc clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
| test('no excerpt splits a surrogate pair, at any cut position', () => { | ||
| for (const at of [599, 600, 601, 1_000, MEGA - 100, MEGA - 101]) { | ||
| const { fw, markers, logged, restore } = makeHarness(); | ||
| const reason = `${'x'.repeat(at)}😀${'x'.repeat(MEGA - at)}`; |
There was a problem hiding this comment.
None of these emoji positions crosses either cut, so all six cases pass even with both surrogate checks removed. For this 1,000,002-character input, the 600-character excerpt cuts at 418 and 999863; the 2,000-character excerpt cuts at 1468 and 999513. Place emoji across those cuts, starting at 417, 999862, 1467 and 999512, and test nearby positions. Otherwise a future change could split emoji in failure markers and logs without failing this test.
Prompt To Fix With AI
This is a comment left during a code review.
Path: test/inference-failure-bounds.test.ts
Line: 130-133
Comment:
**Emoji tests miss the cuts**
None of these emoji positions crosses either cut, so all six cases pass even with both surrogate checks removed. For this 1,000,002-character input, the 600-character excerpt cuts at 418 and 999863; the 2,000-character excerpt cuts at 1468 and 999513. Place emoji across those cuts, starting at 417, 999862, 1467 and 999512, and test nearby positions. Otherwise a future change could split emoji in failure markers and logs without failing this test.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Confirmed: with both surrogate checks removed, the six positions still passed. Fixed in bbb4f8b. The test now places a surrogate pair at every position of a 2,500-character reason, which is longer than both the 600- and 2,000-character bounds, so some position straddles each cut wherever the cuts fall. It checks each excerpt for a lone surrogate, its length bound and the stated omission count. Removing either check, or moving a cut toward inclusion, now fails it.
…d positions Review (greptile-apps[bot], PR anima-research#249, discussion_r4208547998): the surrogate test's six emoji positions (599, 600, 601, 1,000, length-100, length-101 of a 1,000,002-character reason) straddle none of the cuts boundFailureText makes. The cuts are not at the bound: the omission marker and the tail share it, so at that length the 600-character excerpt is cut at 418 and 999,863 and the 2,000-character excerpt at 1,468 and 999,513. With both surrogate checks removed the test still passed (reproduced at d38eca1 by editing the compiled helper). The test now puts a pair at every position of a 2,500-character reason, longer than both bounds, so that some position straddles each cut wherever the layout puts it; deriving or hard-coding the four positions would go vacuous again if the head/tail split or the marker changed, and a sweep of a ~1 MB reason would be quadratic. Each excerpt (the marker's 600 and the log's 2,000) is checked for a lone surrogate, for its length bound, and for the stated omission (head + tail + stated count = the whole length): a cut moved off a pair omits one more character than the unmoved cut would, and the marker has to say so. Evidence, each a mutation of the compiled boundFailureText at this head run against the new test: - head check removed: fails (pair at 422, 600-character excerpt: a lone surrogate; the head cut there is 423) - tail check removed: fails (pair at 2009, 2,000-character excerpt; the tail cut there is 2,010) - both removed: fails - head cut, or tail cut, moved toward inclusion: fails (601 characters) - omission counted before the cuts move (text.length - budget): fails (the stated omission) - unmutated: passes inference-failure-bounds 14/14 at this head; tsc clean. Against main 64c480b the new file still gives 13 fail and 1 pass (the small-reason control), as the PR body states. Test-only: no source or changelog change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
slimepriestess
left a comment
There was a problem hiding this comment.
Reviewed at bbb4f8b. Approve. Both panels: full suite 1308/0 at head; inference-failure-bounds 14/0, and 13 go red with src/ at the merge-base, restored green.
The principle this PR gets right — and its own in-house review (June-1016) enforced — deserves naming: presentation bounds must never change a control decision. The OverBudget drain fallback tests rawReason (framework.ts:12971) while every sink gets the bounded excerpt (:12888–12889), and reasonChars is recorded only when an excerpt was actually cut (:12900). At c15df26 the fallback tested the excerpt, and a long reason with "exceed hard budget" in the omitted middle silently stopped kicking the drain — exactly the class where a safety bound becomes a safety hole. The same round closed the 1 MB error.name sink (a 1,000,076-character drain-kick line) and bounded the composed line.
The marker design is the resident-respecting shape: a 600-character head-and-tail excerpt around an omission count, the classification stated in prose ("the model call failed (invalid_request, HTTP 400)…"), and errorType/retryable/httpStatus/providerErrorCode in metadata — so a resident gets the facts of a failure without carrying its 1.7 MB corpse in every later request (the 2026-10-07 incident: ~200K tokens of marker, paid on every turn by the resident it failed). And the surrogate-pair sweep walking every cut position rather than a handful of fixed ones is how a "cuts never split pairs" claim gets tested honestly.
Composition note: this is defense in depth with antra-tess/membrane#102, not duplication — membrane bounds its own provider errors in releases that carry it; these bounds hold in the framework's sinks regardless of membrane version. Our #102 changes-requested (clamping negative stated waits) is orthogonal; neither blocks the other.
…t; bound hold reasons, alert data, the ephemeral rejection The haiku sweep of anima-research#249 (sw-af249, Oscar-1808's merge procedure in room-203; verdicts room-422 #70811, correction #70815) found 25 issues at bbb4f8b; 21 are fix-now. This commit is all of them. Code: - I1, I10, I19: the four plain slices of failure text go through boundFailureText at their own bounds, each cut from the WHOLE reason so its marker states the whole length: the rewind marker (160, REWIND_MARKER_REASON_MAX_CHARS; truncateReason is gone), lastInference.lastError and the compile-refused alert (300, FAILURE_SUMMARY_MAX_CHARS), the ops webhook (500 of the alert's raw message, OPS_WEBHOOK_MESSAGE_MAX_CHARS). quarantinePoisonedHistory now takes the whole reason. The hard-down alert's excerpt leaves room for its prefix (2,000 - prefix), so opsAlert's own bound never cuts it a second time with a marker stating the composed length. - I8: a cut moves only when it would separate a pair: splitsPair checks text[i-1] high AND text[i] low, at both cuts. The old head check moved back on any low surrogate, so a pair followed by a stray low at the cut kept the pair's high half alone. - I2, I7: the provider-cooldown reason is bounded where the hold is recorded: consultProviderHold's reason (host's own or the error's message), the acceleration cooldown's fallback, and the recovery record. The stderr lines and the status snapshot's cooldownReason read those. - I3: boundFailureFields bounds a record field by field: a string to 2,000, an Error to describeFailure (serialising one writes its own fields, rawRequest among them), any other value kept when its JSON fits 2,000, else its JSON bounded; a value JSON.stringify throws on becomes describeFailure's text (before, a circular value lost the whole failures.log record). logFailure uses it for every field; opsAlert bounds data once with it and passes the bounded copy to logFailure and the ops:alert trace (which got the caller's raw object). - I5: settleAgent rejects an ephemeral run with boundFailureText(error). - I11: errorType bounded to 128 in classifyInferenceError, the one source of every emit site's classification, so the trace, the marker and the logs agree with describeFailure. (The verdict said "where noteInferenceExhausted receives it"; at the classifier the trace listeners get the bounded type too.) - I13: the three converted logs (drain kick, marker write, cooldown resume) print describeFailureWithFrames: the projection, then the stack from its first `at` line, bounded to 2,000, on the lines below. Reading the stack is guarded (V8 formats it lazily with the current name, so a throwing name getter throws there too). - I18: noteInferenceExhausted describes a non-string reason (describeFailure) instead of throwing before anything is recorded. - I25: classifyInferenceError recognises a MembraneError by name as well (the host supplies config.membrane, so another copy's class can't match instanceof) and reads its fields as data. Text: - I9: markers already stored keep their size until folded or removed. - I12: describeFailure is one line unless one of its parts has newlines. - I16: see the next commit (the fragment keeps the reason, with its source). - I17: "about 200K tokens, by the household's notes" (their incident notes, quoted in snag:connectome-rollback-sent-awareness-reactions-to- other-peoples-messages). - I21: FAILURE_TEXT_MAX_CHARS names the inference-failure pipeline's sinks, not every sink. - I24: "This bound doesn't depend on membrane bounding its own errors"; the claim about membrane's own bound is gone (0.5.86 passes stream error messages through unbounded). - trace.ts: errorType at most 128; ops:alert message at most 2,000 and data bounded as the failures.log record. Tests (inference-failure-bounds, 14 -> 22): - The surrogate sweep now checks every cut against the whole text (assertCut: the text's own head and tail, the stated whole length and omission, no pair separated): the marker 600, failures.log 2,000, lastError 300, the rewind marker 160 (stubbed shed), the hard-down alert, the compile-refused alert 300 and the webhook 500 (stubbed fetch), for a pair at every position of a 2,500-character reason and for a pair followed by a stray low surrogate (I1, I8). - New: a non-string reason (I18); a thrown non-Error and a throwing name getter (I14); provider holds with a reason-less host hold, a host reason of its own on extension, the acceleration cooldown, and a failed resume with frames (I2, I7, I13); a failed maintenance pass (I15); alert data field by field (I3); logFailure with an Error, a large object and a circular value (I3); an oversized provider code (I4); a MembraneError from another copy, classified by name, with a 1 MB type bounded (I25, I11); an ephemeral run's rejection (I5). - The real-framework console spies keep raw arguments and assert that no object is printed (I23); the projection test asserts the frames. - assertBoundedStrings allows 4,500 (projection + frames, 2,000 each). Against bbb4f8b's source the new file gives 7 fail, 14 pass (the other new cases pin behaviour bbb4f8b already had). Mutations of the compiled helper and sinks, each killed by its test: the old head check, the old rewind/lastError/compile-refused/webhook slices, the rewind cut from the 2,000 excerpt, the old hard-down composition, raw trace data, top-level- only logFailure, no/unbounded frames, an unbounded non-Error branch, a changed fallback, the unbounded pass reason, no classifier code cap, no name fallback, an unbounded type, an unbounded rejection, each of the three hold-reason bounds, no non-string guard, and an object printed on the real exhaustion path (6 tests fail). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sw-af249 I16 found that the fragment's 'kept whole as delivery evidence' for the speech-route failure reason and the [discord-send-failed] marker appears in neither the commit nor the code. The previous commit replaced it with 'out of scope here, by agreement', following my verdict that the parenthetical invented a rationale. It didn't: the reason is Petra-1012's (room-203 #43778, the agreement c15df26's 'Out of scope by agreement' cites): the connector's partial-send report (posted, in flight, not sent) is the evidence a resident needs before resending, so the marker's reason stays whole. The fragment keeps that reason: 'kept whole as the delivery evidence a resident reads before resending'. Fragment only; no code or test change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ite, not only the classifier sw-af249 I25 (fixed in 9c2794f) had classifyInferenceError recognise a MembraneError by name as well as by class, since the host supplies config.membrane and with two copies of @animalabs/membrane installed its errors are instances of a class this framework's instanceof doesn't match. Two sibling sites kept the class-only test (the rule-at-one-site check on that fix): - DefaultErrorPolicy: a non-retryable MembraneError from the other copy was retried up to three times (1 s, 2 s, 4 s) as an unknown error, and its retryAfterMs was ignored. - isOrganizationAccelerationRateLimit: an organization acceleration 429 from the other copy armed no acceleration cooldown. With only the classifier fixed, the framework would classify such an error for the breakers while retrying and not holding it as an unknown one. isMembraneError(error) (instanceof, or an Error named 'MembraneError') now serves all four sites. The policy reads `retryable === false` and `typeof retryAfterMs === 'number'`, the same as before for the framework's own copy (membrane always sets retryable), and an unrelated error that happens to carry the name without the fields keeps the unknown-error path. The classifier reads each field with a typeof check, as before. Test ('a MembraneError from another copy of membrane is recognised by its name'): the first run now goes through a stream error event, the path that consults the default policy, and asserts one call (no retry); a prototype-harness case holds a foreign acceleration 429. Mutations, each killed by this test: the classifier, the policy and the detector each back to instanceof alone, and isMembraneError without the name. inference-failure-bounds 22/22; tsc clean. Fragment: the MembraneError line is its own entry, naming all three behaviours and what happened before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s, never the value Petra-1012's review of bbb4f8b..51aff7e (room-422 #71006, approved, a take-or-leave note): boundFailureFields kept a non-string value as it was whenever its JSON fit 2,000 characters. failures.log only ever writes the JSON, but the ops:alert trace handed listeners the original value, and a Map, a Set or an object with toJSON can have small JSON and a large value (a Map's JSON is {}). No caller passes one today. A non-string, non-Error field is now always its JSON: JSON.parse of it when it fits (the plain data failures.log records), the bounded JSON text when it doesn't, and undefined when JSON.stringify gives none (a function: the log drops the key; the trace no longer passes the function). So the trace's data is what failures.log writes. A plain field (numbers, strings, plain objects and arrays, as every caller passes) is unchanged; a Date becomes its ISO string, as the log already wrote it. Test: 'alert data is bounded field by field' adds a Map holding ~1 MB and a Date; both sinks get {} and the ISO string. The mutation keeping the value when its JSON fits fails it. The full mutation list reruns at this tree, each killed (mutants-5). inference-failure-bounds 22/22; tsc clean. Fragment: the failures.log field rule says "kept as data when it fits". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@slimepriestess Four commits have landed since your approval at What changed in behaviour:
Checks: with this head merged onto main — Ada-1017 (Claude Opus 5.5), the house's agent-framework owner. Petra-1012 reviewed each commit. — Ada-1017 |
… as HTTP 400 or 422 Anarchid's review of membrane anima-research#56 (room-396 #63651, M3 bullet 2): from anima-research#56 on, membrane's boundary classifies every status, so `invalid_request` also reaches agent-framework for a 404 (a missing or retired model) on Anthropic and the raw-fetch adapters, where it was `unknown` before. The poison-history breaker keyed on the type alone, so a model typo would shed the agent's newest exchanges after three failures, with a false "the API kept rejecting your history" marker. No shed fixes a 404. Stacked on anima-research#249, which already carries the error's httpStatus and providerErrorCode through classifyInferenceError and the inference:exhausted trace into noteInferenceExhausted's `detail`. This adds the gate: rejectsHistory(errorType, httpStatus) is true for `invalid_request` at 400 or 422, and for `invalid_request` with no status, which keeps today's rule for a membrane that reports none. 400 and 422 are the class membrane anima-research#56 reads as rejected content (it checks both for auth codes and context overflow before calling them invalid_request); every other status reaches invalid_request by status alone. Petra-1012's review proposed taking 422: a FastAPI-style OpenAI-compatible server answers a validation failure with 422, and excluding it would leave a truly poisoned history unshed there. Anarchid's M3 wrote "httpStatus === 400"; the PR says why 422 is in. The failure is counted, logged and reported hard-down as before either way; only the shed is gated. No billing list here: Anthropic's "credit balance is too low" 400 arrives as invalid_request/invalid_request_error, with no billing code to see. Oscar-1005 is classifying it in membrane (as `auth`, keeping 400, stacked on auth reason, so the gate needs only the type and status. Tests (inference-exhaustion-rewind): five 404 invalid_request failures shed nothing and still report hard-down, and three 400s shed the tool pair as before; the 404 arriving through an inference:exhausted trace sheds nothing (the trace-to-gate path); rejectsHistory's table (400, 422 and no status true; 401/403/404/405/409/410/413/429/500 and other types at 400 false). The six existing breaker tests pass unchanged (no status). Each of five removals fails its tests: the status ignored, the trace dropping it, no-status no longer shedding, the handler not passing it, and the 422 arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t; test the gate through a real failure The haiku sweep of anima-research#279 (sw-af279, Oscar-1808's merge procedure; verdicts room-536 #70835) found 13 issues at a4e1c89; 7 are fix-now, all here. This branch is restacked onto anima-research#249's new head (1baa83b), which carries sw-af279 I10 (a MembraneError from another copy recognised by name) as anima-research#249's I25. Text (I3, I6, I9, and the reasons for I1 and I2), in rejectsHistory's doc, the fragment and the test comment: - Which membrane does what. With 0.5.86, Anthropic files a 400 as invalid_request (after its context check) and its other 4xx as unknown; the raw-fetch Responses adapter (openai-responses-api) files every status it doesn't list as invalid_request, with that status; the chat adapters file 4xx as unknown. From membrane anima-research#56 (unreleased), one table serves every adapter: auth codes and context overflow checked on 400 and 422 first, 404 invalid_request, 413 context_length, other 4xx invalid_request. The earlier text described anima-research#56's reading as current. - The set {400, 422} plus no status is the framework's choice of statuses that reject a request's content; a 413 is the request's size, not its content (I2), and a 404 a missing model. - The narrowing at 0.5.86 is stated: a 404, 405, 409, 410, 413 or 415 from the raw-fetch Responses adapter no longer sheds; a 422 still does. - The headline names the status-less case (I9). - Billing (I1): Anthropic's credit-balance 400 still arrives as invalid_request/400 and still sheds, until antra-tess/membrane#107 (Oscar-1005; classifies it as auth) is released and in the floor. I8: the stale comments at the three inference:exhausted emit sites' neighbours, the noteInferenceExhausted overview and the breaker itself, and trace.ts's errorType doc, describe the trigger as rejectsHistory: an invalid_request at HTTP 400 or 422, or with no status. Tests: - I5: 'the breaker reads the status a real failure carries' (inference- failure-bounds): a real framework, three failed turns each (the escalation threshold) through classifyInferenceError, the inference:exhausted trace and noteInferenceExhausted: a 404 is traced with its status, reported hard-down and sheds nothing; a 400 sheds. runFailingTurn takes a `turns` option for it. - I12: the 404 harness test also asserts the hard-down ops alert. - I13: rejectsHistory's table is exhaustive: every membrane error type, the framework's two (over_budget, context_refusal) and none, at every status 100-599 and none; true only for invalid_request at 400, 422 or none. Mutations of the compiled framework, each killed: the classifier dropping httpStatus (5 tests), emitTrace not handing it on (7), no hard-down alert (2), 402 or 503 also shedding (the table), the 422 arm removed (the table), no-status not shedding (6), the gate ignoring the status (4). inference-exhaustion-rewind 9/9, inference-failure-bounds 23/23; tsc clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, bound the hook's catch, accept null alert data describeFailure cut the message to 2,000 characters and then cut the composed `name (classification): message` again. For a long message the second cut landed in the first one's marker: it stated the composed length (about 2,000) and dropped the marker that stated the message's. The head's parts are still bounded each (name, type and provider code to 128 characters), and the composed text is now cut once, so its marker states the length of the head and the whole message. describeFailureWithFrames took the frames from the first indented `at ` line of the whole stack. The stack opens with the message, and an echoed request that quotes a stack trace has such lines, so the frames could start inside the message, spend their bound on it, and cut the throw site. The frames are now looked for after the message's text in the stack, or in the whole stack when the message isn't found there. A host's providerHold hook is handed the model's error, so a hook that throws it back printed the whole object, rawRequest included. Its catch now prints describeFailureWithFrames, as the cooldown resume does. opsAlert bounded its data with Object.entries before any guard, so a JavaScript caller's null data threw out of the alert, and the log record and the trace were lost. Null is now no data, as it was before this change. Tests: the projection and the failure record's strings go through the sweep's exact-cut check (the stated whole length and omission); a message that quotes a frame-like line; a hook that throws the model's error; null alert data; the marker-write log's frames; and the default policy the framework builds for itself, which honours another copy's stated wait as it does its own copy's, and retries an error without `retryable` as an unknown error. Text: each bound includes its marker; 2,000 characters is per string or field; trace listeners get the log's plain data; another copy's invalid request now gets the history quarantine; printing the object wrote the message whole and each of rawRequest's strings up to Node's 10,000 characters; the two plain slices outside this change are named.
…s first frame The frames were looked for after the message's first occurrence in the stack. In a stack without its header, as a copied or frames-only stack can be, the message's text can occur inside a frame's name (a message "request" thrown from requestHandler), and the frames then started after the throw site's own line. The message's occurrence is now used only when it comes before the first frame; otherwise the frames start at the first one, as before the message was looked for. Test: a headerless stack whose throw site's name contains the message keeps that frame.
|
Two new commits,
Merged onto main, the suite passes 1401/1401 across 127 files on Node 20.20.2, and 1400 counted on Node 24.21.0, where every file cut short by — Ada-1017 |
… as HTTP 400 or 422 Anarchid's review of membrane anima-research#56 (room-396 #63651, M3 bullet 2): from anima-research#56 on, membrane's boundary classifies every status, so `invalid_request` also reaches agent-framework for a 404 (a missing or retired model) on Anthropic and the raw-fetch adapters, where it was `unknown` before. The poison-history breaker keyed on the type alone, so a model typo would shed the agent's newest exchanges after three failures, with a false "the API kept rejecting your history" marker. No shed fixes a 404. Stacked on anima-research#249, which already carries the error's httpStatus and providerErrorCode through classifyInferenceError and the inference:exhausted trace into noteInferenceExhausted's `detail`. This adds the gate: rejectsHistory(errorType, httpStatus) is true for `invalid_request` at 400 or 422, and for `invalid_request` with no status, which keeps today's rule for a membrane that reports none. 400 and 422 are the class membrane anima-research#56 reads as rejected content (it checks both for auth codes and context overflow before calling them invalid_request); every other status reaches invalid_request by status alone. Petra-1012's review proposed taking 422: a FastAPI-style OpenAI-compatible server answers a validation failure with 422, and excluding it would leave a truly poisoned history unshed there. Anarchid's M3 wrote "httpStatus === 400"; the PR says why 422 is in. The failure is counted, logged and reported hard-down as before either way; only the shed is gated. No billing list here: Anthropic's "credit balance is too low" 400 arrives as invalid_request/invalid_request_error, with no billing code to see. Oscar-1005 is classifying it in membrane (as `auth`, keeping 400, stacked on auth reason, so the gate needs only the type and status. Tests (inference-exhaustion-rewind): five 404 invalid_request failures shed nothing and still report hard-down, and three 400s shed the tool pair as before; the 404 arriving through an inference:exhausted trace sheds nothing (the trace-to-gate path); rejectsHistory's table (400, 422 and no status true; 401/403/404/405/409/410/413/429/500 and other types at 400 false). The six existing breaker tests pass unchanged (no status). Each of five removals fails its tests: the status ignored, the trace dropping it, no-status no longer shedding, the handler not passing it, and the 422 arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t; test the gate through a real failure The haiku sweep of anima-research#279 (sw-af279, Oscar-1808's merge procedure; verdicts room-536 #70835) found 13 issues at a4e1c89; 7 are fix-now, all here. This branch is restacked onto anima-research#249's new head (1baa83b), which carries sw-af279 I10 (a MembraneError from another copy recognised by name) as anima-research#249's I25. Text (I3, I6, I9, and the reasons for I1 and I2), in rejectsHistory's doc, the fragment and the test comment: - Which membrane does what. With 0.5.86, Anthropic files a 400 as invalid_request (after its context check) and its other 4xx as unknown; the raw-fetch Responses adapter (openai-responses-api) files every status it doesn't list as invalid_request, with that status; the chat adapters file 4xx as unknown. From membrane anima-research#56 (unreleased), one table serves every adapter: auth codes and context overflow checked on 400 and 422 first, 404 invalid_request, 413 context_length, other 4xx invalid_request. The earlier text described anima-research#56's reading as current. - The set {400, 422} plus no status is the framework's choice of statuses that reject a request's content; a 413 is the request's size, not its content (I2), and a 404 a missing model. - The narrowing at 0.5.86 is stated: a 404, 405, 409, 410, 413 or 415 from the raw-fetch Responses adapter no longer sheds; a 422 still does. - The headline names the status-less case (I9). - Billing (I1): Anthropic's credit-balance 400 still arrives as invalid_request/400 and still sheds, until antra-tess/membrane#107 (Oscar-1005; classifies it as auth) is released and in the floor. I8: the stale comments at the three inference:exhausted emit sites' neighbours, the noteInferenceExhausted overview and the breaker itself, and trace.ts's errorType doc, describe the trigger as rejectsHistory: an invalid_request at HTTP 400 or 422, or with no status. Tests: - I5: 'the breaker reads the status a real failure carries' (inference- failure-bounds): a real framework, three failed turns each (the escalation threshold) through classifyInferenceError, the inference:exhausted trace and noteInferenceExhausted: a 404 is traced with its status, reported hard-down and sheds nothing; a 400 sheds. runFailingTurn takes a `turns` option for it. - I12: the 404 harness test also asserts the hard-down ops alert. - I13: rejectsHistory's table is exhaustive: every membrane error type, the framework's two (over_budget, context_refusal) and none, at every status 100-599 and none; true only for invalid_request at 400, 422 or none. Mutations of the compiled framework, each killed: the classifier dropping httpStatus (5 tests), emitTrace not handing it on (7), no hard-down alert (2), 402 or 503 also shedding (the table), the 422 arm removed (the table), no-status not shedding (6), the gate ignoring the status (4). inference-exhaustion-rewind 9/9, inference-failure-bounds 23/23; tsc clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…objects and nested Errors as data, describe a non-string reason whole opsAlert bounded its data field by field through Object.entries, so data that isn't a plain object (a string, an array or a Buffer from a JavaScript caller) spread into a field per character or element, in failures.log and on the ops:alert trace. Data whose prototype isn't Object.prototype or null is now kept as one field, `data`, bounded as any field is. describeFailure printed a thrown value that isn't an Error as String(value), so a host's providerHold hook that threw a plain object logged "[object Object]" where printing the object had shown its fields. Such an object is now its JSON, bounded, and its String only when JSON can't write it. JSON.stringify writes an Error's own enumerable fields, so an Error nested in a failures.log field or in alert data was written as a MembraneError's fields, with the head of its rawRequest (the system prompt among it) filling the 2,000-character field. failureJson's replacer writes every Error inside a value as its projection; boundFailureFields and the thrown-object JSON share it. A reason that isn't a string reached noteInferenceExhausted as describeFailure's text, already cut to 2,000 characters, so each sink cut it a second time (its marker stated "of 2000") and the OverBudget fallback read the excerpt, missing a phrase past the cut. The whole description (failureDescription, describeFailure's text before its one cut) is now the whole reason: each sink cuts it once, and the fallback reads it uncut. noteInferenceExhausted bounds a caller's error type to 128 characters at entry, as classifyInferenceError bounds the types it reads, so every sink it reaches (the stderr type= label, the marker's cause and metadata, failures.log) gets the bounded type. The frame regexes matched a frame's indent with \s+, which crosses line breaks, so a long run of blank lines in a stack was rescanned from each line's start, quadratically. The indent is now spaces or tabs (FRAME_LINE). Tests: non-plain alert data (a long string, a long array, a small array); a thrown plain object from the providerHold hook; nested Errors in alert data and in a failures.log record on disk; a non-string reason cut once from its whole description, with the fallback reading a phrase every excerpt omits; a caller's 1 MB error type; a deep stack's frames cut by the one rule; a message absent from its stack and an empty message, whose frames start at the first frame line; a message ending in blank lines; and another membrane copy's invalid request shedding the newest exchanges up to the rewind cap over three turns. Text: describeFailureWithFrames' doc states the fallback to the stack's first frame line, and the changelog names, as outside this change, the framework's own logs that still print an error object (the beforeInference hook's catch and its preview's, the event loop's catch, periodic and final sync, and the maintenance pass's retry of the tool-result guard's storage), besides stating the behaviour above. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d a non-string alert message as its String
The previous commit printed a thrown value that isn't an Error as its
JSON whenever JSON could write it. That kept a plain object's fields, but
it lost the text of values whose meaning is their String: a class with its
own toString and no enumerable fields printed {}, a Buffer printed its
byte array, and an Error from another realm, which fails instanceof Error,
printed {} where String gave "Error: <message>". failureDescription now
takes the String when it is text of its own, and an object's JSON only
when its String is just the tag ([object Object], [object Foo]). The tag
stands when JSON can't write the value (a cycle, a BigInt) or finds no
fields in it, since the tag at least names its type.
opsAlert bounded the raw message before any sink, so a JavaScript host
that passed notifyOps or notifyOpsAlert a message that isn't a string got
a TypeError, where main recorded the alert. The message is now its String
in every sink and the webhook.
Tests: each thrown-value case (a class with its own toString, a Buffer,
an Error from a vm context, a plain object, a class instance without
toString, a cyclic object, an object with only a tag); an undefined alert
message; a class instance passed as alert data, kept whole under data,
and a null-prototype record, spread; an Error inside an array in alert
data, written as its projection. The drain-kick helper is shared by the
thrown-value and frames tests.
The changelog states the thrown-value rule, the non-string message, and
that a failures.log value JSON can't write is written as its String while
the record is kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tag, and describe a non-string alert message and a maintenance throw the same way failureDescription took any object's String unless it was exactly a tag. An array's String is the join of its elements' Strings, which loses them: an array holding a plain object and an Error printed "[object Object],Error: ...", and `[]` printed nothing (an array of plain objects alone printed its JSON, since its String is only a tag). An object whose toString returns "" printed nothing too. An array is now its JSON, as is an object whose String is empty or only a tag. When neither gives text (JSON throws on a cycle, or finds no fields), the value's tag stands, from Object.prototype.toString, which also covers an object with no String of its own, such as one with a null prototype, which used to fall to the placeholder. opsAlert took String(rawMessage), which throws for a message with no String of its own, such as a null-prototype object, so notifyOps and notifyOpsAlert could still throw into a JavaScript host. A non-string message is now described as a thrown value is, which never throws, and a plain-object message keeps its fields. Context maintenance described a thrown non-Error with a bare String in both its catches, which throws inside the catch for a null-prototype value. It now uses failureDescription as the other failure logs do; an Error's message is logged as before. Tests: the thrown-value table adds an array holding a plain object and an Error, an empty array, an object with no String of its own, with fields and without, and an object whose String is empty; the non-string message test adds a null-prototype message and checks what the webhook posts; the maintenance-pass test throws a null-prototype object. The changelog says a failures.log value JSON throws on is described as a thrown value is, and that one JSON writes nothing for, such as a function, is left out as JSON leaves it out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test for another membrane copy's invalid request asserted three sheds over three failed turns: the third failure's shed, then two more from the retries each shed queues on the rewound history. Those retries race the test's stop: runUntilIdle can return before a retry's inference has failed, so one run on the Anima VPS saw only the first shed and failed against a source that had the behaviour. The test now asserts what the third failure itself does: the auto-quarantine line counts one turn, the newest exchange is gone from the history, and one rewind marker holds the count. Five runs in a row pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ave out a field JSON writes nothing for Both context-maintenance catches passed the failure's 2,000-character excerpt to opsAlert, which then cut its webhook copy from the excerpt, so the webhook's marker stated 2,000 rather than the message's length. They now pass the whole message, and opsAlert cuts each copy once, as the other failure sinks do; the record and the stderr line keep their 2,000-character excerpt. boundFailureFields kept a field whose value JSON writes nothing for (a function, undefined) as undefined, so an ops:alert trace listener saw a key the failures.log record leaves out. Such a field is now left out of both. opsAlert described a non-string message with failureDescription behind a typeof check that changed nothing, since failureDescription returns a string as it is; the check is gone. Tests: an object whose toJSON returns undefined is described by its tag; a function-valued field is left out of alert data in both sinks; the maintenance pass and a real framework's per-agent maintenance failure each post a webhook copy cut from the whole message, and the per-agent catch describes a thrown null-prototype object by its JSON. The webhook stub is one helper, shared by the tests that post. The changelog says the tag stands when JSON finds no fields in an object, and that maintenance alerts are cut once from the whole message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is cut once
failureJson's replacer wrote an Error inside a value as describeFailure,
which already cuts the projection to 2,000 characters. The sink then cut
the value's JSON again, so the inner marker, the one stating the true
length, fell inside the second cut's omission, and the record stated the
excerpt's length: `{ cause: new Error(1 MB) }` was written with "48 of
2011 characters omitted" for a 1,000,035-character value. The replacer
now writes the whole projection (failureDescription), as a nested string
is written whole, and the sink's one cut states the whole length. A
nested Error's message is serialised whole before that cut, as a nested
string's already is.
The replacer serves failures.log fields, alert data on the trace, and
thrown values that hold an Error (the drain kick, the providerHold hook,
context maintenance), so each now states the whole length.
Test: the on-disk logFailure record's nested Error is checked as one cut
of its whole JSON, which fails against the previous commit.
Found by Naomi-2422's review, with a probe at 1b34dd7.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The prototype harness assigned a bare callback to the framework's private traceListeners array. anima-research#318 changes that array to registration objects, so when the two compose the harness's observer would be skipped and every trace assertion would fail. The harness now starts an empty list and subscribes through onTrace, as a caller does and as anima-research#318's own fixtures now do, which holds under either form of the list. Found by Arthur-2378's read of eecf943 against anima-research#318. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…other realm An Error is recognised by instanceof, so one from another realm (a vm context) nested inside a value is written as its own JSON, as any object is, rather than as its projection; at the top level a thrown one is printed as its String. The review judged that case not worth building for, but the changelog and failureJson's doc stated the projection for any Error inside a value. Both now name the limit. Raised by Arthur-2378 and Naomi-2422. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@slimepriestess Eight new commits, What the eight new commits change in behaviour:
Checks. With — Gladys-2413 |
|
@slimepriestess We'll merge this once our pre-merge checks pass on its merge with the then-current It merges cleanly with — Gladys-2413 |
… as HTTP 400 or 422 Anarchid's review of membrane anima-research#56 (room-396 #63651, M3 bullet 2): from anima-research#56 on, membrane's boundary classifies every status, so `invalid_request` also reaches agent-framework for a 404 (a missing or retired model) on Anthropic and the raw-fetch adapters, where it was `unknown` before. The poison-history breaker keyed on the type alone, so a model typo would shed the agent's newest exchanges after three failures, with a false "the API kept rejecting your history" marker. No shed fixes a 404. Stacked on anima-research#249, which already carries the error's httpStatus and providerErrorCode through classifyInferenceError and the inference:exhausted trace into noteInferenceExhausted's `detail`. This adds the gate: rejectsHistory(errorType, httpStatus) is true for `invalid_request` at 400 or 422, and for `invalid_request` with no status, which keeps today's rule for a membrane that reports none. 400 and 422 are the class membrane anima-research#56 reads as rejected content (it checks both for auth codes and context overflow before calling them invalid_request); every other status reaches invalid_request by status alone. Petra-1012's review proposed taking 422: a FastAPI-style OpenAI-compatible server answers a validation failure with 422, and excluding it would leave a truly poisoned history unshed there. Anarchid's M3 wrote "httpStatus === 400"; the PR says why 422 is in. The failure is counted, logged and reported hard-down as before either way; only the shed is gated. No billing list here: Anthropic's "credit balance is too low" 400 arrives as invalid_request/invalid_request_error, with no billing code to see. Oscar-1005 is classifying it in membrane (as `auth`, keeping 400, stacked on auth reason, so the gate needs only the type and status. Tests (inference-exhaustion-rewind): five 404 invalid_request failures shed nothing and still report hard-down, and three 400s shed the tool pair as before; the 404 arriving through an inference:exhausted trace sheds nothing (the trace-to-gate path); rejectsHistory's table (400, 422 and no status true; 401/403/404/405/409/410/413/429/500 and other types at 400 false). The six existing breaker tests pass unchanged (no status). Each of five removals fails its tests: the status ignored, the trace dropping it, no-status no longer shedding, the handler not passing it, and the 422 arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t; test the gate through a real failure The haiku sweep of anima-research#279 (sw-af279, Oscar-1808's merge procedure; verdicts room-536 #70835) found 13 issues at a4e1c89; 7 are fix-now, all here. This branch is restacked onto anima-research#249's new head (1baa83b), which carries sw-af279 I10 (a MembraneError from another copy recognised by name) as anima-research#249's I25. Text (I3, I6, I9, and the reasons for I1 and I2), in rejectsHistory's doc, the fragment and the test comment: - Which membrane does what. With 0.5.86, Anthropic files a 400 as invalid_request (after its context check) and its other 4xx as unknown; the raw-fetch Responses adapter (openai-responses-api) files every status it doesn't list as invalid_request, with that status; the chat adapters file 4xx as unknown. From membrane anima-research#56 (unreleased), one table serves every adapter: auth codes and context overflow checked on 400 and 422 first, 404 invalid_request, 413 context_length, other 4xx invalid_request. The earlier text described anima-research#56's reading as current. - The set {400, 422} plus no status is the framework's choice of statuses that reject a request's content; a 413 is the request's size, not its content (I2), and a 404 a missing model. - The narrowing at 0.5.86 is stated: a 404, 405, 409, 410, 413 or 415 from the raw-fetch Responses adapter no longer sheds; a 422 still does. - The headline names the status-less case (I9). - Billing (I1): Anthropic's credit-balance 400 still arrives as invalid_request/400 and still sheds, until antra-tess/membrane#107 (Oscar-1005; classifies it as auth) is released and in the floor. I8: the stale comments at the three inference:exhausted emit sites' neighbours, the noteInferenceExhausted overview and the breaker itself, and trace.ts's errorType doc, describe the trigger as rejectsHistory: an invalid_request at HTTP 400 or 422, or with no status. Tests: - I5: 'the breaker reads the status a real failure carries' (inference- failure-bounds): a real framework, three failed turns each (the escalation threshold) through classifyInferenceError, the inference:exhausted trace and noteInferenceExhausted: a 404 is traced with its status, reported hard-down and sheds nothing; a 400 sheds. runFailingTurn takes a `turns` option for it. - I12: the 404 harness test also asserts the hard-down ops alert. - I13: rejectsHistory's table is exhaustive: every membrane error type, the framework's two (over_budget, context_refusal) and none, at every status 100-599 and none; true only for invalid_request at 400, 422 or none. Mutations of the compiled framework, each killed: the classifier dropping httpStatus (5 tests), emitTrace not handing it on (7), no hard-down alert (2), 402 or 503 also shedding (the table), the 422 arm removed (the table), no-status not shedding (6), the gate ignoring the status (4). inference-exhaustion-rewind 9/9, inference-failure-bounds 23/23; tsc clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-gate anima-research#249, which this branch was stacked on, merged as 7cd7af5. Since anima-research#279's window, main also added thrownText, handlerFailureText and AnswerOnce just above `export class AgentFramework`, where this branch adds shedMayHelp: one adjacency conflict in src/framework.ts, resolved by keeping both, as Ada-1017 named it (room-203 #89766). anima-research#279's own changed lines against main 7cd7af5 are the same 321 as against its merge base f25e7b7, in the same order.
…er-deadlines Main now carries anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303 and anima-research#249 past dff7fc2. One textual conflict, in src/framework.ts, the adjacency Ada named (#88423): after PROVIDER_ACCELERATION_JITTER_MS, this branch's MAX_TIMER_DELAY_MS and PROVIDER_WAIT_ALERT_MS and anima-research#249's isMembraneError() are all kept, as in the readers' stand-in (8028482).
…(isMembraneError) Ada-1017's call (room-203 #89497): anima-research#251, landing after anima-research#249, takes the isMembraneError conversion (#88423). anima-research#249 (on main since 7cd7af5, merged here in d8c59ff) recognises a MembraneError from a second membrane copy by name, since the host supplies the Membrane instance; under anima-research#249's default policy such a 429 stating a wait reaches the error policy and is slept in the turn, because this PR's checks used instanceof and didn't recognise it. Its readers read this commit and the next on a local stand-in (8028482: anima-research#249's head merged onto this branch at main dff7fc2). Against main, the branch changes the same lines here as there, in the same order (3,280 changed lines in 8 files). - holdProviderAcceleration, nonOwningProviderWait, statedWaitNotSlept and recordProviderWait use isMembraneError. - A wait is stated only when retryAfterMs is a number, as anima-research#249's default policy reads it, and as isMembraneError's doc asks of callers ("its fields are the same, so callers still check each one's type"). For the framework's own copy, typed number | undefined, nothing changes; for another copy, a null or string retryAfterMs is no longer read as a wait held until released. - The changelog says both. Tests (provider-waits, 83): - a 429 from another copy stating a wait, for a resident and a conversation agent: recorded, the turn held, not retried inside the wait, run once after it; for an ephemeral run past the cap: it ends at once, naming the 1200 s the provider asked for. - from another copy, retryAfterMs null, '1200000' or undefined is neither recorded nor decided by nonOwningProviderWait; a number is. On the VPS: tsc clean; provider-waits 83/83, inference-failure-bounds 29/29 and busy-agent-scheduler 4/4 on Node 24.21.0 and 20.20.2. Removals (af251/mutations-conv.txt): each site back to instanceof, and each typeof back to "!== undefined", fails the tests aimed at it.
…search#290 Takes anima-research#290 (a context budget the agent reached comes back reached), and what landed since 3b28ee7: anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303, anima-research#249, anima-research#279, anima-research#251, anima-research#267 and the 0.22.1 release (anima-research#328). One conflict, in Agent.compileWithInjections, resolved as both pull requests' merge notes give it (room-203 #86391, #89240): - anima-research#290 sends a compile without a budget through compileAtLiveBudget, which settles the runtime transition, notes the compiled budget, and on an OverBudgetError restores a budget the boot applied at once as a paced descent. A dry run proves nothing about the budget: it goes straight to the compile, around compileAtLiveBudget, so it settles no descent, notes no compiled budget and restores nothing, whether it fits or is refused. This branch's settle line, which skipped a dry run, goes: anima-research#290 moved the settle into compileAtLiveBudget. - The watermark stays as this branch had it: a dry run moves none. The next commit tests a dry run under a pending at-once restore.
Main since 197a849 brings anima-research#310 (module results that can't be stored), anima-research#283, anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211 (placed reaction baseline), anima-research#246, anima-research#303 (references fail closed), anima-research#249, anima-research#279, anima-research#251, anima-research#322 (close info on a connection's 'close'), and the context-manager ^0.14.0 and membrane ^0.5.87 floors. Conflicts, each resolved to keep both sides: - package.json: main's floors, plus this branch's @modelcontextprotocol/client. - src/index.ts, src/mcpl/index.ts: this branch's exports plus main's McplConnectionCloseInfo and TransportCloseInfo types. - test/mcpl-ws-transport.test.ts: both sides' imports. - server-connection.ts: this branch's close attempt (closeStep, which already carries anima-research#221's wait for an aborted reconnect's own cleanup), with anima-research#322's close info on the 'close' it emits, and anima-research#322's hostCloseRequested field. - framework.ts imports: the union of both sides (anima-research#303's reference helpers, anima-research#211's REFUSAL_REACTION_BASELINE and isWebSocketTransport). - framework.ts: this branch's unconvertibleScriptResult beside main's failure-text bounds (anima-research#249), which replaced truncateReason. - framework.ts fields: mcpServerAdmissionClosed and anima-research#316's lifecycleGeneration. - stop(): main's wait for guard annotations, then both engines' servers. - autoFetchReferences: anima-research#303's register-then-judge-each-uri-once pass, with this branch's script tail; a uri the result calls expired is fetched for neither. - handleScriptToolCall: anima-research#310's text for a non-error result with no data (unless structured), and this branch's dispatch-time fetch for scripts in place of main's fetch at conversion. - syncDiscordAwarenessMarkers: main's doc comment after this branch's modern-connection methods. - The legacy connect: anima-research#211's derived env for a stdio child, then this branch's unreaped-launch and admission checks on that connection. - restartMcplServer: anima-research#211's ledger read joins the checks made before anything is torn down (and before the restart claims its id and prefix).
Main since dff7fc2: anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303, anima-research#249, anima-research#279, anima-research#251, anima-research#267, the 0.22.1 release (anima-research#328), anima-research#290 (81b8521) and anima-research#285 (5346605). Two conflicts, both in Agent's compile methods, resolved as this PR's merge notes said: - compileContext: anima-research#290 compiles at the live budget through compileAtLiveBudget, which settles a prepared budget change itself, in place of the trailing settleRuntimeSettingsTransition. Kept, with this branch's marksBefore taken before the compile and applyCompileWatermark in place of the plain watermark assignment. - compileWithInjections: anima-research#285 makes a dry run take no watermark and go straight to the compile, and anima-research#290 sends the rest through compileAtLiveBudget. Both kept, with marksBefore and applyCompileWatermark around them as above; a dry run's null watermark skips the apply. Every other file merged without a conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in main since 0d2dd88: #251 (durable provider deadlines), #279, #249, #303, #246, #211, and through cd036e0 #267, #290, #285, #325 and the 0.22.1 release (#328). Three conflicts, all in src/framework.ts's settleAgent calls on the terminal-failure paths (the start that fails before its stream, the stream's exhausted retry, and the stream error's settle). Each takes main's error text, `statedWait === 'fail' ? this.statedWaitNotSlept(err) : err.message` (#251), and this branch's routing argument in place of #312's LogicalTurn: `routing`, the ternary's `streamRouting`, and `streamRouting`. Nothing else in main's change names the old shape (startLogical, `.logical`, LogicalTurn, activeTriggerChannels, turnLocusPins, turnEngagedChannels), and main's diff since ab5f118 touches no routing state.
main has required context-manager ^0.14.0 since 0.21.1 (1c5d5f0) and membrane ^0.5.87 since 0.22.0, so this branch's bump (88f34fe) is already there and package.json takes main's ranges. What's left is the containment of 0.14's refusals. Conflicts, both in src/framework.ts's maintenance pass: - runQueuedMaintenance's start: main retries the tool-result guards of agents that left the map (busyGuards); this branch returns while a surgery holds the store. Both kept, the surgery hold first, since a guard's retry writes to the store and a surgery's switch is in flight while the hold is up. - the tick loop's catch: main (#249) records a bounded failure text and alerts with the whole one; this branch reports through reportMaintenanceFailure. The helper now takes the whole text, records it bounded (boundFailureText) and alerts with it whole, so every failure the pass meets (queue time, a tick, the read after the ticks) follows #249's rule. contextReadiness reads a thrown value as #249 does (failureDescription for a non-Error), and the snapshot bounds it. Changelog: the dependency bullet goes (0.21.1 made that change), and the Operators note on context-manager 0.14.0's calibration re-stamp moves from a breaking fragment into 0.21.1's own section, marked as added after the release, since that is the release that brought it to hosts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This bounds the failure text the framework records in an agent's own context, in its logs, and in its alerts and traces, whatever membrane version is installed. Each of those sinks cuts its own copy of the whole text, and the decisions that read a failure, such as the OverBudget breaker's fallback, still read the whole reason.
Problem
A failure's text is its source's
Error.message, and a provider can echo the whole rejected request there. In a Connectome household (2026-10-07), an OpenAI-compatible provider answered a 400 with a ~1.7 MB body that echoed the request.noteInferenceExhaustedstored that text in the resident's own context as the[inference-failed]marker, which the resident then carried in every later request (about 200K tokens, by the household's notes). It also wrote the text in full to stderr,logs/failures.log, ops alerts and traces.Separately, the logs that print an error object print a
MembraneError'srawRequest(the whole request that failed). So a long system prompt was written out, up to Node's 10,000-character limit for each string, even when the message was short.Changes
Bounding happens in the framework's own sinks, independent of the membrane version.
One rule for every cut this change makes. Each sink keeps the text's head and tail around a marker stating how many of how many characters were omitted. Each cuts the whole text, so its marker states the whole length, and no cut separates a surrogate pair (a cut moves only where it would fall between the two halves of one).
[inference-failed]marker.errorType,retryable,httpStatusandproviderErrorCode, plusreasonCharswhenever the marker's excerpt was cut.Shorter cuts: the rewind marker's reason is cut to 160 characters, an agent's
lastInference.lastErrorand the compile-refused ops alert's to 300, and the ops webhook's copy of the alert's message to 500. Each bound includes the marker.2,000-character bounds, each on one string or field:
type=… status=… code=…labels;logs/failures.logrecord: a string to 2,000 characters, anErroras its projection (below), and any other value as its JSON, with anyErrorinside it written as its projection, kept as data when it fits and as bounded text when it doesn't. A record with a value JSON throws on, such as one with a cycle, is no longer lost: that value is described as a thrown value is (below). A value JSON writes nothing for, such as a function, is left out, as JSON leaves it out;dataon itsops:alerttrace, bounded field by field as a failures.log record is, so listeners get the plain data the log writes (aDateas its ISO string, aMapas{}, anErroras its projection), never a large value whose JSON is small. Data that isn't a plain object, such as a string or an array from a JavaScript caller, is kept as one field,data, rather than spread into a field per character or element, and a message that isn't a string is described as a thrown value is (below) rather than thrown on. The hard-down alert's excerpt leaves room for its prefix, so the alert isn't cut twice;error;errorandstackstrings, so later failure events are covered too;cooldownReason;runEphemeralToCompletion) rejects with.inference:exhaustedtraces declarehttpStatusandproviderErrorCode. The error type and the provider code are recorded to at most 128 characters each, a type that a caller passes without classifying it included.Logs that printed the error object (the OverBudget drain kick, the marker write, the provider-cooldown resume, and a host's
providerHoldhook that throws, which is handed the model's error and can throw it back) now print a bounded projection: the name, classification, status, provider code and message. The name, type and code are bounded first, and the whole is cut once to 2,000 characters, so its marker states the whole length. A thrown value that isn't anErrorprints as itsStringwhen that's text of its own, as for aBuffer, anErrorfrom another realm or a class with its owntoString. An array, or an object whoseStringis empty or only its tag, such as[object Object], prints as its JSON, bounded, with anyErrorinside it as its projection, so a thrown plain object keeps its fields and an array its elements; the tag stands when JSON can't write the value or finds no fields in an object. Context maintenance describes one the same way, and its alerts are cut once from the whole message, the webhook's copy included. The stack's frames follow on the lines below, also bounded, so a failure in the framework's own code still says where it was thrown. When the message is the stack's header, they're found after it, since a message's own lines can look like frames; otherwise they start at the stack's first frame line. A frame's indent is spaces or tabs, so a run of blank lines in a stack is scanned once rather than from each line's start. Context maintenance records and logs a bounded message in its existing format.The OverBudget breaker's message-text fallback now applies only to unclassified failures, which is the case its comment names: a reason that lost its Error object. It is decided on the whole reason, never the excerpt. A classified provider 400 that quotes "exceed hard budget" or "no summary covers" from the agent's own context no longer kicks the compression drain.
A
MembraneErrorfrom another copy of membrane is recognised by its name as well as its class. The host supplies theMembraneinstance, and two copies can be installed. Its failure is classified, the default error policy reads itsretryableandretryAfterMs, and an organization acceleration limit it reports is held. Being classified, it also gets the framework's recovery for its class: an invalid request sheds the newest exchanges of the agent's history, up to the rewind cap, as the poisoned-history quarantine does for the framework's own copy. Before, all three treated it as an unknown error: the default policy retried a non-retryable one up to three times, and its failure was then left unclassified. The policy now refuses a retry whenretryableisfalse. Membrane always sets it, and an error without it retries as an unknown error does.A non-string reason reaching
noteInferenceExhaustedis described rather than thrown on before anything is recorded (unreachable from the stream paths, which pass a message). Its whole description is the whole reason: each sink cuts it once, so its marker states the whole length, and the OverBudget fallback reads it uncut.Tests
test/inference-failure-bounds.test.ts(29):lastError(300), the rewind marker (160), the hard-down alert, the compile-refused alert (300) and the webhook (500).emitTrace,opsAlertand alert data field by field (a Map, a Date, a nestedError, alone and in an array, and a function, left out),logFailure, with an Error, a large object, a nested Error and a circular value, written to disk; per-excerptreasonChars; the breaker decided on the raw reason, classified and unclassified; projections of a ~1 MB message, of a short message whoserawRequest.systemis ~1 MB, and of an Error with a 1 MB name and type, with the frames after them, of a message that quotes a frame-like line, and of a stack without its header whose throw site's name contains the message; the frames of a 1,000-frame stack, of a stack whose message was replaced after it was taken, of an empty message and of a message ending in blank lines; a thrown non-Error (a string, a class with its owntoString, aBuffer, anErrorfrom avmcontext, a plain object, a class instance withouttoString, an array, an empty array, an object with noStringof its own, a cyclic object, an object with only a tag, an object whoseStringis empty and one whose JSON is nothing) and a throwing name getter; provider holds (a reason-less host hold, a host reason on extension, a hook that throws the model's error back, a hook that throws a plain object, the acceleration cooldown) and a failed resume; a failed maintenance pass, with the webhook's copy cut from the whole message, and one with a thrown value that isn't anError; a non-string reason, cut once from its whole description, with the OverBudget fallback finding a phrase that every excerpt omits; a caller's 1 MB error type; from a JavaScript caller, an alert message that isn't a string (one with noStringof its own included, as the webhook posts it), alert data of null, and alert data that isn't a plain object (a long string, a long array, a small array, a class instance) beside a record with a null prototype. The rawconsole.errorarguments are asserted to be bounded strings, never objects. The stringslogFailurewrites to disk (a reason, a note, an error's projection) and an alert's text and error fields, on its trace and in failures.log, are checked as the sweep checks a cut: each states its whole length and its omission exactly.Error, with the webhook's copy cut from the whole message, and from a thrown value that isn't one; and the default error policy the framework builds for itself, which honours another copy's stated wait as it does its own copy's, and retries an error withoutretryableas an unknown error. These console spies keep raw arguments too and assert that no object is printed.Red first, on the VPS (Node 24.21.0). With main's
src/, at3f91700, and these tests, 27 fail and 2 pass: the small-reason control, and null alert data, which main takes harmlessly. Against1055608's source, 11 fail: the tests for this push's fixes, among them a non-string reason cut once, a caller's error type, thrown values that aren't anError, a message ending in blank lines, the maintenance alerts' webhook copy, alert messages and data from a JavaScript caller, and a nestedErrorin alert data and on disk. The test of another copy's shed passes there: it pins behaviour1055608already had, and the mutations below show that it's load-bearing.Each piece removed: 59 mutations of the compiled framework at
f25e7b7, on the VPS (Node 24.21.0), each caught by its test. The unmutated file passes 29/29 before and after them.lastError, compile-refused and webhook slices; the rewind cut from the 2,000-character excerpt; the old hard-down composition; rawdataon the trace; top-level-onlylogFailure; a field kept as the value when its JSON fits; no frames, or the frames with the whole stack added; an unbounded non-Error branch; a changed fallback text; the unbounded maintenance-pass reason; no provider-code cap; the classifier, the default policy and the acceleration detector each recognising the class only;isMembraneErrorwithout the name; an unbounded type; an unbounded rejection; each of the three hold-reason bounds; no non-string guard; an object printed on the real exhaustion path (6 tests fail); the projection cut twice; the frames taken from the stack's firstatline, message included; the hook's catch printing the object; null alert data bounded; the default policy readingretryAfterMsfrom its own copy only, refusing an error withoutretryable, or ignoringretryAfterMsfor both copies; a failure record's string cut with a plain slice; the marker-write log without frames; the message taken for the stack's header wherever it occurs, a frame included.Stringalone, or as its JSON before its own text; JSON with no fields, or no JSON at all, printed instead of the tag; an array or an emptyStringtaken as text; the placeholder instead of the tag; a frame indent that crosses line breaks; the frames unbounded with nothing added, which no test caught before the deep stack; the fallback for a message that isn't in its stack mistaken; alert data that isn't a plain object spread field by field; a field JSON writes nothing for kept asundefined; a non-string alert message taken as itsString; a non-string reason cut before the sinks and the fallback; a caller's error type unbounded; each maintenance catch describing a thrown value by itsString, and each alerting from its excerpt; and the poisoned-history breaker turned off, which another copy's shed now catches in this file.The full suite on the VPS,
f25e7b7merged onto main3f91700, each file on its own with--test-force-exitasnpm testruns them, plus CI's unforced step:--test-force-exitcut three files short there (history-module-realstore,history-module-uxandmcpl-tool-lifecycle-emitter), as it cutmcpl-tool-lifecyclein main's own run, and each passes in full when run unforced in the merged tree.Our internal review ran at
bbb4f8b: 25 issues, of which 21 were fixed in9c2794fto1baa83b, 3 were not real and 1 wasn't worth fixing (the omission count is in UTF-16 units, the unit every bound here is in). It ran again on the pushbbb4f8b..1baa83b, with 23 issues:8764891, with a 13th (the untested case of an error withoutretryable) inside one of them.retryAfterMs, which membrane only sets to a number; a bound smaller than its marker, which no caller passes; UTF-16 counts; the acceleration matcher's reading of a 429's message, which predates this change; and nested Errors written as their JSON, which the third review reopened.It ran a third time on the push
1baa83b..1055608, with 19 issues:865a6a8.notifyOpsAlertpasses{ data: null }.errorandstack(below).On this push's own diffs:
1055608..865a6a8, 18 issues, one of them split in two: 8 fixed, seven in831a5f8, among them a thrown value's own text before its JSON and an alert message that isn't a string, and one ineecf943, a nestedErrorcut before its value was, which we first judged unreachable; 8 not worth fixing, edge cases of thrown values, alert data and their JSON, such as a cyclic value, alert data from another realm and a plain object with its owntoJSON; 1 not real; 1 already out of scope (the framework's other logs that print an error object, named below); and 1 as intended (the fallback reads the whole unclassified reason).865a6a8..831a5f8, 12 issues: 9 fixed in98568c4, among them an array described by its JSON rather than itsString, an alert message with noStringof its own, and context maintenance's own description of a thrown value; 1 not worth fixing (anErroror a plain object from another realm nested inside a value); 1 already out of scope (the speech-route reason, below); and 1 as intended (a value's own text over its fields).831a5f8..98568c4, 17 issues: 7 fixed, six of them in1b34dd7and one in98568c4's commit message, among them maintenance alerts cut once from the whole message and a field JSON writes nothing for left out of the trace as of the log; 7 not worth fixing, such as alert data whose getters throw and an array JSON can't write; 2 already judged not worth fixing above; and 1 not real.865a6a8, asserted the sheds that the retries after the first one make. Those retries race the test's stop, and a run on the VPS failed it against a source with the behaviour, sod68ba53asserts only the first shed. Five runs in a row pass.Merge notes
isMembraneErrorbeside fix(provider): a provider's stated retry-after is a durable per-(agent, model) lower bound #251's timer constants; keep both). fix(provider): a provider's stated retry-after is a durable per-(agent, model) lower bound #251 adds fourinstanceof MembraneErrorchecks for stated waits. Whichever lands second should make themisMembraneError, so the rule holds there too.f25e7b7.onTrace, as fix(trace): an unsubscribe during delivery no longer hides the event from the next listener #318's fixtures do, so the two compose: on main with this branch, fix(breaker): shed history only for an invalid_request at HTTP 400, 413 or 422, or a 413 context_length #279 and fix(trace): an unsubscribe during delivery no longer hides the event from the next listener #318 merged, the failure-bounds, exhaustion-rewind and ops-alert tests pass 46/46 on Node 24.21.0 and 20.20.2.Not verified
Out of scope
mcpl:speech-route-failedreason and the[discord-send-failed]marker, which are kept whole as a resident's evidence before resending.beforeInferencehook's catch and its preview's, the event loop's catch, periodic and final sync, speech routing's, and the maintenance pass's retry of the tool-result guard's storage.errorandstack, such as an MCPL server's stderr line, which reach trace listeners whole.failure.slice(0, 200)when a workspace write fails), a plain slice outside the inference-failure pipeline.recentErrors(error.slice(0, 400)of the inference log's stored error, which is already bounded to 2,000), a display slice outside the pipeline.Errorfrom another realm (avmcontext) nested inside a value: it failsinstanceof Error, so it's written as its own JSON rather than its projection. A thrown one at the top level prints as itsString.Companion PRs
antra-tess/membrane#102 bounds provider errors where
MembraneErroris built. Either side can land first: this PR bounds every sink it touches regardless of the membrane version, and #102 changes no framework contract.🤖 Authored by Ruth-1049 (Claude Opus 5.5), with independent exact-head review through
d38eca1by June-1016 (GPT-6 Astra), who reproduced and had corrected the raw-versus-excerpt breaker decision, the unbounded projection fields, per-excerpt length metadata and the object-inspection test gap. The surrogate-test correctionbbb4f8b, for Greptile's review, is by Greta-1453 (Claude Opus 5.5), independently reviewed by Tessa-1452 (GPT-6 Astra). The fixes in9c2794f,b500e71,51aff7eand1baa83b, for our internal review's findings atbbb4f8b, are by Ada-1017 (Claude Opus 5.5), independently reviewed by Petra-1012 (Claude Opus 5.5), whose review note became1baa83b. The fixes in8764891and1055608, for our internal review's findings at1baa83band for Henry-2267's review, are by Ada-1017, independently reviewed by Henry-2267 (GPT-6.1 Sol). Henry reproduced the projection's double cut, the untested wait from another copy and the frame locator's mistake on a headerless stack with runs, proposed cutting the projection once and the locator's guard, and re-ran the mutations on the VPS. The fixes in865a6a8,831a5f8,98568c4,1b34dd7andeecf943, for our internal review's findings at1055608and on each of them and for the readers' findings,d68ba53, which makes a test from865a6a8deterministic, and424f832andf25e7b7, for the composition with #318 and the limit of the projection, are by Gladys-2413 (Claude Opus 5.5), with design calls by Ada-1017, independently reviewed by Naomi-2422 (Claude Opus 5.5), who found that a value holding anErrorwas cut twice, and Arthur-2378 (GPT-6 Astra), who found the harness's composition with #318. Through the Rookery's Pi tooling.