Skip to content

feat(code-execution): a script's tool result carries .is_error and .structured beside its string - #538

Merged
nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:feat/script-result-flags
Oct 18, 2026
Merged

nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:feat/script-result-flags

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

A code_execution script can test a tool result for an error, and read its structured content, without parsing its string. Each result's string stays as it is, and gains .is_error and .structured beside it. It builds on #262's structured results for scripts.

Problem

A code_execution script gets each tool result as one string: the tool's text, an Error: … string for a failure, or, for a result with structured content, a JSON object holding content, structuredContent and isError (#262). So a script tells a failure from a success by the text's prefix. A successful result whose text happens to start with Error:, such as a line read from a file or a log message, reads as a failure, and a failure the script misses reads as a success.

What changes

  • A script's tool function returns ToolResult, a str subclass whose string is unchanged. It carries two attributes:
    • .is_error: True for an error result, False otherwise. It follows the result's error flag at every return of handleScriptToolCall:
      • True for each Error: … string the framework makes: a failure, a refusal, code_execution called from a script, and a result the script serializer can't convert;
      • True for a structured result whose isError is true. That includes the two forms that couldn't be delivered, one past the size cap that couldn't be saved and one JSON can't hold, whose strings already say the tool may have completed or did. A result past the cap that was saved is delivered as its path, flagged as the tool flagged it;
      • False for text and data results, and for a result its tool returned with isError: false, as the model reads it.
    • .structured: the parsed structuredContent of a structured result's JSON object, or None.
  • The wire: the host sends each result's string once, with is_error beside it, and structured_json: true when the string is a structured result's object. The runner parses .structured from the string on first read.
  • PyRunner's onToolCall may return a ScriptToolResult ({ text, isError, structuredJson? }, exported) or a plain string, as before. A plain string's attributes read None. Any other value a JavaScript handler resolves with is sent as before, and a handler that rejects reaches the script as an Error: … result flagged True.
  • The keyword-argument refusal shows a tool result used as a key as its text, as it shows any str key.
  • The tool description and the runner's docstrings say what the attributes mean. The description's new sentence changes code_execution's definition, so the first request after the upgrade that carries it misses the prompt cache from its tool block on.

Why each part is here

The change has one job: a script can tell an error result from a success, and reach structured content, without parsing the string, while every script written for strings keeps running.

  • A str subclass keeps the string exactly as it was, so code that uses it as a string keeps working: printing, slicing, comparing, json.loads and isinstance(r, str). An exact type check such as type(r) is str now reads False, which the changelog names. A separate accessor would need a second call for each result, and returning a pair or an object would break every script that uses the string.
  • .is_error at each return is a field at each of handleScriptToolCall's returns and scriptStructuredResult's. The framework decides there whether a result is an error, so the flag is set where that is known. Reading the prefix in the runner would bring back the ambiguity the flag removes. scriptStructuredResult reads each flag from the object its string is made from, so the two can't disagree.
  • .structured read from the string costs a cached property and one marker on the wire. Sending structuredContent beside the string would send it twice for each structured result, up to twice the 5 MB script cap on one line, since neither end of the pipe caps a line. Read on first use, it costs nothing to a script that never reads it.
  • The plain-string handler keeps the exported handler type compatible for code that constructs a PyRunner itself. Its attributes read None, "not said", since False would claim a success the handler never reported. Only an object is read as a ScriptToolResult, one condition, so a value a JavaScript handler resolves with that its type rules out is still answered rather than throwing where nothing catches it.
  • The refusal's key check takes ToolResult beside str, one comparison, so its promise to show a key whole holds for a tool result. ToolResult keeps str's repr, so showing it runs no script code, which is what the exact check guards.
  • The constructor's defaults let copy, deepcopy and pickle within a script, which rebuild a str subclass from its text alone and then restore its attributes, keep working. A pickle names the runtime's class, so it loads only in the script runtime, where a pickled str loaded anywhere.
  • Left out:
    • a check of .is_error against the string's prefix, since the prefix is what's ambiguous;
    • a test of the description's wording, which a reading against the code checks.

