agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
204 lines (203 loc) • 9.63 kB
TypeScript
/**
* LLMCall — the leaf primitive for a single LLM invocation (no tools).
*
* Pattern: Builder (GoF) → produces a Runner backed by a footprintjs FlowChart.
*
* Chart shape — outer client wrapper around an inner llm-subflow:
*
* Client → sf-llm-call → loopTo(client)
*
* Outer `Client` stage:
* - First visit: receives args, writes userMessage to scope.
* - Second visit (after the loop completes): $break()s with
* scope.answer as the chart's TraversalResult.
*
* Inner `sf-llm-call` subflow (drill-down view):
* Initialize → sf-system-prompt → sf-messages → call-llm
* → [sf-thinking if handler] → extract-final
*
* NO `sf-tools` slot — LLMCall has no tools by design (that's Agent's
* territory). Atomic LLMCall's lens chart is a clean 3-node top-level
* view (Client + LLM + loop edge) that drills into the real flowchart
* below.
*
* Loop semantics: LLMCall is one-shot. The loop fires once; the
* second Client visit immediately breaks. The shape is identical to
* chat-mode (future): swap `$break()` for `pause()` and the same
* chart supports multi-turn conversation.
*
* Slot subflows write convention-keyed injections observed by
* ContextRecorder. The call-llm stage typedEmits stream.llm_start
* and stream.llm_end observed by StreamRecorder. When a
* `ThinkingHandler` resolves for the provider, `sf-thinking` mounts
* automatically (auto-wired by provider.name — same convention Agent
* uses).
*
* Emits (through internally-attached recorders):
* agentfootprint.stream.llm_start / llm_end
* agentfootprint.context.injected / slot_composed
* agentfootprint.stream.thinking_end (when sf-thinking mounted)
*/
import { type FlowchartCheckpoint, type StructureRecorder } from 'footprintjs';
import type { RunOptions } from './runner.js';
import type { GroupMetadata, GroupTranslator } from './translator.js';
import type { RunnerPauseOutcome } from './pause.js';
import type { LLMProvider, PricingTable } from '../provider-contract/types.js';
import { RunnerBase } from './RunnerBase.js';
export interface LLMCallOptions {
readonly provider: LLMProvider;
/** Human-friendly name shown in events/metrics. Default: 'LLMCall'. */
readonly name?: string;
/** Stable id used for topology + events. Default: 'llm-call'. */
readonly id?: string;
/** Model to request from the provider. */
readonly model: string;
/** Optional sampling temperature. */
readonly temperature?: number;
/** Optional max output tokens. */
readonly maxTokens?: number;
/**
* Per-slot context budgets, in characters (8.11.0). The LLMCall twin of
* `AgentOptions.contextBudget` — two slots here, since an LLMCall has no
* tools slot.
*
* Each slot warns (and emits `agentfootprint.context.budget_pressure`) when
* it composes over its budget. **Nothing is truncated** — the full content
* still reaches the LLM; the budget is a signal, not a limiter. Defaults:
* `systemPrompt` 4000, `messages` 10000.
*/
readonly contextBudget?: {
readonly systemPrompt?: number;
readonly messages?: number;
};
/**
* Record the ASSEMBLED system prompt on the LLM call (9.50.0). The LLMCall
* twin of `AgentOptions.recordSystemPrompt` — same field, same contract,
* same default. **Opt-in, default OFF**: when `true`,
* `agentfootprint.stream.llm_start` carries `systemPromptText`, the joined
* prompt verbatim as sent. PRIVACY: with the dial on, the full prompt rides
* into every recorder, sink and persisted recording — off, only
* `systemPromptChars` (the length) is on the record.
*/
readonly recordSystemPrompt?: boolean;
/**
* Mint a receipt on the call (9.91.0). Default ON — the LLMCall twin of
* `AgentOptions.recordReceipt`, same field, same contract, same default.
*
* A receipt is the fingerprint of what the model was actually handed,
* committed at the call so a reader can check the rebuilt view against it.
* `false` declines it, for the reason the agent's switch exists: the mint is
* a SHA-256 per system piece, per message and per tool schema, plus one
* commit-log value. Declining does not make the log unreadable — `servedAt`
* still rebuilds the view; what is gone is the witness, and `servedAt` says
* so with the same gap a pre-9.88 recording raises.
*
* @example decline the receipt in a bulk eval loop
* new LLMCall({ provider, model, recordReceipt: false })
*/
readonly recordReceipt?: boolean;
/**
* Pricing adapter. When set, LLMCall emits `agentfootprint.cost.tick`
* after every LLM response with per-call and cumulative USD. Run-scoped
* — the cumulative resets on each `.run()`.
*/
readonly pricingTable?: PricingTable;
/**
* Cumulative USD budget per run. When provided along with `pricingTable`,
* LLMCall emits `agentfootprint.cost.limit_hit` with `action: 'warn'`
* the first time cumulative USD crosses the budget. Execution continues
* — consumers choose whether to abort by listening to the event.
*
* The object form `{ usd, onExceed }` is accepted for symmetry with `Agent`,
* but `onExceed` must be `'warn'` here: halting means "stop at the next
* iteration boundary", and one call has no next boundary. `'halt'` is
* refused at build rather than silently ignored.
*/
readonly costBudget?: number | {
readonly usd: number;
readonly onExceed: 'warn' | 'halt';
};
/**
* Optional build-time recorders threaded into footprintjs's
* `flowChart()` factory. Each recorder observes per-node build
* events (`onStageAdded` / `onSubflowMounted` / etc.) for this
* LLMCall's internal chart (Initialize + slot mounts + CallLLM). When
* omitted, no build-time observation is wired up.
*/
readonly structureRecorders?: readonly StructureRecorder[];
/**
* Optional per-COMPOSITION translator (UI-agnostic). See
* `core/translator.ts`. When attached, `runner.getUIGroup()` invokes
* it with the LLMCall's `GroupMetadata` (kind `'LLMCall'`, id, name,
* empty `members[]`, plus `extra.slots` with the three slot ids —
* `system-prompt`, `messages`, `tools` — so Lens can render the slot
* cards inside an LLMCall card without inspecting `buildTimeStructure`).
* Returns `undefined` when omitted.
*/
readonly groupTranslator?: GroupTranslator;
}
export interface LLMCallInput {
readonly message: string;
}
export type LLMCallOutput = string;
export declare class LLMCall extends RunnerBase<LLMCallInput, LLMCallOutput> {
readonly name: string;
readonly id: string;
private readonly provider;
private readonly model;
private readonly temperature?;
private readonly maxTokens?;
private readonly systemPromptValue;
private readonly pricingTable?;
/** Normalized at construction: a bare number is `{ usd, onExceed: 'warn' }`. */
private readonly costBudget?;
/** Per-slot character budgets (8.11.0). Absent keys keep the slot default. */
private readonly contextBudget?;
/** `LLMCallOptions.recordSystemPrompt` (9.50.0) — opt-in, default OFF. */
private readonly recordSystemPromptValue;
/** `LLMCallOptions.recordReceipt` (9.91.0) — opt-OUT, default ON. */
private readonly recordReceiptValue;
private readonly structureRecorders?;
private readonly groupTranslator?;
/** The provider's DECLARED handler, read at construction (same as Agent —
* see thinkingHandlerFor). When undefined, sf-thinking is NOT mounted and
* the chart has zero thinking overhead (build-time conditional mount). */
private readonly thinkingHandler?;
private currentRunContext;
constructor(opts: LLMCallOptions, systemPromptValue: string);
static create(opts: LLMCallOptions): LLMCallBuilder;
protected getGroupTranslator(): GroupTranslator | undefined;
/** LLMCall has no nested-runner members (slots are subflows of
* the LLMCall's own chart, not Runner instances). The slot ids
* are surfaced via `extra` so Lens can render the slot cards
* inside an LLMCall card without inspecting `buildTimeStructure`.
*
* TWO slots only — LLMCall does not have tools (that's Agent's
* affordance). Atomic LLMCall renders as a clean 2-pill card in
* collapsed (top-level) view. */
protected buildUIGroupMetadata(): GroupMetadata;
run(input: LLMCallInput | string, options?: RunOptions): Promise<LLMCallOutput | RunnerPauseOutcome>;
resume(checkpoint: FlowchartCheckpoint, input?: unknown, options?: RunOptions): Promise<LLMCallOutput | RunnerPauseOutcome>;
private createExecutor;
private finalizeResult;
private buildChart;
}
/**
* Tiny fluent builder. Validates required fields at build() time.
*/
export declare class LLMCallBuilder {
private readonly opts;
private systemPromptValue;
/** Whether `.system()` has been called — see the refusal below for why the
* flag exists separately from the value. */
private systemPromptSet;
constructor(opts: LLMCallOptions);
/**
* Set the system prompt. Once per call — a second `.system()` used to
* REPLACE the first in silence, so the instructions written first were
* never sent and nothing said so. Join the parts yourself and pass one
* string.
*/
system(prompt: string): this;
build(): LLMCall;
}