Skip to content

Run agent

ai.agent({...}) is the lowest rung of the ladder. One LLM call, optional tool loop, optional structured output. Stateless across calls.

import { ai } from "@warlock.js/ai";
import { OpenAISDK } from "@warlock.js/ai-openai";
const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! });
const myAgent = ai.agent({
name: "summarizer", // optional — anonymous gets a fingerprint
model: openai.model({ name: "gpt-4o-mini" }),
systemPrompt: "You are concise.", // string or SystemPromptContract
tools: [searchTool, calcTool], // optional
placeholders: { language: "English" }, // mustache substitution defaults
maxTrips: 10, // tool-loop bound (default 10)
modelOptions: { temperature: 0.2 }, // forwarded to the model
output: summarySchema, // default structured-output schema
middleware: [budgetMw, cacheMw], // optional
streamingToolGuard: {}, // opt-in tool-call recovery
on: { "agent.starting": handler }, // factory-level event handlers
version: "v3-2026-05", // mirrored onto reports
captureMessages: true, // persist the full turn array onto report.messages
observe: true, // route this run's report to observers
});

The factory returns an AgentContract<TOutput>. Every execution spawns a fresh internal Execution — the factory holds no per-call state.

Two of those options feed observability:

  • captureMessages?: boolean — off by default. When set, the full multi-turn conversation (every role, every trip) is persisted onto report.messages. Opt-in because the captured array can be large and may carry sensitive content (full prompts, tool inputs/outputs).
  • observe?: boolean | Observer — route this run’s completed report to your collectors. true sends it to the globally registered observers even when observe-all is off; false opts the run out; an Observer object is a run-local collector. See the Observer seam for the value semantics.

name is optional. Anonymous agents receive a deterministic fingerprint based on provider, model, and tool names:

anon_openai_gpt-4o-mini
anon_openai_gpt-4o-mini_search+calc

Same config across process restarts → same synthetic name. This matters when workflows fingerprint their structure for snapshot drift detection — composing the same anonymous agent twice produces the same identifier.

The blocking call. Returns a promise of AgentResult<T>. Never throws.

const { data, text, report, usage, error } = await myAgent.execute(input);

Every option is optional:

agent.execute(input, {
history, // Message[] — prior turns
attachments, // image attachments (strings or tagged objects)
placeholders, // override factory placeholders for this call
output, // override the structured-output schema for this call
responseSchema, // hand-crafted JSON Schema escape hatch
systemPrompt, // per-call system-prompt override
repair, // { maxAttempts } — re-ask on validation failure
signal, // AbortSignal
sessionId, // stamped onto every report node
modelOptions, // model call options for this run, merged over the agent's
continueOnOutputLimit, // continue turns allowed after a cut-off response
captureMessages, // per-call override of the factory's captureMessages
streamingToolGuard, // per-call override
on, // per-call event handlers
});

Call-site output fully replaces the factory’s output for that run — no merging.

modelOptions on execute() / stream() sets the model call options — reasoning effort, maxTokens, temperature, cacheControl, providerOptions — for that run only. They are shallow-merged over the agent’s modelOptions: a key you pass wins, a key you leave out keeps the agent’s value. The agent is never changed, so one agent can serve many concurrent runs with different settings.

const assistant = ai.agent({
model: anthropic.model({ name: "claude-opus-5-5" }),
modelOptions: { reasoning: { effort: "medium" } },
});
// This run thinks harder and keeps its prompt cache for an hour.
await assistant.execute(question, {
modelOptions: {
reasoning: { effort: "high" },
cacheControl: { breakpoints: 1, ttl: "1h" },
},
});
// The next run is back on the agent's settings.
await assistant.execute(followUp);

The merge is shallow: a nested object you pass (reasoning, cacheControl, providerOptions) replaces the agent’s whole value for that key. Repeat the agent’s fields you want to keep, for example providerOptions: { anthropic: { ...agentBetas, thinkingDisplay: "summarized" } }. tools, signal and responseSchema belong to the run and are ignored here; use the agent’s tools, the signal option and output / responseSchema instead.

Same surface, but you get an async iterable of typed events plus a .result promise:

const stream = myAgent.stream(input);
for await (const event of stream) {
if (event.type === "agent.trip.streaming") {
process.stdout.write(event.delta);
}
}
const result = await stream.result;