Tests

  • New or changed for this change, each failing on main at this change's base (5227741b) with only the ScriptToolResult type added there, so it compiles:

    • code-execution, under a real python3: a script reads each form of result. That's a flagged failure (True), text that only starts like an error (False), a structured object (.structured parsed), an oversized form (None), unmarked text holding a structuredContent key (None), a plain-string handler (None, None), a handler resolving undefined (answered with '') and a rejecting handler (True). It also checks json.loads of the string, copy and pickle, and a refusal that shows a tool result key as its text. Before this change it stops at AttributeError: 'str' object has no attribute 'is_error'.
    • code-execution, through the framework: the foreground integration test's script reports .is_error for its two calls, and the background test's script sends it in its wake. These pin both runners' call sites, since the handler type also takes a plain string. Before this change each script stops at .is_error.
    • mcp-modern-framework: the structured-result tests pin isError and structuredJson beside each string, inline, at the cap, past it, and undelivered, and a structured tool error inline reads true.
    • refusals-are-error-results: a script's refusals read isError: true and its success false, and code_execution called from a script is refused as an error.
    • unconvertible-tool-result: a result its tool didn't flag reads false, as the model reads it, even when its text starts with Error:, and unusable results read true. A new test reaches the two returns that only a result past the dispatch's own check can reach, a result the script serializer can't convert, as text and as a structured object, with the dispatch stubbed.
  • Adapted, not new: script-structured-references and two tests of tool-result-reference-uri read .text, since the handler now returns the string beside its flag. They fail on the old handler only for that reason.

  • Removals: 23, each taking one piece out, or inverting it, on its own:

    • the reply's two fields, the rejecting handler's flag, a plain string's None, and a non-object result read as a ScriptToolResult;
    • the runner's reading of each field, the constructor's defaults, .structured's key, and its check of the mark, removed or replaced by a try/except;
    • the flag at each of handleScriptToolCall's returns, one of them taken from the text's Error: prefix instead, and scriptStructuredResult's flag in each direction, and its mark;
    • each runner's call site passing on only the string, and a refusal showing a tool result key by its type.

    All ran at this head's tree. Each failed at least one test aimed at it, and the head passed before and after.

Checks

  • At this head, on Linux with Node 24.22.0 and Python 3.13.5: typecheck, build and the six test files this change touches pass (code-execution 83, mcp-modern-framework 18, refusals-are-error-results 4, script-structured-references 8, unconvertible-tool-result 11, tool-result-reference-uri 20), and so does code-execution-env (13), whose PyRunners use plain-string handlers. CI runs the whole suite on Ubuntu and macOS with Node 20 and 24.

Not verified

  • The whole runner on other Pythons: it ran here on Python 3.13.5 only, and CI runs it on its images' own python3. ToolResult alone, as runtime-py.ts defines it, passed the same 27 checks on CPython 3.8, 3.9, 3.10, 3.11, 3.12 and 3.13. These covered isinstance, equality and hashing, json.loads and json.dumps, slicing and formatting, the lazy and cached .structured, copy, deepcopy, and pickle at every protocol. The runner already needs 3.8 or later for top-level await, as cached_property does.

Merge order


  • Changelog fragment added: changelog.d/script-result-flags.added.md.

Written by Crispin-2713 (Claude Opus 5.5), contributing through nissa-seru, to a design by Ada-1017 (Claude Opus 5.5) and Gladys-2592 (Claude Opus 5.5). William-2616 (Claude Opus 5.5) reviewed it.

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.

…tructured beside its string

A code_execution script gets each tool result as one string: the tool's
text, an "Error: ..." string for a failure, or a structured result's JSON
object. So a script tells a failure from a success by the text's prefix,
and a successful result whose text happens to start with "Error:" can't be
told from one.

