UNPKG

agentfootprint

Version:

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

247 lines • 10.5 kB
"use strict"; /** * innerRunRecords — the record a tool keeps of its OWN run. * * Pattern: bounded LRU store + a structural join key (a registry symbol). * Role: the one place the outer trace and an inner trace are allowed to * know about each other. Nothing here executes, renders, or * formats — it holds records and hands them back by id. * * THE GAP * ─────── * `inspect_tool_call` resolves a tool call end to end and then stops at a * wall: *"⚠ boundary: what happened INSIDE the tool is not traced."* That * marker is honest for a tool that calls someone else's system. It is * needlessly honest for a tool that IS a footprintjs flowchart — that tool * recorded every stage it ran, and then threw the recording away, because * nobody was holding it. * * So: hold it. `flowchartAsTool({ keepRecord: true })` keeps each * invocation's inner record here, keyed by the `toolCallId` the outer run * already uses to name that call. One id, two levels — which is what makes * the descent a lookup rather than a correlation puzzle. * * WHY BOUNDED, AND WHY LRU * ──────────────────────── * A retained snapshot is retained memory. An agent that runs a chart tool * on every turn for a week would pin every one of those runs, and the * failure would look like a leak rather than like a feature. So the store * has a cap (default {@link DEFAULT_INNER_RUN_LIMIT}), drops the * least-recently-USED record when it overflows, and COUNTS the drops — * because "we kept the last 20 of 340" is an answer a debugging session can * act on, and a silently missing row is not. * * Reading refreshes recency on purpose: a record under active investigation * (three drills into one inner run) must not be the next one evicted by a * turn happening beside it. * * THE JOIN KEY * ──────────── * The store rides the `Tool` object under a registry symbol rather than a * named property: a `Tool` is a consumer-authored shape, and a plain name * like `innerRuns` would be a name a consumer's own tool could collide * with. `Symbol.for` (registry, not unique-per-module) because this package * ships CJS *and* ESM — two module instances of this file must agree on the * key or a tool built in one half would be invisible to the other. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.innerRunsOfConversation = exports.mergeInnerRuns = exports.innerRunStore = exports.innerRunsOf = exports.INNER_RUN_RECORDS = exports.DEFAULT_INNER_RUN_LIMIT = void 0; /** * How many invocations one tool keeps by default. * * Twenty is a debugging window, not an archive: enough that "the call three * turns ago" is still there, small enough that a long-lived server holding * one store per chart tool has a ceiling it can multiply out. Raise it with * `keepRecordLimit` when the sessions are long and the charts are small. */ exports.DEFAULT_INNER_RUN_LIMIT = 20; /** * The key an inner-record store rides on a `Tool`. * * Registry symbol, not a unique one: this package ships CJS and ESM, and a * tool built through one entry point must be readable through the other. */ exports.INNER_RUN_RECORDS = Symbol.for('agentfootprint.trace.innerRunRecords'); /** * Read the inner-run store off a candidate, or undefined when it carries * none. Structural and total — a plain tool, a string, `null` all answer * "no records" rather than throwing. */ function innerRunsOf(candidate) { if (candidate === null || typeof candidate !== 'object') return undefined; const held = candidate[exports.INNER_RUN_RECORDS]; if (held === null || typeof held !== 'object') return undefined; return typeof held.get === 'function' ? held : undefined; } exports.innerRunsOf = innerRunsOf; /** * A bounded LRU store of inner runs. * * @param limit maximum retained records (clamped to at least 1 — a store * that keeps nothing is `keepRecord: false`, and saying so with * a zero would be config that lies). */ function innerRunStore(limit = exports.DEFAULT_INNER_RUN_LIMIT) { const cap = Math.max(1, Math.floor(limit)); // Insertion order IS recency order: delete-then-set moves a key to the // end, so the first key is always the least-recently-used one. Keyed by // run AND call id — see `InnerRunRecord.runId`. const records = new Map(); let dropped = 0; // Drops per outer run ('' = no run), bounded like the records they count. const dropsByRun = new Map(); const keyOf = (record) => `${record.runId ?? ''}\u001f${record.toolCallId}`; const touch = (key, record) => { records.delete(key); records.set(key, record); }; const newestWhere = (toolCallId, accept) => { const all = [...records.entries()]; for (let i = all.length - 1; i >= 0; i--) { const entry = all[i]; if (entry[1].toolCallId === toolCallId && accept(entry[1])) return entry; } return undefined; }; // Reading refreshes recency (a record under investigation stays). const getWhere = (toolCallId, accept) => { const found = newestWhere(toolCallId, accept); if (found === undefined) return undefined; touch(found[0], found[1]); return found[1]; }; return { keep(record) { touch(keyOf(record), record); while (records.size > cap) { const oldest = records.entries().next().value; if (oldest === undefined) break; records.delete(oldest[0]); dropped++; const run = oldest[1].runId ?? ''; dropsByRun.delete(run); dropsByRun.set(run, (dropsByRun.get(run) ?? 0) + 1); while (dropsByRun.size > cap * 4) { const first = dropsByRun.keys().next(); if (first.done === true) break; dropsByRun.delete(first.value); } } }, get: (toolCallId) => getWhere(toolCallId, () => true), getWhere, list() { return [...records.values()].map((record) => ({ toolCallId: record.toolCallId, toolName: record.toolName, outcome: record.outcome, steps: record.steps, ...(record.runId !== undefined && { runId: record.runId }), })); }, get dropped() { return dropped; }, droppedWhere(accept) { let total = 0; for (const [run, count] of dropsByRun) if (accept(run === '' ? undefined : run)) total += count; return total; }, get limit() { return cap; }, }; } exports.innerRunStore = innerRunStore; /** * One lookup over several stores — an agent may mount more than one chart * tool, and the model asking "descend into call c3" should not have to know * which tool produced c3. * * Returns undefined for an empty list so callers can spread the field * conditionally rather than passing an empty lookup that answers "nothing * was kept" when the truth is "nothing keeps anything". */ function mergeInnerRuns(lookups) { if (lookups.length === 0) return undefined; if (lookups.length === 1) return lookups[0]; return { get: (toolCallId) => { for (const lookup of lookups) { const found = lookup.get(toolCallId); if (found !== undefined) return found; } return undefined; }, getWhere: (toolCallId, accept) => { for (const lookup of lookups) { const found = viewGet(lookup, toolCallId, accept); if (found !== undefined) return found; } return undefined; }, list: () => lookups.flatMap((lookup) => lookup.list()), get dropped() { return lookups.reduce((total, lookup) => total + lookup.dropped, 0); }, droppedWhere: (accept) => lookups.reduce((total, lookup) => total + (lookup.droppedWhere?.(accept) ?? 0), 0), get limit() { return Math.min(...lookups.map((lookup) => lookup.limit)); }, }; } exports.mergeInnerRuns = mergeInnerRuns; /** * The records ONE conversation's runs filed — the view a self-explaining agent * serves (`trace-toolpack/selfExplain.ts · SelfExplainBinding`), so a why-question asked in * one conversation can neither descend into, list, nor count a call another * conversation's run made. * * `belongs(runId)` answers for a record's outer run (`InnerRunRecord.runId`); * a record that names no run is asked with `undefined`. A record the view does * not admit answers exactly like a record never kept — the same not-found, so * the view says nothing about whether anyone else called the tool — and its * drops are not in `dropped`. `limit` is the store's. * * @example * ```ts * const mine = innerRunsOfConversation(lookup, (runId) => myRuns.has(runId ?? '')); * mine.get('call-from-another-run'); // undefined — not found, not forbidden * ``` */ function innerRunsOfConversation(lookup, belongs) { const admits = (record) => belongs(record.runId); return { get: (toolCallId) => viewGet(lookup, toolCallId, admits), getWhere: (toolCallId, accept) => viewGet(lookup, toolCallId, (record) => admits(record) && accept(record)), list: () => lookup.list().filter((summary) => belongs(summary.runId)), get dropped() { return lookup.droppedWhere?.(belongs) ?? 0; }, droppedWhere: (accept) => lookup.droppedWhere?.((runId) => belongs(runId) && accept(runId)) ?? 0, get limit() { return lookup.limit; }, }; } exports.innerRunsOfConversation = innerRunsOfConversation; /** `getWhere` when the lookup has it; else `get`, admitted or not found. */ function viewGet(lookup, toolCallId, accept) { if (lookup.getWhere !== undefined) return lookup.getWhere(toolCallId, accept); const found = lookup.get(toolCallId); return found !== undefined && accept(found) ? found : undefined; } //# sourceMappingURL=innerRunRecords.js.map