Skip to content

Agent Loop

The agent stores conversation state in memory for the lifetime of the Agent instance.

For each prompt, it:

  1. Adds the prompt as a user message.
  2. Calls the configured model client.
  3. Emits the assistant response.
  4. Executes any tool_use blocks.
  5. Adds tool_result blocks as a user message.
  6. Repeats until the assistant returns no tool calls.
  7. Emits a final result message.
const agent = createAgent({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/anthropic",
model: "deepseek-v4-flash",
maxTurns: 50,
});
await agent.prompt("Remember that my project is an SDK.");
const result = await agent.prompt("What kind of project is this?");

maxTurns defaults to 50 and protects the host application from infinite tool loops. It counts model requests. Tool executions, permission callbacks, and stream events do not increment it. When the limit is reached, the SDK emits a result with subtype: "error_max_turns".

A tool can also end the loop itself: when any tool in a batch returns endTurn: true, the SDK still records every tool_result of that batch and then finishes with subtype: "success" — step 6 stops without another model request, and the tool’s content becomes the result text.

Requires 0.16.0 or later.

Requires 0.20.0 or later.

Set AgentOptions.outputSchema when a run must end with a validated, typed payload instead of free text. The SDK injects a built-in submit_output tool (its name is exported as SUBMIT_OUTPUT_TOOL_NAME) whose input schema is your schema converted to JSON Schema — OutputSchema<T> is just { parse(input: unknown): T }, so a zod schema works directly.

This changes how the loop is allowed to end:

  • The model finishes by calling submit_output. The payload is validated against the schema first; a validation failure goes back into the loop as an error tool_result, so the model can fix it and retry. A valid submission ends the run with subtype: "success" and the payload on SDKResultMessage.structuredResult.
  • Ending the turn without calling submit_output fails the run with subtype: "error_missing_output" and a MissingOutputError. There is no fallback that parses the final text as JSON — submission is an explicit act.
  • submit_output must be the only tool call in its batch: a batch that mixes it with other calls, or contains two submissions, is rejected with code submit_output_exclusive_batch and the loop continues.
  • The name is reserved while outputSchema is set — registering your own submit_output tool throws from createAgent/addTools.

The structure is enforced by the harness, not by prompt discipline, and only requires a model that can call tools — unlike outputFormat, it does not depend on provider response_format/json_schema support (see Provider Compatibility).

Requires 0.10.0 or later.

An Agent is a conversation, not a reusable client. Its message history is instance state, so a second query started while one is still running would interleave turns from both. The SDK rejects that second query with ConcurrentQueryError rather than corrupting either conversation.

Create one Agent per concurrent conversation. In a server, that means constructing an Agent per request or per user session, not sharing a module-level instance:

// Wrong: every request appends to the same history.
const shared = createAgent({ model: "claude-sonnet-4-6" });
app.post("/ask", async req => shared.prompt(req.body.question));
// Right: one conversation per caller.
app.post("/ask", async req => {
const agent = createAgent({ model: "claude-sonnet-4-6" });
return agent.prompt(req.body.question);
});

Sequential reuse is fine and is how multi-turn conversations work: await one query before starting the next, as in the example above. The guard releases when a query finishes, including on error or abort.

Requires 0.16.0 or later.

By default the history lives only in memory. Attach a HistoryStore to seed it from durable storage and mirror every write back:

import { createAgent, createJsonlHistoryStore } from "agent-lattice";
const agent = createAgent({
model: "claude-sonnet-4-6",
historyStore: createJsonlHistoryStore({ path: ".agent-sessions/ada.jsonl" }),
});
// The first query lazily loads whatever the store holds and continues from it.
await agent.prompt("What is my name?");
// A deep copy of the live history; mutating it is safe.
const transcript = await agent.getHistory();

load() runs once per Agent lifetime, before the first query. Resuming a conversation in another process is a new Agent over the same store — the one-Agent-one-conversation rule above is unchanged. After loading, append(message) follows every message added to the history, and replace(messages) follows compaction, which rewrites the history wholesale, so a store must support full replacement. createJsonlHistoryStore() writes one JSON message per line and skips malformed lines on load, so a torn final write does not lose the transcript. By default a failing store is swallowed and the conversation continues in memory only; bind failOnError: true when the store is created (via createJsonlHistoryStore() options or defineHistoryStore()) to propagate store errors out of query().

The host can also rewrite the history directly with agent.replaceHistory(messages). Requires 0.17.0 or later. It is idle-only — calling it while a query is running throws ConcurrentQueryError — and when a historyStore is configured the store is replaced too, so persistence stays in sync. The SDK does not validate the content: the host owns it, and the replacement must be a well-formed history (e.g. no dangling tool_use without its matching tool_result).

requestTimeoutMs requires 0.11.0 or later.

Two limits apply at different scopes, and long-running agents usually need both.

QueryOptions.signal bounds the whole query — every model request, tool call, and turn together:

const result = await agent.prompt("Audit this repository.", {
signal: AbortSignal.timeout(600_000),
});
// result.subtype === "error_abort"

requestTimeoutMs bounds each single model request instead, so an agent that legitimately runs twenty tool-using turns is not forced to share one budget across all of them:

const agent = createAgent({
model: "claude-sonnet-4-6",
requestTimeoutMs: 120_000,
});
// Or per query, which overrides the agent default.
await agent.prompt("Answer quickly.", { requestTimeoutMs: 15_000 });

