UNPKG

agentfootprint

Version:

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

373 lines • 16.8 kB
"use strict"; /** * Loop — iteration composition: runs a body runner repeatedly until exit. * * Pattern: Builder (GoF) + Adapter over footprintjs's `loopTo` + `$break`. * Role: core-flow/ layer. Enables Reflection, Self-Refine, Debate, * Reflexion, Constitutional AI, and any pattern needing * iterative refinement of a composition output. * Emits: agentfootprint.composition.enter / exit + * composition.iteration_start / iteration_exit * (via compositionRecorder). * * Budget guard is MANDATORY. You must set at least one of: * - maxIterations (default 10 if only .body() is set) * - maxWallclockMs * Hard ceiling of 500 iterations prevents runaway loops even if a guard * misfires; exceeding it throws. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.LoopBuilder = exports.Loop = void 0; const footprintjs_1 = require("footprintjs"); const RunnerBase_js_1 = require("../core/RunnerBase.js"); const runInput_js_1 = require("../core/runInput.js"); const memberRun_js_1 = require("../core/memberRun.js"); const requestOfRun_js_1 = require("../core/requestOfRun.js"); const ContextRecorder_js_1 = require("../recorders/core/ContextRecorder.js"); const StreamRecorder_js_1 = require("../recorders/core/StreamRecorder.js"); const AgentRecorder_js_1 = require("../recorders/core/AgentRecorder.js"); const CompositionRecorder_js_1 = require("../recorders/core/CompositionRecorder.js"); const typedEmit_js_1 = require("../recorders/core/typedEmit.js"); const childOutcome_js_1 = require("./childOutcome.js"); const messageFrom_js_1 = require("../core/messageFrom.js"); const declared_js_1 = require("../redaction/declared.js"); const HARD_ITERATION_CAP = 500; class Loop extends RunnerBase_js_1.RunnerBase { name; id; body; maxIterations; maxWallclockMs; until; opts; /** Per-method translator override on `.repeat()`, when set. Applies * to the body member's `uiGroup`. */ bodyTranslator; currentRunContext = { runStartMs: 0, runId: 'pending', compositionPath: [], }; constructor(opts, body, config) { super(); this.opts = opts; this.name = opts.name ?? 'Loop'; this.id = opts.id ?? 'loop'; this.body = body; this.maxIterations = clampIterations(config.maxIterations); this.maxWallclockMs = config.maxWallclockMs; this.until = config.until; this.bodyTranslator = config.bodyTranslator; // A Loop whose body reads `messageFrom` reads it too (`core/messageFrom.ts`). (0, messageFrom_js_1.readsMessageFromIfAny)(this, [body]); // The body's redaction policy covers this composition's run — the body runs // on its executor (`src/redaction/declared.ts`). (0, declared_js_1.adoptMemberRedaction)(this, [body]); // It hands the run it serves to its members (`core/memberRun.ts`). (0, memberRun_js_1.readsParentRun)(this); // Eager chart construction — see `RunnerBase.initChart` JSDoc. this.initChart(() => this.buildChart()); } static create(opts = {}) { return new LoopBuilder(opts); } // `getSpec()` inherited from RunnerBase — returns the cached chart. // ─── UI group translation (L1b) ─────────────────────────────── getGroupTranslator() { return this.opts.groupTranslator; } /** Loop has a single body member + iteration budgets in `extra`. * Per-method override (L1c) takes precedence over the body * runner's own translator. */ buildUIGroupMetadata() { const members = [ { memberId: 'body', runner: this.body, uiGroup: this.bodyTranslator !== undefined ? this.body.getUIGroupWith(this.bodyTranslator) : this.body.getUIGroup(), }, ]; return { kind: 'Loop', id: this.id, name: this.name, members, extra: { maxIterations: this.maxIterations, ...(this.maxWallclockMs !== undefined && { maxWallclockMs: this.maxWallclockMs, }), hasUntilGuard: this.until !== undefined, }, }; } async run(input, options) { // The incoming request, refused here — before anything moves — when // requestContext() did not build it. const request = (0, requestOfRun_js_1.requestOf)(options, 'Loop.run'); const runInput = (0, runInput_js_1.normalizeRunInput)(input, 'Loop.run'); const executor = this.createExecutor(); this.lastExecutor = executor; // The run every member serves — built once, here, from THIS run's id. const parentRun = (0, memberRun_js_1.openParentRun)(this.currentRunContext.runId, runInput.identity, 'Loop.run', request); try { const result = await (0, RunnerBase_js_1.underRunSignal)(options, (engine) => executor.run({ input: { message: runInput.message, parentRun: parentRun.link }, ...(engine ?? {}), })); return this.finalizeResult(executor, result); } finally { // Released when the run returns — finished, failed or paused (a resume re-opens it). (0, memberRun_js_1.closeParentRun)(parentRun); } } async resume(checkpoint, input, options) { // The incoming request, and the paused run its members serve (re-opened // from the checkpoint's own record) — both refused before anything moves. const request = (0, requestOfRun_js_1.requestOf)(options, 'Loop.resume'); const parentRun = (0, memberRun_js_1.reopenParentRun)(checkpoint.sharedState, 'Loop.resume', request); try { // The leg's policy — the paused run's marks — goes to ITS executor, never via the instance. const resumeLeg = this.emitPauseResume(checkpoint, input); const executor = this.createExecutor(resumeLeg); this.lastExecutor = executor; const result = await (0, RunnerBase_js_1.underRunSignal)(options, (engine) => executor.resume(checkpoint, input, engine)); return this.finalizeResult(executor, result); } finally { (0, memberRun_js_1.closeParentRun)(parentRun); } } createExecutor(resumeLeg) { this.currentRunContext = { runStartMs: Date.now(), runId: (0, RunnerBase_js_1.makeRunId)(), compositionPath: [`Loop:${this.id}`], }; // THIS run's context, captured: a run that overlaps it on this instance // never restamps its events, so each is served under its own run's policy. const runContext = this.currentRunContext; const getRunCtx = () => runContext; // The run's redaction (`src/redaction/`): the policy the body declared. const redaction = this.openRunRedaction(resumeLeg, getRunCtx); // Reuse the cached chart built at constructor time. const spec = this.getSpec(); const executor = new footprintjs_1.FlowChartExecutor(spec, { scopeFactory: redaction.scopeFactoryFor(spec) }); redaction.applyTo(executor); const dispatcher = this.getDispatcher(); executor.attachCombinedRecorder(new ContextRecorder_js_1.ContextRecorder({ dispatcher, getRunContext: getRunCtx, realWrites: redaction })); executor.attachCombinedRecorder((0, StreamRecorder_js_1.streamRecorder)({ dispatcher, getRunContext: getRunCtx })); executor.attachCombinedRecorder((0, AgentRecorder_js_1.agentRecorder)({ dispatcher, getRunContext: getRunCtx })); executor.attachCombinedRecorder((0, CompositionRecorder_js_1.compositionRecorder)({ dispatcher, getRunContext: getRunCtx })); for (const r of this.attachedRecorders) executor.attachCombinedRecorder(r); return executor; } finalizeResult(executor, result) { const paused = this.detectPause(executor, result); if (paused) return paused; if (result instanceof Error) throw result; if (typeof result === 'string') return result; throw new Error('Loop: unexpected result shape — expected string'); } buildChart() { const body = this.body; const maxIterations = this.maxIterations; const maxWallclockMs = this.maxWallclockMs; const until = this.until; const compositionId = this.id; const compositionName = this.name; const seed = (scope) => { const args = scope.$getArgs(); (0, memberRun_js_1.adoptParentRun)(scope, args, 'Loop'); scope.current = args.message; scope.iteration = 0; scope.startMs = Date.now(); (0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.composition.enter', { kind: 'Loop', id: compositionId, name: compositionName, childCount: 1, }); }; const iterationStart = (scope) => { const next = (scope.iteration ?? 0) + 1; scope.iteration = next; (0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.composition.iteration_start', { loopId: compositionId, iteration: next, }); }; /** * Guard stage — runs AFTER the body subflow completes each iteration. * Checks exit conditions; when any fires, emits iteration_exit with * the reason + $break terminates the loop. */ const guard = (scope) => { // A body that ended on a verdict ends the loop with its error (`childOutcome.ts`). (0, childOutcome_js_1.raiseChildVerdict)((key) => scope.$getValue(key)); const iteration = scope.iteration; const latestOutput = scope.current ?? ''; const startMs = scope.startMs; let exitReason; if (iteration >= maxIterations) { exitReason = 'budget'; } else if (maxWallclockMs !== undefined && Date.now() - startMs >= maxWallclockMs) { exitReason = 'budget'; } else if (iteration >= HARD_ITERATION_CAP) { exitReason = 'budget'; } else if (until !== undefined && until({ iteration, latestOutput, startMs })) { exitReason = 'guard_false'; } if (exitReason !== undefined) { (0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.composition.iteration_exit', { loopId: compositionId, iteration, reason: exitReason, }); (0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.composition.exit', { kind: 'Loop', id: compositionId, name: compositionName, status: exitReason === 'budget' ? 'budget_exhausted' : 'ok', durationMs: Date.now() - this.currentRunContext.runStartMs, }); // $break stops the flow BEFORE loopTo fires. The latest string // output is returned as the executor's TraversalResult. scope.$break(); return latestOutput; } // Continue looping: emit "body_complete" for the completed iteration // before the loopTo takes us back. Next iteration's iterationStart // emits iteration_start again. (0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.composition.iteration_exit', { loopId: compositionId, iteration, reason: 'body_complete', }); return latestOutput; }; // Root description prefix `Loop:` is the taxonomy marker — see // FlowchartRecorder.mapTopologyToSteps for the consumer side. return (0, footprintjs_1.flowChart)('Initialize', seed, 'seed', { ...(this.opts.structureRecorders !== undefined && { structureRecorders: [...this.opts.structureRecorders], }), description: 'Loop: iterated body', }) .addFunction('IterationStart', iterationStart, 'iteration-start', 'Loop iteration marker') .addSubFlowChartNext('body', body.getSpec(), 'body', { // After the first iteration the body is handed its OWN previous output — // another run's words, marked for a runner that reads the marker // (`core/messageFrom.ts` · `composedInput`); every other body gets the // input it always did. inputMapper: (parent) => { const input = { message: parent.current ?? '' }; const mapped = (parent.iteration ?? 1) > 1 || parent.messageFrom === 'composed' ? (0, messageFrom_js_1.composedInput)(body, input) : input; // Every iteration runs for this run (`core/memberRun.ts`). return (0, memberRun_js_1.withParentRun)(mapped, (0, memberRun_js_1.parentRunIn)(parent), body); }, // Body's string return becomes next iteration's input via `current`; // a body that ended on a verdict carries it onto the loop's state. outputMapper: (sfOutput) => { const outcome = (0, childOutcome_js_1.childOutcome)(body, sfOutput); if ('verdict' in outcome) return { current: '', ...(0, childOutcome_js_1.carryVerdict)(outcome.verdict) }; return { current: typeof outcome.answer === 'string' ? outcome.answer : '' }; }, }) .addFunction('Guard', guard, 'guard', 'Loop exit-condition guard') .loopTo('iteration-start') .build(); } } exports.Loop = Loop; class LoopBuilder { opts; _body; _bodyTranslator; _maxIterations; _maxWallclockMs; _until; constructor(opts) { this.opts = opts; } /** * The runner that executes each iteration. Required. * Each iteration's output string becomes the next iteration's input `{ message }`. * * Optional second arg `opts.groupTranslator` overrides the body * runner's own translator for THIS loop only — only its * `member.uiGroup` flips to the override's output. */ repeat(runner, opts) { if (this._body !== undefined) { throw new Error('Loop.repeat(): already set'); } this._body = runner; if (opts?.groupTranslator !== undefined) { this._bodyTranslator = opts.groupTranslator; } return this; } /** * Maximum iteration count. Default 10 if only `.repeat()` is called. * Hard ceiling 500 — larger values are clamped. */ times(n) { this._maxIterations = n; return this; } /** * Wall-clock time budget in milliseconds. The loop exits at the next * guard check after this elapses. */ forAtMost(ms) { this._maxWallclockMs = ms; return this; } /** * Exit predicate evaluated after each iteration. Return `true` to exit. * Receives `{ iteration, latestOutput, startMs }`. * * `latestOutput` is the body's string output. For structured exit * conditions, emit JSON from the body and parse it inside the guard — * see the `UntilGuard` JSDoc for the pattern and the design rationale. */ until(guard) { this._until = guard; return this; } build() { if (this._body === undefined) { throw new Error('Loop.build(): .repeat(runner) is required'); } const maxIterations = this._maxIterations ?? 10; return new Loop(this.opts, this._body, { maxIterations, ...(this._maxWallclockMs !== undefined && { maxWallclockMs: this._maxWallclockMs }), ...(this._until !== undefined && { until: this._until }), ...(this._bodyTranslator !== undefined && { bodyTranslator: this._bodyTranslator }), }); } } exports.LoopBuilder = LoopBuilder; function clampIterations(n) { if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) return 1; if (n > HARD_ITERATION_CAP) return HARD_ITERATION_CAP; return n; } //# sourceMappingURL=Loop.js.map