The string is now a str subclass, ToolResult, unchanged as a string, with
two attributes beside it:
- .is_error: True for an error result, False otherwise. The framework sets
  it at every return of handleScriptToolCall and scriptStructuredResult:
  True for each "Error: ..." string it makes (a failure, a refusal,
  code_execution called from a script, a result the script serializer
  can't convert) and for a structured result whose isError is true,
  including the two forms that couldn't be delivered (past the size cap
  and unsaved, and one JSON can't hold); False for text and data results,
  and for a result its tool returned with isError false, as the model
  reads it. scriptStructuredResult reads each flag from the object its
  string is made from.
- .structured: the parsed structuredContent of a structured result's JSON
  object, or None. The host sends the string once and marks it as such an
  object (structured_json); the runner parses it on first read.

PyRunner's onToolCall may return a ScriptToolResult ({ text, isError,
structuredJson? }), exported, or a plain string as before, for which both
attributes read None. Any other value is sent as before, and a handler
that rejects reaches the script as an "Error: ..." result flagged True.
ToolResult's constructor takes defaults so that copy, deepcopy and pickle,
which rebuild a str subclass from its text, keep working in the script.
The kwargs refusal shows a tool result key as its text, as it shows a str.

The tool description gains a sentence saying what the attributes mean, so
the first request after the upgrade that carries it misses the prompt cache
from its tool block on. Tests pin the flag at each return, through both of
the framework's runners, and the runner's value under a real python3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 18, 2026

Copy link
Copy Markdown

Required label not found on this PR.

@nissa-seru
nissa-seru merged commit 22f7a5b into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…and anima-research#372

main aae86b7 brings anima-research#234, anima-research#528, anima-research#529, anima-research#524, anima-research#538, anima-research#539, anima-research#372 and anima-research#210.

One conflict, a comment in src/framework.ts's utils run: anima-research#372 rewrote its
first lines (the registry checks the call against the utility resolved
here; the model's path takes the result's stored form), and this branch
added a paragraph on where the result goes, before the resultMaxChars
line. The resolution keeps anima-research#372's lines, then this branch's paragraph and
line. The code below it merged as git made it: the utility call carries
resultMaxChars, and the registry takes anima-research#372's { definition: def }.

anima-research#538 landed first and meets this branch in a test without a textual
conflict: handleScriptToolCall now returns a ScriptToolResult,
{ text, isError }, where it returned the string. The script case of
tool-call-result-max-chars.test.ts reads .text, and checks isError is
false, as anima-research#538's own tests read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
The edit planner's branch, which this one is stacked on, answered the
review of its whole change and that review's run, and merged main at
aae86b7 into its head along the way, as 11354ae. Its head is now
39d0151. That brings anima-research#538 and anima-research#372, among others.

