UNPKG

agentfootprint

Version:

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

481 lines • 27.5 kB
"use strict"; /** * runbookAsTool — turn a written operational procedure into a tool whose * every answer is EVIDENCE: who decided what, against which threshold, on * which rule version, with the recorded walk to check it against. * * The standard bridge from a footprintjs chart to the Agent's tool surface. * Where `flowchartAsTool` returns bare `JSON.stringify(values)`, * runbookAsTool supplies the missing middle — the honesty envelope every * hand-rolled triage tool was building for itself: * * THE MANDATORY SPINE (every runbook, whatever its shape): * - `af_coverage` — the three-list ledger, with every INNER tool's own * ledger folded upward through the run's dispatch, plus the sentence * naming the rule set and version; * - `result.af_provenance` — re-emitted FIRST (a carried `LOCAL SEED` * confession survives composition); * - `result.rule_version` — the declared rules' version, or the honest * `'undeclared'`; * - `result.walk` — the recorded walk's descriptor. The walk itself * ships as an artifact ticket (kind `recording/chart-walk`), never as * bytes; when it does not fit, the CONTROL FLOW survives and the * projection is declared. Opt into `walk: { recording: true }` and the * inner chart's own `{ snapshot, events, structure }` is filed beside * it (kind `recording/run`) and its ref rides the SAME descriptor as * `walk.recording_ref` — the row projection cannot be drawn, and this * is what makes the walk mountable as the flowchart it ran. * * THE OPTIONAL PROJECTION (selected by `resultKind: 'verdict/*'`): * verdict rows off the chart's `verdicts` state key, capped with * truthful counters, a pre-rendered table over the SAME rows, and * `verdict_meanings` GENERATED from the decider's declared branches + * the rule labels this run's decide() evidence carried. * * WHO RENDERS THE ROWSET (`presentation`, default `'prose'`): the bridge * cannot see which client it is in, so the caller says. Under `'prose'` * the model's words are the rowset's only surface — the table ships * pre-rendered and is output verbatim. Under `'panel'` the host draws * the rowset itself — no table ships, and the note says not to * reproduce rows the reader is already looking at. * * THREE OUTCOMES, honestly: a clean envelope; an inner ABSENCE passed * through verbatim (the framework still reads it as an absence); and * DECLINED rows counted into the ledger as not-checked ground. * * The procedure is a FACTORY invoked per call with the run's own tool * dispatch (`ctx.tools`) — fresh chart every run, stages close over the * dispatch, and every inner tool's honesty ledger folds into this tool's * answer. * * Reserved state keys the bridge reads off the final scope: * - `verdicts` — the rowset (verdict projection only); * - `coverage` — chart-declared coverage entries (`{checked?, not_checked?, * cannot_cover?}`); * - `report` — the app's own result fields, spread into `result` verbatim * BESIDE the spine, never over it: the envelope's own names (the spine * plus the projection this run assembled) are reserved, and a report * field spelling one of them is discarded and NAMED in `report_note`. * * Pause: NOT yet bridged. A paused inner chart throws with the checkpoint * attached, exactly like `flowchartAsTool` — the approval-gate integration * is the next phase of the runbook program. * * @example the smallest legal call — still yields the honest spine * const tool = runbookAsTool({ * name: 'restart_check', * description: 'Run the restart-safety procedure.', * procedure: () => * flowChart<{ safe: boolean }>('restart-safety', (scope) => { * scope.safe = true; * }, 'check').build(), * }); * * @example a triage runbook with rules, verdicts, and an inner tool * const triage = runbookAsTool({ * name: 'backup_triage', * description: 'Assess backup protection posture for every subject.', * resultKind: 'verdict/backup-posture', * rules: { name: 'health-signal', version: 'v1' }, * verdicts: { decider: 'posture' }, * composedOf: ['backup_inventory'], * procedure: (tools) => * flowChart<TriageState>('backup-triage', async (scope) => { * const inner = (await tools.call('backup_inventory', {})) as InventoryResult; * scope.subjects = inner.result.rows; * }, 'inventory') * .addDeciderFunction('Posture', postureDecider, 'posture') * .addFunctionBranch('protected', 'Protected', landProtected) * .addFunctionBranch('unprotected', 'Unprotected', landUnprotected) * .end() * .build(), * }); */ Object.defineProperty(exports, "__esModule", { value: true }); exports.runbookAsTool = void 0; const footprintjs_1 = require("footprintjs"); const trace_1 = require("footprintjs/trace"); const innerRunRecords_js_1 = require("../innerRunRecords.js"); const policy_js_1 = require("../../redaction/policy.js"); const marker_js_1 = require("../../redaction/marker.js"); const servableSnapshot_js_1 = require("../servableSnapshot.js"); const toolShownArgs_js_1 = require("../toolShownArgs.js"); const tools_js_1 = require("../../tools/tools.js"); const coverage_js_1 = require("./coverage.js"); const dispatch_js_1 = require("./dispatch.js"); const recording_js_1 = require("./recording.js"); const report_js_1 = require("./report.js"); const verdicts_js_1 = require("./verdicts.js"); const walk_js_1 = require("./walk.js"); /** The projection selector: a `resultKind` in the `verdict/` namespace gets * the rowset projection; every other kind ships the spine plus the chart's * own `report`. */ const VERDICT_KIND_PREFIX = 'verdict/'; /** The presentations, as the dial spells them. */ const PRESENTATIONS = ['prose', 'panel']; /** * The projection's RESERVED VOCABULARY: the keys this run assembled, plus * `table` — which `presentation: 'panel'` deliberately omits so that no table * reaches the model at all. A `report` field spelling the freed name would put * one straight back, so the mode's promise outranks the vacancy. */ function reservedProjectionNames(projection) { return projection === undefined ? [] : [...Object.keys(projection), 'table']; } function bagOf(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined; } /** Lift the chart's declared input schema when it is a PLAIN JSON-Schema * object. A parseable schema (`.safeParse`/`.parse` — zod et al.) validates * at the executor but cannot be serialized for the model: skipped, default * applies. */ function liftInputSchema(chart) { const schema = bagOf(chart.inputSchema); if (schema === undefined) return undefined; const parseable = typeof schema.safeParse === 'function' || typeof schema.parse === 'function'; return parseable ? undefined : schema; } /** * Wrap a footprintjs procedure as a `Tool` whose every answer carries the * honesty spine. See the module header for the envelope; see * {@link RunbookAsToolOptions} for the full options bag. The smallest legal * call is `{ name, description, procedure }`. */ function runbookAsTool(opts) { const label = `runbookAsTool(${opts.name || '?'})`; if (!opts.name || opts.name.trim().length === 0) { throw new Error('runbookAsTool: `name` is required and must be non-empty.'); } if (!opts.description || opts.description.length === 0) { throw new Error(`${label}: \`description\` is required — a description-less tool is invisible to the model.`); } if (typeof opts.procedure !== 'function') { throw new Error(`${label}: \`procedure\` is required — a factory invoked per call with the run's ` + `tool dispatch, returning a built chart. (Migrating from flowchartAsTool? Wrap ` + `your chart: \`procedure: () => chart\` builds it fresh each run.)`); } if (opts.rules !== undefined) { const { name, version } = opts.rules; if (typeof name !== 'string' || name.trim() === '' || typeof version !== 'string' || version.trim() === '') { throw new Error(`${label}: \`rules\` must carry a non-empty name and version — it is the provenance ` + `stamp every evidence sentence names. Got ${JSON.stringify(opts.rules)}.`); } } if (opts.verdicts !== undefined) { if (typeof opts.verdicts.decider !== 'string' || opts.verdicts.decider.trim() === '') { throw new Error(`${label}: \`verdicts.decider\` must name the decider stage whose branches generate ` + `verdict_meanings. Got ${JSON.stringify(opts.verdicts.decider)}.`); } const maxRows = opts.verdicts.maxRows; if (maxRows !== undefined && (!Number.isInteger(maxRows) || maxRows < 1)) { throw new Error(`${label}: \`verdicts.maxRows\` must be a whole number of rows, at least 1 — got ` + `${String(maxRows)}.`); } } if (opts.presentation !== undefined && !PRESENTATIONS.includes(opts.presentation)) { throw new Error(`${label}: \`presentation\` names WHO renders the rowset — 'prose' (the default: the ` + `model's prose is the rowset's only surface, so the pre-rendered table ships and is ` + `output verbatim) or 'panel' (the host draws the rowset, so no table ships). Got ` + `${JSON.stringify(opts.presentation)}.`); } const cap = opts.walk?.cap; if (cap !== undefined && (!Number.isInteger(cap) || cap < 1)) { throw new Error(`${label}: \`walk.cap\` must be a whole number of rows, at least 1 — got ${String(cap)}.`); } const recordingOpt = opts.walk?.recording; if (recordingOpt !== undefined && typeof recordingOpt !== 'boolean' && (recordingOpt === null || typeof recordingOpt !== 'object' || Array.isArray(recordingOpt))) { throw new Error(`${label}: \`walk.recording\` must be \`true\` (file the inner chart's recording with ` + `the defaults), \`false\`/absent (do not file one), or an options bag ` + `\`{ label?, maxBytes? }\` — got ${JSON.stringify(recordingOpt)}.`); } if (typeof recordingOpt === 'object' && recordingOpt !== null) { const maxBytes = recordingOpt.maxBytes; if (maxBytes !== undefined && (!Number.isInteger(maxBytes) || maxBytes < 1)) { throw new Error(`${label}: \`walk.recording.maxBytes\` must be a whole number of bytes, at least 1 — ` + `got ${String(maxBytes)}. It is the declared ceiling a recording is REFUSED over, ` + `never truncated to.`); } } if (opts.keepRecordLimit !== undefined) { if (opts.keepRecord !== true) { throw new Error(`${label}: \`keepRecordLimit\` caps records that are never kept — add ` + `\`keepRecord: true\`, or drop the limit.`); } if (!Number.isInteger(opts.keepRecordLimit) || opts.keepRecordLimit < 1) { throw new Error(`${label}: \`keepRecordLimit\` must be a whole number of invocations, at least 1 — ` + `got ${String(opts.keepRecordLimit)}.`); } } // ── Definition-time probe: read the chart's declared contract ───────── let probeChart; try { probeChart = opts.procedure((0, dispatch_js_1.probeDispatch)(opts.name)); } catch (err) { throw new Error(`${label}: the procedure factory threw at the definition probe — ` + `${err instanceof Error ? err.message : String(err)}. The factory is invoked once ` + `at definition (stage bodies do not run) to read the chart's declared contract; ` + `it must be side-effect-free at build.`); } if (probeChart === null || typeof probeChart !== 'object' || typeof probeChart.stageMap !== 'object') { throw new Error(`${label}: \`procedure\` must return a BUILT chart (call .build() on the builder).`); } const inputSchema = opts.inputSchema ?? liftInputSchema(probeChart); const isVerdictKind = opts.resultKind?.startsWith(VERDICT_KIND_PREFIX) === true; const rendersItsOwnRows = opts.presentation === 'panel'; const maxRows = opts.verdicts?.maxRows ?? verdicts_js_1.DEFAULT_MAX_ROWS; const walkCap = opts.walk?.cap ?? walk_js_1.DEFAULT_WALK_CAP; // Resolved ONCE at definition. `undefined` is the whole off-switch: every // line the recording feature adds sits behind this being defined, so an // undeclared runbook takes no second snapshot, measures no bytes, and makes // no store call — its envelope is byte-identical to 9.78.0. const recordingPolicy = (0, recording_js_1.resolveRecordingPolicy)(recordingOpt); const store = opts.keepRecord === true ? (0, innerRunRecords_js_1.innerRunStore)(opts.keepRecordLimit ?? innerRunRecords_js_1.DEFAULT_INNER_RUN_LIMIT) : undefined; const tool = (0, tools_js_1.defineTool)({ name: opts.name, description: opts.description, ...(inputSchema !== undefined && { inputSchema }), // GAP-8 closed: the rail-read declarations forward verbatim. ...(opts.resultKind !== undefined && { resultKind: opts.resultKind }), ...(opts.resultClass !== undefined && { resultClass: opts.resultClass }), ...(opts.owner !== undefined && { owner: opts.owner }), ...(opts.resultCeiling !== undefined && { resultCeiling: opts.resultCeiling }), ...(opts.wants !== undefined && { wants: opts.wants }), ...(opts.argumentsFrom !== undefined && { argumentsFrom: opts.argumentsFrom }), ...(opts.composedOf !== undefined && { composedOf: opts.composedOf }), execute: async (args, ctx) => { const dispatch = (0, dispatch_js_1.recordingDispatch)(ctx.tools, opts.name); const chart = opts.procedure(dispatch.tools); // This tool's own policy joined with the one the calling run is covered // by (`ctx.redact`) — the procedure runs NESTED in the agent's run, and a // run's policy covers the runs nested in it: one union for the run, the // walk, the recording and the kept record. The envelope the MODEL reads // is the tool's own business (`modelFacingState`, below). const policy = (0, policy_js_1.unionRedactionPolicies)(opts.redact, ctx.redact); const watch = policy !== opts.redact ? (0, servableSnapshot_js_1.watchRunRule)(chart) : undefined; const executor = new footprintjs_1.FlowChartExecutor(chart, watch !== undefined ? { scopeFactory: watch.scopeFactory } : undefined); if (policy) { executor.setRedactionPolicy(policy); // The kept record says it was served under a policy, so its readers // read a placeholder as a value kept out (`redaction/marker.ts`). executor.attachCombinedRecorder((0, marker_js_1.redactionMarker)()); } for (const recorder of opts.recorders ?? []) { executor.attachCombinedRecorder(recorder); } // Values off: the walk is about the DECISIONS — a per-key value dump // would bury six branch choices under four hundred reads. const walkRecorder = (0, footprintjs_1.narrative)({ includeValues: false }); executor.attachCombinedRecorder(walkRecorder); // Meanings harvest — resolve the decider on THIS run's chart (a fresh // chart per call; the static branches are the same, the resolution is // cheap) and collect rule labels as the evidence fires. const identity = opts.verdicts !== undefined ? (0, verdicts_js_1.resolveDecider)(chart, opts.verdicts.decider) : undefined; const harvest = identity !== undefined ? (0, verdicts_js_1.meaningsRecorder)(identity) : undefined; if (harvest !== undefined) executor.attachCombinedRecorder(harvest.recorder); const ctrl = store ? (0, trace_1.controlDepRecorder)() : undefined; if (ctrl) executor.attachCombinedRecorder(ctrl); const keepRecordOf = (outcome, known) => { if (!store) return; try { const snapshot = known ?? (0, servableSnapshot_js_1.servableSnapshot)(executor, policy); const commitLog = snapshot.commitLog; const lines = walkRecorder.getEntries() .map((entry) => entry.text) .filter((text) => typeof text === 'string' && text.length > 0); store.keep({ toolCallId: ctx.toolCallId, ...(ctx.runId !== undefined && { runId: ctx.runId }), toolName: opts.name, outcome, steps: Array.isArray(commitLog) ? commitLog.length : 0, recording: { snapshot, structure: chart.buildTimeStructure, }, ...(ctrl !== undefined && { controlDeps: ctrl.asLookup() }), ...(lines.length > 0 && { narrative: lines }), }); } catch (e) { store.keep({ toolCallId: ctx.toolCallId, ...(ctx.runId !== undefined && { runId: ctx.runId }), toolName: opts.name, outcome, steps: 0, problem: e instanceof Error ? e.message : String(e), }); } }; const env = {}; if (ctx.signal) env.signal = ctx.signal; try { await executor.run({ input: args, env }); } catch (e) { const signal = (0, dispatch_js_1.absenceSignalOf)(e); if (signal !== undefined) { // ABSENCE PASS-THROUGH: the inner source answered "I looked and // there is nothing", and the runbook has nothing to add. It goes // back EXACTLY as it arrived so the framework still reads it as an // absence. The kept record files as 'error' — the closed outcome // word nearest "the traversal ended on a thrown signal". keepRecordOf('error'); return signal.absence; } keepRecordOf('error'); throw e; } if (executor.isPaused()) { keepRecordOf('paused'); const err = new Error(`${label}: the inner chart paused. Approval-gate integration (typed resume ` + `through agent.resume) is the runbook program's next phase; until it lands a ` + `paused procedure cannot be resumed through the tool boundary. The checkpoint ` + `is on err.checkpoint.`); err.checkpoint = executor.getCheckpoint(); throw err; } // The RECORD view, for the kept record and the recording: the redacted // mirror exactly as footprintjs serves it when a policy is set, the raw // snapshot otherwise — see `servableSnapshot`. const served = (0, servableSnapshot_js_1.servableSnapshot)(executor, policy); keepRecordOf('ok', served); // The envelope's state — rows, coverage, report: what the MODEL reads — // under the tool's own `redact` only; with no caller policy joined, the // served view's state, as always (`servableSnapshot.ts` · // `modelFacingState`). const state = (0, servableSnapshot_js_1.modelFacingState)(executor, opts.redact, watch !== undefined ? ctx.redact : undefined, watch?.rule()); // ── The walk ──────────────────────────────────────────────────────── const entries = walkRecorder.getEntries(); const projected = (0, walk_js_1.projectWalk)(entries, walkCap); const walkOnly = await (0, walk_js_1.mintWalk)(ctx, projected, { toolName: opts.name, toolCallId: ctx.toolCallId, ...(ctx.runId !== undefined && { runId: ctx.runId }), stepsExecuted: walkRecorder.stepCount, }); // ── The recording, beside the walk (opt-in) ───────────────────────── // The row projection cannot be drawn — `structure` is the only route to // a drawable graph and no snapshot carries it. The snapshot here is the // SAME served view the kept record and the envelope's state came from: // the redacted mirror under a policy (the walk and the recording are // scrubbed by one rule, at one moment), the raw snapshot without one. const walk = recordingPolicy === undefined ? walkOnly : { ...walkOnly, ...(await (0, recording_js_1.mintChartRecording)(ctx, (0, recording_js_1.chartRecordingOf)(served, chart.buildTimeStructure), { toolName: opts.name, toolCallId: ctx.toolCallId, ...(ctx.runId !== undefined && { runId: ctx.runId }), policy: recordingPolicy, })), }; // ── The projection (verdict kinds only) ───────────────────────────── let rows; let shown; let declined; if (isVerdictKind) { rows = (0, verdicts_js_1.verdictRowsOf)(state); shown = rows.slice(0, maxRows); declined = rows.filter((row) => row.verdict === verdicts_js_1.DECLINED_VERDICT).length; } const meanings = identity !== undefined && harvest !== undefined ? (0, verdicts_js_1.composeMeanings)(identity, harvest.observed) : undefined; // ── The ledger ────────────────────────────────────────────────────── // The procedure's display name: the chart's own root name (what the // author called it) — the tool name is the fallback, never the // auto-generated multi-line description. const chartName = chart.buildTimeStructure?.name ?? opts.name; const ledger = (0, coverage_js_1.composeLedger)((0, coverage_js_1.foldInnerCoverage)(dispatch.records), (0, coverage_js_1.chartCoverageOf)(state), { chartName, stepsExecuted: walkRecorder.stepCount, ...(opts.rules !== undefined && { rules: opts.rules }), ...(rows !== undefined && { rowsTotal: rows.length }), ...(declined !== undefined && { declinedRows: declined }), }); // ── Provenance — re-emitted FIRST ─────────────────────────────────── const af_provenance = { ...(0, coverage_js_1.carriedProvenanceOf)(dispatch.records), tool: opts.name, toolCallId: ctx.toolCallId, }; // ── The envelope ──────────────────────────────────────────────────── // The spine and the projection are assembled FIRST, and the chart's // `report` is admitted against the names they took: spine keys win, as // the contract says, by explicit precedence rather than by spread order // (which had the report land on top of `af_provenance` and // `rule_version`). A refused field is named in `report_note` — see // report.ts for the law and why the refusal is spoken. const spine = { af_provenance, rule_version: opts.rules?.version ?? 'undeclared', walk, }; // The rowset half is the same in both presentations — the dial names who // RENDERS the rows, never which rows there are. Only the surface differs: // prose gets the pre-rendered table plus "output it verbatim"; a panel // host gets no table and the opposite law, because the rows it would // retype are already on the reader's screen. const projection = shown !== undefined && rows !== undefined ? { verdicts: shown, rows_shown: shown.length, rows_total: rows.length, rows_complete: shown.length === rows.length, ...(rendersItsOwnRows ? { render_note: verdicts_js_1.PANEL_RENDER_NOTE } : { table: (0, verdicts_js_1.renderVerdictTable)(shown), render_note: verdicts_js_1.VERDICT_RENDER_NOTE }), ...(meanings !== undefined && { verdict_meanings: meanings }), } : undefined; const report = (0, report_js_1.admitReport)(bagOf(state.report) ?? {}, [ ...Object.keys(spine), ...reservedProjectionNames(projection), ]); const envelope = { af_coverage: ledger, result: { af_provenance: spine.af_provenance, rule_version: spine.rule_version, ...report.fields, ...(projection ?? {}), ...(report.refused.length > 0 && { [report_js_1.REPORT_NOTE_KEY]: (0, report_js_1.shadowedFieldsNote)(report.refused), }), walk: spine.walk, }, }; return envelope; }, }); // The policy that scrubs the served result also governs the ARGUMENTS an // event may show (`../toolShownArgs.ts` · `argsRedactedBy`) — the // `flowchartAsTool` rule, one rule for both halves of the call. const shown = opts.redact !== undefined ? { [toolShownArgs_js_1.SHOWN_ARGS]: (0, toolShownArgs_js_1.argsRedactedBy)(opts.redact) } : {}; if (store === undefined) return opts.redact === undefined ? tool : { ...tool, ...shown }; const keepsRecords = { ...tool, ...shown, [innerRunRecords_js_1.INNER_RUN_RECORDS]: store }; return keepsRecords; } exports.runbookAsTool = runbookAsTool; //# sourceMappingURL=runbookAsTool.js.map