UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

773 lines • 40 kB
"use strict"; /** * 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 })); */ Object.defineProperty(exports, "__esModule", { value: true }); exports.codeRunnerTool = exports.toolSessionsOf = exports.codeRunsOf = exports.replaceStringLiterals = exports.stripBlockComments = exports.codeShape = exports.CODE_RUN_SLOT = exports.CODE_RUNS = exports.TOOL_SESSIONS = void 0; const fnv1a_js_1 = require("../lib/fnv1a.js"); const linearText_js_1 = require("../lib/linearText.js"); const types_js_1 = require("../provider-contract/types.js"); const tools_js_1 = require("../tools/tools.js"); const toolSessions_js_1 = require("../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. */ exports.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. */ exports.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. */ exports.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`). */ 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()); } exports.codeShape = codeShape; /** * 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. */ 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); } exports.stripBlockComments = stripBlockComments; /** * 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. */ 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); } exports.replaceStringLiterals = replaceStringLiterals; /** * 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 && !(0, linearText_js_1.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. */ function codeRunsOf(candidate) { if (candidate === null || typeof candidate !== 'object') return undefined; const held = candidate[exports.CODE_RUNS]; return held instanceof Map ? held : undefined; } exports.codeRunsOf = codeRunsOf; /** Read the live-session map off a candidate, or `undefined` when it holds none. */ function toolSessionsOf(candidate) { if (candidate === null || typeof candidate !== 'object') return undefined; const held = candidate[exports.TOOL_SESSIONS]; return held instanceof Map ? held : undefined; } exports.toolSessionsOf = toolSessionsOf; 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 = (0, tools_js_1.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 ${types_js_1.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: (0, fnv1a_js_1.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[exports.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, [exports.TOOL_SESSIONS]: sessions, [exports.CODE_RUNS]: codeRuns }; } exports.codeRunnerTool = codeRunnerTool; /** * 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 = (0, toolSessions_js_1.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['${types_js_1.STAGED_INPUTS_ENV}'])['${first}']` : `const path = JSON.parse(process.env.${types_js_1.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 ${types_js_1.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 (!(0, types_js_1.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 ${types_js_1.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 ${types_js_1.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