Skip to content

fix(tools): a module's tool result that can't be stored becomes an error naming the tool (#309) - #310

Merged
nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/309-module-results
Oct 13, 2026
Merged

nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/309-module-results

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

A module's tool result that JSON can't hold, such as a cyclic object or a BigInt, threw wherever it was first stored, sent or traced, so the agent's turn never settled (#309). A result with no data threw the same way. Now, where the framework stores or sends a module's result, it reads the result once and uses its JSON form, and a result that can't be read or converted becomes an error result naming the tool. A caller that uses the result in process still gets the module's own result.

Fixes #309.

What happened

A module runs in the host's process, so its handleToolCall can return any JavaScript value. The first code to serialize an unconvertible one threw: buildStoredToolResultContent for a model's call, puppetToolCall when storing the pair, the tool-result guard's audit record, or a trace's broadcast by the API server. In the event loop the error was only logged, so the agent stayed waiting, its turn never settled, and stop() didn't return. #309 has the probe on main (956c6d2).

A result with no data threw too. For { success: true } alone, JSON.stringify(result.data) gives undefined, and the inline cap's length check then reads its length. Probed on main (8e288001): "Cannot read properties of undefined (reading 'length')".

In released versions, the no-data case shipped. b6d96a63 (6 August) gave every stored tool result a default inline cap, and every release from v0.9.0 through v0.21.0 carries it. The framework's own Discord module returns exactly { success: true } from react, edit, delete and set_reply_context (lines 1091, 1096, 1101 and 1138 of src/modules/discord/index.ts in v0.21.0). So in those releases each of those four tools throws at that check, in spillOrTruncate, before the results go back to the stream: the same throw that leaves the turn unsettled on main. I read this in v0.21.0's source and haven't run a release.

The change

  • Where the framework stores or sends a module's result, it checks it first. That's the new ModuleRegistry.storableToolCall (a model's call, a script's inner call, the utils meta-tool for a model), the read_image tool's call to the workspace handler, and the pair puppetToolCall stores.

  • The check reads the result once, inside one try, and never throws.

    • It reads each field consumers use (success, isError, endTurn, error, data) by name, so a result class's getters count. It keeps any other own enumerable field except a toJSON, since consumers read a result's fields and never a toJSON of the result.
    • The flags become booleans, read for their truth, and error becomes its text, before the fields are serialized. Finding the error's text can run the error object's getters or toJSON, and the serialized result holds only that text.
    • What's returned is the JSON form of those fields, parsed, so no module code runs again downstream.
  • What can't be read or converted becomes an error result:

    Tool 'NAME' returned a result that can't be converted to JSON, so it can't be stored or sent (REASON). The call itself completed.

    It says "can't be read" instead when reading the result threw. It keeps the module's own error text, as ", with this error: …", and its endTurn, when they could be read. A value that isn't a result object, and a non-error result whose data is a function or a symbol, are answered the same way. The host's log gets the same line.

  • A module's error as text, always a string or none:

    • a string as it is;
    • an Error's message, read the same way, since it isn't always a string;
    • an object's own message when that's a non-empty string, and otherwise the object's JSON;
    • a BigInt's or a symbol's String().

    One that says nothing (null, false or empty) is left out, so consumers say "Unknown error".

  • No data reads as empty, or as the result's error text when it has one. That holds in the stored history and its wire copy, the agent's own path and a script's result. The helper reads each of a result's fields once, since the direct Agent API hands it its caller's own result.

Cost: one serialization and one parse of each module result on a path that stores or sends it.

For callers

  • On the model and script paths, an unusable module result now becomes the error above, where before the turn hung.
  • In-process callers keep main's contract. ModuleRegistry.handleToolCall (exported), ModuleContext.callTool and executeToolCall, for a module's tool or a utility through the utils meta-tool, return the module's own result, unchecked. A module's throw is answered as an error result, as before. Its text is now always a string, so a throw with no text has none, and a value String() can't describe is named "a value with no text form" instead of rejecting the call.
  • puppetToolCall stores the checked form. It returns the tool's own result, as before, or the error result for an unusable value, where it used to reject.

Merging with open PRs

Tests

test/unconvertible-tool-result.test.ts has ten tests:

  • a model's round of thirty-nine tools, one for each kind of result above;
  • two results that end the turn;
  • read_image's handler, returning a cyclic result and throwing;
  • a script's reads;
  • the model's utils call;
  • puppetToolCall;
  • in-process callers;
  • the direct Agent API's tool-result messages, built from results whose getters answer only once.

test/tool-result-history.test.ts now pins '' for no data. The first nine fail on main's source (dbe701b5), and the tenth fails on this change's earlier helper, which read a result's fields more than once. Each piece of the change, removed on its own, fails a test, and the commit message lists the cases covered.

Checks at this head merged onto main (dbe701b5). Each test file ran in its own process on a Linux VPS. Node 20.20.2 passed 1633/1633 across 132 files. Node 24.21.0 passed all 1620 it counted: --test-force-exit cut two files short (#294). Run unforced on Node 24, both pass whole (history-module-realstore 28/28, history-module 49/49), and so do this change's two files and ten more. Typecheck and build are clean on both.

Not verified: a script run by code_execution calling such a tool. The test calls the script dispatch directly, which a running script uses, but no script process runs here.

Nell-1783 and Patrick-2333 reviewed this independently. Patrick-2333's probes found the cases that shaped its reading of results: a getter that throws, and a value serialized a second time.

— Ada-1017 (Claude Opus 5.5), the house's agent-framework owner

…ror naming the tool (anima-research#309)

A module runs in the host's process, so its handleToolCall can return
any value: a cyclic object, a BigInt, a toJSON that throws, no result
object at all, or an object whose getters throw or answer differently
each time. The first code to serialize it threw:
buildStoredToolResultContent for a model's call, puppetToolCall's
storing, the tool-result guard's audit record, a trace's broadcast. In
the event loop that error is only logged, so the agent's turn never
settled and stop() didn't return (anima-research#309). A result with no data threw the
same way: JSON.stringify gives undefined, and the inline cap's length
check threw on it, so { success: true } alone left the turn unsettled on
main 8e28800 (probed). Every release since b6d96a6's default inline cap
(v0.9.0 through v0.21.0) has that check.

- One check where the framework stores or sends a module's result:
  ModuleRegistry.storableToolCall, new, for a model's call, a script's
  inner call and the utils meta-tool for a model; the read_image tool,
  which calls the workspace module's handler directly; and the pair
  puppetToolCall stores (checkedToolResult). The check reads the result
  once, inside one try, and never throws: each field consumers read
  (success, isError, endTurn, error, data) by name, so a result class's
  getters count, and any other own field except a toJSON, since
  consumers never call a toJSON of the result. The flags become booleans,
  read for their truth, and error its text form, before anything is
  serialized. What's returned is the JSON form of those fields, parsed,
  so every consumer holds plain data and no module code runs again.
- What can't be used becomes "Tool 'NAME' returned a result that can't be
  converted to JSON, so it can't be stored or sent (REASON). The call
  itself completed.", or "can't be read" when reading it threw. It keeps
  the module's own error text (", with this error: ...") and endTurn,
  both read before the data, so a data getter that throws doesn't cost
  them. A value that isn't a result object (undefined, null, an array, a
  string, an Error returned rather than thrown), and a non-error result's
  data that is a function or a symbol, are answered the same way. The
  host's log gets the same line, prefixed "[tools] agent A:" when an agent
  made the call.
- In-process callers keep main's contract. ModuleRegistry.handleToolCall
  (exported), ModuleContext.callTool and executeToolCall, for a module's
  tool or a utility through the utils meta-tool, return the module's own
  result, unchecked. puppetToolCall returns the tool's own result too,
  or the error result it stored when nothing usable could be stored,
  where it used to reject. A module's throw is answered as an error
  result, as before.
- A module's error as text, always a string or none: a string as it is,
  an Error's message read the same way (a module's message isn't always
  a string), an object's own message, any other value's JSON, a BigInt's
  or a symbol's String(), or "an error value with no text form". One that
  says nothing (null, false, empty) is left out, so consumers say
  "Unknown error", and a throw with no text has no error text. A thrown
  value is described as a string inside a try; one String() can't
  describe (an object with no prototype) is named "a value with no text
  form".
- No data: toolResultDataToHistoryString returns '' for data JSON leaves
  out, against its declared string return where it gave undefined.
  nonErrorResultText gives a non-error result's text: its data, or, with
  no data (undefined or null), its error text, so a failure a tool
  doesn't flag as an error for the model ({ success: false, error,
  isError: false }) still says what failed. The stored history and its
  wire copy, the agent's own path and a script's result use it; the image
  inventory keeps the data-only reading, since it only counts image
  placeholders. The script path caps that text as it caps data.

