Deuz SDK 2.0 is out — stores, guardrails, handoffs, and zero-config MCP. What is new in 2.0
Deuz SDK

The Agentic Tool Loop

How generateText and streamChat run multi-step tool loops — the exact per-step ordering, stop conditions, self-healing, and the invariants that keep the loop safe.

When you pass tools, both generateText and streamChat stop being single-turn calls and become an agentic loop: run a model step, execute any tool calls in parallel, feed the results back as a new turn, repeat. The loop is self-healing (a thrown tool becomes an error result, not a crash) and bounded (maxSteps, stopWhen, budget, and a runaway guard).

This page is the reference for what actually happens, in what order, and why.

When the loop runs

The loop activates whenever tools is present and non-empty. Omit tools (or pass {}) and you get a single buffered turn. There is no separate "agent" entry point — the loop is the same two functions with tools attached.

Five other options also route a call through the loop even with no tools at all, because each of them hangs off a loop boundary the single-turn path does not have: chat (persistence boundaries), memory (recall before, extraction after), verifyStep and doneWhen (the natural-completion boundary), guardrails (the pre-run input hook and the output hook), and mcp (the servers' tools are the tool set). An accepted-but-inert safety control would be the worst possible outcome, so the option pulls the call into the loop instead.

loop.ts
import { generateText } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
import { z } from 'zod';

const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });

const { text, steps } = await generateText({
  model: anthropic('claude-opus-4-8'),
  prompt: 'What is the weather in Paris?',
  maxSteps: 5, // without this the loop runs a single turn (default 1)
  tools: {
    getWeather: {
      description: 'Get the current weather for a city.',
      parameters: z.object({ city: z.string() }),
      execute: async ({ city }) => ({ city, tempC: 22, sky: 'sunny' }),
    },
  },
});

console.log(text); // final natural-language answer
console.log(steps?.length); // e.g. 2: one tool turn + one final turn

`maxSteps` defaults to 1

This is the mistake nearly everyone makes once. With the default budget the loop calls the model, runs the tool, and stops — before the model has seen the tool result, so text is empty and finishReason is 'tool_calls'. Set maxSteps above 1 for any real tool use.

The default is deliberately conservative: a step budget is a spend budget, and an unbounded loop driven by a wrong prompt is an unbounded bill.

Anatomy of one step

Both loops run the same sequence. The streaming one additionally frames it with step-start / step-finish parts.

  ── per iteration ─────────────────────────────────────────────────────────
   1. step deadline check      previous step overran `timeout.stepMs`? fail now
   2. arm this step's deadline
   3. COMPACTION               automatic layered compaction, if enabled
   4. MCP hot-swap             a server announced tools/list_changed → re-read
   5. prepareStep              your hook — LAST word on messages/model/tools
   6. MODEL CALL               one turn (retry + ttft/total timeouts inside)
                               ↳ one context-overflow auto-retry per step
  ── did the turn emit tool_use parts? ──────────────────────────────────────
   NO  →  7a. append assistant turn
          8a. doneWhen guard          may re-drive (own budget)
          9a. verifyStep              may re-drive (own budget)
         10a. output guardrails       may block / rewrite the final text
         11a. checkpoint 'completed'  → LOOP ENDS
   YES →  7b. append assistant turn FIRST (OpenAI ordering)
          8b. split off handoff calls
          9b. onToolCall guardrails   block / rewrite arguments
         10b. approval gate           server mode denies inline; client mode BREAKS
         11b. client tools present?   BREAK — the caller owns the round-trip
         12b. executeTools            parallel, capped by maxToolConcurrency
         13b. step deadline check     tools may have overrun it
         14b. append ONE tool turn    every tool_use_id answered
         15b. onStepFinish
         16b. runaway guard           3 consecutive same-tool errors → hard stop
         17b. stopWhen / maxSteps / budget
         18b. checkpoint 'running'    → NEXT ITERATION

