/** * codeRunnerTool — the tool that turns a {@link CodeRunner} into something an * LLM can call, holding ONE session per isolation key. * * Pattern: Factory over `defineTool` + the 9.7.0 tool-session contract. * Role: the first consumer of `ctx.onTeardown` — and the proof it works. * Emits: nothing directly; the teardown tier reports * `agentfootprint.tools.session_started` / `_reused` / `_closed` / * `_close_failed`. * * ── The doctrine: summarize prose, compute data ───────────────────────────── * A tool that hands the model 40,000 rows has not given it data; it has spent * the window. The motivating failure is real and measured: a production request * of 879,073 tokens, almost all of it one tool result pasted into the prompt. * (Since 9.6.0 that shape at least fails by NAME — * `ContextWindowExceededError` — instead of as a vendor 400. This is the other * half: not failing better, but not needing to.) * * With a code runner the model writes an aggregation, the RUNNER holds the * rows, and what comes back is the number. Prose gets summarized; data gets * computed. * * ── Why the session must be keyed, and keyed WIDELY ENOUGH ────────────────── * The session is the whole value: a code interpreter costs seconds to start and * milliseconds to invoke. But a session holds a filesystem, an environment and * half-run state, so the thing it is keyed on IS the isolation boundary. A * standing agent serving many people from one process, holding one session in a * module map, gives person B person A's files. * * So the key comes from `toolSessionKey(ctx, scope)` — one exported * implementation, composing tenant + principal + (session | run). Never a bare * `sessionId`: that is caller data, and anyone who can reach the host can put * someone else's there. * * ── Degradation is REFUSED, never silent ──────────────────────────────────── * Ask for `scope: 'session'` at a door with no session and this throws, naming * the door. It does not quietly fall back. Falling back to a WIDER key is the * cross-binding bug itself; falling back to a NARROWER one is a silent 30× * latency change nobody sees until the bill. * * @example a session per run, on a local dev machine * const agent = Agent.create({ provider }) * .tool(codeRunnerTool({ runner: localCodeRunner() })) * .build(); * * @example a session per hosted conversation, on a real sandbox * const runner = agentCoreCodeRunner({ region, identifier: 'aws.codeinterpreter.v1' }); * const agent = Agent.create({ provider }) * .tool(codeRunnerTool({ runner, scope: 'session', language: 'python' })) * .build(); * // the composition root says when a session is over: * conversation.onClose(() => void agent.closeToolSessions({ sessionId })); */ import type { CodeRunner, CodeSession } from '../adapters/types.js'; import type { ToolWants } from '../artifacts/wants.js'; import type { CredentialNeed } from '../identity/types.js'; import type { CheckInDemand } from './checkin.js'; import { type Tool } from './tools.js'; import { type TeardownScope } from './toolSessions.js'; /** The scopes a code session can be held under. `'shutdown'` is not one: it is * when everything goes, not a thing to key a session on. */ export type CodeRunnerToolScope = Extract; export interface CodeRunnerToolOptions { /** The backend. `localCodeRunner()` for a dev loop, `agentCoreCodeRunner(...)` * for a real sandbox — the tool is identical across the swap. */ readonly runner: CodeRunner; /** Tool name the model sees. Default `'run_code'`. */ readonly name?: string; /** Description the model sees. A sensible one is composed from `scope` + * `language` when you do not pass one. */ readonly description?: string; /** * How long one session lives. Default `'run'` — a turn's worth of work shares * one interpreter, and nothing outlives the turn. * * `'session'` keeps the interpreter across the turns of one hosted * conversation (variables persist, files persist) and REQUIRES a * session-bound run plus a composition root that calls * `agent.closeToolSessions({ sessionId })`. * * `'call'` starts and stops per invocation — the safest and the slowest. */ readonly scope?: CodeRunnerToolScope; /** Default language for the code the model writes. Default `'python'`. */ readonly language?: string; /** Per-stream ceiling for what reaches the model, in characters. Default 4000. * Anything cut is STATED in the result, never dropped quietly. */ readonly maxOutputChars?: number; /** Per-execution ceiling handed to the runner. */ readonly timeoutMs?: number; /** Demand a human check-in before code runs — `'always'`, or a predicate over * the code string. A pause here does NOT tear the session down. */ readonly checkIn?: CheckInDemand<{ code: string; }>; /** A credential this tool needs (declare-and-push). Resolved before execute. * Do NOT cache it past the call: a session outliving a run outlives its token. */ readonly needs?: CredentialNeed; /** * Artifact arguments, declared exactly as any other tool declares them * (9.26.0): `wants: { dataset: 'dataset/rows' }`. * * The model passes the `art_…` ref as the argument, the framework resolves * it before `execute` under the run's own scope — the same `wants` machinery, * with the same teaching refusals for a stale, unknown or wrong-kind ref — * and then this tool STAGES the resolved payload into the code session as a * file. The data reaches the interpreter without ever entering the context * window, which is the whole doctrine this tool exists for, now with an * inbound leg to match the outbound one. * * ── What the model's code reads ───────────────────────────────────────── * The staged files are named in the `AF_STAGED_INPUTS` environment variable, * a JSON object of `argument name → path`. The composed tool description * states it with a one-line example in the tool's language, so a model needs * nothing beyond the description to use it. * * ── Refused rather than degraded ──────────────────────────────────────── * Declaring `wants` on a runner whose sessions cannot accept staged inputs * (`stageInputs` absent — `agentCoreCodeRunner` today) refuses BY NAME at * dispatch. Running the code without the data it declared would leave the * model reasoning about a file that is not there, which is the exact silent * failure this library refuses to ship. * * Omitted, nothing changes: no schema properties are added, no session is * ever asked to stage, and the description is the one earlier releases * composed. */ readonly wants?: ToolWants; } /** * The per-tool session map, riding the `Tool` under a REGISTRY symbol. * * `Symbol.for`, not a unique symbol: this package ships CJS and ESM, and a tool * built through one entry point must be readable through the other. The same * move `INNER_RUN_RECORDS` makes for `flowchartAsTool({ keepRecord })` — and * deliberately a DIFFERENT symbol, so one tool can carry both (spreading a tool * preserves symbol keys, which is why `{...tool, [SYM]: store}` composes). * * Invisible to the LLM, invisible to `Tool`'s shape, reachable by a test and by * whatever inspector comes next. */ export declare const TOOL_SESSIONS: unique symbol; /** * Where a finished code run leaves its facts, keyed by `toolCallId`. * * `Symbol.for` for the same CJS/ESM reason as {@link TOOL_SESSIONS}, and keyed * by the CALL rather than stored as "the last run" because two tool calls in one * iteration run concurrently — a single slot would report one call's facts under * the other's name. The dispatch loop takes the entry and deletes it. */ export declare const CODE_RUNS: unique symbol; /** What one finished code run is worth reporting, minus the code itself. */ export interface CodeRunFacts { readonly tool: string; readonly language: string; readonly stagedInputs: number; readonly outputChars: number; readonly truncated: boolean; readonly ok: boolean; readonly shapeHash: string; } /** A `Tool` that records what its code runs were shaped like. */ export interface RecordsCodeRuns { readonly [CODE_RUNS]: ReadonlyMap; } /** * A program reduced to its CALL SHAPE: which operations, in what order. * * Strings, numbers, comments and identifier names are what make two runs of the * same computation look different, and they are also the half that quotes the * data — so removing them is both what makes the hash group correctly and what * makes it safe to emit. `groupBy(rows, 'wwn')` and `groupBy(items, 'serial')` * reduce to one shape; a totals-then-threshold written eleven times this month * hashes to one value eleven times, which is the signal worth having. * * **The callee names are KEPT, and that is the point.** The operation IS the * signal. The first version of this erased them too, which made `groupBy` and * `sortBy` one shape and collapsed the whole backlog into a single meaningless * bucket — caught by a clean-room probe on the published package, and missed by * a test whose two examples happened to differ elsewhere as well. A function * name is code, not data; the data lives in the literals and the variable * names, and those are what go. * * Deliberately crude — a lexical reduction, not a parse. It has to work on * whatever language the runner was configured for, and a wrong parse would be a * worse answer than a coarse one. */ export declare function codeShape(code: string): string; /** A `Tool` that holds live sessions, keyed by isolation key. */ export interface HoldsToolSessions { readonly [TOOL_SESSIONS]: ReadonlyMap; } /** Read the code-run facts off a candidate, or `undefined` when it records none. */ export declare function codeRunsOf(candidate: unknown): Map | undefined; /** Read the live-session map off a candidate, or `undefined` when it holds none. */ export declare function toolSessionsOf(candidate: unknown): ReadonlyMap | undefined; export declare function codeRunnerTool(options: CodeRunnerToolOptions): Tool<{ code: string; }, string> & HoldsToolSessions & RecordsCodeRuns;