UNPKG

agentfootprint

Version:

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

295 lines • 14.1 kB
/** * tools/runChain — INTERNAL. The one place a chain is walked. * * Pattern: Chain of Responsibility driver. * Role: tools/ layer. Both stages (tool dispatch, message boundary) and * the MCP server call in here, so the ordering law, the * throw-is-a-denial law and the ledger row shape are written once * and cannot drift between the three callers. * Emits: N/A — it RETURNS rows; the caller commits and emits them, so a * middleware still never touches scope. * * ## The rules this file implements * * **Order is declaration order.** The chain is walked front to back, and * each link is handed the previous link's output — so a scrubber placed * first is a scrubber every later rule sees the effect of. * * **The first non-allow answer wins.** A deny or an ask stops the walk; * the links behind it do not run. There is no "second opinion" pass, * because a refusal that a later middleware could overturn is not a * refusal. * * **A middleware that throws is a denial, never a pass.** The thrown * message becomes the reason. This is the same deny-by-default the * permission gate has always applied to a checker that throws: a * governance layer whose failure mode is "allow" is not a governance * layer. * * **A middleware that malfunctions is a denial too** (8.18.0). Two ways to * malfunction at the message boundary: answer `ask` where no pause exists to * carry it, or `allow` a value that is not text. Both used to fall through to * the allow branch — the ask filed an allow row for a rule that thought it had * paused, and the non-string became `content: undefined` on the wire. Same law, * same row: a link whose answer cannot be honoured has not permitted anything. * * **The result comes back through the onion.** `runToolAfterChain` walks the * SAME list in reverse, so the first-declared link gets the first word about * the call and the last word about the result. An outer rule is the one that * sees what every inner rule already did — which is the only order in which * "wrap" means anything. * * **A link that has no hook for a moment files no row.** It did not decide; * saying it allowed would be inventing a decision, and the ledger's whole * value is that a row means somebody looked. */ /** Walk the tool chain. Never throws — a throwing link becomes a denial. */ export async function runToolChain(chain, input) { const decisions = []; const askPolicy = input.askPolicy ?? 'pause'; let args = input.args; for (let i = input.startIndex ?? 0; i < chain.length; i++) { const mw = chain[i]; // A result-only link takes no part in dispatch — and files no row here, // because it did not decide anything about this call. if (typeof mw.onToolCall !== 'function') continue; const row = (extra) => ({ middleware: mw.name, moment: 'before-tool', at: 'tool', toolName: input.toolName, toolCallId: input.toolCallId, iteration: input.iteration, outcome: 'allow', changed: false, ...extra, }); let outcome; try { outcome = await mw.onToolCall({ toolName: input.toolName, // Present only when the tool HAS a source. An `undefined` key is not // the same fact as an absent one to a middleware that spells its rule // `'toolSource' in call`, and "the agent's own" is an absence. ...(input.toolSource !== undefined && { toolSource: input.toolSource }), toolCallId: input.toolCallId, iteration: input.iteration, args, history: input.history, ...(input.identity && { identity: input.identity }), ...(input.signal && { signal: input.signal }), }); } catch (err) { const reason = `middleware '${mw.name}' threw: ${err instanceof Error ? err.message : String(err)}`; decisions.push(row({ outcome: 'deny', why: reason })); return { kind: 'deny', reason, middleware: mw.name, args, decisions }; } if (outcome.kind === 'deny') { decisions.push(row({ outcome: 'deny', why: outcome.reason })); return { kind: 'deny', reason: outcome.reason, middleware: mw.name, args, decisions }; } if (outcome.kind === 'ask') { if (askPolicy === 'refuse') { const reason = `middleware '${mw.name}' asked a person to decide, and there is no pause here to ` + `carry that ask: ${outcome.payload.question}`; decisions.push(row({ outcome: 'deny', why: reason })); return { kind: 'deny', reason, middleware: mw.name, args, decisions, refusedAsk: true }; } decisions.push(row({ outcome: 'ask', why: outcome.payload.question })); return { kind: 'ask', payload: outcome.payload, middleware: mw.name, index: i, args, decisions, }; } if (outcome.value !== undefined) { const before = args; args = outcome.value; decisions.push(row({ changed: true, ...(outcome.why && { why: outcome.why }), before, after: args, // The middleware's own declaration of where the values came from — // present only when it declared one (honesty layer 2). ...(outcome.from !== undefined && { from: outcome.from }), })); } else { // `allow(undefined, why)` — nothing moved, and the link said why it was // comfortable. The reason belongs on the row of the call it permitted. decisions.push(row(outcome.why ? { why: outcome.why } : {})); } } return { kind: 'allow', args, decisions }; } /** * Walk the chain BACKWARDS over a finished call. Never throws — a throwing * link becomes a denial, which at this moment means the model reads the * reason instead of the result. * * Only called for a call that actually executed. A call the chain denied, or * one still waiting on a person, has no result to decide about, and asking a * rule about a result that does not exist would be the same fabrication the * outcome union exists to prevent. */ export async function runToolAfterChain(chain, input) { const decisions = []; const real = input.result; let result = real; // Reverse: first-declared saw the call first, so it sees the result last. for (let i = chain.length - 1; i >= 0; i--) { const mw = chain[i]; const hook = mw.onToolResult; if (typeof hook !== 'function') continue; const row = (extra) => ({ middleware: mw.name, moment: 'after-tool', at: 'tool', toolName: input.toolName, toolCallId: input.toolCallId, iteration: input.iteration, outcome: 'allow', changed: false, ...extra, }); let outcome; try { outcome = await hook.call(mw, { toolName: input.toolName, ...(input.toolSource !== undefined && { toolSource: input.toolSource }), toolCallId: input.toolCallId, iteration: input.iteration, args: input.args, result, ...(input.error === true && { error: true }), history: input.history, ...(input.identity && { identity: input.identity }), ...(input.signal && { signal: input.signal }), }); } catch (err) { const reason = `middleware '${mw.name}' threw: ${err instanceof Error ? err.message : String(err)}`; decisions.push(row({ outcome: 'deny', changed: true, why: reason, before: result, after: reason })); return { kind: 'deny', reason, middleware: mw.name, result: real, decisions }; } if (outcome.kind === 'deny') { // `changed: true` on purpose: unlike a refusal before dispatch, this one // DOES replace something. The real result rides `before` — often the // only copy of it in the run. decisions.push(row({ outcome: 'deny', changed: true, why: outcome.reason, before: result, after: outcome.reason, })); return { kind: 'deny', reason: outcome.reason, middleware: mw.name, result: real, decisions }; } if (outcome.value !== undefined) { const before = result; result = outcome.value; decisions.push(row({ changed: true, ...(outcome.why && { why: outcome.why }), before, after: result })); } else { decisions.push(row(outcome.why ? { why: outcome.why } : {})); } } return { kind: 'allow', result, decisions }; } /** Walk the message chain. Never throws — a throwing link becomes a denial. */ export async function runMessageChain(chain, input) { const decisions = []; let content = input.content; for (const mw of chain) { const row = (extra) => ({ middleware: mw.name, moment: input.phase, at: 'message', phase: input.phase, iteration: input.iteration, outcome: 'allow', changed: false, ...extra, }); let outcome; try { outcome = await mw.onMessage({ phase: input.phase, content, history: input.history, ...(input.identity && { identity: input.identity }), ...(input.signal && { signal: input.signal }), }); } catch (err) { const reason = `middleware '${mw.name}' threw: ${err instanceof Error ? err.message : String(err)}`; decisions.push(row({ outcome: 'deny', why: reason })); return { kind: 'deny', reason, middleware: mw.name, content, decisions }; } if (outcome.kind === 'deny') { decisions.push(row({ outcome: 'deny', why: outcome.reason })); return { kind: 'deny', reason: outcome.reason, middleware: mw.name, content, decisions }; } // `ask` at a message boundary (8.18.0). `MessageOutcome` has no ask arm, so // TypeScript refuses this at the call site — but a JS consumer, an `as any`, // or a shared link written for the TOOL chain reaches here at runtime, and // an ask used to fall straight through the value test below and file an // ALLOW row. A rule that believed it had paused for a person had in fact // approved the message, and the ledger agreed with the rule. The message // boundary is a plain stage with no pause to carry an ask, so the honest // answer is the one `askPolicy: 'refuse'` already gives the tool chain: a // denial, naming the middleware. if (outcome.kind === 'ask') { const question = outcome.payload ?.question; const reason = `middleware '${mw.name}' asked a person to decide about the ${input.phase} message, ` + `and there is no pause at the message boundary to carry that ask` + `${question ? `: ${question}` : ''}. Ask at a TOOL moment (onToolCall), where a pause ` + `exists, or decide here with allow()/deny().`; decisions.push(row({ outcome: 'deny', why: reason })); return { kind: 'deny', reason, middleware: mw.name, content, decisions }; } if (outcome.value !== undefined) { // A transform must produce a message. A link that returns a non-string // is malfunctioning, not permitting — and this file's law for a // malfunctioning link is already written: a middleware that throws is a // denial, never a pass. Before 8.18.0 the value was assigned anyway and // became `content: undefined`/an object on the wire, surfacing as // `s.slice is not a function` at the input phase and as an unattributed // "unexpected result shape" at the output phase. // Cast through `unknown`: the TYPE says `string`, which is exactly why // the check is here — the values that get this far are the ones the type // could not stop. if (typeof outcome.value !== 'string') { const reason = `middleware '${mw.name}' returned allow(${describeValue(outcome.value)}) at the ` + `${input.phase} message boundary, and a message is text. Return allow(someString, why) ` + `to rewrite it, allow() to pass it through unchanged, or deny(reason) to refuse it.`; decisions.push(row({ outcome: 'deny', why: reason })); return { kind: 'deny', reason, middleware: mw.name, content, decisions }; } const before = content; content = outcome.value; decisions.push(row({ changed: true, ...(outcome.why && { why: outcome.why }), before, after: content })); } else { decisions.push(row(outcome.why ? { why: outcome.why } : {})); } } return { kind: 'allow', content, decisions }; } /** Name the SHAPE of a middleware's return for a refusal — never its contents. */ function describeValue(value) { if (value === null) return 'null'; if (Array.isArray(value)) return 'an array'; return typeof value === 'object' ? 'an object' : `a ${typeof value}`; } //# sourceMappingURL=runChain.js.map