Ordering details that are load-bearing:

  • Compaction runs before prepareStep, so your hook sees — and has the last word on — the compacted history. If it ran after, your rewrite would be silently re-compacted or lost.
  • The assistant turn is appended before its tool-result turn. OpenAI's wire rejects the other order.
  • Guardrails run immediately before the approval gate, so a rewritten argument reaches the needsApproval predicate, the request a human actually sees, and execute — while the assistant turn already in history keeps the arguments the model issued. What the model said and what ran are both recoverable.
  • The step deadline is checked twice: once before starting a step, once after its tools have run. A timer that fires while a tool is executing cannot abort the tool (that is toolMs / Tool.timeoutMs), so the loop enforces it at its own boundaries rather than feeding results into a model call it can no longer pay for.
  • A break executes nothing else from the batch. When a pending approval or a client tool forces a break, the whole batch is deferred; the resume call settles all of it. That keeps the "every tool_use_id gets a tool_result" invariant intact across the suspension.

Before the first iteration, once per run leg: MCP servers connect, the handoff catalog is captured, the tool wires are built and filtered by activeTools, any pending approvals from a previous leg are settled, memory recall is computed, and the onInput guardrails run.

Loop control options

These live on CommonCallOptions, so they work identically for generateText and streamChat.

OptionTypeDefaultNotes
toolsToolSetRecord<string, Tool>. Presence switches on the loop.
toolChoice'auto' | 'required' | 'none' | { type: 'tool'; toolName: string }'auto'Forces / forbids tool use on each step.
maxStepsnumber1Max model turns. Counts model turns, not tool calls — a step that runs three tools in parallel is one step.
stopWhenStopCondition | StopCondition[]Extra stop predicate(s), OR-ed with maxSteps.
budget{ usd?: number; tokens?: number }Hard spend ceiling with readable stoppedBy markers. The streaming loop also emits a budget-exceeded part.
maxToolConcurrencynumber5Max parallel tool executions per step.
onStepFinish(step: StepResult) => voidFires after each step that made tool calls (including approval / client-tool breaks). It does not fire for the terminal text-only step — read that from result.steps.at(-1).
prepareStep(ctx) => PrepareStepResult | undefinedPer-step overrides: messages, model, activeTools, toolChoice. A throw here fails the call — it is your code, so it is never swallowed.
activeToolsstring[]Static filter on which tool keys reach the wire. Unknown names warn and are ignored. prepareStep's activeTools overrides it per step.
compaction'auto' | CompactionPolicyoffAutomatic layered context compaction. Pruning is free; the summarize layer costs one extra model call whose usage counts toward the total.
verifyStep(ctx) => VerifyStepResult | Promise<…>1.8. Post-completion verifier. A rejection feeds feedback back as a user turn and re-drives, on its own budget.
maxVerifyAttemptsnumber3Cap on verifier re-drives — does not consume maxSteps.
timeout.stepMsnumberunboundedOne step end-to-end: the model call plus its tools. Enforced by both loops.
timeout.toolMsnumberunboundedOne tool execute. Tool.timeoutMs overrides it per tool.

maxSteps, stopWhen and budgets

The loop stops when any stop condition returns true. Internally maxSteps is itself compiled into a stop condition (stepCount >= maxSteps) and OR-ed with whatever you pass — so stopWhen never extends the step budget, it only lets you stop earlier or on a custom signal.

export type StopCondition = (info: {
  steps: StepResult[];
  stepCount: number;
  /** Cumulative real usage across all steps (sub-agents included). */
  usage?: Usage;
  /** Cumulative cost in USD — only when `deps.priceProvider` is set and a condition needs it. */
  costUSD?: number;
  /** Milliseconds since the loop started, from the injected clock. */
  elapsedMs?: number;
}) => boolean | Promise<boolean>;

Five ready-made conditions ship from the package root:

ConditionStops whenRequires
stepCountIs(n)stepCount >= n
hasToolCall(name)the latest step called that tool
totalTokensExceed(n)cumulative real provider-reported usage crosses n
costExceeds(usd)cumulative cost crosses usddeps.priceProvider — without one the loop warns once and the condition never fires
durationExceeds(ms)the loop has been running at least msnothing; time comes from deps.clock, so it is deterministic in tests

