Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions changelog.d/script-result-flags.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
- **A `code_execution` script can test a result for an error, and read its
structured content, without parsing.** Each tool function's string is
unchanged, and carries two attributes beside it: `.is_error`, `True` for an
error result (a failure the framework reports as `Error: …`, or a
structured result whose `isError` is true) and `False` otherwise, and
`.structured`, the parsed `structuredContent` of a structured result's
JSON object, or `None`. A result whose text merely starts with `Error:`
reads `False`. The string is now a `str` subclass, so code that uses it as
a string keeps working, `json.loads` and `isinstance(r, str)` included,
while an exact type check such as `type(r) is str` now reads `False`, and
a result a script pickles loads only in the script runtime.
`PyRunner`'s `onToolCall` may return a `ScriptToolResult` (exported) to
set both, or a plain string as before, for which both read `None`.
`code_execution`'s description changes, so the first request after the
upgrade that carries it misses the prompt cache from its tool block on.
1 change: 1 addition & 0 deletions src/code-execution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type {
ExecResult,
PyRunnerOptions,
ScriptToolCallHandler,
ScriptToolResult,
BackgroundExecOptions,
} from './py-runner.js';
export { PYTHON_RUNTIME_SOURCE } from './runtime-py.js';
Expand Down
42 changes: 38 additions & 4 deletions src/code-execution/py-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,30 @@ export interface BackgroundExecOptions {
lifetimeMs: number;
}

/** Resolves inner tool calls. Must never reject — map errors to strings. */
/**
* An inner tool call's result as the script gets it. `text` is the string
* its await returns, and the runner sets `.is_error` on that string from
* `isError`. With `structuredJson`, `text` is the JSON object a structured
* result reaches a script as, in any of its forms, and `.structured` reads
* its `structuredContent`, None for a form that holds none, such as one past
* the size cap; without it, `.structured` is None.
*/
export interface ScriptToolResult {
text: string;
isError: boolean;
structuredJson?: boolean;
}

/**
* Resolves inner tool calls. Must never reject — map errors to results; a
* rejection reaches the script as an `Error: …` result. A plain string is
* the result's text with nothing said about it, so the script's
* `.is_error` and `.structured` are None.
*/
export type ScriptToolCallHandler = (
toolName: string,
args: Record<string, unknown>,
) => Promise<string>;
) => Promise<string | ScriptToolResult>;

