UNPKG

agentfootprint

Version:

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

265 lines • 11.5 kB
"use strict"; /** * window/turns — where a turn boundary is, and which turns may leave. * * Pattern: Pure functions over the window (no scope, no I/O, no clock). * Role: core/ layer. THE refusal engine. Every window strategy — the three * that ship, and any a consumer writes — decides what leaves the * window by calling these functions and nothing else, because the * whole safety argument of the family lives here: * * a removal that splits an assistant's `tool_use` from its * `tool_result` produces a request the vendor rejects, and a * removal that swallows an unanswered question destroys the * referent of the answer that has not arrived yet. * * Strategies never import this module. `WindowStrategyInput` hands * them `planRemoval` already bound to this iteration's turns and * guards, so a strategy CANNOT skip the refusal rules — that is a * property of the seam, not of the documentation. * Emits: N/A. * * Testable on its own — see `test/core/window-turns.test.ts`. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.windowChars = exports.planRemoval = exports.refusalFor = exports.answeredCallIds = exports.segmentTurns = void 0; /** * Segment a window into turns. * * A new turn starts at any non-`tool` message; `tool` messages join the turn * in progress. A leading `tool` message (only reachable from a hand-built * history) starts its own turn rather than being silently dropped. */ function segmentTurns(history) { const turns = []; let current = []; let start = 0; const flush = () => { if (current.length === 0) return; turns.push({ index: turns.length, start, length: current.length, messages: current }); current = []; }; for (let i = 0; i < history.length; i++) { const msg = history[i]; if (msg.role !== 'tool' || current.length === 0) { flush(); start = i; } current.push(msg); } flush(); return turns; } exports.segmentTurns = segmentTurns; /** Every tool_call id that has a matching `role: 'tool'` message. */ function answeredCallIds(history) { const answered = new Set(); for (const msg of history) { if (msg.role === 'tool' && msg.toolCallId) answered.add(msg.toolCallId); } return answered; } exports.answeredCallIds = answeredCallIds; /** * Why this turn may NOT leave the window, or `undefined` when it may. * * Order matters only for which reason gets reported first; every check is * independent. `paused-tool` / `pending-check-in` are separated from * `unresolved-tool-call` on purpose: they are the same shape but a different * fact about the world, and "we are waiting on a human" is what the person * reading the trace needs to see. */ function refusalFor(turn, guards) { // First, because it is the strongest rule in the family: the message the // run is executing is the one thing the model cannot work without. A turn // holds at most one non-`tool` message, so this never competes with the // reasons below for the same turn. const request = guards.currentRequestIndex; if (request !== undefined && request >= turn.start && request < turn.start + turn.length) { return 'current-request'; } for (const msg of turn.messages) { if (msg.role === 'system') return 'system-envelope'; } const paused = guards.pausedToolCallId; if (paused !== undefined && paused.length > 0) { for (const msg of turn.messages) { const holdsPaused = msg.toolCallId === paused || (msg.toolCalls ?? []).some((tc) => tc.id === paused); if (holdsPaused) return guards.pausedCheckIn === true ? 'pending-check-in' : 'paused-tool'; } } for (const msg of turn.messages) { for (const call of msg.toolCalls ?? []) { if (!guards.answeredCallIds.has(call.id)) return 'unresolved-tool-call'; } } // LAST, because every reason above is a fact about the WIRE or about a // human, and these two are policies. A pinned turn that also holds an // unanswered call reports the unanswered call: that is the reason a reader // needs, and it is the one that would still be true with the pin switched // off. if (guards.pinnedTurnIndexes?.has(turn.index) === true) return 'last-tool-result'; // After the recency pin, because a turn held by both is held by the // content-blind rule first — the one that would still be true if the model // had declared nothing. The stage's stand-down reads both names as one // family (`stages/window.ts · pinIsBlocking`), so nothing hides behind // this order. if (guards.factPinnedTurnIndexes?.has(turn.index) === true) return 'ledger-fact'; return undefined; } exports.refusalFor = refusalFor; /** * Spend ONE pin's ceiling, and only on turns that would otherwise leave. * * A pin inside `keepRecentTurns` is already safe — `planRemoval` never even * asks about those turns — so it is filtered out here and costs nothing (the * free-pin law). The rest are admitted newest first, up to the ceiling; the * remainder is counted as `yielded` so the record can say the pin wanted more * than it was given. Nothing admitted → nothing returned, so a plan with no * contested pin carries no observations block at all. * * One function for both pins on purpose: the ledger-fact pin (9.102.0) is * the last-tool-result pin's content-aware sibling, and the ceiling is the * ONE grammar they share — a second spender would be a second place for the * free-pin law to drift. */ function spendCeiling(pins, limit, candidateCount) { if (limit <= 0 || pins.length === 0) return {}; const contested = pins.filter((p) => p.turnIndex < candidateCount); const admitted = contested.slice(0, limit); if (admitted.length === 0) return {}; return { turnIndexes: new Set(admitted.map((p) => p.turnIndex)), observations: { pinned: admitted.map((p) => ({ toolName: p.toolName, turnIndex: p.turnIndex, chars: p.chars, })), yielded: contested.length - admitted.length, limit, }, }; } /** * Admit both pins' candidates against their own ceilings and hand back the * guards `refusalFor` reads. Each pin's block is present exactly when that * pin held something, so the guards and the plan of an agent with no * contested pin are the exact shape they were before either pin existed. */ function admitPins(guards, candidateCount) { const latest = spendCeiling(guards.toolResultPins ?? [], guards.keepLastToolResults ?? 0, candidateCount); const facts = spendCeiling(guards.ledgerFactPins ?? [], guards.keepLedgerFacts ?? 0, candidateCount); return { guards: { ...guards, ...(latest.turnIndexes !== undefined && { pinnedTurnIndexes: latest.turnIndexes }), ...(facts.turnIndexes !== undefined && { factPinnedTurnIndexes: facts.turnIndexes }), }, ...(latest.observations !== undefined && { observations: latest.observations }), ...(facts.observations !== undefined && { ledgerFacts: facts.observations }), }; } /** * Choose the removal span: the LONGEST CONTIGUOUS run of removable candidate * turns, starting at the oldest removable one. * * Contiguity is not fussiness — it is what keeps the conversation in order. * A fold replaces its span with ONE summary message; if the span skipped over * an unremovable turn, that turn would end up after a summary of things that * happened before it. So an unremovable turn at the front is stepped over (the * span "takes the next oldest instead") and an unremovable turn in the middle * ends the span. Everything not removed keeps its position. * * The drop strategies take the same span for a second reason: it is what makes * a refusal reason mean the same thing under every strategy. A turn that ends * the span this iteration is retried the next one — by which time the tool * result it was waiting on has usually arrived. * * @param turns the window's turn segmentation * @param keepRecent how many trailing turns are off-limits * @param guards what must not leave (unanswered calls, the pause) * @param isExistingSummary optional: true for a turn that is a summary a prior * fold wrote. When the whole span is one such turn, the plan refuses with * `only-existing-summary` — re-summarizing a summary spends an LLM call to * lose detail. The drop strategies omit it: a drop spends nothing, so there * is nothing to protect against. */ function planRemoval(turns, keepRecent, guards, isExistingSummary) { const refusals = []; const candidateCount = Math.max(0, turns.length - keepRecent); for (let i = candidateCount; i < turns.length; i++) { const turn = turns[i]; refusals.push({ reason: 'inside-keep-window', turnIndex: i, messageIndex: turn.start }); } if (candidateCount === 0) return { from: -1, to: -1, refusals }; // Spend the pin ceiling before asking anything, so `refusalFor` is answering // one settled question per turn. const admission = admitPins(guards, candidateCount); const effective = admission.guards; const observed = admission.observations; const heldFacts = admission.ledgerFacts; const before = []; let from = -1; let to = -1; for (let i = 0; i < candidateCount; i++) { const turn = turns[i]; const reason = refusalFor(turn, effective); if (reason === undefined) { if (from === -1) from = i; to = i; continue; } before.push({ reason, turnIndex: i, messageIndex: turn.start }); if (from !== -1) break; // an unremovable turn ENDS the span } // A span that is nothing but one existing summary is not worth a call: // re-summarizing a summary spends tokens to lose detail and names nothing // new. It becomes foldable again as soon as a real turn joins it. const summaryOnly = isExistingSummary !== undefined && from !== -1 && from === to && isExistingSummary(turns[from]); if (summaryOnly) { return { from: -1, to: -1, refusals: [ ...before, { reason: 'only-existing-summary', turnIndex: from, messageIndex: turns[from].start }, ...refusals, ], ...(observed !== undefined && { observations: observed }), ...(heldFacts !== undefined && { ledgerFacts: heldFacts }), }; } return { from, to, refusals: [...before, ...refusals], ...(observed !== undefined && { observations: observed }), ...(heldFacts !== undefined && { ledgerFacts: heldFacts }), }; } exports.planRemoval = planRemoval; /** Total characters of message content in a window. Exact; not tokens. */ function windowChars(history) { let total = 0; for (const msg of history) total += msg.content.length; return total; } exports.windowChars = windowChars; //# sourceMappingURL=turns.js.map