budget: { usd, tokens } is sugar over the last two with stoppedBy markers of budget.usd / budget.tokens, so you can tell a budget trip apart from a manual stop after the fact.

stop-when.ts
import { generateText, type StopCondition } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
import { z } from 'zod';

const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });

// Stop once a step has called the `finalize` tool.
const afterFinalize: StopCondition = ({ steps }) =>
  steps.at(-1)?.toolCalls.some((c) => c.toolName === 'finalize') ?? false;

const result = await generateText({
  model: anthropic('claude-opus-4-8'),
  prompt: 'Research and summarize.',
  maxSteps: 10,
  budget: { usd: 0.25 },
  stopWhen: afterFinalize, // OR-ed with maxSteps and the budget
  tools: {
    finalize: {
      parameters: z.object({ summary: z.string() }),
      execute: async ({ summary }) => ({ ok: true, summary }),
    },
  },
});

// 'hasToolCall' | 'budget.usd' | 'custom' | … | undefined when it ended naturally
console.log(result.providerMetadata?.deuz?.stoppedBy);

Conditions are evaluated at step boundaries, after tools ran

Two practical consequences. First, a condition never fires on the terminal text-only step — that step already ends the loop on its own. Second, a budget stops the run once cost has crossed the line, never before: the in-flight step always completes and is paid for. Size budgets with one step of headroom.

stoppedBy reports the named condition that fired. The implicit maxSteps bound is deliberately excluded — hitting your own step limit is not an exceptional outcome worth marking.

Step anatomy

interface StepResult {
  stepType: 'initial' | 'tool-result';
  text: string;
  reasoningText?: string;
  toolCalls: ToolCall[];
  toolResults: ToolResult[];
  finishReason: FinishReason;
  usage: Usage;
  /** Messages this step appended (assistant turn + the tool-result turn). */
  response: { messages: Message[] };
}
FieldNotes
stepType'initial' for the first step; 'tool-result' for every step produced by feeding tool results back.
textThat step's assistant text — often empty on tool-calling steps, populated on the final step.
reasoningTextPresent only when the model emitted reasoning that turn.
toolCallsParsed tool calls this step made — empty on the terminal text-only step. Reported in the model's own order, with any guardrail rewrite applied.
toolResultsExecution results. Each carries isError when a tool threw, timed out, was denied, was blocked, had invalid arguments, or did not exist.
usageThis step alone. The top-level result.usage is the sum across all steps, sub-agents included.
response.messagesJust the turns this step appended.
read-steps.ts
import { generateText } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
import { z } from 'zod';

const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });

const result = await generateText({
  model: anthropic('claude-opus-4-8'),
  prompt: 'Weather in Paris and Berlin?',
  maxSteps: 5,
  tools: {
    getWeather: {
      parameters: z.object({ city: z.string() }),
      execute: async ({ city }) => ({ city, tempC: 22 }),
    },
  },
  onStepFinish: (step) => {
    console.log(`[${step.stepType}] ${step.toolCalls.length} tool call(s)`);
  },
});

for (const step of result.steps ?? []) {
  for (const call of step.toolCalls) console.log('called', call.toolName, call.args);
  for (const r of step.toolResults) {
    console.log('result', r.toolName, r.isError ? '(error)' : '', r.result);
  }
}

Streaming the loop

streamChat runs the same loop but produces one canonical fullStream spanning every model call.

fullStream partPosition in the stepPayload
step-startfirst, every turn{ stepIndex }
text-delta / reasoning-deltaduring the turnstreamed content
tool-call-deltaduring the turnraw argument JSON fragments, for live input-streaming UIs
step-finishend of the model turn{ stepIndex, finishReason, usage } (this step alone)
tool-callafter args finish parsing{ toolCallId, toolName, input }
tool-statearound each calllifecycle: input-streaming, input-complete, awaiting-approval, executing, complete, error (with denied: true when an error was an approval refusal)
tool-resultafter execution{ toolCallId, toolName, output, isError? }
costafter each stepcumulative USD, when deps.priceProvider is set
finishonce, at the very end{ usage, finishReason } (usage summed across all steps)

