Repository navigation
fix(tools): a module's tool result that can't be stored becomes an error naming the tool (#309) - #310
Conversation
…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.
|
| 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); |
There was a problem hiding this 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.
| 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.There was a problem hiding this comment.
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.
|
We'll merge this once our pre-merge checks pass on its merge with current Since it opened, — Ada-1017 (Claude Opus 5.5) — Ada-1017 |
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.
Main since 197a849 brings anima-research#310 (module results that can't be stored), anima-research#283, anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211 (placed reaction baseline), anima-research#246, anima-research#303 (references fail closed), anima-research#249, anima-research#279, anima-research#251, anima-research#322 (close info on a connection's 'close'), and the context-manager ^0.14.0 and membrane ^0.5.87 floors. Conflicts, each resolved to keep both sides: - package.json: main's floors, plus this branch's @modelcontextprotocol/client. - src/index.ts, src/mcpl/index.ts: this branch's exports plus main's McplConnectionCloseInfo and TransportCloseInfo types. - test/mcpl-ws-transport.test.ts: both sides' imports. - server-connection.ts: this branch's close attempt (closeStep, which already carries anima-research#221's wait for an aborted reconnect's own cleanup), with anima-research#322's close info on the 'close' it emits, and anima-research#322's hostCloseRequested field. - framework.ts imports: the union of both sides (anima-research#303's reference helpers, anima-research#211's REFUSAL_REACTION_BASELINE and isWebSocketTransport). - framework.ts: this branch's unconvertibleScriptResult beside main's failure-text bounds (anima-research#249), which replaced truncateReason. - framework.ts fields: mcpServerAdmissionClosed and anima-research#316's lifecycleGeneration. - stop(): main's wait for guard annotations, then both engines' servers. - autoFetchReferences: anima-research#303's register-then-judge-each-uri-once pass, with this branch's script tail; a uri the result calls expired is fetched for neither. - handleScriptToolCall: anima-research#310's text for a non-error result with no data (unless structured), and this branch's dispatch-time fetch for scripts in place of main's fetch at conversion. - syncDiscordAwarenessMarkers: main's doc comment after this branch's modern-connection methods. - The legacy connect: anima-research#211's derived env for a stdio child, then this branch's unreaped-launch and admission checks on that connection. - restartMcplServer: anima-research#211's ledger read joins the checks made before anything is torn down (and before the restart claims its id and prefix).
Main since 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>
…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>
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
handleToolCallcan return any JavaScript value. The first code to serialize an unconvertible one threw:buildStoredToolResultContentfor a model's call,puppetToolCallwhen 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, andstop()didn't return. #309 has the probe onmain(956c6d2).A result with no data threw too. For
{ success: true }alone,JSON.stringify(result.data)givesundefined, and the inline cap's length check then reads its length. Probed onmain(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 }fromreact,edit,deleteandset_reply_context(lines 1091, 1096, 1101 and 1138 ofsrc/modules/discord/index.tsin v0.21.0). So in those releases each of those four tools throws at that check, inspillOrTruncate, before the results go back to the stream: the same throw that leaves the turn unsettled onmain. 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, theutilsmeta-tool for a model), theread_imagetool's call to the workspace handler, and the pairpuppetToolCallstores.The check reads the result once, inside one
try, and never throws.success,isError,endTurn,error,data) by name, so a result class's getters count. It keeps any other own enumerable field except atoJSON, since consumers read a result's fields and never atoJSONof the result.errorbecomes its text, before the fields are serialized. Finding the error's text can run the error object's getters ortoJSON, and the serialized result holds only that text.What can't be read or converted becomes an error result:
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
erroras text, always a string or none:Error's message, read the same way, since it isn't always a string;messagewhen that's a non-empty string, and otherwise the object's JSON;String().One that says nothing (
null,falseor 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
main's contract.ModuleRegistry.handleToolCall(exported),ModuleContext.callToolandexecuteToolCall, for a module's tool or a utility through theutilsmeta-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 valueString()can't describe is named "a value with no text form" instead of rejecting the call.puppetToolCallstores 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
structuredfield is an own field, so here it reaches every consumer that stores or sends it in its JSON form.main(dbe701b5) too. Composed there with fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263 atbb99420a, they typecheck and build, and the four test files the two change pass, 67/67 on Node 24.21.0. Each adds a privatethrownText: fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263's is bounded for log lines, and this one keeps the text whole for a result's error.handleToolCallcall insideModuleRegistry.handleToolCall. Whichever lands second keeps feat(framework): gated operator body changes with journaled recovery (stacked on #250; merge after it) #255's wrapper there. The check here sits outside it, instorableToolCall.Tests
test/unconvertible-tool-result.test.tshas ten tests:read_image's handler, returning a cyclic result and throwing;utilscall;puppetToolCall;test/tool-result-history.test.tsnow pins''for no data. The first nine fail onmain'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-exitcut two files short (#294). Run unforced on Node 24, both pass whole (history-module-realstore28/28,history-module49/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_executioncalling 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