Exceeding it produces subtype: "error_timeout" with a TimeoutError, distinct from the "error_abort" a caller-initiated cancellation produces, so a host can retry timeouts without retrying deliberate cancellations. The value must be a positive integer number of milliseconds. When unset, the SDK applies no deadline of its own and the provider client’s default applies.

Both limits are enforced by the SDK itself rather than delegated. ModelRequest carries signal and timeoutMs so a client can cancel its own work, but the agent loop also races the call: a ModelClient that ignores both cannot stall the loop indefinitely. Losing that race abandons the call rather than cancelling it, so a non-cooperative client may keep working in the background while the loop moves on.

Requires 0.16.0 or later.

QueryOptions.signal terminates the query: it ends with subtype: "error_abort" and is meant for cancellation. When the host instead wants to take the conversation back — the user typed a correction, a higher-priority instruction arrived — agent.interrupt() ends the current query with subtype: "interrupted", which is normal control flow rather than an error (is_error stays false):

const pending = agent.prompt("Draft the release notes.");
agent.interrupt();
const result = await pending;
// result.subtype === "interrupted"
// Same Agent, same history: inject the new message and continue.
await agent.prompt("Actually, skip 0.15.x and cover 0.16.0 only.");

Only the in-flight turn is lost: the partial assistant message is dropped exactly as on abort, while every completed turn stays in the history, so the follow-up query sends the model the full prior conversation. An interrupt that lands while a tool batch is executing takes effect once the batch completes — its tool results are written to history first, and the query ends "interrupted" before the next model call. interrupt() is a no-op when no query is running.

Requires 0.10.0 or later.

Every result message carries usage, summed over the model requests in that query, and stop_reason from the last response:

const result = await agent.prompt("Summarize this file.");
console.log(result.usage); // { input_tokens, output_tokens, cache_read_input_tokens?, ... }
if (result.stop_reason === "max_tokens") {
// subtype is still "success", but result is a fragment, not an answer.
}

stop_reason: "max_tokens" means the model ran out of output budget mid-response. The SDK does not treat that as an error, so checking it is the only way to tell a complete answer from a truncated one. Raise maxTokens or ask for less output.

When a response containing tool calls is truncated at max_tokens, the SDK does not execute those calls: the last tool_use input may be incomplete, and a truncated value can even survive JSON parsing with its meaning changed. Every call in the batch receives an error tool_result explaining the truncation and asking the model to reissue the call with a shorter output, then the loop continues.

Requires 0.18.0 or later.

Usage is reported by the model client. The built-in Anthropic client fills it in; a custom ModelClient that omits usage yields zeroed counts rather than an error.

Assistant messages also carry provider response metadata: providerResponseId (the provider-assigned response id) and model (the model that actually served the response, which may differ from the requested one). The built-in Anthropic client fills both in on streaming and non-streaming requests; a custom ModelClient may set them on the AssistantModelMessage it returns. Both are absent when the client does not report them.

Requires 0.16.0 or later.

Requires 0.13.0 or later; recovery after an overflow requires 0.14.0.

History only grows, so a long-running agent eventually exceeds the model’s context window. Enable autoCompact to have the SDK replace the older part of the conversation with a model-written summary:

const agent = createAgent({
model: "claude-sonnet-4-6",
autoCompact: true, // or { thresholdTokens: 150_000, keepRecentMessages: 8 }
});

Compaction runs between turns, once a response has reported more input tokens than thresholdTokens (default 100000). The SDK summarizes everything except the last keepRecentMessages messages (default 6), then rebuilds the history as the summary followed by those retained messages. The summary is wrapped in an instruction telling the model that compaction just happened and to continue from it, so the next turn resumes the task rather than restarting it.

Unlike the onModelRequest hook, which only shapes one outgoing request, this rewrites the stored conversation. That is the point — the saving has to persist across turns — but it means the replaced turns are gone from the agent.

The cut point is chosen so the retained messages stand on their own: a tool_result is never separated from the tool_use that produced it, since the model API rejects that pairing. If no safe cut leaves anything to summarize, compaction is skipped.

Compaction costs a model call. Its tokens are folded into result.usage, so the total stays honest, and a system message with subtype: "compaction" is emitted when it happens:

for await (const message of agent.query("Refactor this module.")) {
if (message.type === "system" && message.subtype === "compaction") {
console.log(`compacted ${message.compacted_messages} messages`, message.usage);
}
}

The trigger depends on reported usage, so an agent using a custom ModelClient that omits usage never compacts. Override the summarization instruction with prompt, or read the built-in one from DEFAULT_COMPACTION_PROMPT.

Recovering from an overflow that already happened

Section titled “Recovering from an overflow that already happened”

The threshold is a forecast, so a single large tool result can still carry a request past the window. When that happens, compaction also runs as a recovery: the SDK summarizes, then retries the same turn rather than failing the query. Two signals trigger it:

Signal Meaning
stop_reason: "model_context_window_exceeded" The window ran out mid-request. The response is unusable and is never added to the history.
An API error naming a too-long prompt The input already exceeded the window, so the request was rejected before generation and there is no stop reason at all.

Recovery is attempted once per query. If summarizing fails, or there is nothing left to summarize, the original failure surfaces unchanged rather than being replaced by a second, derived one.

stop_reason: "max_tokens" deliberately does not trigger compaction. It means the output hit maxTokens, not that the input was too large — the model had plenty of room to read and ran out of room to write. Compacting the history would not make the answer complete. Raise maxTokens or ask for less output instead.