Repository navigation
fix(tool-results): send a string result as its text, not JSON-quoted - #400
Conversation
A tool result whose data is a string was JSON-encoded on its way to the model: quoted, with every newline, tab and quote escaped. A module tool that answers with a line-per-item listing reached context, the live wire and its spill file as one escaped line, which workspace--read could then page only by character and workspace--grep matched as a single line. toolResultDataToHistoryString now keeps a string as it is; objects, other scalars and non-MCP arrays still go as JSON, MCP content arrays as their blocks. The live wire's fallback in toMembraneToolResult follows the same rule, so the four sites that share the serializer (stored history, the live wire, scripts, the direct Agent API) stay converged. Tests: the serializer keeps a string verbatim and truncates its text; an oversized string spills as its own lines and grep finds one by line number; a small one reaches history and the wire byte-identical; a code_execution script gets the string itself. Two tests that pinned the quoted form are updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryString tool results now keep their text across stored history, spill files, live requests, Python tool calls, and the direct
Reviews (1) · Last reviewed commit: "fix(tool-results): send a string result ..." · Reviewed by Greptile |
|
@Anarchid This change also reaches every MCP tool whose result is text only, and it breaks Text-only MCP results are strings too. The MCPL tool-result mapping turns a result whose blocks are all text into their joined text, so its
So the break reaches any script that parses an MCP tool's text answer with When the text is JSON, scripts break too. The Compatibility section says a script's Why the fragment's kind matters. Filed as - **Script authors (`code_execution`):** a script's tool function now
returns a tool's text result as that text, where it returned the text
JSON-encoded. That covers every MCP tool whose result is text only, and
every module tool whose `data` is a string. Drop a `json.loads` that only
unquoted such a result. Where the text is itself JSON, one `json.loads` now
returns the data, so drop the second. Unchanged: an object, a number, a
boolean, or an array other than an MCP content array still arrives as its
JSON.Written by Hilda-2681. — Lottie-2661 |
|
Replying to this comment. @Anarchid The rest of our review. On every path we ran, a string result now reaches its reader as its text. The PR's new tests fail on What we ran, on Linux with Python 3.13.5:
A test for the MCP path. None of the PR's tests covers the change's widest case, an MCP tool's text-only result. Commit
Lines shaped like reference stubs. The image-slot scan reads reference stubs line by line ( Open PRs that pin the old form. #181, #262 and #337 assert the JSON-quoted form of an MCP tool's text result in their tests ( Written by Hilda-2681. — Lottie-2661 |
…l and a script as that text The MCPL tool-result mapping (src/framework.ts, the "Convert MCP tool result to framework ToolResult" block) hands a result whose blocks are all text on as their joined text, so an MCP tool's text answer is string data too, and the serializer's string rule reaches it as it reaches a module tool's string. This pins that case end to end: a real stdio MCPL server whose tool answers with one text block, called once by the model and once from a code_execution script (real python3). The model's tool_result holds the text as the server sent it, and the script gets the same text: it splits into its two lines, and it doesn't start with a quote. The server accepts the framework's initial featureSets/update, as the fixtures' servers do, so the test doesn't wait out the policy request's 15 s timeout. On main's src it fails at the model's assertion: the model gets the text as a JSON string literal. With that assertion left out, it fails at the script's, since the script gets the same literal, one line, and has no second line to print. It passes with this branch's string rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A code_execution script's tool function returns the same text the model reads. With this change, a script that ran json.loads on a string result to unquote it now raises, and one that parsed a tool's JSON text with two json.loads calls now raises at the second, since the first already returns the data. CONTRIBUTING's "Breaking changes" asks that such a change be filed as breaking, so that npm version opens a new minor for it and a dependent's caret range doesn't take it at its next fresh install. - verbatim-string-tool-results-scripts.breaking.md: an audience-scoped entry for script authors, with who acts, the migration and what's unchanged. - verbatim-string-tool-results.changed.md: its two sentences about scripts move to that entry. The model's and the spill files' change stays a change. Its `data: ''` sentence now says it holds for a result without an error, since nonErrorResultText falls back to a result's error only when `data` is undefined or null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@Anarchid We've pushed two commits to this branch, so nothing is needed from you for them. One request: merge this after 0.22.2 is cut, for the reason in the last paragraph. The two commits:
With a breaking entry pending, Written by Hilda-2681. — Lottie-2661 |
|
Replying to this comment. @Anarchid 0.22.2 is out, on npm and as a GitHub release: https://github.com/anima-research/agent-framework/releases/tag/v0.22.2 It was cut from — Lottie-2661 |
|
@Anarchid We've opened #698, which carries this pull request onto
The new PR's description has the details and what we ran. Written by Cuthbert-2453. — Lottie-2661 |
Problem
A tool result whose
datais a string is JSON-encoded on its way to the model: wrapped in quotes, with every newline, tab and quote escaped.toolResultDataToHistoryStringfalls back toJSON.stringify(data)for anything that isn't an MCP content array, and a string isn't one.It came up with a resident's module tool that returns a line-per-item listing (one PR per line, a few hundred lines) as a plain string. The listing reached context as one escaped line,
"As of …\nShowing 80 of 87 matching.\n\n[review] …". Once it outgrew the inline cap, its spill file undertool-results/held that same single line.workspace--readcould page the file only by character offset, andworkspace--grepmatched the whole file as line 1. The agent couldn't walk the listing by lines or grep it.Changes
src/tool-result-history.ts:toolResultDataToHistoryStringkeeps a string as it is. Objects, numbers, booleans and arrays that aren't MCP content arrays still go as JSON, and MCP content arrays still go as their blocks.src/framework.ts: the live wire's fallback intoMembraneToolResultfollows the same rule. The live path normally reuses the stored copy, so this only keeps the fallback consistent.src/types/events.ts:ToolResult.datadocuments how each shape reaches the model.changelog.d/verbatim-string-tool-results.changed.md.All four sites that share the serializer change together: stored history and its spill file, the live wire, a
code_executionscript's tool function, and the direct Agent API (agent.ts). The tool-result guard's withheld stub serializes an object of kept fields, so it is unaffected.Tests
npm run buildthennpm test, same machine:main@ 91e70ed: 2362 tests, 2362 pass, 0 fail.surgery-marks-receipt"a suppression with nothing addressable…" ("Suppression branch name must differ from the active branch"). That's the same-millisecond branch-name collision filed as Default rollback/suppress/undo branch names collide within one millisecond (flaky surgery-marks-receipt) #396. The file passes 15/15 on three reruns.history-module-ux(16, on an earlier run) andhistory-module-overview(2, on this run) were not reported. Run alone, those files pass 26/26 and 28/28. It looks like--test-force-exitending a file before its later suites register, which is unrelated to this change. The counts above include it, so the branch lists 5 new tests minus those 2 missing ones againstmain.New tests, all 5 failing on
main'ssrc(checked by stashingsrcand rebuilding):tool-result-history: a string is kept verbatim, including one that holds JSON;maxCharstruncates the text itself.tool-result-spill: an oversized string spills as its own lines. The file is byte-identical to the string, 3000 lines, andworkspace--grepfinds the item at line 3000. A small string reaches history and the live wire byte-identical.code-execution(real python3): a script calling a module tool that returns a string gets the string itself: it splits on real newlines, and it doesn't start with a quote.Updated, since they pinned the quoted form:
tool-result-history"non-array data: JSON stringify" (dropped'hello'→'"hello"', addedtrue);tool-result-guard#281 withheld-file expectations (405 characters, not 407 with quotes);unconvertible-tool-resultdirect Agent API reads.Compatibility
code_executioncontract already says a script's tool function returns "the same text the tool would have returned to you directly", so scripts change with it. A script that ranjson.loadson a string tool's result to unquote it now gets an error, unless the string itself is JSON.data: ''is now stored as no text, asdata: undefinedand an empty content array already were.data: JSON.stringify(obj)) now reaches the model as that JSON, not as a JSON string holding JSON.Not verified
🤖 Generated with Claude Code