The same lifecycle events fire whether you call execute or stream — streaming adds per-token deltas on top, plus reasoning deltas (agent.trip.reasoning), tool-argument deltas (agent.tool.streaming) and mid-stream usage (agent.trip.usage) where the provider streams them — see Stream reasoning and tool arguments. The final envelope on stream.result is identical to what execute would resolve with.

type AgentResult<T> = {
type: "agent";
data?: T; // typed structured output when `output` schema was supplied
text?: string; // raw final LLM text
blocks?: ProviderContentBlock[]; // the final trip's raw provider blocks (thinking, compaction…)
report: AgentReport;
usage: Usage; // aggregated tokens + per-channel cost breakdown
error?: AIError;
};
type AgentReport = {
status: "completed" | "failed" | "cancelled" | "incomplete";
startedAt: string;
endedAt: string;
duration: number;
model: { name: string; provider: string };
trips: LLMTrip[];
toolCalls: ToolCall[];
systemPrompt?: string; // the resolved system message sent as role: "system"
messages?: CapturedMessage[]; // full turn array — present only when captureMessages is set
};

trips and toolCalls are flat lists with timing and outcome on every entry — easy to write reporting on top of. systemPrompt carries the resolved system text (persona + instructions + any auto-appended structured-output instruction), absent when the agent ran without one. messages is the full assembled conversation as CapturedMessage[] — present only when the factory’s captureMessages was set. See the Observer seam for the full treatment.

usage is more than a token total. It carries the full per-channel breakdown so cost dashboards can tell how a number was reached:

type Usage = {
input: number; // prompt tokens as the provider reports them — see inputIncludesCache
output: number; // completion tokens (includes reasoningTokens)
total: number;
cachedTokens?: number; // prompt tokens served from the provider's prompt cache (read hits)
cacheWriteTokens?: number; // prompt tokens WRITTEN to the cache this call
cacheWrite1hTokens?: number; // the 1-hour-lifetime share of cacheWriteTokens
inputIncludesCache?: boolean; // false on Anthropic / Bedrock: input excludes the cache tokens
reasoningTokens?: number; // the reasoning/thinking subset of output (priced separately when the model does)
cost?: {
// computed at emit time from tokens × the model's declared pricing
input: number;
output: number;
cachedInput?: number; // cache reads
cacheWrite?: number; // cache writes (both lifetimes)
reasoning?: number; // only when reasoning is priced apart from output
};
};

cost is captured as a historical fact at emit time — stored reports stay accurate even after the upstream pricing table changes. It’s undefined when no pricing is available (unknown model names, models the adapter ships no list price for). For one scalar total use totalCost:

import { totalCost } from "@warlock.js/ai";
const total = totalCost(result.usage.cost); // number | undefined

Every first-party adapter reports this surface, and attaches usage.cost to model.complete() and the streaming done chunk as well, so side calls made outside an agent are costed the same way.

Schema tokens. Tool schemas are sent on every trip. toolSchemas: "compact" (agent config, or per execute() call) sends them without descriptions, titles, examples and defaults — validation is unchanged:

const lean = ai.agent({ model, tools: [searchTool], toolSchemas: "compact" });
await lean.execute("find the warlock docs", { toolSchemas: "full" }); // per-call override
``` See the [cost tracking recipe](/v/latest/ai/recipes/cost-tracking/) for rolling it up across a report tree.
### Reasoning and prompt caching
Reasoning-capable and prompt-caching models accept extra `modelOptions`, gated by the model's own `capabilities` — an adapter that lacks the feature **ignores** the option rather than forwarding an unsupported parameter:
```ts
await myAgent.execute(input, {
modelOptions: {
reasoning: { effort: "high" }, // honored when capabilities.reasoning
cacheControl: { breakpoints: 1 }, // honored when capabilities.promptCaching
},
});

reasoning.effort maps to the provider-native control (OpenAI reasoning_effort); reasoning.maxTokens caps the thinking budget. cacheControl.breakpoints translates into provider prompt-cache breakpoints. The resulting reasoning / cache token counts flow back through usage.

Options that only one provider understands go under providerOptions, keyed by the adapter (anthropic, …). Each adapter reads its own key and ignores the rest, so the same modelOptions work in a fallback chain. See the Anthropic provider page for what it accepts.

