UNPKG

agentfootprint

Version:

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

571 lines • 31.7 kB
"use strict"; /** * 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) */ Object.defineProperty(exports, "__esModule", { value: true }); exports.LLMCallBuilder = exports.LLMCall = void 0; const footprintjs_1 = require("footprintjs"); const requestOfRun_js_1 = require("./requestOfRun.js"); const advanced_1 = require("footprintjs/advanced"); const conventions_js_1 = require("../lib/conventions.js"); const abort_js_1 = require("../lib/abort.js"); const ContextRecorder_js_1 = require("../recorders/core/ContextRecorder.js"); const StreamRecorder_js_1 = require("../recorders/core/StreamRecorder.js"); const ErrorBridge_js_1 = require("../recorders/core/ErrorBridge.js"); const CostRecorder_js_1 = require("../recorders/core/CostRecorder.js"); const EvalRecorder_js_1 = require("../recorders/core/EvalRecorder.js"); const MemoryRecorder_js_1 = require("../recorders/core/MemoryRecorder.js"); const SkillRecorder_js_1 = require("../recorders/core/SkillRecorder.js"); const typedEmit_js_1 = require("../recorders/core/typedEmit.js"); const resilienceHooks_js_1 = require("../recorders/core/resilienceHooks.js"); const ResilienceRecorder_js_1 = require("../recorders/core/ResilienceRecorder.js"); const receipt_js_1 = require("./agent/receipt/receipt.js"); const cost_js_1 = require("./cost.js"); const RunnerBase_js_1 = require("./RunnerBase.js"); const runInput_js_1 = require("./runInput.js"); const buildSystemPromptSlot_js_1 = require("./slots/buildSystemPromptSlot.js"); const buildMessagesSlot_js_1 = require("./slots/buildMessagesSlot.js"); const buildThinkingSubflow_js_1 = require("./slots/buildThinkingSubflow.js"); const thinkingHandlerFor_js_1 = require("../thinking/thinkingHandlerFor.js"); const composeRequest_js_1 = require("./agent/composeRequest.js"); class LLMCall extends RunnerBase_js_1.RunnerBase { name; id; provider; model; temperature; maxTokens; systemPromptValue; pricingTable; /** Normalized at construction: a bare number is `{ usd, onExceed: 'warn' }`. */ costBudget; /** Per-slot character budgets (8.11.0). Absent keys keep the slot default. */ contextBudget; /** `LLMCallOptions.recordSystemPrompt` (9.50.0) — opt-in, default OFF. */ recordSystemPromptValue = false; /** `LLMCallOptions.recordReceipt` (9.91.0) — opt-OUT, default ON. */ recordReceiptValue = true; structureRecorders; 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). */ thinkingHandler; // Run-scoped; refreshed each run(). currentRunContext = { runStartMs: 0, runId: 'pending', compositionPath: [], }; constructor(opts, systemPromptValue) { super(); this.provider = opts.provider; this.name = opts.name ?? 'LLMCall'; this.id = opts.id ?? 'llm-call'; this.model = opts.model; this.temperature = opts.temperature; this.maxTokens = opts.maxTokens; this.systemPromptValue = systemPromptValue; // A dial without its switch — refused rather than run as a no-op (8.13.0). // Same check and same message as Agent: the pair is identical here, and so // was the silence. (0, cost_js_1.assertCostBudgetHasPricing)('LLMCall', opts.pricingTable, opts.costBudget); if (opts.pricingTable) this.pricingTable = opts.pricingTable; // `onExceed: 'halt'` is refused here — one call has no next boundary to // stop at, so accepting it would be a stop button wired to nothing. const resolvedCostBudget = (0, cost_js_1.resolveCostBudget)('LLMCall', opts.costBudget); if (resolvedCostBudget !== undefined) this.costBudget = resolvedCostBudget; if (opts.contextBudget !== undefined) this.contextBudget = opts.contextBudget; if (opts.recordSystemPrompt === true) this.recordSystemPromptValue = true; if (opts.recordReceipt === false) this.recordReceiptValue = false; if (opts.structureRecorders) this.structureRecorders = opts.structureRecorders; if (opts.groupTranslator) this.groupTranslator = opts.groupTranslator; // The ThinkingHandler the provider DECLARES — the same resolution Agent // uses (`thinkingHandlerFor`). A provider that declares none leaves the // field undefined and sf-thinking is not mounted. const declared = (0, thinkingHandlerFor_js_1.thinkingHandlerFor)(opts.provider); if (declared) this.thinkingHandler = declared; // Eager chart construction (footprintjs inventor convention): build // once at constructor time so `buildTimeStructure` is a stable // immutable object reference, each `StructureRecorder` fires // exactly N times (N = node count) per LLMCall, and reference-equality memos // on `getSpec()` work. Subsequent `getSpec()` calls return the // cached chart; each `run()` reuses it in a fresh executor. this.initChart(() => this.buildChart()); } static create(opts) { return new LLMCallBuilder(opts); } // `getSpec()` is inherited from `RunnerBase` — returns the chart // cached by `initChart()` above. No subclass override needed. // ─── UI group translation (L1b) ─────────────────────────────── getGroupTranslator() { return this.groupTranslator; } /** 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. */ buildUIGroupMetadata() { return { kind: 'LLMCall', id: this.id, name: this.name, members: [], extra: { slots: [conventions_js_1.SUBFLOW_IDS.SYSTEM_PROMPT, conventions_js_1.SUBFLOW_IDS.MESSAGES], }, }; } async run(input, options) { // Refused like every runner's; it serves no tool here, so it goes nowhere. (0, requestOfRun_js_1.requestOf)(options, 'LLMCall.run'); // Normalize or refuse first. Before 8.18.0 a bare string reached here as // `input.message === undefined`, the messages slot composed ZERO messages // from it, and the model was called with an empty conversation — an // answer to nothing, returned as though the call had worked. const runInput = (0, runInput_js_1.normalizeRunInput)(input, 'LLMCall.run'); const executor = this.createExecutor(); this.lastExecutor = executor; const result = await (0, RunnerBase_js_1.underRunSignal)(options, (engine) => executor.run({ input: { message: runInput.message }, ...(engine ?? {}), })); return this.finalizeResult(executor, result); } async resume(checkpoint, input, options) { // Refused like every runner's; it serves no tool here, so it goes nowhere. (0, requestOfRun_js_1.requestOf)(options, 'LLMCall.resume'); // 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); } createExecutor(resumeLeg) { this.currentRunContext = { runStartMs: Date.now(), runId: (0, RunnerBase_js_1.makeRunId)(), compositionPath: [`LLMCall:${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/`). An LLMCall declares no policy of // its own — mounted in a composition, the composition's run decides — but // the redaction is opened for every run so its stages' events still reach // the real-value path (a `toSSE({ format: 'text' })` reply stream). const redaction = this.openRunRedaction(resumeLeg, getRunCtx); // Reuse the cached chart built at constructor time. `getSpec()` and // every `run()` share the same `FlowChart` object reference. 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 })); // NOTE: no contextEvaluatedRecorder here — LLMCall composes its slots // directly and does NOT mount the Injection Engine, so context.evaluated // never fires in an LLMCall (that event is Agent-only). executor.attachCombinedRecorder((0, StreamRecorder_js_1.streamRecorder)({ dispatcher, getRunContext: getRunCtx })); // Terminal-failure bridge: footprintjs onRunFailed → typed error.fatal, // so a thrown run clears in-flight live state + flips monitor status. executor.attachCombinedRecorder((0, ErrorBridge_js_1.errorBridge)({ dispatcher, getRunContext: getRunCtx })); if (this.pricingTable) { // Only attach cost bridge when pricing is configured — zero overhead // on runs that don't opt into cost accounting. executor.attachCombinedRecorder((0, CostRecorder_js_1.costRecorder)({ dispatcher, getRunContext: getRunCtx })); } // Always-on bridges for consumer-emitted domain events (eval / memory / // skill). EmitBridge early-exits when no listener is attached, so // these are zero-alloc on runs that don't emit. executor.attachCombinedRecorder((0, EvalRecorder_js_1.evalRecorder)({ dispatcher, getRunContext: getRunCtx })); executor.attachCombinedRecorder((0, MemoryRecorder_js_1.memoryRecorder)({ dispatcher, getRunContext: getRunCtx })); executor.attachCombinedRecorder((0, SkillRecorder_js_1.skillRecorder)({ dispatcher, getRunContext: getRunCtx })); // Provider-decorator telemetry (withFallback / withRetry reports from // the call-llm stage's `resilienceHooks`). LLMCall has its own in-run // provider.complete(), so it needs its own bridge. executor.attachCombinedRecorder((0, ResilienceRecorder_js_1.resilienceRecorder)({ 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; // Client stage is the chart's last-executed stage (it $break()s // on the second visit). It deliberately returns void per the // TypedStageFunction contract; the LLM answer flows through // `scope.answer` on the outer-chart state. Read it from the // snapshot here to bridge to LLMCall.run()'s string return. if (typeof result === 'string') return result; const snap = executor.getSnapshot(); const sharedState = snap?.sharedState; const answer = sharedState?.answer; if (typeof answer === 'string') return answer; throw new Error('LLMCall: unexpected result shape — expected string'); } // ─── Internal chart construction ──────────────────────────────── buildChart() { const provider = this.provider; const model = this.model; const temperature = this.temperature; const maxTokens = this.maxTokens; const systemPromptValue = this.systemPromptValue; const pricingTable = this.pricingTable; const costBudget = this.costBudget; const thinkingHandler = this.thinkingHandler; const recordReceipt = this.recordReceiptValue; // THE SALT (9.91.0). `createExecutor` mints a fresh run id on every run and // every resume, and the chart is built once — so the receipt reads it // through `this` AT CALL TIME rather than closing over a value that would // be one run stale on the second run of the same LLMCall. const getRunId = () => this.currentRunContext.runId; // ─── Outer Client stage ───────────────────────────────────────── // First visit: receives args, sets userMessage on outer scope. // Second visit (post-loop): scope.answer has been populated by the // inner subflow's outputMapper. `$break` terminates the loop AND // returns scope.answer as the chart's TraversalResult. // // Returning the answer here is load-bearing for composition: when // LLMCall is mounted inside Sequence/Parallel/Conditional, the // parent's outputMapper reads `sfOutput` which IS the subflow's // TraversalResult (footprintjs convention). Without the return, // parent.current ends up as '' and downstream steps see no input. // // Cost counters live exclusively in the inner subflow's scope — // they don't need to cross the boundary because LLMCall is // one-shot. Threading them via inputMapper would seal them as // readonly input on the inner side (footprintjs convention) and // break `emitCostTick`'s scope writes. const client = (scope) => { if (scope.answer !== undefined) { scope.$break('LLMCall: one-shot complete'); return scope.answer; } const args = scope.$getArgs(); scope.userMessage = args.message; scope.iteration = 1; return undefined; }; // ─── Inner subflow stages ─────────────────────────────────────── // Inner seed initializes the per-call injection arrays AND the // local cost counters that emitCostTick mutates inside callLLM. // userMessage + iteration arrive via inputMapper from outer Client. const innerSeed = (scope) => { scope.systemPromptInjections = []; scope.messagesInjections = []; scope.cumTokensInput = 0; scope.cumTokensOutput = 0; scope.cumEstimatedUsd = 0; scope.costBudgetHit = false; }; // slot subflow builders. Each emits InjectionRecord[] + SlotComposition // through the convention scope keys; ContextRecorder observes and // dispatches context.* events. NO tools slot — LLMCall has no tools. // Per-slot budgets (8.11.0) — forwarded only where the consumer set one, // so an unset slot keeps its own default. const budget = this.contextBudget; const recordSystemPrompt = this.recordSystemPromptValue; const systemPromptSubflow = (0, buildSystemPromptSlot_js_1.buildSystemPromptSlot)({ prompt: systemPromptValue, reason: 'LLMCall.system()', ...(budget?.systemPrompt !== undefined && { budgetCap: budget.systemPrompt }), }); const messagesSubflow = (0, buildMessagesSlot_js_1.buildMessagesSlot)({ ...(budget?.messages !== undefined && { budgetCap: budget.messages }), }); const callLLM = async (scope) => { const systemPromptInjections = (scope.systemPromptInjections ?? []); const messagesInjections = (scope.messagesInjections ?? []); const iteration = scope.iteration ?? 1; const systemPrompt = (0, composeRequest_js_1.joinSystemPrompt)(systemPromptInjections); // The messages-slot join (9.88.0). One exported rule, for the same // reason the system-prompt join is one: `LLMCall` has no `history`, so // these records ARE the committed conversation, and `servedAt` rebuilds // it by calling this identical function over the identical records. // // The framework-field strip rides with it (9.91.0) — the third rule of // the one assembly. It is identity on what this slot composes today, and // it is here so the two sides cannot drift the day that changes: the // rebuild applies it, so the mint applies it. const messages = (0, composeRequest_js_1.stripFrameworkFields)((0, composeRequest_js_1.messagesFromInjections)(messagesInjections)); (0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.stream.llm_start', { iteration, provider: provider.name, model, systemPromptChars: systemPrompt.length, // Opt-in (9.50.0): the assembled prompt VERBATIM, exactly the string // handed to the provider below. ...(recordSystemPrompt && { systemPromptText: systemPrompt }), messagesCount: messages.length, toolsCount: 0, ...(temperature !== undefined && { temperature }), }); const startMs = Date.now(); // ONE request object, built once and both sent and fingerprinted — // never assembled twice (9.91.0). A second literal at the mint would be // a second assembly, and the whole point of a receipt is that it // describes THIS one. const request = { systemPrompt: systemPrompt.length > 0 ? systemPrompt : undefined, messages, model, ...(temperature !== undefined && { temperature }), ...(maxTokens !== undefined && { maxTokens }), }; // ── THE RECEIPT (9.91.0) ───────────────────────────────────────── // Written immediately before the provider is called, so it is committed // in the call-llm bundle that already exists — and before rather than // after on purpose: the stage commits on the error path too, so a call // that throws still leaves the record of what it was about to send. // // The mint is `receipt.ts` · `buildReceipt`, the same one the agent's // `call-llm` calls, salted with this run's own id. There is no cache // strategy on this chart, so the request handed out is the request // assembled: `baseRequest` and `preparedRequest` are the same object and // `cache.transform` records `'unchanged'` — exactly what an agent // running a pass-through strategy records, and true for the same reason. // `cache.strategy: null` is the half `transform` cannot say (9.93.0): // nothing stood between assembly and the port, so the served view // raises no `cache-transform` gap on this chart. // // NOT HERE: `omittedForAttention`. This chart has no window, so nothing // is ever dropped before a call, and the absent key says exactly that. if (recordReceipt) { scope[conventions_js_1.RECEIPT_KEY] = (0, receipt_js_1.buildReceipt)({ runId: getRunId(), epoch: iteration, model, provider: provider.name, systemText: systemPrompt, systemPieces: (0, receipt_js_1.receiptPieces)(systemPromptInjections), messages, requestOnly: [], tools: [], forced: null, withheld: null, baseRequest: request, preparedRequest: request, strategy: null, }); } // Raw provider errors propagate untouched (reliability + merge // layers classify on the raw message). The friendly translation // for non-developers happens once at the terminal boundary — // ErrorBridge humanizes the `error.fatal` event the monitor reads. // // The run's signal (its deadline included) rides the request and bounds // the wait, and an answer that lands after it is refused before // `llm_end` or the cost tick could count it (lib/abort.ts · callUnder). const response = await (0, abort_js_1.callUnder)(request, scope.$getEnv?.()?.signal, (wired) => provider.complete(wired, // Resilience-report channel: a decorated provider's fallback / // retry / recovery becomes an in-run typed event here. (0, resilienceHooks_js_1.resilienceHooks)(scope, recordReceipt))); const durationMs = Date.now() - startMs; (0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.stream.llm_end', { iteration, content: response.content, toolCallCount: response.toolCalls.length, usage: response.usage, stopReason: response.stopReason, durationMs, }); (0, cost_js_1.emitCostTick)(scope, pricingTable, costBudget, { provider: provider.name, model }, response.usage); // Write outputs to scope so downstream stages (sf-thinking, // extract-final) can read them. The chart's TraversalResult // bubbles up via scope.answer + outputMapper, NOT via this // stage's return value (which is intentionally undefined now). scope.answer = response.content; if (response.rawThinking !== undefined) { scope.rawThinking = response.rawThinking; } return undefined; }; // extract-final stage — symmetric with Agent's sf-final branch. // For LLMCall (no tools) it's a thin "final answer is ready" marker // that gives lens a discrete commit boundary and matching chart // node. scope.answer is already set by callLLM; this stage just // surfaces a real "final" moment. const extractFinal = (scope) => { // No-op data-wise — scope.answer was set in callLLM. This stage // exists purely for chart shape (lens "Final" node + commit). void scope; }; // ─── Build the inner sf-llm-call subflow ──────────────────────── let innerBuilder = (0, footprintjs_1.flowChart)('Initialize', innerSeed, conventions_js_1.STAGE_IDS.SEED, { description: 'LLMCall: invocation internals', }) .addSubFlowChartNext(conventions_js_1.SUBFLOW_IDS.SYSTEM_PROMPT, systemPromptSubflow, 'System Prompt', { inputMapper: (parent) => ({ userMessage: parent.userMessage, iteration: parent.iteration, }), outputMapper: (sfOutput) => ({ systemPromptInjections: sfOutput.systemPromptInjections }), }) // Declared milestones (9.90.0): these slots are `addSubFlowChartNext` // mounts, so — unlike the Agent charts' selector branches — they CAN // carry the `slot` tag on their first commit bundle. .tag(...(0, conventions_js_1.milestoneTagsFor)(conventions_js_1.SUBFLOW_IDS.SYSTEM_PROMPT)) .addSubFlowChartNext(conventions_js_1.SUBFLOW_IDS.MESSAGES, messagesSubflow, 'Messages', { inputMapper: (parent) => { // Wrap the single user message as a one-entry history for the // messages slot. Full conversation history arrives with Agent. const userMessage = parent.userMessage; return { messages: userMessage ? [{ role: 'user', content: userMessage }] : [], iteration: parent.iteration, }; }, outputMapper: (sfOutput) => ({ messagesInjections: sfOutput.messagesInjections }), }) .tag(...(0, conventions_js_1.milestoneTagsFor)(conventions_js_1.SUBFLOW_IDS.MESSAGES)) .addFunction('CallLLM', callLLM, conventions_js_1.STAGE_IDS.CALL_LLM, 'LLM invocation') // Declared milestone (9.90.0): the LLM turn. .tag(...(0, conventions_js_1.milestoneTagsFor)(conventions_js_1.STAGE_IDS.CALL_LLM)); // Conditional sf-thinking — mounted only when a ThinkingHandler // resolved (declared by the provider, read in the constructor). Same // build-time conditional pattern Agent uses (buildAgentChart.ts). if (thinkingHandler) { innerBuilder = innerBuilder.addSubFlowChartNext(conventions_js_1.SUBFLOW_IDS.THINKING, (0, buildThinkingSubflow_js_1.buildThinkingSubflow)(thinkingHandler, provider.name), 'NormalizeThinking', { inputMapper: (parent) => ({ rawThinking: parent.rawThinking, iteration: parent.iteration, }), outputMapper: (sfOutput) => ({ thinkingBlocks: sfOutput.thinkingBlocks }), // Replace not concatenate — fresh thinking per iteration. arrayMerge: advanced_1.ArrayMergeMode.Replace, }); } const innerSubflow = innerBuilder .addFunction('ExtractFinal', extractFinal, conventions_js_1.STAGE_IDS.EXTRACT_FINAL, 'Final response ready') .build(); // ─── Outer chart: Client → sf-llm-call → loopTo(client) ───────── // Description prefix `LLMCall:` is a taxonomy marker — consumers // distinguish LLMCall subflows from Agent subflows (`Agent:`) via // this prefix. Only Agent subflows surface as "agent boundaries" // in Lens; LLMCall is a primitive invocation, not an agent. // // Untyped <string, TypedScope<LLMCallState>> overload is used so // Client's `string | undefined` return is accepted — the chart's // TraversalResult is the answer string, which downstream // compositions (Sequence/Parallel/Conditional) read via // outputMapper's `sfOutput` parameter. return ((0, footprintjs_1.flowChart)('Client', client, conventions_js_1.STAGE_IDS.CLIENT, { ...(this.structureRecorders !== undefined && { structureRecorders: [...this.structureRecorders], }), description: 'LLMCall: one-shot', }) .addSubFlowChartNext(conventions_js_1.SUBFLOW_IDS.LLM_CALL, innerSubflow, 'LLM', { inputMapper: (parent) => ({ userMessage: parent.userMessage, iteration: parent.iteration, }), outputMapper: (sfOutput) => ({ answer: sfOutput.answer, }), }) // Declared milestone (9.90.0): the mount is the iteration boundary on the // outer log — one turn per LLMCall run. .tag(...(0, conventions_js_1.milestoneTagsFor)(conventions_js_1.SUBFLOW_IDS.LLM_CALL)) .loopTo(conventions_js_1.STAGE_IDS.CLIENT) .build()); } } exports.LLMCall = LLMCall; /** * Tiny fluent builder. Validates required fields at build() time. */ class LLMCallBuilder { opts; systemPromptValue = ''; /** Whether `.system()` has been called — see the refusal below for why the * flag exists separately from the value. */ systemPromptSet = false; constructor(opts) { this.opts = opts; } /** * 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) { if (this.systemPromptSet) { throw new Error('LLMCallBuilder.system: already set. An LLMCall has one system prompt — a second call ' + 'used to REPLACE the first silently, so the instructions you wrote first were never ' + 'sent. Join the parts into one string and pass it once. (An Agent has richer options ' + 'here: .steering(...) for a second always-on block, .configure(...) for a per-run ' + 'prompt.)'); } this.systemPromptSet = true; this.systemPromptValue = prompt; return this; } build() { return new LLMCall(this.opts, this.systemPromptValue); } } exports.LLMCallBuilder = LLMCallBuilder; // (Helpers previously inlined are now in ./slots/helpers.ts — the slot // builders import them directly. LLMCall stays thin: Builder + chart // assembly + internal recorder wiring.) //# sourceMappingURL=LLMCall.js.map