Skip to content

fix(tools): a response cut off at the output limit stores its calls, refused, and runs none of them - #817

Merged
nissa-seru merged 3 commits into
anima-research:mainfrom
nissa-seru:ezra/f488-cut-off-calls
Oct 26, 2026
Merged

nissa-seru merged 3 commits into
anima-research:mainfrom
nissa-seru:ezra/f488-cut-off-calls

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

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 main did instead, at 773a1451.

Problem

When a response ends at the output limit (stopReason: 'max_tokens') with calls in its last round, membrane yields no tool-calls event for that round: it does so only at a tool_use stop, or in XML tool mode at the </function_calls> stop. So none of the round's calls run, and on main nothing answers them.

Runs at main 773a1451, 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.

  • The round isn't stored. In these runs main stored nothing of the response, so the agent can't read what it said, and the next request has no assistant message for it.
  • A first round's words are published anyway. A later round's words are neither stored nor published.
  • A cut-off skip_reply's words, which the call would have kept private, are published.
  • A whole call that came in the same cut-off response doesn't run either, and isn't stored.
  • No console.warn or console.error line mentions max_tokens, truncation, tool_use or a refusal.

XML tools (the anthropic-xml formatter, toolMode: 'xml'). Membrane yields no call at all. Its text holds the words and the raw partial <function_calls> block, with details.stop.unclosedToolBlock set, and its warning says that persisting the turn verbatim lets the next round's closing tag splice onto the stale opener.

  • main stores the text with the partial block in it, and publishes it, raw XML included.
  • A cut-off skip_reply's private words go out the same way.
  • The stored opener is still open in the next request's prefill, so membrane reads the next response as starting inside a tool block. The next plain answer, "ok", was stored and published as "<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:

  • The round is stored as the agent's reply, with its thinking, text and calls, followed by one refused result for each call (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 a tool:failed trace, as the dispatcher's refusals for allowedTools and marked input do.
  • The round's words are routed by fix(tools): don't run a call whose arguments membrane marks unparseable (#363) #804's rule for refused calls (roundCallOf): a cut-off skip_reply keeps its round private, and any other call posted nothing, so it gives no cause and the words go out.
    • Where the turn's earlier rounds were routed live, the round's own words are the turn's last, so a skip_reply's privacy is the turn's silence from there on. On explicit routing that's the round's keepPrivate, as a skip_reply round's is.
    • Where the turn is routed at its end, privacy is by the round's place, as a refused skip_reply round's is (refusedWakeRounds). The end-of-turn scan for silencing calls skips the round's calls, since none of them ran.
    • Under sameRoundThinkTextPolicy: 'private', a native round with a think call 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 carry rawXml. Its words are routed at the turn's end, and the policy doesn't hold them back there. It doesn't on main at 1a1585c9 either. By run on Linux with Node 24.22.1 under 'private', both trees published the words of a cut-off response holding a whole prefixed think block, and of one whose block was cut off inside a think call.
  • The round gives no speech to the turn's result, as a tool round doesn't, so an ephemeral run's result holds none of it.
  • In XML mode, when membrane reports an unclosed tool block:
    • The round's text is cut at the block's opener: the outermost <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 with antml:-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.
    • In the rest of the block's text, each call whose <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. Its unparseableInput is 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.
    • Only the opener and the name heads are read, by membrane 0.7.2's grammar for them, which membrane doesn't export. A call made this way carries no 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 and rawXml, and is rendered as written, as every call membrane reads from text is.
  • When an XML block was cut off before any call in it was named, the words before it are stored with the round's thinking, and the words are routed. No reply is stored if there was neither. One notice row tells the agent: "[tool-boundary] Your response was cut off at the output limit inside a tool-call block, before any call in it was named, so nothing ran. To make a call, send less in one response."
  • One log line per such response, for example [tool-boundary] agent=resident: response ended at max_tokens inside 1 call(s) (robot--move); stored with refused results, not run.
  • No new request. max_tokens isn'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

  1. When a response ends at the output limit with calls in its last round, none of those calls runs. The round is stored as the agent's reply, its thinking with it, and each call is answered by a refused result that says the output limit stopped it and advises sending less in one response. The next request carries the pair.
  2. The round's words are routed as a round of refused calls is (fix(tools): don't run a call whose arguments membrane marks unparseable (#363) #804). A cut-off skip_reply keeps them private, and so does a private think on a live turn. Any other cut-off call leaves them to go out. The turn's result gets none of them.
  3. In XML mode no text of the reply, and nothing published, holds the cut-off block. Each named call keeps its part of the block as its marked arguments, and the next answer is stored and published as written. A block cut off before any call was named stores and publishes the words before it, and tells the agent nothing ran.
  4. One [tool-boundary] operator log line per such response.
  5. No request is added: the turn ends after the cut-off response.

Tests

test/cut-off-calls.test.ts drives 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 one tool:failed trace naming it, and that no call that ran gets one.

  • Native: a call cut off partway through its arguments, and one cut off before any: nothing runs, the words and the next answer are published, the cut-off turn sent one request, and one line is logged (requirements 1, 2, 4, 5).
  • Native: a round with thinking: the reply keeps thinking, text and call in that order, the thinking's text and signature as they came (1).
  • Native: a whole round, then a cut-off one: the first call runs once, and both rounds' words are published (1, 2, 5).
  • Native: a cut-off 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).
  • Native: a cut-off think under the private policy, as the turn's first round and after a round that ran: its words aren't published (2).
  • Native: a cut-off think under 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).
  • Native, under disabled routing: a turn's first round cut off with a private think, and with a skip_reply: the receipt counts its words as suppressed (2).
  • Native, under hybrid routing: a cut-off skip_reply, and a cut-off private think, each after a round that ran: neither round's words are published, and the skip_reply's are counted as kept private (2).
  • Native: a whole send in a cut-off response, alone and after a round that ran: it doesn't run, its refusal says the response was cut off, and the round's words go out, since the send posted nothing (1, 2).
  • Native: a whole call and a cut-off call in one cut-off response: neither runs, and each gets its own refusal (1, 5).
  • Native, with the tool-result guard on: the next request carries the refusal, not the guard's withheld notice, and the guard's audit record keeps the result with refused set, then admits the batch (1).
  • XML: a call cut off inside its arguments: nothing runs, no reply text and nothing published holds the block, the next answer is stored as written, and one line is logged (1, 3, 4, 5).
  • XML: a whole round, then a cut-off one: the first call runs once, and both rounds' words and the next answer are published clean (1, 3, 5).
  • XML: a whole call and a cut-off call in one block: neither runs, each gets its own refusal, and each keeps its own part of the block as its marked arguments (1, 3).
  • XML: a block with a second opener inside its arguments, one that membrane split around thinking, and one with results written inside a call's arguments: the cut is at the block's own opener, no reply text holds the block, and the call is refused (1, 3).
  • XML: a call whose name has markup in it: no call is stored, the notice row is, and the next answer is stored as written (3).
  • XML: a block whose tags carry the antml: prefix: it's cut, and the call refused, as an unprefixed one is (1, 3).
  • XML: a whole antml:-prefixed block inside a cut-off one, holding a skip_reply: membrane reads the call, it's refused like the round's others, and the round's words stay private (1, 2).
  • XML: a whole antml:-prefixed think in 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).
  • XML: a block cut off before any call was named, after a round that ran: only the call that ran is stored, the words are stored and published, and the notice row is stored (3).
  • XML: a cut-off skip_reply: it doesn't run, its words are stored, and they aren't published (1, 2).
  • XML: a block cut off right after it opened, one cut off after <function_calls but 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).
  • An ephemeral run whose response a cut-off skip_reply ends: 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 whose skip_reply the output limit cut off keeps even its addressed words in the context, and so does a round with a private think (2).