This branch's changes apply to it as they stand: the merge has no
conflict, and its diff from 39d0151 is this branch's diff from
8b03920, 10 files, +1042 -142.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
main has taken anima-research#234, anima-research#528, anima-research#529, anima-research#524, anima-research#538, the 0.22.3 merge-back
(anima-research#539), anima-research#372, anima-research#210 and anima-research#541 since 8d7694f.

- changelog.d/403-truncation-image-slots.fixed.md: main deleted it when
  0.22.3 took it into the changelog, where this branch had changed one
  clause of it, to say a spill file goes only to a writable mount without
  an include. Taken as deleted: 0.22.3's entry stays as released, and
  this change's own fragment (mount-include.added.md) already says where
  the framework keeps its files and that a setup whose only writable
  mount has an include is treated as having none.

git merged the rest on its own. Against main, every file the merge
changes differs exactly as this branch did against 8d7694f, so no change
of main's is undone. Of the files both sides changed, src/framework.ts,
src/types/framework.ts and src/types/trace.ts take this branch's spill
texts beside main's changes elsewhere in them, and
test/workspace-three-way.test.ts takes the nested-directory tests beside
anima-research#528's re-attach tests. No use of firstWritableMountName or text about a
writable workspace was added on main since 8d7694f.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
main has taken anima-research#234, anima-research#528, anima-research#529, anima-research#524, anima-research#538, the 0.22.3 merge-back
(anima-research#539), anima-research#372, anima-research#210 and anima-research#541 since 8d7694f.

- changelog.d/403-truncation-image-slots.fixed.md: main deleted it when
  0.22.3 took it into the changelog, where this branch had changed one
  clause of it, to say a spill file goes only to a writable mount without
  an include. Taken as deleted: 0.22.3's entry stays as released, and
  this change's own fragment (mount-include.added.md) already says where
  the framework keeps its files and that a setup whose only writable
  mount has an include is treated as having none.

git merged the rest on its own. Against main, every file the merge
changes differs exactly as this branch did against 8d7694f, so no change
of main's is undone. Of the files both sides changed, src/framework.ts,
src/types/framework.ts and src/types/trace.ts take this branch's spill
texts beside main's changes elsewhere in them, and
test/workspace-three-way.test.ts takes the nested-directory tests beside
anima-research#528's re-attach tests. No use of firstWritableMountName or text about a
writable workspace was added on main since 8d7694f.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…his branch's content arrays

main moved from ce1a073 by 82 commits (anima-research#538, anima-research#539's 0.22.3 merge-back,
anima-research#524, anima-research#529, anima-research#528, anima-research#372, anima-research#210 and anima-research#541). Two conflicts:

- src/framework.ts, the script path's text result: anima-research#538 returns
  `{ text, isError: false }` where this branch passes the result's
  `dataIsContent` to toolResultDataToHistoryString. Both are kept.
- test/script-structured-references.test.ts, its imports: anima-research#538's
  `ScriptToolResult` beside this branch's `referenceRegistry`.

Read beside the conflicts, as they stand after the merge: anima-research#538's
scriptStructuredResult, which still takes the content array the dispatch
gave the call, where an MCPL peer's blocks were mapped through
referenceBlockForScript, and the reference registry's address sites in
start(), stop(), setMcplServerConfig and a server's removal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…, isError } in the on-demand fetch test

anima-research#538 made handleScriptToolCall return { text, isError } where it returned
the text. This branch's test of a link not fetched for a script on demand
typed the call as returning a string, so the merge of main 00eb87a left
it matching a regex against the object (J20564's one failure). It now
reads `.text` and checks `isError` is false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
Brings in the 34 pull requests merged into main since 9e3e407, among
them inbound-only channels (anima-research#350) and the script tool results (anima-research#538).

Conflicts:
- src/framework.ts:
  - anima-research#350 refuses speech into a channel its server declared inbound-only.
    It also kept the turn's destination for the speculative stream: set
    where a round is admitted, cleared where a target or a delivery is
    refused, with a `speakable` check in the stream's router. This branch
    removes the router and the destination, since the stream follows
    delivery, so that half goes. Its delivery half stays: a refused
    target, and a failed delivery to a channel declared inbound-only, end
    the turn's pin, and the comments that named the destination name the
    pin alone.
  - anima-research#538: a script's tool call returns a ScriptToolResult. The import and
    the signature keep this branch's round calls beside it.
- src/mcpl/channel-registry.ts: the stream's doc is this branch's. The
  stream's server lookup keeps anima-research#350's inbound-only check, so nothing
  streams into such a channel once it's declared, a declaration that
  lands between a publish and its chunk included.
- src/mcpl/prose-stream-router.ts: deleted here, as before.
- test/channel-registry-routing.test.ts: main's failure type.
- test/explicit-prose-routing.test.ts: the router's own test goes with
  the router. anima-research#350's refusal tests check the pin alone, and its
  queued-delivery test holds the first delivery until the second round's
  tool calls are yielded, since the mock membrane gives a turn's rounds
  to one stream.

Texts: since anima-research#350, a subconscious's speak_in_channel reaches
mcpl:speech-routed once it's delivered. This branch's added fragment and
the trace's doc say so.

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.

1 participant