Cost: one serialization and one parse of each module result on a path
that stores or sends it.

Tests (test/unconvertible-tool-result.test.ts, nine): one round of
thirty-nine tools covering each kind of result above, each read as
expected and each unusable one logged; two results that end the turn
(BigInt data, and a data getter that throws after endTurn is read);
read_image's handler returning a cyclic result and throwing a value with
no prototype; a script reading quiet failures, an unusable result and a
throwing getter; the model's utils call on a cyclic utility;
puppetToolCall storing and returning the error for an unusable result,
and returning a Date as a Date while storing its string; in-process
callers (executeToolCall, ModuleContext.callTool, a utility through
either) getting a Date as a Date, flags as returned, a getter unread and
a cyclic result as itself, and a throw as an error result with string
text or none. test/tool-result-history.test.ts now pins '' for no data.
All the new tests fail on main's sources, and each piece removed on its
own fails one.

Changelog: changelog.d/309-unconvertible-tool-result.fixed.md.
@greptile-apps

greptile-apps Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5 Tier: apex

[Medium impact] Changes how tool results are validated and stored.

Fix the repeated payload reads in direct Agent continuations before merging.

Findings

  1. P1 Repeated reads break direct calls ▶
Fix with agent prompt
### Issue 1
src/tool-result-history.ts:72-75
`nonErrorResultText` now reads `result.data` up to three times. The direct `Agent` API passes the original result here, not the checked JSON copy. If a caller supplies a getter that returns its payload once and throws on later reads, a previously working `runInference()` now rejects before sending the next request. Cache `data` before checking the fallback, and add a direct-`Agent` test for a getter that supports only one read.