Each trip records why the model stopped: stop, tool_calls, length, context_exceeded (the history filled the context window; compact or trim it), pause_turn, refusal or error. The agent resumes a pause_turn by itself, within maxTrips; the continuation carries only the new content, so result.text (and the structured output parsed from it) joins the paused segments with the final one. refusal ends the run; read report.trips.at(-1)?.finishReason to decide whether to retry on another model. length and context_exceeded end it as incomplete, below.

Cut-off responses — report.status: "incomplete"

Section titled “Cut-off responses — report.status: "incomplete"”

When the final response is cut off — by the output token cap (length) or the context window (context_exceeded) — the run has no complete answer. It ends with report.status: "incomplete" and result.error set to an AgentIncompleteError:

FieldValue
code"AGENT_INCOMPLETE" (category "incomplete")
reason"output_limit" (length) or "context_window" (context_exceeded)
tripIndexthe trip whose response was cut off
continuationscontinue turns already sent before giving up

result.text keeps the partial text. A tool call the model was still writing when the output ran out may have incomplete input, so it is never executed; it is listed on that trip’s truncatedToolCalls.

import { AgentIncompleteError } from "@warlock.js/ai";
const result = await worker.execute(task);
if (result.error instanceof AgentIncompleteError) {
if (result.error.reason === "output_limit") {
// Raise maxTokens, split the task, or let the agent continue (below).
} else {
// The context window is full: compact or trim the history.
}
}

Automatic continuation. continueOnOutputLimit: n (on the agent or per call) lets one run recover from up to n output-cap cut-offs. The agent keeps the partial text in the conversation, sends a continue turn, and joins the segments in result.text (and the structured output parsed from it). If the cut-off happened inside a tool call, the partial call is dropped and the continue turn tells the model the call did not run, so it issues it again — usually smaller. Each continuation is a normal trip, counted against maxTrips. When the continuations or trips run out, the run ends incomplete as above. A full context window is never continued.

const writer = ai.agent({
model: anthropic.model({ name: "claude-haiku-4-5" }),
tools: [writeFile],
continueOnOutputLimit: 2,
});
const result = await writer.execute("Write src/report.ts");
// A response cut off mid-text continues where it stopped; a cut-off
// `write_file` call is discarded and re-issued by the model.

Streaming behaves the same: the continuation’s deltas follow the partial text on the stream, and stream.result carries the joined text or the AgentIncompleteError.

Some providers return content the neutral text cannot carry, such as Anthropic thinking and compaction blocks. The adapter puts the whole turn on blocks, and the agent replays it verbatim inside the run.

A replayed thinking block is only accepted when every turn before it is unchanged, including the assistant tool-call turns and tool results the run made. So keep blocks between runs only together with those turns. report.messages (with captureMessages: true) has them, each assistant turn with its blocks; append the final reply from the last trip:

// The agent runs with `captureMessages: true`.
const result = await chat.execute(question, { history });
const finalTrip = result.report.trips.at(-1);
history = [
// Your history, the question, and every tool-call turn and tool result of the run.
// The agent re-adds the system prompt on each run, so leave it out here.
...(result.report.messages ?? []).filter((message) => message.role !== "system"),
// The final reply, as the model sent it.
{ role: "assistant", content: finalTrip?.output ?? "", blocks: finalTrip?.blocks },
];

result.blocks is set only when the run was a single trip, the one case where { content: result.text, blocks: result.blocks } is the whole turn. report.messages stores message content as text, so a question sent with attachments comes back as its JSON form; keep your own copy of such turns if you replay them.

import { v, type Infer } from "@warlock.js/seal";
const summarySchema = v.object({
summary: v.string(),
keyPoints: v.array(v.string()).min(1),
});
const result = await myAgent.execute(input, { output: summarySchema });
if (result.data) {
// typed as Infer<typeof summarySchema>
}

Bake it into the agent for end-to-end typing:

const summarizer = ai.agent({
model: openai.model({ name: "gpt-4o-mini" }),
output: summarySchema,
});
const result = await summarizer.execute(input);
// ^? AgentResult<{ summary?: string; keyPoints?: string[] }>

Adapters with native capabilities.structuredOutput forward the schema as JSON Schema. Adapters without it get a soft “respond in JSON only” instruction. Client-side validation always runs.

await myAgent.execute(input, {
output: schema,
repair: { maxAttempts: 1 },
});

Off by default. Each repair attempt counts against maxTrips. The framework tells the model what failed so it can correct.

