Skip to content
Merged
1 change: 1 addition & 0 deletions changelog.d/f55-code-execution-text.breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **Hosts:** a foreground `code_execution` result's `data` is now the text the model reads, as a list of one text block, and its fields, `stdout`, `stderr`, `return_code` and `time_limit_note`, are in `structured`. A host that reads those fields from `data` reads them from `structured`. We found no host that reads them. A background script's results are unchanged.
1 change: 1 addition & 0 deletions changelog.d/f55-code-execution-text.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `code_execution`'s result now reaches the model as text, where it was one line of JSON with the script's output escaped inside it. How the script ended comes first, as `exit code: N` and, when the call's `time_limit_ms` was capped, the time-limit note; then the script's stderr, then its stdout, each under a label. A result past the agent's inline cap (`tool_result_inline_max_chars`, 24,000 characters by default) is cut a stream at a time to each one's head and tail, around a count of what was cut, at a line's end where one is near: stderr keeps up to a third of the room, or more when stdout needs less, and its tail keeps its last line whole when that line fits. So at any cap that holds a cut of each stream, however long stdout runs, it no longer pushes the exit code and the end of stderr, which holds a traceback's last line and the reason a stopped script gives, past the cut. With a writable workspace mount that has no include list, the whole output goes to a file of its own in the folder a long result spills to, and the result's last line says where; without one, or when writing it fails, that line says what was cut wasn't kept. A cap too small to hold a cut of each stream beside that line, a few hundred characters, leaves the whole text to the inline cap's usual spill, which keeps its head.
2 changes: 1 addition & 1 deletion changelog.d/fix-code-execution-output-to-host.fixed.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
- What the commands a `code_execution` script runs write to stdout and stderr now comes back in the script's result, beside the script's own prints, line by line in the order written on each stream: `subprocess` without `capture_output`, `os.system`, and C code writing to file descriptors 1 and 2. It used to reach only the host's log, and a line a command wrote without a newline ran into the runtime's own reply, so the script waited out its time limit. A command with a lot of output now fills the result as the script's own prints do: each stream keeps up to 2,000,000 characters, counted as UTF-16 code units, so a character outside the Basic Multilingual Plane, such as an emoji, counts as two, and a long result spills to a workspace file, as any result does. Output belongs to the script running when it arrives: a command a script left running writes into the result of a later script running then, and into the host's log between scripts or once its interpreter has been replaced. The tool's description now says that what the script and the commands it runs print comes back, and that a background script's output streams to a log file when the agent has a writable workspace. That changes the tool definitions every request carries, and by [Anthropic's prompt-caching documentation](https://platform.claude.com/docs/en/build-with-claude/prompt-caching#what-invalidates-the-cache), a prompt cache written before the upgrade isn't reused.
- What the commands a `code_execution` script runs write to stdout and stderr now comes back in the script's result, beside the script's own prints, line by line in the order written on each stream: `subprocess` without `capture_output`, `os.system`, and C code writing to file descriptors 1 and 2. It used to reach only the host's log, and a line a command wrote without a newline ran into the runtime's own reply, so the script waited out its time limit. A command with a lot of output now fills the result as the script's own prints do: each stream keeps up to 2,000,000 characters, counted as UTF-16 code units, so a character outside the Basic Multilingual Plane, such as an emoji, counts as two, and a result past the agent's inline cap is cut to fit it, with a note naming the workspace file that holds the whole of it or saying that it wasn't kept. Output belongs to the script running when it arrives: a command a script left running writes into the result of a later script running then, and into the host's log between scripts or once its interpreter has been replaced. The tool's description now says that what the script and the commands it runs print comes back, and that a background script's output streams to a log file when the agent has a writable workspace. That changes the tool definitions every request carries, and by [Anthropic's prompt-caching documentation](https://platform.claude.com/docs/en/build-with-claude/prompt-caching#what-invalidates-the-cache), a prompt cache written before the upgrade isn't reused.
- A command a `code_execution` script runs now reads the end of its input at once: its standard input is `/dev/null`. It used to read the runtime's channel from the host, so a command that reads its input, such as `cat`, or `ssh` without `-n`, took the host's messages, and the script failed or waited out its time limit.
- A script that closes or redirects its file descriptors 0, 1 or 2 leaves the next script its output: the runtime's channel to the host has file descriptors of its own, 3 and 4, which no command a script runs is given, and each script starts with 0, 1 and 2 as the host set them, blocking even after a command set one non-blocking. A script that points 1 or 2 at the runtime's channel gets it put back at its end, so a partial line it left there reaches its result; what a script or its commands write onto that channel, or read from it, can still garble the runtime's messages: the script's own, and, from a command started holding the channel, a later script's for as long as that command runs. A script whose result is garbled that way waits out its time limit and the grace, and the interpreter restarts, so the next script's variables are gone. A script's `os.close(1)` used to leave it waiting out its time limit, and its `os.close(0)` sometimes left the next script doing so. A script that closes or replaces one of the runtime's own file descriptors, 3, 4, or its copies of 0 to 2, now ends the interpreter by the end of that script at the latest, and the result says which and what to check, as does one that shuts down the socket under file descriptor 1, 3 or 4. That line is lost when the script also closed or replaced both the runtime's copy of file descriptor 2 and file descriptor 2 itself, or shut down the socket under them, and the result leaves it out once the script's stderr has passed its 2,000,000-character cap.
- What a script and its commands wrote before it was stopped, as far as it had reached the host, now comes back ahead of the message saying why, when it's killed at its time limit, aborted, or its interpreter crashes; it used to be lost. A partial line still in the script's writer when the interpreter is killed or crashes is lost with it. A crash's own text comes back too, and goes to the host's log: a fatal error's, which goes to the script's file descriptor 2 wherever the script pointed it, and now a traceback for a segfault or an abort, which faulthandler writes where CPython alone wrote nothing.
Expand Down
111 changes: 111 additions & 0 deletions src/code-execution/result-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* What the model reads when a foreground `code_execution` script ends.
*
* How the script ended comes first: its exit code, and the time-limit note
* when the call's limit was capped. stderr comes next, ahead of stdout: its
* end holds a traceback, and for a script the runner stopped, why it stopped,
* which the runner writes on a line of its own after the script's own stderr.
* Within a budget, each stream is cut to its head and its tail around a count
* of what was cut, and stderr keeps a share of the room of its own, so in any
* budget that holds a cut of each stream, however long stdout runs, it can't
* push the exit code or stderr's end out of view.
*/

export interface ScriptEnd {
stdout: string;
stderr: string;
returnCode: number;
/** Set when the call's time_limit_ms was capped at the deployment's maximum. */
timeLimitNote?: string;
}

/** The whole text, nothing cut. */
export function scriptEndText(end: ScriptEnd): string {
return heading(end) + section('stderr', end.stderr) + section('stdout', end.stdout);
}

/**
* The text within `budget` characters. When the whole text is longer, stderr
* keeps up to a third of the room the streams have, or more when stdout needs
* less, and stdout takes the rest, each cut to its head and tail. stderr's
* tail keeps its last line whole when the line fits its room. Returns null
* when the streams' room is three cut markers or less, so that stderr's share,
* when stderr is cut, keeps some of its own text beside its count.
*/
export function scriptEndTextWithin(end: ScriptEnd, budget: number): string | null {
const whole = scriptEndText(end);
if (whole.length <= budget) return whole;
const { stderr, stdout } = end;
const marker = cutMarker(Math.max(stderr.length, stdout.length)).length;
const room = budget - heading(end).length - labelLength('stderr', stderr) - labelLength('stdout', stdout);
if (room <= 3 * marker) return null;
const stderrRoom = Math.min(stderr.length, Math.max(Math.ceil(room / 3), room - stdout.length));
return heading(end)
+ section('stderr', cutMiddle(stderr, stderrRoom, true))
+ section('stdout', cutMiddle(stdout, room - stderrRoom));
}

function heading(end: ScriptEnd): string {
return `exit code: ${end.returnCode}\n` + (end.timeLimitNote ? `time limit: ${end.timeLimitNote}\n` : '');
}

/** A stream under its label, ending in a newline; nothing for an empty stream. */
function section(name: string, body: string): string {
if (body === '') return '';
return `--- ${name} ---\n${body}${body.endsWith('\n') ? '' : '\n'}`;
}

/** What a stream's section adds besides the stream itself, at most: its label and a closing newline. */
function labelLength(name: string, body: string): number {
return body === '' ? 0 : `--- ${name} ---\n`.length + 1;
}

/** The count of what was cut, on a line of its own. */
function cutMarker(cut: number, afterLineEnd = false): string {
return `${afterLineEnd ? '' : '\n'}[… ${cut} characters cut …]\n`;
}

/**
* `text` in at most `keep` characters: its head and its tail around a count of
* what was cut. Each cut falls at a line's end when one is near, within a
* quarter of the part it ends, so a part line doesn't read as a whole one;
* otherwise it falls mid-line, never inside a surrogate pair. With
* `keepLastLine`, the tail is at least the last line when that line fits.
* `keep` must hold the marker for a cut of `text.length`.
*/
function cutMiddle(text: string, keep: number, keepLastLine = false): string {
if (text.length <= keep) return text;
const left = keep - cutMarker(text.length).length;
let headLength = Math.floor(left / 2);
let tailLength = left - headLength;
if (keepLastLine) {
// The tail keeps the last line whole, taking from the head, when the
// line fits; a longer one keeps its end. Blank lines at the end, LF or
// CRLF, go with the line before them.
let contentEnd = text.length;
while (contentEnd > 0 && (text[contentEnd - 1] === '\n' || text[contentEnd - 1] === '\r')) contentEnd--;
const lastLine = text.length - (text.lastIndexOf('\n', contentEnd - 1) + 1);
if (lastLine > tailLength && lastLine <= left) {
tailLength = lastLine;
headLength = left - lastLine;
}
}
let headEnd = headLength;
const lineEnd = headEnd > 0 ? text.lastIndexOf('\n', headEnd - 1) : -1;
if (lineEnd >= 0 && headEnd - (lineEnd + 1) <= headLength / 4) headEnd = lineEnd + 1;
else if (headEnd > 0 && isHighSurrogate(text.charCodeAt(headEnd - 1))) headEnd--;
let tailStart = text.length - tailLength;
const lineStart = text.indexOf('\n', tailStart - 1) + 1;
if (lineStart > 0 && lineStart - tailStart <= tailLength / 4) tailStart = lineStart;
else if (tailStart < text.length && isLowSurrogate(text.charCodeAt(tailStart))) tailStart++;
const head = text.slice(0, headEnd);
return head + cutMarker(tailStart - headEnd, head === '' || head.endsWith('\n')) + text.slice(tailStart);
}

function isHighSurrogate(code: number): boolean {
return code >= 0xd800 && code <= 0xdbff;
}

function isLowSurrogate(code: number): boolean {
return code >= 0xdc00 && code <= 0xdfff;
}
70 changes: 61 additions & 9 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ import {
import { randomUUID, createHash } from 'node:crypto';
import { PyRunner, buildInjectedTools, formatLimit, type ScriptToolResult } from './code-execution/py-runner.js';
import { scriptToolNames } from './code-execution/script-tool-names.js';
import { scriptEndText, scriptEndTextWithin, type ScriptEnd } from './code-execution/result-text.js';
import {
buildCodeExecutionToolDefinition,
CODE_EXECUTION_TOOL_NAME,
Expand Down Expand Up @@ -19444,9 +19445,21 @@ export class AgentFramework {
this.scriptRoutingInterpreters.set(agentName, { interpreter, names: new Set([...earlier, ...reachable]) });
}

// The model reads how the script ended as text, its ending first, within
// the bound its caller reads whole: the one the call carries, as an
// in-process caller may set it, or else the one the framework gives the
// calls it dispatches (resultMaxCharsFor), the agent's inline cap. Hosts
// read the fields in `structured`.
const cap = call.resultMaxChars ?? this.resultMaxCharsFor(agentName, call.id);
const text = await this.scriptEndResultText(
{ stdout: exec.stdout, stderr: exec.stderr, returnCode: exec.returnCode, ...(capNote ? { timeLimitNote: capNote } : {}) },
cap,
call.id,
);
return {
success: true,
data: {
data: [{ type: 'text', text }],
structured: {
stdout: exec.stdout,
stderr: exec.stderr,
return_code: exec.returnCode,
Expand All @@ -19457,6 +19470,39 @@ export class AgentFramework {
};
}

/**
* A foreground script's ending as the model reads it: whole when it fits
* `cap`, or else cut to fit beside a notice, with the whole text written
* to a file of its own in the tool results folder. Its name differs from
* the one the inline cap's spill gives the result before the call id,
* which a long id can cut short, so a later spill of the cut text, as at a
* cap lowered before the result is stored, can't write over it. The cut
* leaves room for the longest notice it could end with, so it's decided
* before anything is written. When the cap can't hold a cut of each stream
* beside that notice, the whole text comes back, unwritten. On a model's
* call, the inline cap's spill then keeps its head as the result is stored;
* an in-process caller that set so small a bound gets the whole text.
*/
private async scriptEndResultText(end: ScriptEnd, cap: number, callId: string): Promise<string> {
const whole = scriptEndText(end);
if (whole.length <= cap) return whole;
const label = `${new Date().toISOString().slice(0, 10)}-output-${callId}`;
const keptIn = (path: string) =>
`[cut to fit; the whole output (${whole.length} chars): workspace file ${path}. ${this.spillPageAdvice(path, cap)}]`;
const writeFailed = (path: string, error: string) =>
`[cut to fit; writing the whole output (${whole.length} chars) to workspace file ${path} FAILED (${error.slice(0, 200)}); what was cut was not retained]`;
const noWorkspace = `[cut to fit; the whole output was ${whole.length} chars, and with no writable workspace mount without an include, what was cut was not retained]`;
const place = this.toolResultFilePlace(label);
const reserve = place
? Math.max(keptIn(place.path).length, writeFailed(place.path, 'x'.repeat(200)).length, noWorkspace.length)
: noWorkspace.length;
const within = scriptEndTextWithin(end, cap - reserve);
if (within === null) return whole;
const written = place ? await this.writeToolResultFile(label, whole) : null;
if (!written) return within + noWorkspace;
return within + (written.error === undefined ? keptIn(written.path) : writeFailed(written.path, written.error));
}

// ---------------------------------------------------------------------------
// Background (daemon) scripts — model-authored watchers that outlive the turn
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -19946,6 +19992,19 @@ export class AgentFramework {
return { path, error: failure };
}

/** How to read a tool result's file back a page at a time, with each page
* sized to come back within `cap`. */
private spillPageAdvice(path: string, cap: number): string {
// JSON serialization can expand each read-back code unit to six
// chars (\\u0000). Leave room for the page's metadata and a possible
// extra code unit at a surrogate-pair boundary.
const pageOverhead = JSON.stringify(path).length + 256;
const readLimit = Math.max(1, Math.min(2000, Math.floor((cap - pageOverhead) / 6) - 1));
return `Page with workspace--read ${JSON.stringify({ path, offsetChars: 0, limitChars: readLimit })}; `
+ 'use nextOffsetChars as offsetChars until null, keeping limitChars. Or raise the inline cap via '
+ 'agent_settings update tool_result_inline_max_chars.';
}

private async spillOrTruncate(
content: string,
cap: number | undefined,
Expand All @@ -19969,17 +20028,10 @@ export class AgentFramework {
const written = await this.writeToolResultFile(label, content);
if (written && written.error === undefined) {
const path = written.path;
// JSON serialization can expand each read-back code unit to six
// chars (\\u0000). Leave room for the page's metadata and a possible
// extra code unit at a surrogate-pair boundary.
const pageOverhead = JSON.stringify(path).length + 256;
const readLimit = Math.max(1, Math.min(2000, Math.floor((cap - pageOverhead) / 6) - 1));
return {
text: head
+ `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}`
+ `. Page with workspace--read ${JSON.stringify({ path, offsetChars: 0, limitChars: readLimit })}; `
+ 'use nextOffsetChars as offsetChars until null, keeping limitChars. Or raise the inline cap via '
+ 'agent_settings update tool_result_inline_max_chars.]'
+ `. ${this.spillPageAdvice(path, cap)}]`
+ kept,
filePath: path,
};
Expand Down
13 changes: 8 additions & 5 deletions src/types/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,14 @@ export interface ToolResult {
/** Whether this was an error (for LLM) */
isError?: boolean;
/**
* An MCP tool's `structuredContent`: its machine-readable result, beside
* `data`, which is the content view. Present exactly when the server sent
* one, so `false`, `0` and `null` are values here, not absence. Programs
* read it (scripts get it in their result object); the model sees content,
* and sees this rendered as JSON only when the content carries no text.
* The result's machine-readable form, beside `data`. Programs read it: a
* script gets it in its result object, and a host in the tool result
* event. The model never reads this field: it sees the value only where an
* MCP conversion put it in the content as JSON, which happens only when the
* server sent no text. An MCP tool's is its `structuredContent`, present
* exactly when the server sent one, so `false`, `0` and `null` are values
* here, not absence. Any other tool can set one too, as `code_execution`
* does with its script's stdout, stderr, exit code and any time-limit note.
*/
structured?: unknown;
/**
Expand Down
Loading
Loading