agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
119 lines • 6.74 kB
JavaScript
/**
* 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.
*/
import { safeStringify } from './validators.js';
/** Type guard for consumers reading `tool_end.result` or a tool message. */
export function isTruncatedToolResult(value) {
return (typeof value === 'object' &&
value !== null &&
value.truncated === true &&
typeof value.reason === 'string');
}
/**
* 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.
*/
export 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.`);
}
}
/** The string form the model would have read — the thing being measured. */
function measured(value) {
return typeof value === 'string' ? value : 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.
*/
export 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 = 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 && 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 };
}
//# sourceMappingURL=toolResultCap.js.map