await myAgent.execute("What's in this?", {
attachments: ["./photo.png", "https://cdn.example.com/cat.jpg"],
});

Shorthand strings infer the image kind from extension. Tagged form for explicit control:

attachments: [
{ type: "image", source: "./photo" },
{ type: "image", source: { base64: "...", mediaType: "image/png" } },
];

The model must declare capabilities.vision or you get a typed error at the boundary. OpenAI adapter auto-infers vision from the model name; override with openai.model({ name, vision: true }).

Attachment references are frequently user-controlled — chat uploads, document-ingestion endpoints, anything where the model decides what to fetch. The framework treats server-side attachment I/O as hostile by default and gates it with an AttachmentPolicy. Set it once on the factory (AgentConfig.attachmentPolicy) or per call (AgentExecuteOptions.attachmentPolicy, which overrides the factory one):

import { resolve } from "node:path";
const myAgent = ai.agent({
model,
attachmentPolicy: {
allowRemoteFetch: true,
outbound: { hostAllowlist: ["cdn.example.com"], maxBytes: 1_000_000 },
allowedRoots: [resolve("storage/uploads")],
allowBareLocalPaths: false,
},
});

Omitting the policy keeps the safe defaults: remote text fetch is denied, and bare-string local paths warn (a staged deprecation).

OptionDefaultWhat it gates
allowRemoteFetchfalseFetching a remote text attachment (an http/https URL given as { type: "text" }). Default-deny — such a fetch throws OutboundPolicyError unless enabled. URL image attachments are unaffected (they’re handed to the provider as a URL, never fetched server-side).
outboundstrict OutboundPolicy defaultsThe OutboundPolicy applied to a permitted remote-text fetch — https-only, host allowlist, post-DNS private-IP deny, 5 MiB cap, 10s timeout.
allowedRootsunsetSandbox roots for local file attachments. A local path must resolve inside one of these absolute roots or the read is rejected. When omitted, local reads are not path-restricted (back-compat).
allowBareLocalPathstrue (warns once)Allow bare-string local paths ("./notes.txt"). Staged deprecation — set false to hard-deny them now; the typed { source: { absolutePath } } route stays supported (still gated by allowedRoots).

Both denials surface as a typed OutboundPolicyError, never a raw throw, so you catch and report them at the boundary. See Outbound policy for the shared SSRF guard.

const ctrl = new AbortController();
const promise = myAgent.execute(input, { signal: ctrl.signal });
setTimeout(() => ctrl.abort("too slow"), 30_000);
const { error, report } = await promise;
if (report.status === "cancelled") {
// error is an AgentCancelledError (code "AGENT_CANCELLED")
// carrying `cancelledAt` + the abort `reason`
}

Between-trip abort is guaranteed, and so is mid-trip abort on every first-party adapter: honouring signal is part of the model adapter contract, so aborting rejects the in-flight model call and cancels its HTTP request (the provider stops generating). report.status === "cancelled" and error.category === "cancelled" are stable signals. A host racing streams against its own abort is no longer needed.

sessionId is metadata you control. The framework stamps it onto every report node produced during the run so downstream pipelines (cost dashboards, log aggregators) can group by session without joining the report tree.

const sessionId = "user_42_2026-05-12";
await myAgent.execute("what's my order?", { sessionId });
await myAgent.execute("cancel it", { sessionId });

No implicit persistence. No automatic history. Just a string that rides along. For a stored conversation, use ai.session().

Names:

  • agent.starting, agent.trip.started, agent.trip.streaming, agent.trip.completed
  • agent.trip.reasoning, agent.trip.usage, agent.tool.streaming — agent.stream() only
  • agent.tool.calling, agent.tool.called, agent.tool.failed
  • agent.completed, agent.error

Three subscription tiers, fired in order:

// Factory level — fires for every execute
ai.agent({ model, on: { "agent.starting": handler } });
// Instance level — returns an unsubscribe
const unsubscribe = myAgent.on("agent.error", handler);
// Per-call level — fires only this run
await myAgent.execute(input, { on: { "agent.trip.completed": handler } });

Every payload carries runId and rootRunId so nested runs (workflow → agent → tool) stitch into one trace.

The three tool events also carry callId, the provider’s id for the tool call. One trip can call the same tool several times, and those calls run concurrently by default, so pair each result with its call by callId, not by tool name. The same id is on the ToolCall record (report.children[].callId) and on the tool middleware context (ctx.callId).

