UNPKG

agentfootprint

Version:

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

125 lines • 7.11 kB
"use strict"; /** * toolResultCap — the opt-in ceiling on what ONE tool result may cost. * * Pattern: a measured replacement at the dispatch boundary. Not a transform of * the tool's value (the tool still returns whatever it returns), and * not a policy about tools — a last-resort net on the ONE number a * context window cannot survive: characters. * Role: consumed by the tool-dispatch loop (`stages/toolCalls.ts`) on every * path that turns a tool result into a `role: 'tool'` message. The * one `role: 'tool'` message it never measures is the batch * settlement's (9.113.0): that is the library's own fixed sentence * for a call that never ran, not a tool result (`stages/README.md`). * Emits: nothing. The truncation is visible because the marker IS the result * — `agentfootprint.stream.tool_end` carries it, and so does history. * * ── Why this is opt-in, and why it will stay opt-in ────────────────────────── * A default here would silently modify tool results. A tool that returns 200KB * of rows is doing what somebody wrote it to do, and a framework that quietly * replaced that with a stub the first time it ran would be lying to the app * about its own tool. So: no `maxToolResultChars`, no measurement, no marker, * byte-identical behaviour. Set it and every result on every dispatch path is * measured against it. * * ── What the model is handed, and why it is shaped like a lesson ───────────── * The failure this exists for is not "the result was big". It is "the result * was big, the window blew, and the model never learned that asking for less * was an option". A bare truncation teaches nothing — the model reads a * sentence that stops mid-word and assumes the data ends there. The marker says * what happened, how far over the cap it was, and what to do next: * * { truncated: true, * reason: "orders_export returned 812431 chars, over the 20000-char cap. * Narrow the request and call again.", * head: "<the first N characters, verbatim>" } * * ONE shape, always the object, for a string result and an object result alike * — a sink can branch on `.truncated` without parsing prose, and the model * reads it as JSON on the `role: 'tool'` message. `head` is verbatim: it is the * only part of the answer that survived, and paraphrasing it would make the * marker the second thing in the transcript that cannot be trusted. * * ── The head budget ───────────────────────────────────────────────────────── * `head` gets whatever the cap has left after the envelope that explains it — * so the serialized marker stays within the cap, and a bigger cap buys a * proportionally bigger head. When the cap is smaller than its own explanation, * `head` is absent and the marker is the whole result: an explanation that had * to be truncated to fit under a cap would teach nothing, which is the failure * this module exists to prevent. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.capToolResult = exports.assertMaxToolResultChars = exports.isTruncatedToolResult = void 0; const validators_js_1 = require("./validators.js"); /** Type guard for consumers reading `tool_end.result` or a tool message. */ function isTruncatedToolResult(value) { return (typeof value === 'object' && value !== null && value.truncated === true && typeof value.reason === 'string'); } exports.isTruncatedToolResult = isTruncatedToolResult; /** * The refusal for a cap that cannot cap anything. * * `0` and negatives are not "disable it" — omitting the option is. A cap of `0` * would replace every result with a marker that has no room for a head, which * is a working agent turned into a wall of refusals; a negative one has no * reading at all. Refused where it is configured, naming the value, rather than * at the first tool call of the first run. */ function assertMaxToolResultChars(site, value) { if (value === undefined) return; if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) { throw new Error(`${site}: maxToolResultChars must be a positive whole number of characters, and ` + `${String(value)} is not one. It is the ceiling on ONE tool result — over it, the ` + `model is handed a marker naming the tool, the size and the cap, and told to narrow ` + `the request. To turn the ceiling OFF, omit the option: absent means results are ` + `never measured and never replaced, which is what every release before 9.11.0 did.`); } } exports.assertMaxToolResultChars = assertMaxToolResultChars; /** The string form the model would have read — the thing being measured. */ function measured(value) { return typeof value === 'string' ? value : (0, validators_js_1.safeStringify)(value); } /** * Measure one tool result against the cap and, when it is over, replace it. * * Returns the ORIGINAL value by reference when the cap is absent or the result * fits — so an under-cap dispatch is byte-identical, not a round-trip through * `JSON.parse(JSON.stringify(...))` that would quietly re-shape it. */ function capToolResult(value, opts) { const cap = opts.maxChars; if (cap === undefined || cap <= 0) return { value, truncated: false }; const text = measured(value); if (text.length <= cap) return { value, truncated: false }; const reason = `${opts.toolName} returned ${text.length} chars, over the ${cap}-char cap. ` + `Narrow the request and call again.`; // What the envelope costs before any head is added. `head` is measured as the // JSON string it will become, so escapes inside it are paid for honestly. const envelope = (0, validators_js_1.safeStringify)({ truncated: true, reason, head: '' }); const budget = cap - envelope.length; if (budget <= 0) return { value: { truncated: true, reason }, truncated: true }; // Shrink until the SERIALIZED marker fits: one escaped character can cost six // (`"` → `\"`, a control char → ``), so a slice of `budget` characters // is an upper bound rather than the answer. let take = budget; let marker = { truncated: true, reason, head: text.slice(0, take) }; while (take > 0 && (0, validators_js_1.safeStringify)(marker).length > cap) { // Proportional back-off, then exact: cheap for plain text (one pass), // bounded for text that is mostly escapes. take = take > 16 ? Math.floor(take * 0.8) : take - 1; marker = { truncated: true, reason, head: text.slice(0, take) }; } if (take <= 0) return { value: { truncated: true, reason }, truncated: true }; return { value: marker, truncated: true }; } exports.capToolResult = capToolResult; //# sourceMappingURL=toolResultCap.js.map