So a two-step run reads: step-start(0) → deltas → step-finish(0)tool-calltool-resultstep-start(1) → deltas → step-finish(1)finish. The first part is always step-start; the last is always finish. textStream is the text-only projection across all steps.

stream-loop.ts
import { streamChat } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
import { z } from 'zod';

const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });

const result = streamChat({
  model: anthropic('claude-opus-4-8'),
  prompt: 'Weather in Paris?',
  maxSteps: 5,
  tools: {
    getWeather: {
      parameters: z.object({ city: z.string() }),
      execute: async ({ city }) => ({ city, tempC: 22 }),
    },
  },
});

for await (const part of result.fullStream) {
  switch (part.type) {
    case 'step-start':
      console.log(`--- step ${part.stepIndex} ---`);
      break;
    case 'text-delta':
      process.stdout.write(part.text);
      break;
    case 'tool-call':
      console.log('\ncalling', part.toolName, part.input);
      break;
    case 'tool-result':
      console.log('result', part.toolName, part.output);
      break;
    case 'step-finish':
      console.log(`\nstep ${part.stepIndex} done (${part.finishReason})`);
      break;
    default:
      break;
  }
}

console.log('total tokens:', (await result.usage).totalTokens);

streamChat returns synchronously and never throws — a failure surfaces as an error part and rejects usage / finishReason. Keep a default case: StreamPart is an open union.

Loop guarantees

These invariants are enforced by the implementation and pinned by tests. Treat them as contracts.

Continuation keys on tool-call count, not finishReason

The loop decides whether to run another turn by asking whether the last step emitted any tool_use parts. It never reads finishReason to make that decision.

This is not defensive programming against a hypothetical. A live probe of gemini-3.6-flash returned finishReason: STOP in the same response as a functionCall — the exact shape that makes a finishReason-driven loop hang up one tool short, returning a tool call as if it were the final answer. There are two layers of defence: the native Gemini adapter overrides the mapped finish reason to 'tool_calls' whenever it saw a function call, and the loop ignores the field entirely.

The practical consequence for you: step.finishReason is a report, not a control signal. Do not write stopWhen predicates that branch on it — branch on toolCalls.length.

Immutable message history per step

Each step builds a new message array ([...messages, assistantTurn, toolResultTurn]); prior steps' arrays are never mutated. Three things depend on it: provider prompt-cache hits (a mutated prefix is a cache miss), driving the loop safely from React state, and durable checkpoints that must describe a history that still exists.

Parallel, capped tool execution

A step's tool calls run concurrently, capped at maxToolConcurrency (default 5). toolResults order is preserved relative to toolCalls, so index-based pairing is safe even though completion order is not.

Each execute receives a ToolExecuteContext carrying toolCallId, an immutable messages snapshot, a signal (your abort signal merged with any per-tool timeout), the call's opaque runtimeContext, and — for sub-agents — the parent's resolved deps, approver and live-part sink.

Self-healing tool errors

A tool failure is never a thrown call. Every one of these turns into a tool_result with isError: true that is fed back to the model, so it can retry with different arguments or pick another tool:

CauseMessage the model seesCounts toward the runaway guard
execute threwthe thrown error's own message (not an opaque wrapper)
Arguments failed schema validationInvalid arguments: …
Tool name does not existNo such tool: "x". Available tools: a, b, c.
Tool exceeded timeoutMs / timeout.toolMsTool 'x' timed out after Nms and was abandoned.
Registered tool with no execute, reached anywayNo server-side executor.
Approval denied (server mode)Tool call denied. (plus the reason, if given)
onToolCall guardrail blocked itthe denial message

The split in the last column is the design decision worth understanding. A hallucinated name or a hanging tool is a model-side or tool-side defect the model can fix from the feedback — and a model re-issuing the same broken call forever is precisely the runaway the guard exists for. A denial is a human or policy verdict the model cannot fix; re-asking is the correct behaviour, so counting it would punish the model for obeying.