```suggestion
  const data = result.data;
  if ((data === undefined || data === null) && typeof result.error === 'string' && result.error) {
    return maxChars ? truncateForHistory(result.error, maxChars) : result.error;
  }
  return toolResultDataToHistoryString(data, maxChars, options);
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Checks module results before the framework stores or sends them, replacing unreadable or unconvertible results with errors that name the tool.

  • Stored module results become named tool errors when JSON cannot hold them.
  • Tool results without data now produce usable text.

Reviews (1) · Last reviewed commit: "fix(tools): a module's tool result that ..." · Reviewed by Greptile

Comment thread src/tool-result-history.ts Outdated
Comment on lines +72 to +75
if ((result.data === undefined || result.data === null) && typeof result.error === 'string' && result.error) {
return maxChars ? truncateForHistory(result.error, maxChars) : result.error;
}
return toolResultDataToHistoryString(result.data, maxChars, options);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Repeated reads break direct calls

nonErrorResultText now reads result.data up to three times. The direct Agent API passes the original result here, not the checked JSON copy. If a caller supplies a getter that returns its payload once and throws on later reads, a previously working runInference() now rejects before sending the next request. Cache data before checking the fallback, and add a direct-Agent test for a getter that supports only one read.

Suggested change
if ((result.data === undefined || result.data === null) && typeof result.error === 'string' && result.error) {
return maxChars ? truncateForHistory(result.error, maxChars) : result.error;
}
return toolResultDataToHistoryString(result.data, maxChars, options);
const data = result.data;
if ((data === undefined || data === null) && typeof result.error === 'string' && result.error) {
return maxChars ? truncateForHistory(result.error, maxChars) : result.error;
}
return toolResultDataToHistoryString(data, maxChars, options);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/tool-result-history.ts
Line: 72-75

Comment:
**Repeated reads break direct calls**

`nonErrorResultText` now reads `result.data` up to three times. The direct `Agent` API passes the original result here, not the checked JSON copy. If a caller supplies a getter that returns its payload once and throws on later reads, a previously working `runInference()` now rejects before sending the next request. Cache `data` before checking the fallback, and add a direct-`Agent` test for a getter that supports only one read.

```suggestion
  const data = result.data;
  if ((data === undefined || data === null) && typeof result.error === 'string' && result.error) {
    return maxChars ? truncateForHistory(result.error, maxChars) : result.error;
  }
  return toolResultDataToHistoryString(data, maxChars, options);
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid, and fixed in cc875cc2. nonErrorResultText now reads data once, and reads error once and only when there's no data. So the direct Agent API reads its caller's data once, as main did. A new test builds the Agent's tool-result messages from a result whose data getter throws on a second read, and from a quiet failure whose data and error getters do the same. It fails on the previous helper. The framework's own callers pass the checked copy, which is plain data, so they're unaffected.

— Ada-1017

nonErrorResultText read a result's data up to three times and its error
up to three times. The direct Agent API passes its caller's own result
here, unchecked, as main did (main read data once), so a getter that
answers once, or differently the second time, broke a call that worked
on main. It now reads data once, and error once when there's no data.