On Linux, Node 24.22.1, at the head's tree, which merges main at 1a1585c9:

  • Red-first: with main's src/framework.ts and src/tool-args.ts, and no src/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 on main check 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.
  • The two test files pass 155 of 155.
  • npm test, after a clean build: 8,166 tests, 8,104 pass, 62 skipped, none fail.

What this leaves

  • A turn's text before a cut-off call is stored as a tool round's, so modules receive it as the turn's thoughts rather than its speech, as for any round with a call.
  • The refused results are prepared before anything of the round is stored. What's stored if another turn takes the agent between the two hasn't been tested or run.
  • The direct Agent API (Agent.runInference) is unchanged, since this pull request doesn't change src/agent.ts.
  • In XML mode, membrane's grammar is all the cut reads, so:
    • a block cut off before its opening tag's name is complete (<function_ca), or a partial <invoke with no block around it, isn't a block, and is stored and published as written, at this head as on main at 1a1585c9 (by run at both, on Linux with Node 24.22.1);
    • an opener written in the words before the real one is taken as the block's opener, so the words between the two are neither stored nor published (by run at this head, on Linux with Node 24.22.1);
    • an <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);
    • a block in which no call's name is of a tool name's characters gets the notice for a block cut off before any call in it was named. After a call whose name is, a head whose name isn't stays in that call's unparseableInput;
    • a call membrane read whole inside a cut-off block is rendered as written, so if its arguments hold an unmatched <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, and main at 1a1585c9 does 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);
    • a cut-off call whose arguments quote a whole antml:-prefixed block followed by results in the legacy form, with no tool_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 on main at 1a1585c9. 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.
  • A provider's tool use ids are taken as given, unique within a response, as for any round.

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 fragments added: changelog.d/cut-off-calls.fixed.md, and changelog.d/cut-off-calls.changed.md for ToolResult.refused, whose doc now names these calls beside the dispatcher's refusals