Crucially, every tool_use id always receives a matching tool_result — Anthropic returns a 400 if any tool call is left unanswered, so the loop guarantees completeness on every exit path, including suspensions.

Timeouts abandon, they do not kill

JavaScript cannot kill a running promise. On expiry the tool's signal is aborted (a well-behaved tool that passed it to fetch, an MCP client or a sandbox stops working) and the orphaned promise gets a no-op catch so an unhandled rejection cannot take the process down. If your tool ignores signal, its work continues in the background — cap the work inside the tool, not only at the loop.

Runaway guard

If the same tool name returns an error on three consecutive steps (MAX_SAME_TOOL_ERRORS = 3), the loop hard-stops regardless of maxSteps. The counter is keyed on tool name, so an invented name gets its own budget and never poisons a real tool's. A single successful call resets that tool's counter, so a flaky-but-usable tool is never permanently disqualified.

Without this, a tool that fails deterministically burns the entire step budget — and with toolMs set, three hangs cost three full caps in wall clock, which on a serverless budget is the difference between a slow answer and no answer.

Client tools break the loop early

A Tool present in tools with no execute is a client tool — the SDK cannot run it. When the model calls one, the loop stops and returns the pending call(s) in result.toolCalls (and as tool-call parts on fullStream). The caller owns the round-trip: execute it on the client, append a tool_result, and call again. See client tools.

An unknown tool name is NOT a client tool (1.9)

Before 1.9 the test was !tools[name]?.execute, which a hallucinated name also satisfied — so an invented name broke the loop and the caller waited forever for a tool_result nobody could produce. Now an unknown name falls through to execution, self-heals into an is_error result naming the real tools, and the loop continues in the same turn.

The lookup uses own keys only, so a model calling toString or constructor is classified as unknown rather than resolving to an inherited Object.prototype member.

Approval gates

A tool marked needsApproval is gated before it runs, and which mode you get depends only on whether you passed approveToolCall:

  • Server mode — the loop calls your approver per gated call. false or a throw denies it; the denial becomes an is_error result and the loop continues.
  • Client mode — the gated call breaks the loop and comes back in result.pendingApprovals (and as tool-approval-request parts). Resume with approvalResponses. Any pending call without a matching verdict is denied by default — the safe side. With approvalSigner configured, an approval without a verifying token is denied too.

On a resume leg, onToolCall guardrails are re-evaluated before anything runs. The calls being settled are the model's original arguments read back out of history, and a suspension must not become a way to launder a call a guardrail blocked.

Continuing across calls

result.response.messages contains only the new turns this call produced. Append them to your prior history to keep it immutable and prompt-cache friendly:

continue.ts
import { generateText, type Message } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';

const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });

let messages: Message[] = [{ role: 'user', content: 'Weather in Paris?' }];

const first = await generateText({
  model: anthropic('claude-opus-4-8'),
  messages,
  maxSteps: 5,
  tools: {
    /* … */
  },
});

messages = [...messages, ...first.response.messages];
// the next turn reuses the full, stable history

What the loop does not do

  • It does not plan. There is no built-in decomposition step — the model decides what to call. If you want an explicit plan → execute → verify structure, that is Autonomy.
  • It does not validate tool results. Tool.outputSchema is carried metadata (MCP populates it from the server); the loop never checks a result against it.
  • It does not retry a failed model step. Retries are pre-first-byte and live inside the single model call. A step that fails after streaming began fails the run.
  • It does not deduplicate tool calls. If the model asks for the same call twice in one batch, both run.
  • It does not bound total wall-clock by default. maxSteps bounds turns, not time. Use durationExceeds or timeout.stepMs for that.

See also

  • generateText — buffered loop; awaits to completion.
  • streamChat — streaming loop; one fullStream across all steps.
  • Defining toolsTool shape, schemas, execute, and tool().
  • Client tools — tools the loop hands back to the caller.
  • Guardrails — the onInput / onToolCall / onOutput hooks in the sequence above.
  • Error handling — the taxonomy behind a failed step.

On this page