Skip to content

fix(tools): don't run a call whose arguments membrane marks unparseable (#363) - #804

Merged
nissa-seru merged 5 commits into
anima-research:mainfrom
nissa-seru:hilda/af363
Oct 26, 2026
Merged

nissa-seru merged 5 commits into
anima-research:mainfrom
nissa-seru:hilda/af363

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

TL;DR: a tool call whose arguments membrane marks as not the model's (unparseableInput) no longer runs. The model gets an error result that says what came instead, so it can call again. A call marked as JSON null still runs, as a call with no arguments.

Closes #363.

Problem

From membrane 0.6.0 every adapter hands a call's input over as a plain object. When the model's arguments weren't one, membrane gives {} (or, for a streamed Anthropic or Bedrock call, what the block's start carried) and keeps what came in unparseableInput, on the block and on its ToolCall (ToolUseContent.unparseableInput in membrane 0.7.1's src/types/content.ts).

agent-framework ran the call with that input anyway. On main (0a4a2c6979), a module tool called with input: {} ran with {} and answered success for each of these markers:

  • '{"steps": 1', cut off;
  • '', a stream that ended before the call's block did;
  • '[1,2]';
  • '{"steps":5}', an object's JSON. membrane marks a call with this when its stream ended before the call's block stopped, or when its stream broke the API's block order and so "can't vouch for which call any arguments belong to".

For a tool whose parameters have defaults, that's a different action from the one the model asked for, carried out without a word to the model.

A call marked 'null' ran too, with whatever input its block carried. That's right when the input is {}, as it is in practice, but not when the block's start carried more.

Changes

  • markedInputRefusal (src/tool-args.ts) decides. A marker whose text is JSON null reads as no arguments, as fix(tools)!: a module tool's call is checked against its schema before the module runs #372's check reads null, and the call runs with {} (asCalled). Any other marker is refused in fix(tools)!: a module tool's call is checked against its schema before the module runs #372's form, Nothing was done: <tool> <problem>.:
  • Where it's read (src/framework.ts, src/module-registry.ts):
    • The round's calls, as a tool-calls event brings them, are read once with asCalled, so everything the round does with a null-marked call (its routing, its dispatch, what it stores) sees {}.
    • dispatchToolCall, after the allowedTools check, through refusedResult, so the call is refused as an allowedTools refusal is, and RFC-007's lifecycle forgets it. A script's calls can't carry the marker, since the prelude builds them from objects.
    • roundCallOf, so a round counts a refused marked call as it counts one the allowedTools refuse: a refused skip_reply keeps its own round private and silences nothing after it, and a refused send counts for nothing.
    • The turn-end routing for a turn in which no round was routed live, as when a turn's channel registry appears after its round, which leaves a refused marked call out of the calls it counts, as it leaves out one the allowedTools refuse.
    • scriptMayRoute, so a refused code_execution call leaves no round waiting for a script that never starts.
    • executeToolCallFrom, for every origin, as a failure: executeToolCall (the puppet, and a host) and a module's callTool.
    • ModuleRegistry.handleToolCall, so a queued tool-call event, and a host calling the registry, meet the same rule.
  • Agent.runInference's calls keep the marker, so a caller that runs them can refuse them too, and ToolCall declares it.
  • The tool-calls case's comment said every call there has valid input, which isn't so on membrane 0.7, and it's gone.
  • MockMembrane carries the marker from a tool_use block to the tool-calls event and the response's toolCalls, so a test can hand the framework a marked call. F119's call (test/tool-input-null-arguments.test.ts), whose arguments text is 'null', now reaches the framework marked, and still runs.

Requirements and their tests

In test/tool-input-unparseable.test.ts unless named:

  1. A model's call marked with text that isn't JSON, or with none, doesn't run, and the model gets an error result for that call naming the tool, and the start of the text where there is any. No tool lifecycle event goes out for it, and the turn goes on with that result. Tests: one per kind of marker, and one that shows only the start of a long text.
  2. One marked with an array, a number, a string or a boolean doesn't run, and gets fix(tools)!: a module tool's call is checked against its schema before the module runs #372's words.
  3. One marked with an object's JSON doesn't run.
  4. One marked 'null' runs as a call with no arguments, whatever input its block carried, and an unmarked call runs as it came. Tests: the null test, the unmarked test, and F119's.
  5. Each call of a round is decided on its own marker.
  6. A refused marked skip_reply keeps its own round private and silences nothing after it, in a round routed live and in a turn where none was, and a refused marked send counts for nothing. A null-marked skip_reply counts as the call with no arguments that ran, whatever its block carried, in both.
  7. A refused marked code_execution starts no script, and its round's prose goes out.
  8. A marked call through executeToolCall, a module's callTool, a queued tool-call event or ModuleRegistry.handleToolCall doesn't run, and through executeToolCall it's a failure traced once, and through the registry a failure with the same text. A null-marked one, through executeToolCall or a queued event, runs with no arguments, a channel tool's included.
  9. runInference's tool calls carry the marker, an empty one included.

What callers will notice

  • A model whose call membrane marks gets an error result instead of the tool's answer, and can call again. A model that sends null for a call with no arguments sees no change.
  • A host calling executeToolCall or ModuleRegistry.handleToolCall, or queuing a tool-call event, with a marked call gets a failure (success: false) with the same text, and its tool doesn't run. A call marked 'null' runs with no arguments.
  • A host or test that reads ToolCall sees unparseableInput declared.

Not covered

  • In a stream that broke the block order, a call whose arguments were JSON null reads as no arguments and runs, while the other calls of its response are refused. Its text says no arguments either way.
  • XML tool mode's fallback (a round without roundContent) rebuilds the stored tool_use blocks from the calls, without the marker, as before. membrane 0.7.1's XML parsers (src/utils/tool-parser.ts, stream-parser.ts) never set the marker.
  • A response that stops at max_tokens in the middle of a call yields complete, not tool-calls, so its cut-off call never ran and still doesn't, and gets no result, as before.

Overlaps

#190 adds a refusal of its own at three of the places this change reads the marker in src/framework.ts: roundCallOf, the turn-end routing's filter, and the round's calls as the tool-calls case reads them. A merge of the two conflicts there, and each place takes both conditions. In every other open pull request, a trial merge with this one conflicts only in files that already conflict with main.

Checks

At acd05ca4, on Linux with Node 24.22.1:

  • The new tests and F119's: 24 of 24 pass.
  • Red-first: against main's sources, 22 of the new file's 23 fail. The unmarked test passes there, as the rule leaves those calls as they were.
  • The whole suite (npm run build && npm test from a clean dist): 7,595 tests, 7,551 pass, 0 fail, 44 skipped.

Written by Hilda-2580 (Claude Opus 5.5). Crispin-2406 (Claude Opus 5.5) read every commit but two: one renames a test as he asked, and one adds a next step to one refusal.

Description checked and posted by Lottie-2661 (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.

Hilda-2580 and others added 4 commits October 26, 2026 00:05
…le (anima-research#363)

A ToolCall membrane marks with unparseableInput carries an input that
isn't the model's arguments, and main ran it as given (a probe on
0a4a2c6 ran a module tool with {} for a cut-off text, '', an array,
null and an object's JSON from a broken-order stream).

markedInputRefusal (tool-args.ts) decides: a marker whose text is JSON
null reads as no arguments and the call runs; any other is refused in
anima-research#372's form,
'Nothing was done: <tool> <problem>.':
- '' (a stream that ended before the call's block did): cut off before
  any arrived;
- text that isn't JSON: shown as anima-research#372 shows a value, cut at 80;
- an array, number, string or boolean: anima-research#372's not-an-object words, now
  one helper (notAnObject) for both;
- an object's JSON (a stream that broke the API's block order, whose
  arguments may be another call's): call it again.

It's read wherever a call is decided:
- dispatchToolCall, after the allowedTools check, through
  refusedResult, so RFC-007's lifecycle forgets the call;
- roundCallOf, so a refused marked skip_reply keeps its round private
  and silences nothing after it, as an allowedTools refusal does;
- scriptMayRoute, so a refused marked code_execution leaves no round
  waiting on a script that never starts;
- executeToolCallFrom, for every origin, as a failure.
Agent.extractToolCallsAndSpeech keeps the marker on runInference's
calls, and ToolCall declares it. MockMembrane carries it from a
tool_use block to the tool-calls event and the response's toolCalls.

The tool-calls case's comment that every call there has valid input
is false on membrane 0.7 and now says what the marker means.

Tests (test/tool-input-unparseable.test.ts) pin each refusal's text
and that the tool didn't run, the bounded start, null and unmarked
calls running, the skip_reply and code_execution rounds,
executeToolCall and runInference. Against main's sources 12 of the 13
fail; the null/unmarked one passes there, as the rule leaves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rn-end routing refuse, and truer words

From the change's review and a reader's read:
- A null-marked call ran with whatever input its block carried;
  asCalled (tool-args.ts) gives it {}. The round reads its calls once
  with it as a tool-calls event brings them, and so do
  executeToolCallFrom and ModuleRegistry.handleToolCall.
- ModuleRegistry.handleToolCall refuses a marked call itself, so a
  queued tool-call event and a host calling the registry meet the rule.
- The turn-end routing for a turn in which no round was routed live
  leaves a refused marked call out of what it counts, as it leaves out
  one the allowedTools refuse.
- The words. Empty or whitespace text: its arguments never arrived (a
  cut-off block, or one still open when a stream broke the block
  order). Text that isn't JSON: may have been cut off. An object's
  JSON: the response didn't arrive whole and in order (an unstopped
  block is marked with what had arrived, which can be a whole object:
  membrane anthropic.ts 749-753).
- The tool-calls case's replacement comment is cut.
- Tests: toolUseId and no lifecycle event on each refusal, the unmarked
  call's started/completed, a null call whose block carried input, two
  calls in one round, a marked send, a turn with no live round's
  skip_reply, callTool and a queued event, a null call through
  executeToolCall to a module and to a channel tool, and an empty
  marker through runInference.
- The changelog names the null exception for the programmatic routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it reaches

membrane 0.7.1's XML parsers never set unparseableInput, so no XML-mode
turn carries a marked call. The case the turn-end filter meets is a
turn in which no round was routed live, as when a turn's channel
registry appears after its round. The test's title says that now, and
its helper (asTurnWithNoLiveRound, which removes roundContent and so
reaches the same branch) loses the doc that claimed XML mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The refusal for arguments text that isn't JSON said what came and that
it may have been cut off, but not what to do, as the other refusals
do. It now ends 'call it again with one JSON object'.

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: merge and fast-follow. 2 medium severity findings can be fixed after the merge.

Reviewed commit 7248206 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 the project. This bug fix belongs in the project. Calls whose arguments membrane marks as unparseableInput were running with {}.

5 reasons with sources

The change refuses them through the same refusal paths and argument-check module the framework already uses for tool dispatch, and it adds the changelog fragment and tests the contributing guide asks for.

  • It fixes tool dispatch, which the project says it owns, by stopping a tool from running with arguments the model never sent. The README lists tool dispatch as one of the framework's own jobs ("Tool calls → Module.handleToolCall() → results → stream resumes"). Running a call that membrane marks as not the model's arguments, with {} or with what the block's start carried, does a different action from the one the model asked for. Refusing such a call corrects existing behavior; it doesn't add a feature. (README.md (Overview))
  • The rule sits in the existing argument-check module and reuses its wording rather than adding a second way to check arguments. markedInputRefusal and asCalled sit next to toolArgsProblems. A shared notAnObject helper now writes the 'must be an object' text for both checks, so the new refusals use the same "Nothing was done:" form as #372. (src/tool-args.ts)
  • Refused calls go through the framework's existing refusal paths, so they are treated like allowedTools refusals. dispatchToolCall chains the new check after agentToolPermissionFailure and returns through refusedResult. roundCallOf counts a refused marked call the same way as a refused one, and ModuleRegistry.handleToolCall applies the same rule. The separately reported turn-end fallback issue with null-marked input is a code defect for the other review, not a design departure. (src/framework.ts (dispatchToolCall, roundCallOf, refusedResult))
  • It moves the framework's behavior to match the membrane upstream it depends on, which is expected for a Connectome repo. The guide says this repo sits directly on membrane. The marker is membrane's ToolUseContent.unparseableInput, and CHANGELOG.md already records membrane 0.6.0's handling of it. The one new public field is the optional ToolCall.unparseableInput, and it only passes membrane's existing marker through. (CONTRIBUTING.md (intro; How changes land))
  • It follows the contribution conventions: a changelog fragment, tests for the behavior change, and a conventional-commit title. It adds changelog.d/363-unparseable-tool-input.fixed.md in the required format, plus test/tool-input-unparseable.test.ts. The mock-membrane change only carries the marker through so the tests can supply marked calls. (CONTRIBUTING.md (What a PR should contain; Changelog))

Findings

2 findings of medium or high severity, see the inline comments:

3 more findings, less severe
  • Low severity, maintainability, src/framework.ts:22230-22231. markedInputFailure is passed an agent name that is never used. executeToolCallFrom calls this.markedInputFailure(call.callerAgentName ?? '__ephemeral__', call, false). With reportRefusal = false, markedInputFailure returns before it reads agentName, which only feeds refusedResult. The '__ephemeral__' fallback looks like it means something but has no effect. A reader may also think the refusal is recorded against that agent's lifecycle, which it isn't. A signature without the agent name on the failure path, mirroring how agentToolPermissionFailure is used, would be clearer.
  • Low severity, documentation, src/tool-args.ts:74-96. New exported helpers markedInputRefusal and asCalled have no doc comments. Every other helper in tool-args.ts has a /** */ comment that states its contract (shown, takesNull, toolArgsProblems, etc.). The two new exports, which six call sites across framework.ts and module-registry.ts now depend on, have none. Undocumented are the return contract (undefined means run, a string is the refusal text), the special case for a 'null' marker, and the fact that asCalled keeps the marker on the call while replacing input with {}. Callers must read the body to learn that asCalled and markedInputRefusal have to be used together.
  • Low severity, test coverage, test/tool-input-unparseable.test.ts:173-188. Queued-event test checks only side effects, not what the result or traces say. The test pushes two queued tool-call events and asserts only which module calls ran. It never checks the tool-result the refused queued call produces: its error text, success: false, and that it isn't marked refused. It also never checks the traces emitted for it. So the description's claim that a queued event meets 'the same rule' and answers 'with the same text, as a failure' is unverified, and the started/completed tracing of a refused call goes unnoticed.

For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.

Comment thread src/framework.ts
Comment on lines 21131 to 21139
const toolCalls = response.content
.filter((b) => b.type === 'tool_use')
.map((b) => b as unknown as { name?: unknown; input?: unknown })
.filter((c): c is { name: string; input?: unknown } =>
typeof c.name === 'string' && this.canAgentUseTool(agent.name, c.name));
.map((b) => b as unknown as { name?: unknown; input?: unknown; unparseableInput?: string })
.filter((c): c is { name: string; input?: unknown; unparseableInput?: string } =>
typeof c.name === 'string' && markedInputRefusal({ ...c, name: c.name }) === undefined
&& this.canAgentUseTool(agent.name, c.name));
const toolNames = toolCalls.map((c) => c.name);
// A refused skip_reply isn't a silencing call; a plain one is.
const fallbackCause = liveProseRouting

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. Turn-end fallback routing reads a null-marked call's raw block input rather than the {} it ran with.

In the tool-calls case the round's calls go through asCalled, so a call marked 'null' is dispatched with input: {}. The turn-end fallback path builds toolCalls from response.content and only filters with markedInputRefusal. It never applies asCalled, so refusesSelfWake(c) and silenceCauseOf read the input the block carried. Take a null-marked skip_reply whose block start carried a wake_in_seconds this host refuses. The dispatch runs it as a plain skip_reply with no arguments, which silences the turn. The fallback counts it as a refused skip_reply, which silences nothing, so trailing prose is published even though the model's skip_reply ran. Mapping the calls through asCalled here would keep the two paths in step.

Comment on lines +213 to +223
assert.equal(results[0]!.isError, true);
assert.ok(routed.includes('Public round prose'), "a refused send's round goes out");
assert.ok(routed.includes('Public trailing prose'));
} finally { await h.close(); }
});

test('in a turn where no round was routed live, a refused marked skip_reply silences nothing after its round', async () => {
const h = await harness();
try {
const routed = captureRouting(h.framework);
const call = marked('{"reason": "done', 'skip_reply');

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. No test for a null-marked call on the turn-end fallback path, or a null-marked skip_reply in routing.

The fallback-path test covers only a refused (non-JSON) skip_reply. No test sends a 'null'-marked call whose block carries arguments through asTurnWithNoLiveRound or through a live round with a channel registry. So nothing checks that the round's routing (roundCallOf, the fallback refusesSelfWake/silenceCauseOf) reads the same {} input the dispatch runs with. The inconsistency at framework.ts:21131-21141 passes this suite.

…g, and the registry's answer

From the automated review of 7248206:
- Two tests send a null-marked skip_reply whose block carried a
  wake_in_seconds this host refuses, in a round routed live and in a
  turn where no round was. The round reads its calls with asCalled as
  a tool-calls event brings them, so its routing counts the skip_reply
  that ran, with no arguments, and what follows is silenced. Without
  that reading both fail.
- The turn-end filter is left reading the block's input. Where a
  channel registry is there at the round, the round's own count has
  already silenced the turn. Where one appears only after the round,
  the filter is the only count, but a null-marked call's block carries
  {} there: an Anthropic or Bedrock tool_use's content_block_start
  carries input {}, which membrane keeps on a marked block, and
  toolUseInputFromValue(null) gives {} on the other paths. So asCalled
  changes nothing that reaches the filter, and no run told the two
  apart.
- A test calls ModuleRegistry.handleToolCall with a marked call and
  checks the failure it answers with, text included.
- executeToolCallFrom refuses a marked call itself, with its trace,
  rather than through markedInputFailure with an agent name it never
  read; markedInputFailure is the dispatcher's alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit e48f10a into anima-research:main Oct 26, 2026
12 checks passed
@nissa-seru
nissa-seru deleted the hilda/af363 branch October 26, 2026 07:05
nissa-seru pushed a commit that referenced this pull request Oct 26, 2026
…ol ran

When a provider refuses the request carrying a tool round's results, the
guard withholds the whole batch, and every result's text became "Tool
result withheld by the guard. The tool has already executed." That's
false for a call that never ran: one the framework refused before it ran
(ToolResult.refused), such as an allowedTools refusal, a skip_reply wake
the host can't set, an MCPL server absent or still connecting, a denied
MCPL tool, a conversation fork's channel call outside its home channel,
or a sleep or wake called from a conversation fork or an ephemeral run;
and one the module registry answered before its module ran, such as an
arguments check or an unknown module ("Nothing was done: ..."), which
isn't marked refused. Found while reviewing the #363 fix (#804); it's
pre-existing on main.

The guard now reads each original's flags when it stages a batch
(WithheldNoticeKind, kept per tool use id, as the stub's kept fields
already are) and words each withheld result by them:
- refused: "Tool result withheld by the guard. The tool didn't run.";
- any other error (isError, the flag the model's tool_result carries):
  "Tool result withheld by the guard. The call returned an error, and
  this notice can't say whether the tool ran.", since an error can come
  before or after the tool runs and only `refused` says it came before.
  When the original was written to a workspace file, the settled stub
  adds "Read it before calling again." after naming the file, keeping
  what "already executed" was there for: not running something twice.
  Not for a write that failed or was still under way;
- anything else, a failure the tool doesn't flag as an error included:
  "Tool result withheld by the guard. The tool has already executed.",
  as before.

The staged stub, the settled stub (withheldResultNotice's new optional
fourth argument, the kind), submissionResults' placeholder when the
audit can't be made durable, and the pending batch's wire reserve all
read each result's kind; the retry is compiled from the stubs. No notice
names the provider's refusal or its category (#159).

Tests (test/tool-result-guard.test.ts): a round with a call that runs,
one allowedTools refuses, one whose tool answers with an error and one
whose tool answers an unflagged failure, withheld (the staged history,
the retry and the settled history) and with the audit's sync failing
(the placeholders and the stored stubs); an error result whose original
is written to a workspace file; withheldResultNotice for an error at
each place its original can be; and the existing batch test's error
result turned round to expect the error notice. With main's
src/tool-result-guard.ts, the pinning tests fail.

docs/tool-result-guard.md gives the three forms, and
changelog.d/guard-refused-notice.fixed.md says what agents now read.

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 26, 2026
main moved 112 commits past 0.27.0. Two of them meet this branch's lines:
anima-research#716 (an unprefixed line in a round that calls the drafts tool is held as a
draft) and anima-research#804 (a tool call membrane marks unparseable doesn't run). Nine
blocks conflicted, eight in src/framework.ts and one in
test/code-execution.test.ts. Each keeps both sides:

- The explicit-mode routing help keeps main's drafts-round sentence and this
  branch's private-turn sentence.
- The delivery receipt keeps main's "skip_reply, a drafts call or a private
  think" line and this branch's private-turn line.
- Hybrid speech with no destination: a private turn's is kept private first,
  as before (a private turn's plain speech is never drafted). Main's hold of a
  draft then takes what's left, a failed envelope's hold. Since every caller
  here passes allowLocus as "not private", main's "!allowLocus && !sticky"
  can't reach the hold once the private branch has taken it, so that half of
  main's condition is left out.
- The round's calls are main's `calls`, judged with this branch's private-turn
  argument.
- Mid-turn hybrid delivery takes main's round options (the drafts round) and
  this branch's allowLocus.
- A silenced round takes main's drafts cause, and this branch's private-turn
  branch follows it as before.
- The XML fallback's filter and roundCallOf each refuse a marked unparseable
  call (main) and a private turn's channel_publish with no channel (this
  branch).
- The code-execution test helper's options take both channelPublish and
  serveWakes.

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 26, 2026
main's anima-research#804 added tool-input-unparseable, whose harness wakes the agent with
an `external-message` that names no channel. On this branch that is a
private surface's message, and its turn publishes none of its plain speech.
The four tests asserting that a refused marked call's round, or what
follows it, goes out failed in the full check at 8e7c70d. The trigger now
names the channel its stub registry resolves every locus to, as this
branch's other changes to main's tests do, so those tests run in a
channel's turn, as they do on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 26, 2026
…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 #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>
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.

Don't run a tool call whose arguments membrane marks unparseable; answer the model instead (antra-tess/membrane#126)

1 participant