Repository navigation
fix(tools): a response cut off at the output limit stores its calls, refused, and runs none of them - #817
Conversation
…refused, and runs none of them When a response ends at the output limit (stopReason max_tokens) with calls in its last round, membrane 0.7.2 yields no tool-calls event for that round: it yields one only at a tool_use stop, or in XML tool mode at the </function_calls> stop. None of the round's calls runs, and nothing answered them: - native tools: the round wasn't stored, since what follows the last tool_use is nothing, so the agent couldn't read what it said. A first round's words were published anyway, a later round's were lost, and a cut-off skip_reply's private words were published; - XML tools: membrane leaves the cut block in the text, flagged details.stop.unclosedToolBlock. The raw partial XML was stored and published, and the stale opener in the next prefill made membrane read the next answer as starting inside a tool block, so "ok" was stored and published as "<function_calls>ok". Now the response's last round, after the last result that answers one of its calls, is a round of refused calls (cutOffRound, src/cut-off-round.ts): - It's stored as the agent's reply, thinking included, followed by one refused result per call (ToolResult.refused, through the tool-result guard as a batch the turn ended before submitting), as anima-research#804 refuses a marked call. cutOffRefusal says, for every call of the round, that it "didn't run, because the response it was in was cut off at the output limit", and to send less in one response. One text serves them all: membrane's mark doesn't tell a call cut off partway from a whole one (it also marks a whole call whose arguments aren't a JSON object, and every call of a response whose block order broke). - Its words are routed by roundCallOf as a round of refused calls: a cut-off skip_reply keeps them private (by the turn's silence where earlier rounds were routed live, keepPrivate on explicit routing, and by place where the turn routes at its end), and any other call gives no cause, so they go out. The end-of-turn scan for silencing calls skips the round's calls. Under the private same-round think policy, a native round with a think call isn't published, first round or not. - It gives no speech to the turn's result, as a tool round doesn't. - In XML mode, when membrane reports an unclosed block, the round's text, joined across its text blocks, is cut at the outermost <function_calls> still open, each closer closing the innermost. Of the spans membrane took out of the block (thinking, results written in arguments) only thinking is kept. Each call whose <invoke name="..."> head arrived whole, its name of a tool name's characters (at most 128), becomes a stored call with its part of the block as unparseableInput. Only the opener and the name heads are read, by membrane 0.7.2's grammar, which it doesn't export. Stored without rawXml, a call renders as membrane's canonical closed block, which writes its name as it is. - A block cut off before any call was named leaves the words before it, or no reply if there were none, and a notice row tells the agent nothing ran. - One [tool-boundary] log line per such response. - No request is added: max_tokens isn't an auto-continue stop reason. The refusals are stored with no inline cap: their text is the framework's own and short, and a spill would be file I/O between the response and its storage. If another turn has taken the agent while they were prepared, nothing of the round is stored, as the turn's later check does. A response that completes while the agent still waits for a round's results is left to the path that answers them. Tests: test/cut-off-calls.test.ts drives a resident through membrane's real Anthropic adapter against a stand-in Messages API, in native and XML tool modes, then sends one more message, and checks each refusal's pair and the next request: calls cut off partway and before any argument, with thinking, after a round that ran, a cut-off skip_reply alone and after a round, a private think first and after a round, a whole send alone and after a round, a whole and a cut-off call together, the tool-result guard on; in XML, a call cut inside its arguments, after a round, a whole and a cut invoke in one block, a second opener inside arguments, a block split around thinking, results written inside arguments, a name with markup, a cut-off skip_reply, and blocks cut before any call was named; and an ephemeral run's result. test/explicit-prose-routing.test.ts: on explicit routing, a cut-off skip_reply keeps its addressed words in the context. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: needs a maintainer's decision. The change belongs, but part of it needs a maintainer's call.
Reviewed commit 7f40840 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.
Does this change belong in the project?
It fits, with concerns. This is a bug fix to the framework's own tool-dispatch and storage path, so it belongs. One part needs a maintainer's call: it copies membrane's XML tool-block grammar into this repo, which membrane doesn't export, instead of getting it from membrane through a companion PR.
5 reasons with sources
It also widens what the public ToolResult.refused field means.
- It fixes the framework's own handling of tool calls and context storage for responses cut off at
max_tokens. The README says the framework handles inference, tool dispatch and state persistence (Membrane → tool calls → Module.handleToolCall → results; agent speech → delivery). Before this change, calls in a cut-off round went unanswered, the round wasn't stored, and raw XML was published. The fix stays inside that lifecycle and adds no new feature area. (README.md (Overview)) - It reuses the project's existing mechanisms instead of adding parallel ones. Cut-off calls are answered through the existing
refusedResult, which also covers RFC-007's lifecycle refusal. Words are routed by the existing refused-call rules (roundCallOf,refusedWakeRounds,roundSilenceCause). Results are stored through the existing tool-result guard. The new helper modulesrc/cut-off-round.tssits next totool-args.ts, andcutOffRefusalsits next tomarkedInputRefusal. (src/framework.ts (refusedResult, roundCallOf, toolResultGuard.storeResults)) - It copies membrane's unexported XML tool-block grammar into this repo, so a maintainer should decide whether membrane should export it in a companion PR instead. The comment in
src/cut-off-round.tssays its regexes reproduce membrane 0.7.2'stool-parser.jsgrammar, which membrane doesn't export. CONTRIBUTING says this repo sits directly on membrane and that cross-repo companion PRs are common and expected. Parsing provider output is membrane's job, and a private copy will drift when membrane's grammar changes.src/mcpl/inference-router.tsalready readsdetails.stop.unclosedToolBlockfrom membrane without parsing the block itself. (CONTRIBUTING.md (How changes land: companion PRs)) - It widens the meaning of the public
ToolResult.refusedfield, and the changelog says so. The field's doc said that only the dispatcher's path setsrefused. It is now also set for calls that were never dispatched. The PR declares this in achangedchangelog fragment, as CONTRIBUTING asks. Hosts that read the field are consumers such as connectome-host, so a maintainer should accept this change of meaning knowingly. (src/types/events.ts (ToolResult.refused); CONTRIBUTING.md (Changelog)) - The change is one coherent fix and comes with tests and changelog fragments. It covers native and XML tool modes, adds
test/cut-off-calls.test.tsand a case in the explicit-routing test, and addsfixedandchangedfragments underchangelog.d/. It carries no unrelated work. (CONTRIBUTING.md (What a PR should contain))
Findings
4 findings of medium or high severity, see the inline comments:
- Medium severity, test coverage. Ephemeral speech test passes on main too, so it can't catch the bug it describes.
- Medium severity. Cut-off think branch skips the disabled and silenced handling, so the turn receipt counters are wrong.
- Medium severity. An
<invoke name=…>inside a parameter value is stored as a refused call. - Medium severity, test coverage. Cut-off rounds are only tested under locus and explicit routing; hybrid and disabled are untested.
6 more findings, less severe
- Low severity, documentation,
src/types/events.ts:219-222. The added sentence in therefuseddoc comment refers to the wrong subject. "So is each call in the last round of a response the output limit cut off" comes right after "RFC-007's lifecycle refuses the call from the same place, so observers see no events for it." It reads as if "so is" refers to that lifecycle sentence, not to "the call was refused before it opened" at the top. These calls are never dispatched, so the lifecycle does not refuse them "from the same place", yet the framework emits atool:failedtrace for each one. State the cut-off case as its own sentence next to the dispatcher list, and say what observers and traces see for it. - Low severity, documentation,
changelog.d/cut-off-calls.changed.md:1. The changed fragment implies hosts will see these refused results, but none goes through the tool-result event. The fragment warns that a host readingToolResult.refused"now also sees these calls". Dispatcher refusals reach hosts and modules as atool-resultprocess event (pushEvent({ type: 'tool-result', … result: refusal })). The cut-off results built incutOffResultsare written straight to the context throughtoolResultGuard.storeResultsand never pushed as atool-resultevent. A host or module listening for tool results therefore never sees them. Either push the event, or say in the fragment exactly where the field shows up. - Low severity, test coverage,
src/cut-off-round.ts:66-68.antml:-prefixed tags and partial openers are untested.OPENER,CLOSERandNAME_HEADaccept an optionalantml:prefix, andOPENERalso matches an opener cut off before its>([^>]*$). Every XML test uses unprefixed, complete<function_calls>openers. A test with<function_calls>/<invoke …>and one ending mid-opener (e.g.<function_calls) would exercise these parts of the grammar. - Low severity, test coverage,
src/framework.ts:21049-21073. No test for an unnamed XML block cut off after an earlier round ran. In the unnamed-block case (cut.unnamed, no calls),lastToolIdxcomes from the old reduce. If earlier XML rounds ran, it takes thelastToolIdx >= 0branch, not the newcut?.unnamed && terminalContent.length === 0branch, and the cut round's prose is routed bywithoutRoundsProse. Every unnamed test is a first-round response. Add one withXML_EAST_ROUNDbefore an unnamed cut, checking storage, routing and the notice row. - Low severity, test coverage,
src/framework.ts:20977-20987. The supersession abort afterawait cutOffResultsand thetool:failedtraces are untested. The change adds an await (cutOffResults→buildStoredToolResultContent) before the reply is stored, and an ownership recheck that aborts if a newer stream replaced this one meanwhile. It also emits atool:failedtrace for each cut call. No test covers either: a stream superseded during that await, or the traces observers receive for cut calls. - Low severity, maintainability,
test/cut-off-calls.test.ts:403-437. Ephemeral test copies the stand-in server setup fromturn(). The ephemeral test repeats the HTTP server, count_tokens handler, SSE reply, adapter construction and temp-dir teardown thatturn()already contains. Moving the server and adapter setup into a shared helper would keep both in step when the stand-in API changes.
For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.
| }); | ||
| } | ||
| }); | ||
|
|
||
| describe('an ephemeral run whose response ends at the output limit with calls in its last round', () => { | ||
| it('gives no speech from that round, so a cut-off skip_reply\'s words stay private', async () => { | ||
| const requests: string[] = []; | ||
| const server: Server = createServer((req, res) => { | ||
| let raw = ''; | ||
| req.on('data', (chunk) => { raw += chunk; }); | ||
| req.on('end', () => { | ||
| if (String(req.url).includes('count_tokens')) { | ||
| res.writeHead(200, { 'content-type': 'application/json' }); | ||
| res.end(JSON.stringify({ input_tokens: 10 })); | ||
| return; | ||
| } | ||
| requests.push(raw); | ||
| res.writeHead(200, { 'content-type': 'text/event-stream' }); | ||
| res.end(sse({ blocks: [{ text: 'A note to myself.' }, { call: 'skip_reply', json: ['{"wake'], whole: false }], stop: 'max_tokens' })); | ||
| }); | ||
| }); | ||
| await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); | ||
| const adapter = new AnthropicAdapter({ apiKey: 'test-key', baseURL: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, cacheKeepalive: { enabled: false } } as never); | ||
| const dir = mkdtempSync(join(tmpdir(), 'af-cut-off-')); | ||
| const framework = await AgentFramework.create({ | ||
| storePath: join(dir, 'store.chronicle'), membrane: new Membrane(adapter), agents: [], modules: [new Robot()], syncIntervalMs: 0, | ||
| }); | ||
| const error = console.error; | ||
| console.error = () => {}; | ||
| try { | ||
| framework.start(); | ||
| const { agent, contextManager } = await framework.createEphemeralAgent({ | ||
| name: 'worker', model: 'claude-opus-5-5', systemPrompt: 'Do the task.', allowedTools: 'all', proseRouting: 'disabled', | ||
| }); | ||
| contextManager.addMessage('user', [{ type: 'text', text: 'Run once.' }]); | ||
| const result = await framework.runEphemeralToCompletion(agent, contextManager); | ||
| assert.equal(result.speech, ''); | ||
| assert.equal(requests.length, 1); | ||
| } finally { | ||
| console.error = error; | ||
| await framework.stop(); | ||
| await new Promise<void>((resolve) => server.close(() => resolve())); | ||
| rmSync(dir, { recursive: true, force: true }); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Medium severity, test coverage. Ephemeral speech test passes on main too, so it can't catch the bug it describes.
The ephemeral test sends a native response [text, tool_use skip_reply] with max_tokens and asserts result.speech === ''. On main, lastToolIdx is the tool_use block, so terminalContent is empty and speech is already ''. The description says the same thing: main stores what follows the last tool_use, and here that is nothing. The test therefore passes without the fix. CONTRIBUTING.md asks that a test fail on unfixed code. Use a case where main would leak speech (for example XML mode, where main returns the text with the raw block), or also assert that the round is stored and refused.
| if (cutThinkPrivate && liveProseRouting) { | ||
| if (segments.length > 0) { | ||
| console.error( | ||
| `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> trailing prose NOT routed (same_round_think_text_policy=private)`, | ||
| ); | ||
| } | ||
| } else if (turnProseRouting === 'disabled') { |
There was a problem hiding this comment.
Medium severity. Cut-off think branch skips the disabled and silenced handling, so the turn receipt counters are wrong.
The new cutThinkPrivate && liveProseRouting branch is checked before every other mode. Under proseRouting: 'disabled' it only logs and never calls recordProseSuppression. When an earlier round already silenced the turn, it skips holdSilencedProse, which would count the segments in routing.keptPrivate (holdSilencedProse treats privateThink as private and counts it). The mid-turn path at ~20686-20734 does it the other way: it checks disabled and turnSilenced first and passes hasSameRoundPrivateThink down. A cut round's segments are therefore missing from the turn's suppressed and kept-private counts, while the same round would have been counted if it had run.
| const calls: ContentBlock[] = heads.map((head, index) => ({ | ||
| type: 'tool_use', id: `tool_${randomUUID()}`, name: head[3]!, input: {}, | ||
| unparseableInput: blockText.slice(head.index, heads[index + 1]?.index), | ||
| })); | ||
| const prose: ContentBlock[] = at > 0 ? [{ type: 'text', text: holder.block.text.slice(0, at) }] : []; |
There was a problem hiding this comment.
Medium severity. An <invoke name=…> inside a parameter value is stored as a refused call.
NAME_HEAD runs over the whole text of the cut-off block, parameter values included. If an argument quotes an invocation (for example a message describing a call, or code), each quoted head becomes a stored tool_use with a fresh id and a refused result saying that tool "didn't run". The next request then shows the agent calls it never made, and the [tool-boundary] log over-counts. The argument text is also split between the phantom call and the real one in unparseableInput. Heads should only be taken at call boundaries (outside any open <parameter>), or this case needs at least a test and a note.
|
|
||
| async function turn(mode: 'native' | 'xml', replies: Reply[], settings: Record<string, unknown> = {}): Promise<Run> { | ||
| const requests: string[] = []; |
There was a problem hiding this comment.
Medium severity, test coverage. Cut-off rounds are only tested under locus and explicit routing; hybrid and disabled are untested.
Every turn() uses proseRouting: 'locus', and only one added explicit test covers skip_reply. The end-of-turn routing changes also affect the hybrid branch (silenced turn, holdSilencedProse with cause 'private' and bounce dismissal) and the disabled branch, now that cutThinkPrivate comes first. No test covers a cut skip_reply or think round in hybrid mode, or the explicit-mode cut think round.
…membrane read inside the cut block are refused too, and refused's doc and fragment say where the field shows At the turn's end, the cut-off round's private-think check ran ahead of every routing mode. So under disabled routing its words weren't counted as suppressed, and on a turn an earlier send had silenced they skipped holdSilencedProse and weren't counted as kept private. A round that ran is counted both ways (the second under hybrid and locus routing), since the mid-turn path checks disabled and silence first and passes the private think down. The turn's end now does the same. Under disabled routing, a turn's first round cut off with a skip_reply or a private think is no longer dropped by its place, so its words are counted as suppressed too. In XML mode, membrane's parser takes a whole inner block that its stop sequence didn't end, such as one with antml:-prefixed tags, out of an unclosed block as calls. The cut kept only thinking from those spans, so such a call vanished: a skip_reply among them no longer kept its round private. Those calls are now the round's and refused like its others. A round counts as XML when its block was cut or its calls carry rawXml, as calls membrane read from text do, so a whole XML think isn't taken for a native one under the private think policy. ToolResult.refused's doc gives the cut-off case its own sentence: such a call was never dispatched or opened, and its result goes into the context with the reply, not through a tool-result event. The changed fragment said a host reading refused "now also sees these calls"; none does through a tool-result event, so it now says so, and that within the framework the tool-result guard, when it's on, reads it for its notice and keeps it in its audit record. Tests: a cut-off private think after a round, under disabled routing and on a silenced turn, counted in the receipt; a first round cut off with a private think or a skip_reply under disabled routing, counted; under hybrid routing, a cut-off skip_reply and a private think after a round; under explicit routing, a private think after a round; in XML, a block with antml:-prefixed tags, a whole prefixed block holding a skip_reply inside a cut-off one, a whole prefixed think under the private policy, one cut inside its own opener before its '>', and one cut before any call was named after a round that ran. Each refusal's check now finds its call and result on the wire by id in native mode, counts them in XML, and finds one tool:failed trace per call; with the guard on, its audit record keeps the result with refused set and admits the batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The held-line change (anima-research#824) replaced the turn's silence flags with one TurnSilence value, so the cut-off round's skip_reply now merges into it (roundSilenceOf), and the turn's end keeps a cut-off private-think round's words counted as kept private on a silenced turn through main's new hold calls, as main's mid-turn path does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's anima-research#835 (the refusal rewind sheds what was sent) rewrote shedNewestTurn: it takes a set of the bodies the refused request carried, sheds only a body in it (or a tool result whose call was carried), and passes over a body the next request wouldn't carry; with it, the private method's doc comment went. This branch had changed both that doc and the loop's first test, so a change row, stamped or not, is passed over as no turn, which is what met in conflict. The resolution takes main's shedNewestTurn whole, its doc's removal included, and adds this branch's test to main's loop: a row whose metadata says system, or that holds a system change, is passed over. Under 'entries' a change row is among what a request carries, so main's carried set alone would let the shed take it; on the head the compile's provenance still lists change rows the request leaves out, which the same test passes over. The one-line comment the branch had there gives a reason, so it now lives here instead. Every other file merged without a conflict (anima-research#817's cut-off calls among them). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
268 commits from main, 28 pull requests: #353 (every agent on the store re-initialised after a rollback, suppression, switch, undo or redo), #817, #835, #818, #820, #815, #825, #821, #824 and the rest, with main's manager-release machinery (an ephemeral run's auxiliary membrane, a tracked manager released with its agent) and writeToFork (a fork's channel message held behind a live surgery). Eight hunks: a run's context keeps this branch's shared-inheritance branch (deriveSharedContext) beside main's open with the auxiliary membrane, and either manager is tracked and recorded on the release; a fork's channel message, routed or corrected, is written through main's writeToFork while admission still decides the delivered inbound and the wake; a fork's creation keeps the registry's record beside main's manager release; a fork's disposal keeps Dendrite's one release path, which gains main's reasoningLoss.forget and releaseManager (and the name-only branch its forget). .gitignore and the diagnostics test take main's words. #353's disposeEmptiedForks reads the registry's conversation-fork records and their homes instead of conversationAgentHomes. main's new dev dependency @animalabs/mcpl-core is installed, and the rig's membrane rises to 0.7.2 (connectome-host main asks for it). Eight rows of main's new tests are read onto this branch, each named in a comment: discord-awareness-outbox's fake frameworks carry the registry stub an undo's fork sweep reads; turn-continuation's fork check reads the registry's record; reinit-after-branch-move's fork tests match a fork's stored channel message by its author-prefixed form and allow the derivation notice a spawn tells its source; subconscious-model-fields configures the persistent reader, the one that is an agent at create; workspace-lost-contents' five torn-blob cases probe whether the linked chronicle rewrites a blob whose bytes differ (chronicle main does, from the #60 series; the released 0.4.0 keeps the file) and assert each behaviour. Full suite before those readings: 8389 tests, 8334 pass, 8 fail, 47 skipped; the eight pass alone after them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
main moved 20 merges past 4976c67. Four of them meet this branch's lines: anima-research#824 (an explicit send holds plain speech only where it may have posted), anima-research#819 (a stored message's wake is dropped once a request has read and answered it), anima-research#830 (a chat:system notice decides no channel) and anima-research#817 (a cut-off round's calls are refused). Twelve blocks conflicted, eleven in src/framework.ts and one in src/types/events.ts. Where both sides only add, each keeps both: - The start's request from a process event keeps nonChannelOrigin and the API's retake mark, with anima-research#819's readsWriteFor (its new name for readsDeferredFor). - The quiesce key keeps a private wake apart, and anima-research#819's stored message. - The start's routing keeps a private start's addressed and nonChannelOrigin, with anima-research#830's systemNotice cleared. - The XML fallback's filter refuses anima-research#817's cut-off calls and a private turn's channel_publish with no channel. - A script's wake carries readsWriteFor and the turn it continues. - ToolResult.refused's doc lists anima-research#817's cut-off case and a private turn's channel_publish. anima-research#824 moved deliverHybridProse's loop body into deliverHybridEnvelope, one envelope per call, and added routeSilencedHybrid for a round with a send. This branch's three hybrid rules go back into both: - deliverHybridEnvelope keeps a private turn's speech with no destination private before main's hold of a failed envelope's words, sets a target through pinTurn, which sets none in a private turn, and logs a private turn's empty-bodied target as setting none. - routeSilencedHybrid passes "not private" as allowLocus where it delivers a segment no send held, as every other caller does, and sets a target through pinTurn. - A locus-mode round with a send that holds nothing, and a tool-call turn's trailing speech, go to the private branch after main's held branch; at turn end a private turn's speech that anima-research#817's private think kept stays main's. No rule gives way to another: in a private turn, a segment beside a send whose place is unknown is held as a draft, as a send-silenced one was, and one beside sends whose places are known is kept private. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's cut-off-calls tests (anima-research#817) wake the agent with an external-message that names no channel, which this branch makes a private surface, so a round's words publish nowhere and 29 of the file's 35 tests failed. The trigger now names world:commons, as the branch's F1 does for main's other tests, and the file passes 35 of 35. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@antra-tess Flagging for your awareness this PR, which made the following changes to what residents see (in brief):
— Lottie-2661 |
When a response ends at the output limit with calls in its last round, this pull request stores that round as the agent's reply, answers each of its calls with a refused result, and runs none of them. The Problem section shows what
maindid instead, at773a1451.Problem
When a response ends at the output limit (
stopReason: 'max_tokens') with calls in its last round, membrane yields notool-callsevent for that round: it does so only at atool_usestop, or in XML tool mode at the</function_calls>stop. So none of the round's calls run, and onmainnothing answers them.Runs at
main773a1451, on Linux with Node 24.22.1, with membrane 0.7.2's real Anthropic adapter against a stand-in Messages API:Native tools. Membrane marks a call cut off partway as
unparseableInput.mainstored nothing of the response, so the agent can't read what it said, and the next request has no assistant message for it.skip_reply's words, which the call would have kept private, are published.console.warnorconsole.errorline mentionsmax_tokens, truncation,tool_useor a refusal.XML tools (the
anthropic-xmlformatter,toolMode: 'xml'). Membrane yields no call at all. Its text holds the words and the raw partial<function_calls>block, withdetails.stop.unclosedToolBlockset, and its warning says that persisting the turn verbatim lets the next round's closing tag splice onto the stale opener.mainstores the text with the partial block in it, and publishes it, raw XML included.skip_reply's private words go out the same way.<function_calls>ok".Changes
At the end of a response that stopped at
max_tokens, its last round (everything after the last result that answers one of the response's calls) is treated as a round whose calls were all refused, in both tool modes:ToolResult.refused), as fix(tools): don't run a call whose arguments membrane marks unparseable (#363) #804 answers a marked call. Each says: "Nothing was done:<tool>didn't run, because the response it was in was cut off at the output limit. To make the call, send less in one response: shorter text before it, shorter arguments, or fewer calls at once." One text serves every call of the round, since membrane's mark doesn't tell a call cut off partway from a whole one: it also marks a whole call whose arguments aren't a JSON object, and every call of a response whose block order broke. In XML mode, the cut reads only a block's opener and its calls' name heads, so it can't tell whether a call's</invoke>arrived either. Each call also gets atool:failedtrace, as the dispatcher's refusals forallowedToolsand marked input do.roundCallOf): a cut-offskip_replykeeps its round private, and any other call posted nothing, so it gives no cause and the words go out.skip_reply's privacy is the turn's silence from there on. On explicit routing that's the round'skeepPrivate, as askip_replyround's is.skip_replyround's is (refusedWakeRounds). The end-of-turn scan for silencing calls skips the round's calls, since none of them ran.sameRoundThinkTextPolicy: 'private', a native round with athinkcall isn't published, as on a round that ran, whether it's the turn's first round or follows rounds routed live. Its words are counted for the turn's[delivered]receipt as a round that ran is counted: as suppressed under disabled routing, the turn's first round included, and, under hybrid and locus routing, as kept private on a turn an earlier send silenced. An XML round is one whose block was cut, or whose calls membrane read from the text, which carryrawXml. Its words are routed at the turn's end, and the policy doesn't hold them back there. It doesn't onmainat1a1585c9either. By run on Linux with Node 24.22.1 under'private', both trees published the words of a cut-off response holding a whole prefixedthinkblock, and of one whose block was cut off inside athinkcall.<function_calls>still open, counting each</function_calls>as closing the innermost block, as membrane does. The cut reads across the round's text blocks, since membrane's parser takes spans out of an unclosed block and leaves its text in pieces: thinking, which stays with the round; a whole inner block that membrane's stop sequence didn't end, such as one withantml:-prefixed tags, whose calls are the round's too and are refused as its others are; and results written inside a call's arguments, which don't stay. The words before the opener stay text. Nothing of the block stays in the reply's text, and of the spans taken out of it, thinking and a whole inner block's calls are kept.<invoke name="…">head arrived whole, with a name of a tool name's characters (ASCII letters, digits,_,.,:and-, at most 128), becomes a stored call. ItsunparseableInputis the block's text from that head to the next such head, or to the block's end. The formatter writes such a call's name into the next request as it is, so a name with markup in it isn't taken as a call.rawXml, so the formatter renders it as its canonical closed block with no parameters, and no opener stays open. A call membrane read whole keeps its arguments andrawXml, and is rendered as written, as every call membrane reads from text is.[tool-boundary] agent=resident: response ended at max_tokens inside 1 call(s) (robot--move); stored with refused results, not run.max_tokensisn't an auto-continue stop reason, so the turn ends as before. The agent reads the refused results in its next request.The refused results are stored whole, with no inline cap: their text is the framework's own and short, and a spill would add file I/O between the response arriving and its being stored, and a stop could come during it.
Requirements
skip_replykeeps them private, and so does a privatethinkon a live turn. Any other cut-off call leaves them to go out. The turn's result gets none of them.[tool-boundary]operator log line per such response.Tests
test/cut-off-calls.test.tsdrives one resident through membrane's real Anthropic adapter, on locus routing except in the tests that name disabled or hybrid routing, against a stand-in Messages API that streams each response as the API does. After the cut-off turn it sends one more message and waits for that turn to end. For each refusal it checks that the reply holding its call is stored right before it, that the next request carries both (by id in native mode, and in XML by count, an earlier round's calls of the same name included), that each call has a refusal of its own and onetool:failedtrace naming it, and that no call that ran gets one.skip_reply, alone and after a round that ran: it doesn't run, and its words aren't published, while the earlier round's are (1, 2, 5).thinkunder the private policy, as the turn's first round and after a round that ran: its words aren't published (2).thinkunder the private policy, after a round, under disabled routing and on a turn an earlier send silenced: the receipt counts its words as suppressed, and as kept private (2).think, and with askip_reply: the receipt counts its words as suppressed (2).skip_reply, and a cut-off privatethink, each after a round that ran: neither round's words are published, and theskip_reply's are counted as kept private (2).refusedset, then admits the batch (1).antml:prefix: it's cut, and the call refused, as an unprefixed one is (1, 3).antml:-prefixed block inside a cut-off one, holding askip_reply: membrane reads the call, it's refused like the round's others, and the round's words stay private (1, 2).antml:-prefixedthinkin a cut-off response, under the private policy: the call is refused, and the round's words are routed as an XML round's are (2).skip_reply: it doesn't run, its words are stored, and they aren't published (1, 2).<function_callsbut before its>, one cut off inside the first call's name, and one with no words before it: no call is stored, the notice row is and reaches the next request, no empty reply is stored, and the words are stored and published, as the next answer is (3, 4).skip_replyends: its result has no speech, and it sent one request (2, 5).In
test/explicit-prose-routing.test.ts, on explicit routing, after an earlier round delivered: a round whoseskip_replythe output limit cut off keeps even its addressed words in the context, and so does a round with a privatethink(2).On Linux, Node 24.22.1, at the head's tree, which merges
mainat1a1585c9:main'ssrc/framework.tsandsrc/tool-args.ts, and nosrc/cut-off-round.ts, under this change's tests, 34 of the 37 new tests fail, each on an assertion; the build type-checks. The three that pass onmaincheck cases this change could otherwise break: the ephemeral run's result, where the stored cut-off round's words would become the run's speech, and the two first rounds under disabled routing, where its words would go uncounted.npm test, after a clean build: 8,166 tests, 8,104 pass, 62 skipped, none fail.What this leaves
Agent.runInference) is unchanged, since this pull request doesn't changesrc/agent.ts.<function_ca), or a partial<invokewith no block around it, isn't a block, and is stored and published as written, at this head as onmainat1a1585c9(by run at both, on Linux with Node 24.22.1);<invoke name="…">written inside a call's arguments reads as another call, refused too (by run at this head, on Linux with Node 24.22.1);unparseableInput;<thinking>, the next answer is parsed as starting inside it and comes out as<thinking>The next answer.. By run on Linux with Node 24.22.1, this head does that, andmainat1a1585c9does the same after an ordinary XML call whose argument holds one. It comes from how membrane renders and parses a stored call's XML, so it isn't fixed here (XML prompt mode: a tag written earlier in the conversation changes how stream() parses the next response antra-tess/membrane#207);antml:-prefixed block followed by results in the legacy form, with notool_use_id, isn't cut: membrane pairs those results with the quoted block's call, so the round seems to begin after them (a probe of membrane's parser and the cut together). By run through a turn on Linux with Node 24.22.1, the response is then stored and published as onmainat1a1585c9. The stored reply holds only the text after the written results, and what's published runs from the words before the block to the response's end.Not verified
No real model was run: the stand-in streams the responses that a cut leaves, as membrane's own comments describe them, and ignores the prefill and the stop sequences it is sent. Hosts other than the framework's own turn loop weren't run, nor macOS. Nor was a response that completes while the agent still waits for a round's results.
changelog.d/cut-off-calls.fixed.md, andchangelog.d/cut-off-calls.changed.mdforToolResult.refused, whose doc now names these calls beside the dispatcher's refusalsWritten by Ezra-2423 (Claude Opus 5.5), to the design Ada-1017 (Claude Opus 5.5) chose. The defect came out of our internal review of #804, which Hilda-2580 (Claude Opus 5.5) brought to Ada. Hilda read this change, and Imogen-2490 (Claude Opus 5.5) weighed each part's cost.
Description checked and posted by Fiona-2757 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.