const pending = new Map<string, string>();
for await (const event of assistant.stream(question)) {
if (event.type === "agent.tool.calling") {
pending.set(event.callId, event.tool.action ?? event.tool.name);
} else if (event.type === "agent.tool.called" || event.type === "agent.tool.failed") {
ui.finishToolChip(event.callId, pending.get(event.callId));
}
}

A streamingToolGuard config recovers a tool call when the model emits the structured input as text in the content stream instead of as a real tool_call. See the streaming tool guard recipe for the full story.

ai.agent({
model: someFastModel,
tools: [suggestFollowupsTool],
streamingToolGuard: {}, // empty object = on with defaults
});

Off by default. Set this explicitly on agents whose registered tools have been observed to leak.

A handful of small utilities take the friction out of common agent patterns. They’re not new primitives — each returns a plain agent, model, or result you already know how to use.

Seed a system prompt from a file, read once at construction:

const writer = ai.agent({
model,
systemPrompt: ai.systemPrompt.fromFile("./prompts/writer.md"),
});

Also available as SystemPrompt.fromFile(path).

You no longer need to call .asTool() by hand to compose primitives. Pass a workflow, supervisor, or orchestrator straight into an agent’s tools array and it is auto-adapted into a tool:

const concierge = ai.agent({
model,
tools: [searchTool, refundWorkflow, supportSupervisor], // workflow + supervisor auto-adapted
});

Wrap an ordered model list that fails over to the next on transient provider errors (rate limits, timeouts) — drop it in anywhere a model is expected:

const model = ai.fallbackModel([
openai.model({ name: "gpt-4o" }),
anthropic.model({ name: "claude-sonnet-4" }),
]);
const agent = ai.agent({ model });

Run any executable over a dataset with bounded concurrency and per-item retry. A batch never fails as a whole — each item’s outcome lives on its own BatchItemResult:

const { items, data, usage, report } = await ai.batch(summarizer, articles, {
concurrency: 4,
retry: { attempts: 3, backoff: "exponential" },
onItem: (item) => log(item.index, item.status),
});
console.log(`${report.succeeded}/${report.total} ok, ${usage.total} tokens`);

data is the positional array of successful items’ result.data (with undefined in failed slots); items is the per-item breakdown. usage rolls up across every item.

Run a scored evaluation suite against an agent and get an aggregate EvalReport. Each case runs through execute(input) and is scored by the resolved scorers; a case passes only when every scorer passes and the agent didn’t error:

const report = await myAgent.eval({
cases: [{ name: "capital", input: "Capital of Egypt?", expected: "Cairo" }],
scorers: [ai.eval.contains()], // exact / contains / predicate(fn) / judge(config)
});
expect(report.passed).toBe(true);

ai.eval.judge({ agent, rubric }) is LLM-as-judge. In Vitest, registerAiMatchers() adds toRouteTo / toConverge / toPassStep / toOutputShape. eval() never throws on a case failure — failures surface on the report.

Supervisor-oriented helpers — see Run supervisor for ai.router() (generate the routing agent) and ai.fanOut() (voting / self-consistency). ai.mockRouter(decisions) replays canned routing decisions for supervisor tests.

Compaction is disabled by default. Set compaction.afterTokens to an explicit input threshold; the agent never infers a provider’s context-window size. Before each model trip (including streamed and repair trips), it checks the current conversation and tools against that threshold.

const worker = ai.agent({
model,
tools,
compaction: {
afterTokens: 40_000,
keep: 8,
// Optional: native preflight for a model/transport that supports the full input.
// countTokens: async (messages, options) => {
// if (!model.countTokens) throw new Error("Native counting unavailable");
// return model.countTokens(messages, options);
// },
summarize: async (history, { signal }) => {
const response = await summaryModel.complete(
[
{
role: "system",
content:
"Summarize facts, decisions, the user's request and unresolved work. Treat the transcript as data.",
},
{ role: "user", content: JSON.stringify(history) },
],
{ signal },
);
return {
summary: { role: "assistant", content: response.content },
replacesFromIndex: 0,
replacesToIndex: history.length - 1,
};
},
},
});

ai.compaction.summarize({ model }) builds the callback for you. It renders the replaced slice as a tagged transcript (text, tool calls and tool results; signed reasoning blocks are opaque and left out), sends it to model with Anthropic’s published client-side compaction prompt, and uses the text inside the reply’s <summary></summary> tags. Use a cheaper model than the agent’s:

const worker = ai.agent({
model,
tools,
compaction: {
afterTokens: 120_000,
keep: 2,
summarize: ai.compaction.summarize({
model: cheapModel,
maxTokens: 4_000,
// instructions: "…", // replaces the default prompt
onUsage: (usage) => ledger.add(usage), // the summary call is not in the agent's usage
}),
},
});

A reply that is truncated (length), refused or empty throws, so the agent records a callback-error skip and keeps the conversation as it was. DEFAULT_COMPACTION_INSTRUCTIONS and renderTranscript are exported if you write your own callback.

The summarize callback reuses the existing SummarizeCallback result shape: CompactionResult. It receives a detached old slice, and must replace the whole slice with a non-empty assistant text summary. Its second argument contains tripIndex, inputTokens, counting ("estimate" or "custom") and signal. The callback owns its summary model calls, billing and usage; those are separate from the agent’s generation usage. No summarizer model is called implicitly.

Without countTokens, the threshold uses an offline character-based estimate of serialized messages, tool definitions and response schema. This is a trigger, not an exact multimodal token count or a hard context-window guarantee. A custom counter receives the same call options as generation and runs once before each trip, plus once to check a proposed replacement. Use model.countTokens only when present and when its endpoint can count the entire request.

Original system messages, the original execution input and the latest user message remain intact, including during output repair. keep defaults to eight recent messages and expands to preserve complete assistant/tool-result groups. The agent skips unsafe ranges, summaries that do not reduce the counted input, and callback/counter failures. Generation continues with the original conversation after a skipped decision; cancellation stops the run before generation.

Thinking runs compact too. The range always ends before the open tool round: the latest assistant turn and the tool results that answer it go back verbatim, signed thinking blocks included, because a provider needs that turn’s reasoning to finish the round (and budget-mode thinking requires the last assistant turn to begin with its thinking block). With keep: 1 everything before that round is summarized; a larger keep retains more recent turns verbatim.

When kept turns after the range carry signed reasoning (thinking blocks, thought signatures), the summary is framed as user context (Summary of earlier conversation:) so a kept assistant turn still starts with its own reasoning, and each such turn is marked compaction: "retained" (the summary is marked compaction: "summary"). The markers travel with report.messages, durable snapshots and ai.session() history. Turns before the range are untouched: the conversation before them did not change.

Whether retained reasoning is still valid is the provider’s rule. Models that bind thinking to the exact history before it (Anthropic’s preserved thinking on Claude Fable 5.1, Opus 5.5, Sonnet 5.5 and Haiku 5.5) can’t use reasoning produced before a client-written summary; the Anthropic adapter replays those turns without their thinking blocks, so the model reasons afresh from the summary. Older models accept the kept blocks as they are. See Anthropic → Preserved thinking and client-side compaction; on those models, server-side compaction (serverCompaction) keeps more of the model’s own reasoning.

report.compactions records threshold-crossing decisions and counter failures as type: "applied" or "skipped", with the trip index, counting method, input count and either the applied CompactionResult (indices in the live conversation before replacement) or a reason: no-safe-range, not-smaller, callback-error. The input count is absent when the counter failed before producing one. No field is added when compaction is disabled or the threshold is never reached. After an applied compaction, result.blocks is absent even for one generation trip: final thinking is signed against the compacted history. Enable captureMessages and persist report.messages plus the final trip’s output and blocks for replay. The original caller history is unchanged. A summary replacing the first non-system turn is framed as user-supplied conversation context, so the transcript remains user-first. Bedrock combines adjacent turns of the same role while preserving their content blocks, including parallel tool results and a summary beside an assistant tool-call turn.

Durable snapshots preserve decisions, the compacted conversation and the original input message’s index. A persisted index is used only when it is an integer inside the conversation that points at a user message; otherwise (a corrupted or hand-edited snapshot), and for older snapshots that lack it, the index is derived from an unambiguous matching user input. If the input is missing or ambiguous, compaction records no-safe-range and leaves the conversation intact. Changing the threshold or keep triggers durable drift detection; bump the agent’s version when changing summary callback semantics.

  • Fixed pipeline of several agents → Run workflow.
  • One input routed across specialists → Run supervisor.
  • Multi-turn conversation with persistent session → Run orchestrator — durable session state across runs.
  • Plan generated up front, then executed → Planner.