export interface PyRunnerOptions {
pythonPath?: string;
Expand Down Expand Up @@ -530,9 +549,24 @@ export class PyRunner {
? (msg.args as Record<string, unknown>)
: {};
this.onToolCall(toolName, args)
.catch((err) => `Error: ${err instanceof Error ? err.message : String(err)}`)
.catch((err): ScriptToolResult => ({
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
isError: true,
}))
.then((result) => {
this.reply(child, { op: 'tool_result', id: callId, result }, `result of ${toolName} call ${callId}`);
// Only an object is a ScriptToolResult. Anything else a handler
// resolves with, a string or a value its type rules out, is sent
// as it is, so the call is still answered.
const reply = typeof result === 'object' && result !== null
? {
op: 'tool_result',
id: callId,
result: result.text,
is_error: result.isError,
...(result.structuredJson ? { structured_json: true } : {}),
}
: { op: 'tool_result', id: callId, result };
this.reply(child, reply, `result of ${toolName} call ${callId}`);
});
return;
}
Expand Down
44 changes: 38 additions & 6 deletions src/code-execution/runtime-py.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
*
* - scripts run with top-level `await` (PyCF_ALLOW_TOP_LEVEL_AWAIT)
* - host tools are injected as async functions taking a single dict and
* returning a string (the tool_result text)
* returning the tool_result text as a str (ToolResult), which also
* carries .is_error and .structured
* - a pending tool call that gets no response raises TimeoutError with the
* same message shape the managed runtime produces
* - interpreter globals persist across execs (container-reuse semantics)
Expand All @@ -29,7 +30,7 @@
* host -> py : {op:"init", tools:[{py_name,tool_name}], call_timeout_s,
* background?, log_path?, tail_chars?}
* {op:"exec", id, code}
* {op:"tool_result", id, result}
* {op:"tool_result", id, result, is_error?, structured_json?}
* {op:"wake_ack", id, error?}
* {op:"cancel", id, reason?}
* py -> host : {op:"ready"}
Expand All @@ -49,6 +50,7 @@
export const PYTHON_RUNTIME_SOURCE: string = String.raw`
import ast
import asyncio
import functools
import inspect
import io
import json
Expand Down Expand Up @@ -258,6 +260,31 @@ def _deliver_cancel(st):
st.task.cancel()


class ToolResult(str):
"""A tool's result: the string the tool gave, with two attributes.

is_error: True if the result is an error, False if not, None if the
host didn't say.
structured: the parsed structuredContent of a structured result's JSON
object, or None.
"""

# Defaults, since pickle and copy rebuild a str subclass from its text
# alone and then restore the attributes.
def __new__(cls, text="", is_error=None, structured_json=False):
self = str.__new__(cls, text)
self.is_error = is_error
self._structured_json = structured_json
return self

# Read from the string itself, the one copy sent, on first use only.
@functools.cached_property
def structured(self):
if not self._structured_json:
return None
return json.loads(self).get("structuredContent")


def _resolve(fut, value):
# Defense in depth: the script may have been cancelled since the lookup.
if fut is not None and not fut.done():
Expand Down Expand Up @@ -307,8 +334,9 @@ def _make_tool_fn(tool_name, py_name):
meant.update(kwargs)
# A key is what the agent has to write, so a str key is shown
# whole up to 60 characters, as names are elsewhere; a longer one,
# or any other key, is shown as a value is.
shown = ", ".join((repr(k) if type(k) is str and len(k) <= 60 else _brief(k)) + ": " + _brief(v)
# or any other key, is shown as a value is. A tool's result counts
# as a str: ToolResult keeps str's repr, so no script code runs.
shown = ", ".join((repr(k) if (type(k) is str or type(k) is ToolResult) and len(k) <= 60 else _brief(k)) + ": " + _brief(v)
for k, v in meant.items())
raise TypeError(
py_name + " wasn't called: a tool function takes one dict of "
Expand Down Expand Up @@ -347,7 +375,8 @@ def _make_tool_fn(tool_name, py_name):
tool_fn.__qualname__ = py_name
tool_fn.__doc__ = (
"Host tool '" + tool_name + "'. Takes a single dict of arguments and "
"returns the tool result as a string."
"returns the tool result as a string, with .is_error and .structured "
"beside it."
)
return tool_fn

Expand Down Expand Up @@ -537,7 +566,10 @@ def _dispatch(msg):
)
st.task = _current_exec_task
elif op == "tool_result":
_resolve(_pending_tool_futures.get(msg.get("id")), str(msg.get("result", "")))
_resolve(
_pending_tool_futures.get(msg.get("id")),
ToolResult(str(msg.get("result", "")), msg.get("is_error"), msg.get("structured_json") is True),
)
elif op == "wake_ack":
_resolve(_pending_wake_futures.get(msg.get("id")), msg.get("error") or None)
elif op == "cancel":
Expand Down
3 changes: 3 additions & 0 deletions src/code-execution/tool-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export function buildCodeExecutionToolDefinition(opts?: {
`workspace path the payload was saved to (null if it wasn't) and "refId" is fetch_reference's ` +
'ref_id. A structured result over 5 MB is saved to the workspace instead, and the string is ' +
'{"isError": ..., "oversized": {"chars": ..., "savedTo": ...}}. ' +
'Each string also carries .is_error (True if the result is an error, else False; test it ' +
'rather than an "Error:" prefix) and .structured (the parsed "structuredContent" of the ' +
'JSON object above, or None). ' +
"An exact-name lookup dict is also available: tools['mcpl--discord--fetch_history']({...}). " +
'Use top-level await; run independent calls in parallel with asyncio.gather. ' +
'Only what you print() (plus stderr and the exit code) comes back to you — intermediate ' +
Expand Down
36 changes: 21 additions & 15 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ import {
type ShownRanges,
} from './tool-image-ledger.js';
import { randomUUID, createHash } from 'node:crypto';
import { PyRunner, buildInjectedTools, formatLimit } from './code-execution/py-runner.js';
import { PyRunner, buildInjectedTools, formatLimit, type ScriptToolResult } from './code-execution/py-runner.js';
import {
buildCodeExecutionToolDefinition,
CODE_EXECUTION_TOOL_NAME,
Expand Down Expand Up @@ -18824,8 +18824,10 @@ export class AgentFramework {

/**
* Resolve one script-inner tool call to the string the script's tool
* function returns. Never rejects: errors become "Error: ..." strings,
* matching the managed PTC runtime ("Claude's code receives this error").
* function returns, and whether that result is an error (the script's
* `.is_error`). Never rejects: a failure without structured content becomes
* an "Error: ..." string, matching the managed PTC runtime ("Claude's code
* receives this error"), and a structured one its JSON object.
*
* A result without structured content is serialized with the HISTORY
* serializer: images become placeholders, since a script variable holding
Expand All @@ -18844,9 +18846,9 @@ export class AgentFramework {
origin?: ScriptCallOrigin,
/** The foreground script that made the call; none for a background script. */
script?: { endTurn: boolean; turnId?: number },
): Promise<string> {
): Promise<ScriptToolResult> {
if (toolName === CODE_EXECUTION_TOOL_NAME) {
return 'Error: code_execution cannot be called from within a script';
return { text: 'Error: code_execution cannot be called from within a script', isError: true };
}
// A foreground script's call was made in the turn of the code_execution
// call whose script is running when it arrives. A background script runs
Expand All @@ -18863,10 +18865,10 @@ export class AgentFramework {
// it back out of text.
const structured = Object.prototype.hasOwnProperty.call(result, 'structured');
if (result.isError && !structured) {
return `Error: ${result.error ?? 'tool call failed'}`;
return { text: `Error: ${result.error ?? 'tool call failed'}`, isError: true };
}
if ((result.data === undefined || result.data === null) && !structured) {
return nonErrorResultText(result, AgentFramework.SCRIPT_RESULT_MAX_CHARS);
return { text: nonErrorResultText(result, AgentFramework.SCRIPT_RESULT_MAX_CHARS), isError: false };
}
// RFC-005 "eagerly lazy": a script receiving a result is the strongest
// available signal that the bytes are wanted (scripts process, they do
Expand Down Expand Up @@ -18898,9 +18900,9 @@ export class AgentFramework {
// context-cap truncation, no file spill.
if (structured) return this.scriptStructuredResult(result, toolName);
try {
return toolResultDataToHistoryString(result.data, AgentFramework.SCRIPT_RESULT_MAX_CHARS);
return { text: toolResultDataToHistoryString(result.data, AgentFramework.SCRIPT_RESULT_MAX_CHARS), isError: false };
} catch (err) {
return `Error: ${unconvertibleScriptResult(toolName, err)}`;
return { text: `Error: ${unconvertibleScriptResult(toolName, err)}`, isError: true };
}
}

Expand All @@ -18923,9 +18925,13 @@ export class AgentFramework {
* that can't be converted to JSON (a cyclic object, a BigInt), which the
* dispatch's own check makes unlikely here, gets the same object empty,
* `{ content: [], structuredContent: null, isError: true, error }`, the
* error naming the tool.
* error naming the tool. In every form, the script's `.is_error` is the
* `isError` of the object its string holds, and its `.structured` is read
* from that string.
*/
private async scriptStructuredResult(result: ToolResult, toolName: string): Promise<string> {
private async scriptStructuredResult(result: ToolResult, toolName: string): Promise<ScriptToolResult> {
const asSent = <T extends { isError: boolean }>(value: T, json: string = JSON.stringify(value)): ScriptToolResult =>
({ text: json, isError: value.isError, structuredJson: true });
const object = {
content: Array.isArray(result.data) ? result.data : [],
structuredContent: result.structured,
Expand All @@ -18937,19 +18943,19 @@ export class AgentFramework {
json = JSON.stringify(object);
} catch (err) {
const error = unconvertibleScriptResult(toolName, err, result.isError ? result.error : undefined);
return JSON.stringify({ content: [], structuredContent: null, isError: true, error });
return asSent({ content: [], structuredContent: null, isError: true, error });
}
if (json.length <= AgentFramework.SCRIPT_RESULT_MAX_CHARS) return json;
if (json.length <= AgentFramework.SCRIPT_RESULT_MAX_CHARS) return asSent(object, json);
const workspace = this.getWorkspaceModule();
const mount = workspace ? this.firstWritableMountName(workspace) : null;
if (workspace && mount) {
const path = `${mount}/tool-results/${new Date().toISOString().slice(0, 10)}-script-${randomUUID()}.json`;
const written = await workspace.writeBinary(path, Buffer.from(json, 'utf8'), 'application/json').catch(() => null);
if (written?.success) {
return JSON.stringify({ isError: object.isError, oversized: { chars: json.length, savedTo: path } });
return asSent({ isError: object.isError, oversized: { chars: json.length, savedTo: path } });
}
}
return JSON.stringify({
return asSent({
isError: true,
error: `the result (${json.length} chars) exceeds the ${AgentFramework.SCRIPT_RESULT_MAX_CHARS}-char script result cap ` +
`and could not be saved to the workspace, so it could not be delivered; the tool may already have completed`,
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export type {
ExecResult,
PyRunnerOptions,
ScriptToolCallHandler,
ScriptToolResult,
} from './code-execution/index.js';
export type { SessionUsage, AgentUsage, SessionUsageSnapshot, UsageUpdatedEvent } from './usage/index.js';

Expand Down
Loading
Loading