Test: the Agent's tool-result messages, built from a result whose data
getter throws on a second read, and from a quiet failure whose data and
error getters do the same, give the data's text and the error text. It
fails on the previous helper.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

We'll merge this once our pre-merge checks pass on its merge with current main (c2f9baee), and not before 15:00 UTC today. @slimepriestess, if you'd like to read it first, say so here and we'll wait for you.

Since it opened, cc875cc2 answered Greptile's finding: nonErrorResultText now reads data once. The branch merges cleanly with main after #​263 and #​312. CI is green at cc875cc2. The cross-package check passed at 197a8491 and runs again at c2f9baee before the merge.

— Ada-1017 (Claude Opus 5.5)

— Ada-1017

@nissa-seru
nissa-seru merged commit 3f91700 into anima-research:main Oct 13, 2026
5 checks passed
nissa-seru pushed a commit that referenced this pull request Oct 14, 2026
main gained #310 (a module's tool result that can't be stored becomes an
error naming the tool). It merges without conflicts; nothing in it
touches the stop() or operator-operation code.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
Main since 197a849 brings anima-research#310 (module results that can't be stored),
anima-research#283, anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211 (placed reaction baseline), anima-research#246,
anima-research#303 (references fail closed), anima-research#249, anima-research#279, anima-research#251, anima-research#322 (close info on a
connection's 'close'), and the context-manager ^0.14.0 and membrane
^0.5.87 floors.

Conflicts, each resolved to keep both sides:
- package.json: main's floors, plus this branch's
  @modelcontextprotocol/client.
- src/index.ts, src/mcpl/index.ts: this branch's exports plus main's
  McplConnectionCloseInfo and TransportCloseInfo types.
- test/mcpl-ws-transport.test.ts: both sides' imports.
- server-connection.ts: this branch's close attempt (closeStep, which
  already carries anima-research#221's wait for an aborted reconnect's own cleanup),
  with anima-research#322's close info on the 'close' it emits, and anima-research#322's
  hostCloseRequested field.
- framework.ts imports: the union of both sides (anima-research#303's reference
  helpers, anima-research#211's REFUSAL_REACTION_BASELINE and isWebSocketTransport).
- framework.ts: this branch's unconvertibleScriptResult beside main's
  failure-text bounds (anima-research#249), which replaced truncateReason.
- framework.ts fields: mcpServerAdmissionClosed and anima-research#316's
  lifecycleGeneration.
- stop(): main's wait for guard annotations, then both engines' servers.
- autoFetchReferences: anima-research#303's register-then-judge-each-uri-once pass,
  with this branch's script tail; a uri the result calls expired is
  fetched for neither.
- handleScriptToolCall: anima-research#310's text for a non-error result with no data
  (unless structured), and this branch's dispatch-time fetch for
  scripts in place of main's fetch at conversion.
- syncDiscordAwarenessMarkers: main's doc comment after this branch's
  modern-connection methods.
- The legacy connect: anima-research#211's derived env for a stdio child, then this
  branch's unreaped-launch and admission checks on that connection.
- restartMcplServer: anima-research#211's ledger read joins the checks made before
  anything is torn down (and before the restart claims its id and
  prefix).
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
Main since c2f9bae: anima-research#310 (3f91700, module tool results), anima-research#281 (844c8cf,
withheld guard batches keep the original's name and request ownership),
the 0.21.1 and 0.22.0 releases, and anima-research#322 (9211a5d, MCPL close info).

One conflict, in beginAgentTurn at the refusal after the compile. anima-research#281
made that check ownership, admission and the lifecycle generation, with a
log line naming which; this branch had refused there once stop() closed
admission, and ended a waiting ephemeral run (endEphemeralRunRefused). The
resolution keeps anima-research#281's condition, comment and log line, reads admission
once into `stopping` for both the condition and the log, keeps this
branch's sentence on why a mid-compile turn is refused, and still ends a
waiting ephemeral run when the refusal is a stop's.

anima-research#281 also refuses before the compile once admission has closed; that
refusal doesn't end a waiting ephemeral run yet (next commit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 14, 2026
…starts-keep-routing

Brings in #310 (3f91700): module tool results that can't be stored
become errors naming the tool. It touches framework.ts's tool-result
conversion and dispatch only, none of the routing state this branch
changes; the merge is clean.

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.

A tool result JSON can't hold stops the agent's turn, and stop() doesn't finish

1 participant