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
JavaScript
"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