UNPKG

agentfootprint

Version:

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

352 lines • 16.8 kB
/** * runRedaction — one run's redaction: the policy in force, the rule it is * decided by, and the two places an event leaves a stage. * * Pattern: a per-run object (`createRunRedaction`) opened by the runner that * owns the executor (`RunnerBase · openRunRedaction`), plus a scope * registry so the one emit helper can find the run a stage belongs to. * Role: the Walker half of `src/redaction/` — the wiring every runner's * `createExecutor` goes through, for every run, policy or not. * * WHY EVENTS ARE SERVED AT THEIR SOURCE. A typed event leaves a stage through * footprintjs's `$emit`, and footprintjs hands that payload to every channel * at once: the runner's EmitBridge (→ `agent.on`), every recorder attached to * the executor, the deferred-observer tier, and the executor's own narrative * (`[emit] name: payload` lines). footprintjs serves a `$emit` payload by its * NAME only (`emitPatterns`); a selected key inside it reaches all of those as * it is. Serving it anywhere downstream would be serving it in one channel of * five. So `emitServed` serves it BEFORE `$emit`, once, and every channel gets * the same served payload — footprintjs's own rule for a record handed out * whole: decided once, at the boundary, before any observer sees it. * * WHY THERE IS A SECOND, REAL-VALUE PATH. A handful of the library's own * mechanisms run ON events: the crash checkpoint `resumeOnError` replays * (`Agent · installCheckpointTracker`), the window strategy's token reading * (`CompactionMeter`), the reply a hosted agent streams to the person who * asked (`standingAgent`), the reply `toSSE({ format: 'text' })` streams. Each * is what the agent computes on or hands back to its caller — the class the * redaction law never covers (live state, the resume checkpoint, the caller's * own answer). They read the event as the stage produced it, through * `EventDispatcher · onRealEvent`, which no consumer can reach. Nothing on * that path is a record: it is never stored, exported or shown. * * WHY A FEW WRITES ARE RELAYED. Some events are DERIVED from scope writes * rather than emitted: the context recorder turns each slot's injection and * composition writes into `context.injected` / `slot_composed` / * `budget_pressure`. The scope channel serves a write of a selected key as the * placeholder, and an event derived from a placeholder is no event — the * record would deny the injections happened instead of keeping their content * out. So those writes go through `setEventSource`, which hands the written * value to the run (`RunRedaction · takeRealWrite`) as it writes it; the * recorder derives from that value, and the event it dispatches is served by * name like every other. * * THE RULE. Events are decided by the executor's OWN rule — the one footprintjs * builds from the policy for the run and installs on every stage context * (`StageContext · getRedactionRule`) — so a mark the run made (a per-call * `$setValue(key, value, true)`, a subflow mapper's copy of a selected value * under a new name) covers the events too. Until the first stage has started * there is no executor rule to read, so a rule built from the same policy * stands in: it has marked nothing, and neither has the run. */ import { createTypedScopeFactory, RedactionRule } from 'footprintjs/advanced'; import { buildEventMeta } from '../bridge/eventMeta.js'; import { conversationRedaction } from './conversation.js'; import { namesAnything } from './policy.js'; import { coverageOfOpenedRun, policyOfCoverage, UNKNOWN_COVERAGE, } from './coverage.js'; import { eventServing, SERVED_PLACEHOLDER } from './served.js'; import { redactionMarker } from './marker.js'; /** * Every executor a run opened → what the run was covered by (`coverage.ts`): * `covered` with its policy, or `declared-none` — set by `applyTo`, the one * place an executor gets its policy, so what a snapshot is served under is a * fact about THAT executor and can never be another run's * (`coverageOfExecutor`). An executor this map does not hold is `unknown`, * never "no policy". Weak: dies with the executor. */ const executorCoverage = new WeakMap(); /** * What `executor`'s run was covered by: `covered`, `declared-none`, or * `unknown` — an executor no run of this library opened, whose records * nothing vouches for. A reader serves an unknown executor's records under * nothing it could guess: it refuses (`RunnerBase · getLastSnapshot` hands * back nothing). */ export function coverageOfExecutor(executor) { return executorCoverage.get(executor) ?? UNKNOWN_COVERAGE; } /** * Every executor a run opened → that run's serving, set by `applyTo` beside * its policy — for a fact the runner files about THAT run after its executor * returned (its pause request), whatever run opened on the instance since * (`servingOfExecutor`). Weak: dies with the executor. */ const executorServings = new WeakMap(); /** * The serving of the run that opened `executor` — what a fact the runner * files about that run is served as. `undefined` for an executor no run of * this library opened: such a fact is refused (`EventDispatcher · * dispatchForRun`). */ export function servingOfExecutor(executor) { return executorServings.get(executor); } /** Every scope a run's executor made → that run, and the stage it was made for. Weak: a scope dies with its stage. */ const scopeRuns = new WeakMap(); /** * Open the redaction for one run. Called by every runner's `createExecutor` * for every run — with no policy the serving is the identity and nothing is * walked, but the scopes are still tied to the run, because the real-value path * is wiring, not redaction. */ export function createRunRedaction(args) { const { policy, dispatcher, getRunContext } = args; // The rule the run decides with — one built from the policy stands in until // a stage context offers the executor's own. The serving reads it through // this box and holds nothing else of the run (`servingOf`): the dispatcher // keeps a run's serving for its late facts, never the run's values. const decided = { rule: new RedactionRule(policy) }; const serving = servingOf(decided, policy); // Relayed writes waiting for their recorder, oldest first, per stage and key. // Keyed by the stage's runtimeStageId (unique per execution), so an entry no // recorder claims (a deferred tier that dropped the write) can never be // handed to a later write — it is held, in memory only, until this run's // redaction is dropped when the next run opens. const waiting = new Map(); const slot = (runtimeStageId, key) => `${runtimeStageId}\u001f${key}`; const coverage = coverageOfOpenedRun(policy); const run = { coverage, serving, real(type, payload, stageId) { if (!dispatcher.hasRealListeners()) return; // The stage's position, as the bridge stamps it on the served twin // (`buildEventMeta`); only footprintjs's `sourcePosition` is the bridge's alone. const origin = stageId !== '' ? { runtimeStageId: stageId } : undefined; dispatcher.deliverReal({ type, payload, meta: buildEventMeta(origin, getRunContext()), }); }, keepsOut(name) { return policy !== undefined && decided.rule.isKeyRedacted(name); }, noteWrite(runtimeStageId, key, value) { // No policy: the scope channel serves the write as written — nothing to relay. if (policy === undefined) return; const at = slot(runtimeStageId, key); const written = { value, keptOut: decided.rule.isKeyRedacted(key) }; const queue = waiting.get(at); if (queue === undefined) waiting.set(at, [written]); else queue.push(written); }, }; return { policy, serving, scopeFactoryFor(spec) { // Every chart the builder makes carries its factory; the fallback is the // builder's own default, so a hand-built chart scopes the same way. const base = spec.scopeFactory ?? createTypedScopeFactory(); return (context, stageName, readOnlyContext, executionEnv) => { const scope = base(context, stageName, readOnlyContext, executionEnv); if (scope !== null && typeof scope === 'object') { // The traverser stamps the stage's runtimeStageId before it builds the scope. scopeRuns.set(scope, { run, stageId: context?.runtimeStageId ?? '' }); } const own = context?.getRedactionRule?.(); if (own !== undefined) decided.rule = own; return scope; }; }, applyTo(executor) { executorCoverage.set(executor, coverage); executorServings.set(executor, serving); if (policy === undefined) return; executor.setRedactionPolicy(policy); // The served snapshot says so itself (`marker.ts`): readers of the record // read a placeholder as kept out only when a policy covered the run. // Once the run settles, its serving — kept by the dispatcher for the // run's late facts — holds the rule's NAMES only (`retiredRule`). executor.attachCombinedRecorder(redactionMarker(() => { decided.rule = retiredRule(decided.rule, policy); })); }, takeRealWrite(runtimeStageId, key) { const at = slot(runtimeStageId, key); const queue = waiting.get(at); if (queue === undefined) return undefined; const written = queue.shift(); if (queue.length === 0) waiting.delete(at); return written; }, }; } /** * A settled run's rule as its serving keeps it: the policy and the marks — * names only — and nothing else of the run. footprintjs's rule also remembers * the masked form of every thrown value it served (so `onRunFailed`, the * logger and a fork envelope agree); the live run needed that, a late fact * does not, and keeping it would keep each run's thrown values reachable for * as long as the dispatcher keeps the run's serving. * * @internal — exported for the test that pins it. */ export function retiredRule(rule, policy) { const names = new RedactionRule(policy); const marks = rule.marksForCheckpoint(); if (marks !== undefined) names.restoreMarks(marks); return names; } /** * The serving of one run, reading the rule in `decided` — built in its own * scope, so what the serving holds is the box and the policy: never the run's * relayed writes or its context, which `createRunRedaction`'s other closures * share. */ function servingOf(decided, policy) { return eventServing(() => decided.rule, namesAnything(policy)); } /** * The serving for facts a runner dispatches BEFORE its run's executor exists — * a resume's `pause.resume`, whose payload is the person's reply — under the * policy that run will be covered by, which the runner computed from typed * declarations (`undefined` there is positively none: the identity). A rule * built from the policy alone: no stage has run yet, so the run has marked * nothing. */ export function servingAhead(policy) { const rule = new RedactionRule(policy); return eventServing(() => rule, namesAnything(policy)); } /** * The {@link OutsideRun} of a runner whose declaration is `declaration`: * `covered` serves under its policy, `declared-none` as emitted (positively * none), `unknown` refuses every payload — the placeholder, fail closed. */ export function outsideRunFor(declaration) { if (declaration.state === 'unknown') { return Object.freeze({ declaration, scopeRun: { coverage: declaration, serving: REFUSING_SERVING, real: () => undefined, noteWrite: () => undefined, keepsOut: () => true, }, }); } const policy = policyOfCoverage(declaration); const rule = new RedactionRule(policy); return Object.freeze({ declaration, scopeRun: { coverage: declaration, serving: eventServing(() => rule, namesAnything(policy)), real: () => undefined, noteWrite: () => undefined, keepsOut: (name) => rule.isKeyRedacted(name), }, }); } /** * The serving of a run whose state is unknown: every payload is the * placeholder, and so is the identity on its meta (who asked); the address is * kept. */ const REFUSING_SERVING = Object.freeze({ active: () => true, payload: () => SERVED_PLACEHOLDER, meta: (meta) => meta.principal === undefined && meta.tenant === undefined ? meta : { ...meta, ...(meta.principal !== undefined && { principal: SERVED_PLACEHOLDER }), ...(meta.tenant !== undefined && { tenant: SERVED_PLACEHOLDER }), }, }); /** * Tie `scope` to `outside` — the run of its runner's chart mounted outside any * of that runner's runs (`chartBinding.ts` · `bindChartStages` calls it as each * stage starts, with the runner's own {@link OutsideRun}). A scope a run * already made keeps its run. A runner that declares no policy ties the scope * as positively none (served as emitted) — never left untied, which would * read as unknown. */ export function adoptScopeOutsideRun(scope, outside) { if (scope === null || typeof scope !== 'object') return; if (scopeRuns.has(scope)) return; scopeRuns.set(scope, { run: outside.scopeRun, stageId: '' }); } /** * THE way a typed event leaves a stage. The real value goes to the run's own * mechanisms (`onRealEvent`), the served value to footprintjs's `$emit` — and * from there to every channel at once. A runner's chart mounted into an * executor the app built is tied to the runner's declaration as each stage * starts (`adoptScopeOutsideRun`). A scope NOTHING tied — not made by a run of * this library, not bound to a runner — is a run whose state is unknown: its * payload is the placeholder (fail closed), never the payload as made. */ export function emitServed(scope, type, payload) { const entry = scopeRuns.get(scope); if (entry === undefined) { scope.$emit(type, SERVED_PLACEHOLDER); return; } entry.run.real(type, payload, entry.stageId); scope.$emit(type, entry.run.serving.payload(type, payload)); } /** * THE way a stage writes a value the library derives events from (a slot's * injections and composition — `ContextRecorder`): written as `$setValue` * writes it, and handed to the run as written (`RunRedaction · * takeRealWrite`), so the derived events exist under a policy that keeps the * key itself out of the record. A scope no run made just writes. */ export function setEventSource(scope, key, value) { const entry = scopeRuns.get(scope); entry?.run.noteWrite(entry.stageId, key, value); scope.$setValue(key, value); } /** * Whether the run `scope` belongs to keeps `name` out of its records — asked * of the run's own rule. For text the library writes OUTSIDE any record from * a value the run keeps out (a console warning that would quote the model's * draft): it leaves the value out too. `true` for a scope no run made — its * run's state is unknown, so the value is left out (fail closed). */ export function runKeepsOut(scope, name) { return scopeRuns.get(scope)?.run.keepsOut(name) ?? true; } /** * What the run `scope` belongs to is covered by (`coverage.ts`) — `unknown` * for a scope no run of this library made. */ export function coverageInForce(scope) { return scopeRuns.get(scope)?.run.coverage ?? UNKNOWN_COVERAGE; } /** * The policy in force for the run `scope` belongs to — what a tool call hands * down to a run it starts (`ToolExecutionContext.redact`), what seed commits * for a pause to carry. `undefined` ONLY for a run positively covered by none; * a run whose state is unknown hands down the whole conversation vocabulary — * fail closed, never "none". */ export function policyInForce(scope) { const coverage = coverageInForce(scope); return coverage.state === 'unknown' ? conversationRedaction() : policyOfCoverage(coverage); } //# sourceMappingURL=runRedaction.js.map