agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
263 lines (262 loc) • 14.5 kB
TypeScript
/**
* 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 '../provider-contract/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/tools.js';
import { type TeardownScope } from '../tools/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<TeardownScope, 'call' | 'run' | 'session'>;
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 — a backend that cannot write into its own session)
* refuses BY NAME at dispatch. `localCodeRunner` and `agentCoreCodeRunner`
* both stage. 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;
/**
* Where a code run's facts ALSO go when the AGENT dispatched the call: a slot
* the dispatch loop puts on that one call's execution context
* (`core/agent/stages/toolCalls.ts`, both execute boundaries) and reads back
* when `execute` returns. The published map above is filled exactly as
* before; the slot is the dispatch's own copy, the one the script record
* attests from and `agentfootprint.tools.code_run` carries.
*
* Why not the map alone: it is keyed by the provider's call id, and two runs —
* two agents sharing one tool object, or one run whose provider reuses an id —
* can hold the same id at once. A fact read back by id then reaches whichever
* dispatch reads first: one run's exit code attested another run's script.
* The slot is the dispatch's own object, so its facts can only reach it. A
* wrapper tool that hands the same context (or a spread of it) to this one
* keeps the slot. `Symbol.for`, for the CJS/ESM reason above.
*
* @internal Library plumbing between the dispatch loop and this tool.
*/
export declare const CODE_RUN_SLOT: unique symbol;
/** One dispatch's slot for the facts of the code it ran. @internal */
export interface CodeRunSlot {
facts?: CodeRunFacts;
}
/** 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;
/** What the runner REPORTED (`CodeResult.ok`). ABSENT when it reported
* nothing — unknown, never recorded as success. A `false` is told apart by
* the three fields below. */
readonly ok?: boolean;
/** The program's exit status — exactly when the runner supplied one. */
readonly exitCode?: number;
/** `true`: the runner flagged an error with no exit status, so whether the
* code ran is not confirmed — not a failed program. */
readonly serviceError?: true;
/** The signal that ended the process before it exited, when the runner
* knows it (`'SIGKILL'`): no exit status exists, and none is recorded. */
readonly terminatedBy?: string;
readonly shapeHash: string;
}
/** A `Tool` that records what its code runs were shaped like. */
export interface RecordsCodeRuns {
readonly [CODE_RUNS]: ReadonlyMap<string, CodeRunFacts>;
}
/**
* 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.
*
* The code is model output, so every pass is linear in it. Block comments and
* string literals are scanned rather than matched: the regexes that used to
* find them re-read the rest of the code from every unclosed comment opener or
* quote — quadratic on openers repeated with text between them, or on a quote
* followed by a long run of escaped quotes (16,000 of them took about half a
* second). The scans produce exactly what those regexes did
* (`test/security/linear-scanners.test.ts`).
*/
export declare function codeShape(code: string): string;
/**
* Every `/* … *\/` replaced by one space, each ending at the first `*\/` after
* its `/*`. Once a `/*` has no `*\/` after it, no later one has either — the
* scan stops there instead of re-trying each.
*/
export declare function stripBlockComments(code: string): string;
/**
* Every string literal replaced by `S`, leftmost first: from a quote, the
* shortest run of escapes (`\` + anything but a line break) and other
* characters up to the same quote unescaped.
*
* A literal that never closes is the costly case — the walk ends at a `\`
* before a line break, or at the end — and the regex re-tries it from every
* later quote. But a later opening of the SAME quote, before where that walk
* stopped, walks the same tail (whether a character is escaped depends only on
* the backslashes just before it, and a quote is not one), so it fails the
* same way: those are skipped. Each quote's failed walks therefore cover
* disjoint stretches, and the literals that close are jumped over.
*/
export declare function replaceStringLiterals(code: string): string;
/** A `Tool` that holds live sessions, keyed by isolation key. */
export interface HoldsToolSessions {
readonly [TOOL_SESSIONS]: ReadonlyMap<string, CodeSession>;
}
/** Read the code-run facts off a candidate, or `undefined` when it records none. */
export declare function codeRunsOf(candidate: unknown): Map<string, CodeRunFacts> | undefined;
/** Read the live-session map off a candidate, or `undefined` when it holds none. */
export declare function toolSessionsOf(candidate: unknown): ReadonlyMap<string, CodeSession> | undefined;
export declare function codeRunnerTool(options: CodeRunnerToolOptions): Tool<{
code: string;
}, string> & HoldsToolSessions & RecordsCodeRuns;