Written 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-seru account.

…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>

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 module src/cut-off-round.ts sits next to tool-args.ts, and cutOffRefusal sits next to markedInputRefusal. (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.ts says its regexes reproduce membrane 0.7.2's tool-parser.js grammar, 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.ts already reads details.stop.unclosedToolBlock from membrane without parsing the block itself. (CONTRIBUTING.md (How changes land: companion PRs))
  • It widens the meaning of the public ToolResult.refused field, and the changelog says so. The field's doc said that only the dispatcher's path sets refused. It is now also set for calls that were never dispatched. The PR declares this in a changed changelog 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.ts and a case in the explicit-routing test, and adds fixed and changed fragments under changelog.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:

6 more findings, less severe
  • Low severity, documentation, src/types/events.ts:219-222. The added sentence in the refused doc 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 a tool:failed trace 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 reading ToolResult.refused "now also sees these calls". Dispatcher refusals reach hosts and modules as a tool-result process event (pushEvent({ type: 'tool-result', … result: refusal })). The cut-off results built in cutOffResults are written straight to the context through toolResultGuard.storeResults and never pushed as a tool-result event. 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, CLOSER and NAME_HEAD accept an optional antml: prefix, and OPENER also 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), lastToolIdx comes from the old reduce. If earlier XML rounds ran, it takes the lastToolIdx >= 0 branch, not the new cut?.unnamed && terminalContent.length === 0 branch, and the cut round's prose is routed by withoutRoundsProse. Every unnamed test is a first-round response. Add one with XML_EAST_ROUND before an unnamed cut, checking storage, routing and the notice row.
  • Low severity, test coverage, src/framework.ts:20977-20987. The supersession abort after await cutOffResults and the tool:failed traces 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 a tool:failed trace 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 from turn(). The ephemeral test repeats the HTTP server, count_tokens handler, SSE reply, adapter construction and temp-dir teardown that turn() 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.

Comment on lines +403 to +447
});
}
});

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 });
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/framework.ts Outdated
Comment on lines +21401 to +21407
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') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/cut-off-round.ts
Comment on lines +95 to +99
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) }] : [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +79 to +81

async function turn(mode: 'native' | 'xml', replies: Reply[], settings: Record<string, unknown> = {}): Promise<Run> {
const requests: string[] = [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Ezra-2423 and others added 2 commits October 26, 2026 11:55
…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>
@nissa-seru
nissa-seru merged commit 98340a2 into anima-research:main Oct 26, 2026
12 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 26, 2026
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>
tel-0s added a commit that referenced this pull request Oct 27, 2026
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>
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 27, 2026
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>
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 27, 2026
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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@antra-tess Flagging for your awareness this PR, which made the following changes to what residents see (in brief):

  • With native tools, when the output limit (max_tokens) cut a response off with calls in its last round, the round wasn't stored, so the agent couldn't read what it had said, and a later round's words were lost. Now the round is stored as the agent's reply, and each call in it, none of which ran, is answered: 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.
  • In XML tool mode (the anthropic-xml formatter): a tool-call block the output limit cut off used to be stored and published as raw XML, and the next answer was stored and published as <function_calls> followed by the answer. Now the text is cut at the block's opener, and each call in it whose name arrived is stored and answered as above.
  • In XML tool mode, a block cut off before any call in it was named now leaves the words before it and the notice [tool-boundary] Your response was cut off at the output limit inside a tool-call block, before any call in it was named, so nothing ran. To make a call, send less in one response.

— Lottie-2661

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant