agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
764 lines • 39.3 kB
JavaScript
/**
* 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 { fnv1a } from '../lib/fnv1a.js';
import { isLineBreak } from '../lib/linearText.js';
import { canStageCodeInputs, STAGED_INPUTS_ENV } from '../provider-contract/types.js';
import { defineTool } from '../tools/tools.js';
import { toolSessionKey } from '../tools/toolSessions.js';
const DEFAULT_MAX_OUTPUT_CHARS = 4_000;
/**
* 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 const TOOL_SESSIONS = Symbol.for('agentfootprint.tools.sessions');
/**
* 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 const CODE_RUNS = Symbol.for('agentfootprint.tools.codeRuns');
/**
* 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 const CODE_RUN_SLOT = Symbol.for('agentfootprint.tools.codeRunSlot');
/**
* 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 function codeShape(code) {
const withoutLiterals = replaceStringLiterals(stripBlockComments(code)
.replace(/(^|[^:])\/\/[^\n]*/g, '$1 ')
.replace(/#[^\n]*/g, ' '));
return (withoutLiterals
.replace(/\b\d[\d_.eE+-]*\b/g, 'N')
// Only identifiers that are NOT callees. The operation name is the whole
// signal, so it stays.
.replace(/\b(?!if|else|for|while|return|function|const|let|var|def|import|from|class|try|catch|await|async|in|of|new|not|and|or|S|N)[A-Za-z_$][\w$]*\b(?!\s*\()/g, 'V')
.replace(/\s+/g, ' ')
.trim());
}
/**
* 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 function stripBlockComments(code) {
const parts = [];
let copied = 0;
for (;;) {
const start = code.indexOf('/*', copied);
if (start === -1)
break;
const close = code.indexOf('*/', start + 2);
if (close === -1)
break;
parts.push(code.slice(copied, start), ' ');
copied = close + 2;
}
return copied === 0 ? code : parts.join('') + code.slice(copied);
}
/**
* 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 function replaceStringLiterals(code) {
const parts = [];
let copied = 0;
const failsBefore = new Map();
let at = 0;
while (at < code.length) {
const quote = code.charCodeAt(at);
if (isQuote(quote) && at >= (failsBefore.get(quote) ?? 0)) {
const walk = walkLiteral(code, at, quote);
if (walk.closed) {
parts.push(code.slice(copied, at), 'S');
copied = walk.end + 1;
at = walk.end + 1;
continue;
}
failsBefore.set(quote, walk.end);
}
at++;
}
return copied === 0 ? code : parts.join('') + code.slice(copied);
}
/**
* The walk `(?:\\.|(?!q)[^\\])*q` takes from the quote at `open`: `end` is the
* closing quote when `closed`, otherwise where the walk stopped.
*/
function walkLiteral(code, open, quote) {
let at = open + 1;
while (at < code.length) {
const char = code.charCodeAt(at);
if (char === quote)
return { closed: true, end: at };
if (char !== 0x5c) {
at += 1;
}
else if (at + 1 < code.length && !isLineBreak(code.charCodeAt(at + 1))) {
at += 2;
}
else {
return { closed: false, end: at };
}
}
return { closed: false, end: at };
}
/** `'`, `"` or a backtick. */
function isQuote(code) {
return code === 0x27 || code === 0x22 || code === 0x60;
}
/** Read the code-run facts off a candidate, or `undefined` when it records none. */
export function codeRunsOf(candidate) {
if (candidate === null || typeof candidate !== 'object')
return undefined;
const held = candidate[CODE_RUNS];
return held instanceof Map ? held : undefined;
}
/** Read the live-session map off a candidate, or `undefined` when it holds none. */
export function toolSessionsOf(candidate) {
if (candidate === null || typeof candidate !== 'object')
return undefined;
const held = candidate[TOOL_SESSIONS];
return held instanceof Map ? held : undefined;
}
export function codeRunnerTool(options) {
const name = options.name ?? 'run_code';
const scope = options.scope ?? 'run';
const language = options.language ?? 'python';
const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
/**
* Live sessions by isolation key.
*
* A `Map` and not a closure variable, because two keys are two sandboxes: one
* variable would be exactly the cross-binding this whole feature exists to
* prevent. Entries are removed by the teardown the tool registers alongside
* them, so the map cannot outlive what it points at.
*/
const sessions = new Map();
/** In-flight starts, so two parallel tool calls under one key open ONE session. */
const starting = new Map();
/** Facts of finished runs, taken and deleted by the dispatch loop. */
const codeRuns = new Map();
const wants = options.wants;
const wantNames = wants === undefined ? [] : Object.keys(wants);
const tool = defineTool({
name,
description: options.description ?? describe(scope, language, wants),
inputSchema: {
type: 'object',
properties: {
code: {
type: 'string',
description: `${language} source to execute. Print what you want back — stdout is the result. ` +
'Compute over big data here rather than asking for it to be pasted back to you.',
},
// One string property per declared artifact argument. `assertToolWants`
// requires the property to exist and to be a string — the model passes
// the TICKET, never the bytes — so composing them here is what makes
// the declaration usable rather than merely legal.
...Object.fromEntries(wantNames.map((argName) => [
argName,
{
type: 'string',
description: `The art_… ref of the ${wants?.[argName] ?? 'artifact'} to work on. Its data ` +
`is written into this session as a file; the path is under '${argName}' in ` +
`the ${STAGED_INPUTS_ENV} environment variable. Pass the ref, never the data.`,
},
])),
},
required: ['code'],
additionalProperties: false,
},
...(options.checkIn !== undefined && { checkIn: options.checkIn }),
...(options.needs && { needs: options.needs }),
...(wants !== undefined && { wants }),
// This tool RUNS what the model passes as `code` — declared, so the record
// can say which program a call submitted (`tools/script.ts`). The language
// is the fence's info string when the library shows the program.
script: { argument: 'code', language },
execute: async (args, ctx) => {
const key = requireKey(ctx, scope, name);
const session = await acquire(key, ctx);
// Staging happens BEFORE the code runs and after the framework resolved
// every declared ref — `args[argName]` is the DATA by now, not the
// ticket. A declared argument the model did not pass is simply not
// staged: an optional artifact input is the model choosing not to use
// one, exactly as `resolveToolWants` treats it.
const stagedNames = await stageDeclaredInputs(session, args, wantNames, name, ctx);
const result = await session.execute({
code: args.code,
language,
...(options.timeoutMs !== undefined && { timeoutMs: options.timeoutMs }),
...(ctx.signal && { signal: ctx.signal }),
});
const minted = await mintProducedFiles(result, ctx);
const rendered = render(result, maxOutputChars, name, minted, stagedNames, wantNames);
// Facts only, never `args.code` — generated code quotes the data it was
// handed, so the code is the one part of this that must not travel.
const facts = {
tool: name,
language,
stagedInputs: stagedNames.length,
outputChars: rendered.length,
truncated: rendered.length >= maxOutputChars,
// The runner's own verdict, or nothing: a runner that reported no
// outcome leaves no `ok` here, so the record never says "ran clean"
// on the strength of silence.
...(result.ok !== undefined && { ok: result.ok }),
// The facts that tell a failed program from an unconfirmed run and
// from a stopped one, copied exactly when the runner supplied them —
// never inferred here.
...(result.exitCode !== undefined && { exitCode: result.exitCode }),
...(result.serviceError === true && { serviceError: true }),
...(result.terminatedBy !== undefined && { terminatedBy: result.terminatedBy }),
shapeHash: fnv1a(codeShape(args.code)),
};
// The published map, by the call's id, exactly as always — and, for a
// call an agent dispatched, that dispatch's own slot as well.
const slot = ctx[CODE_RUN_SLOT];
if (slot !== undefined)
slot.facts = facts;
codeRuns.set(ctx.toolCallId, facts);
return rendered;
},
});
/** Get the session for `key`, opening one if this is the first call. */
const acquire = async (key, ctx) => {
const live = sessions.get(key);
if (live) {
// A reuse re-registers under the SAME (tool, scope, key). The tier keeps
// the FIRST cleanup — it is the one holding this handle — and treats the
// repeat as a touch, which is how the idle sweep and the LRU bound learn
// this session is still in use. Registering only once would make a busy
// session look as cold as an abandoned one.
register(key, live, ctx);
return live;
}
// Two parallel tool calls in one iteration must not open two sandboxes.
const inFlight = starting.get(key);
if (inFlight)
return inFlight;
const opening = options.runner
.start({ key, language, ...(ctx.signal && { signal: ctx.signal }) })
.then((opened) => {
sessions.set(key, opened);
register(key, opened, ctx);
return opened;
})
.finally(() => {
starting.delete(key);
});
starting.set(key, opening);
return opening;
};
const register = (key, session, ctx) => {
ctx.onTeardown?.(async () => {
// Drop the entry FIRST: if `stop()` throws, the map must not keep
// handing out a session the runtime has already given up on.
sessions.delete(key);
await session.stop();
}, { scope, key, runnerId: options.runner.id, label: language });
};
// The live map rides the Tool under a registry symbol — see TOOL_SESSIONS.
return { ...tool, [TOOL_SESSIONS]: sessions, [CODE_RUNS]: codeRuns };
}
/**
* Derive the isolation key, or REFUSE by name.
*
* The three refusals are the security surface of this tool, so each says what
* is missing, why it matters, and the one-line fix. None of them degrades
* quietly: a fallback to a wider key is the cross-binding bug, and a fallback
* to a narrower one silently multiplies latency and cost.
*/
function requireKey(ctx, scope, name) {
const supported = ctx.teardownScopes;
if (supported !== undefined && !supported.includes(scope)) {
throw new Error(`${name}: this tool holds a '${scope}'-scoped code session, and the door it is running ` +
`behind honours ${supported.length === 0 ? 'no teardown scopes' : supported.join(', ')}. ` +
'A session nothing will ever close is a sandbox left running. Either run this tool ' +
`inside an Agent, or build it with scope: '${supported[0] ?? 'call'}'.`);
}
const key = toolSessionKey(ctx, scope);
if (key)
return key;
if (scope === 'session') {
throw new Error(`${name}: scope 'session' needs a hosting session, and this run has no sessionId. ` +
'Pass one — `agent.run({ message, sessionId })`, which `standingAgent` does from ' +
"`HostRequest.sessionId` — or build this tool with scope: 'run'. It is not silently " +
'narrowed, because a session-scoped interpreter and a run-scoped one differ by about ' +
'30x in start-up cost, and by everything in what persists between turns.');
}
throw new Error(`${name}: scope 'run' needs a run, and this call has no runId — it is being served ` +
'outside an Agent (over `mcpServe`, or from a script). A served call is one call, not a ' +
"turn, so nothing would ever end the session. Build this tool with scope: 'call' for " +
'that door.');
}
/** The description the model reads when the caller did not write one. */
function describe(scope, language, wants) {
const persistence = scope === 'call'
? 'Each call runs in a fresh environment — nothing carries over, so include everything you need.'
: scope === 'run'
? 'State persists across calls within this turn: variables and files you create stay available.'
: 'State persists across the whole conversation: variables and files you create stay available in later turns.';
return (`Execute ${language} code and return its output. ${persistence} ` +
'Use this to COMPUTE over data rather than asking for the data itself — fetch, filter, ' +
'aggregate and print the answer, so a large result never has to travel through this ' +
'conversation. Print what you want to see; stdout is what you get back.' +
stagingClause(language, wants));
}
/**
* The staging half of the description — added ONLY when the tool declares
* artifact arguments, so a tool without them reads exactly as it always did.
*
* It names the mechanism rather than the paths, because the paths do not exist
* until the session does: one environment variable, one JSON object, and a
* one-line example in this tool's own language. A model that reads this needs
* nothing else to use a staged file.
*/
function stagingClause(language, wants) {
if (wants === undefined)
return '';
const names = Object.keys(wants);
const first = names[0] ?? 'input';
const example = language.toLowerCase().startsWith('py')
? `import json, os; path = json.loads(os.environ['${STAGED_INPUTS_ENV}'])['${first}']`
: `const path = JSON.parse(process.env.${STAGED_INPUTS_ENV})['${first}']`;
return (` Pass artifact refs as ${names.map((n) => `'${n}'`).join(', ')} and their data is written ` +
`into this session as files BEFORE your code runs — never paste the data into the code. ` +
`The file paths are in the ${STAGED_INPUTS_ENV} environment variable, a JSON object keyed ` +
`by argument name: ${example}. Open that path and read it.`);
}
/**
* Stage every declared artifact argument the model actually passed.
*
* ── Refused rather than degraded ────────────────────────────────────────────
* A runner whose sessions cannot stage refuses BY NAME, naming both the runner
* and the tool. Running the code anyway would leave the model reading a file
* that is not there — an error it would try to debug, in a session that never
* had the data, for a reason nothing in the conversation could reveal.
*
* @returns the argument names that were staged, for the result's own preamble.
*/
async function stageDeclaredInputs(session, args, wantNames, toolName, ctx) {
if (wantNames.length === 0)
return [];
const inputs = [];
for (const argName of wantNames) {
const resolved = args[argName];
// Absent = the model chose not to pass this one. Nothing to stage, and
// nothing to complain about: `resolveToolWants` treats an absent optional
// argument the same way.
if (resolved === undefined)
continue;
const meta = ctx.wanted?.[argName];
inputs.push({
// The manifest KEY is the argument name the model spoke — which is what
// the description told it to look up. The FILE gets an extension derived
// from the artifact's own stated media type, so an interpreter's loader
// sees something familiar; the two are separate fields precisely so they
// cannot drift.
name: argName,
fileName: fileNameFor(argName, meta?.mediaType),
data: bytesOf(resolved),
...(meta?.mediaType !== undefined && { mediaType: meta.mediaType }),
});
}
if (inputs.length === 0)
return [];
if (!canStageCodeInputs(session)) {
throw new Error(`${toolName}: this tool declares artifact arguments (${wantNames.join(', ')}), and the ` +
`code runner behind it cannot put data into its own session — its CodeSession has no ` +
`stageInputs. The code would run against files that are not there, so it is not run. ` +
`Use a runner that stages (localCodeRunner and agentCoreCodeRunner do), or drop ` +
`\`wants\` and have the model fetch the data inside the code.`);
}
const landed = await session.stageInputs(inputs);
// Keyed back by the ARGUMENT name the model spoke, which is what the
// manifest promises and what the description told it to look up.
return landed.map((entry) => entry.name);
}
/** `dataset` + `text/csv` → `dataset.csv`. The extension comes from the meta's
* own stated media type, never sniffed — and an unknown type gets no
* extension rather than a guessed one. */
function fileNameFor(argName, mediaType) {
const ext = mediaType === undefined ? undefined : EXT_BY_MEDIA_TYPE[mediaType.toLowerCase()];
return ext === undefined ? argName : `${argName}.${ext}`;
}
/** The reverse of MEDIA_TYPE_BY_EXT, for the types a staged payload plausibly
* carries. Absent means "no extension", which is honest. */
const EXT_BY_MEDIA_TYPE = {
'application/json': 'json',
'text/csv': 'csv',
'text/plain': 'txt',
'text/markdown': 'md',
'text/html': 'html',
'image/svg+xml': 'svg',
'image/png': 'png',
'image/jpeg': 'jpg',
'application/pdf': 'pdf',
};
/**
* The resolved payload as bytes for the file.
*
* Strings and binary are written verbatim; anything else is JSON, which is
* what the store measured and digested it as. A value JSON cannot carry never
* got into the store in the first place — `canonicalPayloadBytes` refuses it
* at `put` — so there is no lossy branch here to hide.
*/
function bytesOf(resolved) {
if (typeof resolved === 'string' || resolved instanceof Uint8Array)
return resolved;
return JSON.stringify(resolved) ?? 'null';
}
// ── Mint-on-output (9.22.0): the store behind `CodeResult.artifacts` ────────
//
// When a store is attached, every file the run handed back IN-BAND
// (`artifact.data` — see `CodeResult`) is checked into the artifact store
// under the run's own scope, and the rendered line names the ref, so the
// model can route the file — pass it to a `wants`-tool, `present` it —
// instead of asking for its contents. Entries without `data` stay
// described-only: minting needs bytes, and a described file whose bytes never
// left the sandbox is a fact, not a failure. Zero-cost: no store, or no
// in-band files, and not one line here runs.
//
// STAGING-IN ARRIVED IN 9.26.0, through the door 9.22.0 said it was waiting
// for. The note here used to read: "pushing a resolved artifact payload
// through `execute` would mean inlining megabytes into an argv (`python3 -c …`
// has OS argument limits) in language-specific quoting — declared input refs
// wait for a session file-write verb on the port." That verb is
// `CodeSession.stageInputs` (optional, feature-detected, absent on backends
// that cannot write into their own session), and `wants` on this tool is what
// uses it. Data now flows BOTH ways without entering the window: refs in as
// staged files, produced files out as refs.
/** `report.csv` → `file/csv`; extensionless → `file`. The kind vocabulary a
* code-produced file is minted under — derived from the producer's OWN
* stated name, deterministically, never sniffed from content. */
function kindOfFile(name) {
const dot = name.lastIndexOf('.');
const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
return ext.length > 0 ? `file/${ext}` : 'file';
}
/** The honest well-known-extension table. Unknown extensions fall back on
* what the payload's own JS shape proves: text for a string, opaque bytes
* for a Uint8Array. */
const MEDIA_TYPE_BY_EXT = {
json: 'application/json',
csv: 'text/csv',
txt: 'text/plain',
md: 'text/markdown',
html: 'text/html',
svg: 'image/svg+xml',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
pdf: 'application/pdf',
};
function mediaTypeOfFile(name, data) {
const dot = name.lastIndexOf('.');
const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
return (MEDIA_TYPE_BY_EXT[ext] ?? (typeof data === 'string' ? 'text/plain' : 'application/octet-stream'));
}
/**
* The refs this call's declared artifact INPUTS resolved to — the parents of
* anything the code produced from them.
*
* The framework already redeemed them (that is what `ctx.wanted` is), so the
* lineage is a FACT it holds, not an inference: every file this execution
* handed back was computed in a session whose only declared inputs were these.
* Stamping them automatically is what makes the derive step of the claim-check
* journey actually carry lineage — a consumer folding `head()` over
* `parentRefs` walks from the chart back to the dataset without the tool
* author having to remember.
*
* Deduplicated, because two arguments may legitimately name one artifact and
* an object store's metadata budget counts every ref.
*/
function parentRefsOf(ctx) {
const wanted = ctx.wanted;
if (wanted === undefined)
return [];
return [...new Set(Object.values(wanted).map((meta) => meta.ref))];
}
/**
* Mint every in-band file into the store. Per-entry failures are CONTAINED
* and stated in the rendered line (the code ran; a full store must not turn
* its success into a throw) — the store's own `artifacts.refused` event has
* already put the reason on the record.
*
* Lineage rides every mint: the resolved input refs go on as `parentRefs`,
* validated at mint like any other derivation fact. That validation is why
* this is not silently degraded to "drop the parents if they no longer
* resolve" — a parent that expired mid-call means the lineage this artifact
* would claim is not true, and a claim-check store that mints an unprovable
* derivation is exactly the lie `UnknownParentRefError` exists to refuse. The
* refusal lands per entry, stated in the line the model reads.
*/
async function mintProducedFiles(result, ctx) {
const outcomes = new Map();
if (!ctx.hasArtifacts)
return outcomes;
const parentRefs = parentRefsOf(ctx);
for (const artifact of result.artifacts ?? []) {
if (artifact.data === undefined)
continue;
try {
const meta = await ctx.artifacts.put({
kind: kindOfFile(artifact.name),
mediaType: artifact.mediaType ?? mediaTypeOfFile(artifact.name, artifact.data),
data: artifact.data,
label: artifact.name,
// Absent when this call resolved no refs — an empty list would read
// as "derived from nothing", which is a different statement from
// "nothing said where this came from".
...(parentRefs.length > 0 && { parentRefs }),
});
outcomes.set(artifact.name, { ref: meta.ref });
}
catch (err) {
outcomes.set(artifact.name, {
failed: err instanceof Error ? err.message : String(err),
});
}
}
return outcomes;
}
/**
* Render a `CodeResult` for the model.
*
* TRUNCATION IS ALWAYS STATED. A slice the model cannot see is a silent
* success: it goes on to reason over a fragment of a table believing it has the
* table. The marker is deliberately plain text in the result body, not
* metadata, because the result body is the only channel the model reads.
*/
function render(result, maxOutputChars,
/** The tool's own name — what the no-inputs line anchors to, so the sentence
* names a call rather than pointing at "this" one. */
toolName, minted, staged = [], declared = []) {
const parts = [];
// What was put IN, stated before what came out. A model whose code failed to
// find a file needs to know the file was staged and under which name — the
// alternative is debugging an absence that never happened.
//
// Anchored to the CALL, like its `else` below and for the same reason: a
// session outlives the call, so "staged into this session: dataset" cannot
// say WHICH call staged it. Two staged calls in one turn leave two such
// lines in `history`, differing only in the names they list, and a model
// re-reading them has no way to attribute either — it can conclude both
// files are present in the session it is looking at. Naming the call the
// result answers is what makes a preamble re-readable, and it is the one
// half of this ternary that did not have it.
if (staged.length > 0) {
parts.push(`[staged for the ${toolName} call this result answers, before its code ran: ` +
`${staged.join(', ')} — their paths were in ${STAGED_INPUTS_ENV} for that call]`);
}
else if (declared.length > 0) {
// The OTHER half of the same law, and the one an absent statement makes
// expensive: this tool declares artifact arguments, the model passed
// none, so the session holds no staged data and the manifest variable is
// not set at all. Optional inputs are legitimate (a call that needs no
// stored data is a real call), but a model whose code just failed on a
// missing environment variable would otherwise debug an absence nothing
// in the conversation explains.
//
// Anchored, because this line is a TOOL RESULT: it stays in `history` and
// is re-read on every later call of the turn. "on this call" moves with
// each re-reading — three calls on, one of which DID pass a ref, it denies
// the staging that happened. Naming the call this result answers fixes the
// sentence to the one call it is true of, and the tenses follow it.
parts.push(`[no artifact inputs were passed on the ${toolName} call this result answers, so ` +
`nothing was staged into that call's session and ${STAGED_INPUTS_ENV} was not set ` +
`for it. To work on stored data, pass its art_… ref as ` +
`${declared.map((n) => `'${n}'`).join(' / ')}.]`);
}
const stdout = clip(result.stdout, maxOutputChars, result.truncated?.stdout === true);
const stderr = clip(result.stderr, maxOutputChars, result.truncated?.stderr === true);
if (stdout.text.length > 0)
parts.push(stdout.text);
if (stderr.text.length > 0)
parts.push(`stderr:\n${stderr.text}`);
const outcome = outcomeLine(result, toolName);
if (outcome !== undefined)
parts.push(outcome);
for (const artifact of result.artifacts ?? []) {
const outcome = minted?.get(artifact.name);
// A ref the RUNNER already stamped is honored; one this tool minted wins
// freshness (both name the same bytes when both exist).
const ref = outcome !== undefined && 'ref' in outcome ? outcome.ref : artifact.ref;
parts.push(`[artifact: ${artifact.name}, ${artifact.bytes} bytes${artifact.uri ? `, ${artifact.uri}` : ''}${ref !== undefined
? `, stored as ${ref} (${kindOfFile(artifact.name)}) — route this ref: pass it to a ` +
`tool that wants it, or present it`
: ''}${outcome !== undefined && 'failed' in outcome
? `, artifact-store mint failed: ${outcome.failed}`
: ''}]`);
}
if (parts.length === 0) {
// "Nothing was printed" and "this returned nothing" are different facts and
// only one of them tells the model what to do next.
parts.push('(the code ran and printed nothing — print the value you want returned)');
}
return parts.join('\n');
}
/**
* The one line that states how the run ended — built only from the fields the
* runner supplied, and never contradicting one of them.
*
* - `[exit N]` appears only when N was supplied. An absent exit status is
* never written as one ("non-zero" was a guess the runner never made).
* - A service error with no exit status is NOT a failed program: whether the
* code ran is not confirmed.
* - A process ended before it exited says so, with the signal when known.
* - An exit status that was supplied is never called missing: with
* `exitCode: 0` and no verdict, the run reported its exit, so there is
* nothing to add.
* - Only when the runner supplied none of these is the outcome stated as
* unknown — UNKNOWN IS STATED, like a cut, because output alone cannot say
* a script finished: it can print its answer and then fail.
*/
function outcomeLine(result, toolName) {
if (result.ok === true)
return undefined;
if (result.serviceError === true && result.exitCode === undefined) {
return (`[not confirmed: the code runner flagged an error for the ${toolName} call this result ` +
'answers and reported no exit status, so whether that code ran is not known]');
}
if (result.exitCode !== undefined) {
if (result.exitCode === 0 && result.ok === undefined)
return undefined;
return (`[exit ${result.exitCode}] the code did not complete successfully — read stderr above ` +
'and fix it.');
}
if (result.ok === false) {
const ended = result.terminatedBy === undefined ? '' : ` (terminated by ${result.terminatedBy})`;
return (`[no exit status reported${ended}: the code of the ${toolName} call this result answers ` +
'did not run to completion — read stderr above]');
}
return (`[exit status unknown: the code runner reported neither an exit status nor an error for ` +
`the ${toolName} call this result answers, so whether that code finished is not known]`);
}
/** Clip to the tool's ceiling, and SAY SO — including a cut the runner made. */
function clip(text, max, alreadyCutUpstream) {
const cutHere = text.length > max;
const shown = cutHere ? text.slice(0, max) : text;
if (!cutHere && !alreadyCutUpstream)
return { text: shown };
const of = cutHere ? text.length : undefined;
return {
text: `${shown}\n[truncated: showing ${shown.length}${of !== undefined ? ` of ${of}` : ''} ` +
'characters. Re-run printing a summary, a slice, or an aggregate instead of the whole ' +
'value.]',
};
}
//# sourceMappingURL=codeRunnerTool.js.map