AI Agents
An agent is a module-owned contract the runtime uses to dispatch an LLM call with a deterministic tool surface, feature-gated access, and an explicit mutation posture. Agents live in each module's ai-agents.ts at the module root and are discovered by the generator.
Writing an ai-agents.ts
// packages/core/src/modules/customers/ai-agents.ts
import type { AiAgentDefinition } from '@saasframe/ai-assistant'
export const accountAssistant: AiAgentDefinition = {
id: 'customers.account_assistant',
moduleId: 'customers',
label: 'Account Assistant',
description: 'Read-only CRM explorer for people, companies, deals.',
systemPrompt: '...composed from PromptTemplate sections...',
allowedTools: [
'search.entities',
'attachments.download',
'meta.describe_agent',
'customers.get_person',
'customers.list_deals',
'customers.get_activities',
],
taskPlan: { enabled: true },
readOnly: true,
mutationPolicy: 'read-only',
requiredFeatures: ['customers.view'],
acceptedMediaTypes: ['image', 'pdf'],
domain: 'customers',
keywords: ['crm', 'people', 'companies', 'deals'],
suggestions: [
{ label: 'Show recent deals', prompt: 'Show recent deals' },
],
}
export default [accountAssistant]
After adding or editing ai-agents.ts, run:
yarn generate
The generator aggregates every module's agents into apps/saasframe/.saasframe/generated/ai-agents.generated.ts.
AiAgentDefinition fields
| Field | Required | Notes |
|---|---|---|
id | yes | Must match <moduleId>.<agent> (lowercase, digits, underscores). |
moduleId | yes | Used for SF_AI_<MODULE>_MODEL resolution and generator grouping. |
label / description | yes | Shown in the agent picker and playground. |
systemPrompt | yes | The composed system prompt the runtime feeds to the model. |
allowedTools | yes | Whitelist. Tools not listed here are never exposed — even when registered. |
readOnly | no (default true) | When true, every isMutation: true tool is filtered out BEFORE the model sees it. |
mutationPolicy | no | read-only | confirm-required | destructive-confirm-required. |
requiredFeatures | no | ACL features gated at the dispatcher route. |
acceptedMediaTypes | no | Subset of image | pdf | file. |
executionMode | no (default chat) | chat or object. See below. |
output | no | Structured-output schema when executionMode === 'object'. |
defaultProvider | no | Provider id this agent prefers (e.g. 'openai', 'anthropic'). Must match a registered provider id. Omit for generic shipped agents so SF_AI_PROVIDER controls the runtime default. Set this only when an agent's defaultModel intentionally requires a specific provider; paired provider/model defaults fail closed if the provider is not configured. Phase 1 of spec 2026-04-27-ai-agents-provider-model-baseurl-overrides. |
defaultModel | no | Optional agent-level model pin fed through the factory (see Overview). Omit for generic shipped agents so SF_AI_MODEL controls the runtime default. Accepts a plain id (claude-haiku-4-5) or a slash-qualified <provider>/<model> shorthand (e.g. openai/gpt-5-mini). The slash prefix must match a registered provider id; DeepInfra-style ids that contain slashes are kept intact via the registry-membership guard. |
maxSteps | no | Deprecated. Use loop.maxSteps instead. Honored as an alias when loop is omitted. |
loop | no | Agentic loop controls — stopWhen, prepareStep, repairToolCall, onStepFinish, activeTools, toolChoice, and budget (maxToolCalls, maxWallClockMs, maxTokens). See Agentic loop controls below. |
executionEngine | no (default stream-text) | 'stream-text' (default streamText dispatch) or 'tool-loop-agent' (opt into Vercel Experimental_Agent). See Choosing an execution engine. |
allowRuntimeOverride | no (default true) | Permit per-call <ModelPicker> + ?provider=&model=&loopBudget=... overrides. Renamed from allowRuntimeModelOverride. |
taskPlan | no | { enabled: true } opts the agent into the visible planning helper. The registry exposes meta.update_task_plan automatically; omit it from allowedTools. CRM/customer agents enable this by default, other shipped agents do not. |
resolvePageContext | no | Async callback invoked when the request carries { entityType, recordId }. Returns extra prompt text appended to systemPrompt. |
suggestions | no | Starter prompts shown by generic chat launchers before the first message. |
keywords / domain / dataCapabilities | no | Metadata for the agent picker and search. |
Provider and model configuration
defaultProvider and defaultModel
Most agents should omit both fields and inherit the runtime default from SF_AI_PROVIDER / SF_AI_MODEL. Set them only when an agent intentionally needs a preferred provider and model without overriding the global envs:
export const merchandisingAssistant: AiAgentDefinition = {
id: 'catalog.merchandising_assistant',
moduleId: 'catalog',
// ...
defaultProvider: '<provider-id>',
defaultModel: '<model-id>', // or slash-qualified: '<provider-id>/<model-id>'
}
defaultProvider must match a registered provider id (anthropic, google, openai, deepinfra, groq, together, fireworks, azure, litellm, ollama). When defaultProvider is paired with defaultModel, the provider/model pair is atomic: if that provider is not configured, the factory fails before any upstream call. A provider-only preference can still fall through to the next configured provider because no provider-specific model id is at risk.
Slash-qualified defaultModel values (e.g. openai/gpt-5-mini) consume the provider axis in one go: the slash prefix names the provider and the remainder names the model, so defaultProvider is effectively overridden by the slash prefix. DeepInfra-style ids that already contain slashes (e.g. meta-llama/Llama-3.3-70B-Instruct-Turbo) are kept intact via the registry-membership guard.
allowRuntimeOverride
By default every agent shows the <ModelPicker> in the chat composer, allowing operators to select a different provider + model at runtime, and accepts per-call loop overrides (?loopBudget=...). Set allowRuntimeOverride: false to disable both for this agent:
export const auditAgent: AiAgentDefinition = {
id: 'audit.audit_assistant',
moduleId: 'audit',
// ...
allowRuntimeOverride: false, // always uses the server-side resolution chain; no per-call loop overrides
}
When false, the runtime also rejects ?provider= / ?model= / ?loopBudget= query parameters sent to the chat dispatcher route — the server-side resolution chain (steps 2–7 in Overview → Model resolution order) still applies.
The field was renamed from
allowRuntimeModelOverridein Phase 4 of spec2026-04-28-ai-agents-agentic-loop-controlsbecause its scope now covers loop overrides as well. The runtime accepts both names —allowRuntimeOverridewins when both are set. Migrate at your convenience; the deprecated alias remains for one minor version.
Runtime model picker (<ModelPicker>)
The <AiChat> component automatically renders a <ModelPicker> dropdown in the composer toolbar for every agent where allowRuntimeModelOverride !== false. The dropdown is populated by GET /api/ai_assistant/ai/agents/:agentId/models and shows all providers and models configured on the server side. The user's selection is persisted to localStorage under the key om-ai-model-picker:<agentId> and forwarded to the chat dispatcher as ?provider=&model= query parameters. If the tenant allowlist later hides the stored model, <AiChat> replaces the stale selection with the first still-allowed model from the server response so the dispatcher never receives a now-forbidden picker value.
The picker is stateless on the server — it never stores the user's selection in the database or the session. When the component unmounts or the agent is changed, the selection reverts to the default. This is by design (R6 mitigation): the picker exposes only models from the curated registry; free-form baseURL input is intentionally absent.
// Embed AiChat — ModelPicker is wired automatically
import { AiChat } from '@saasframe/ui/ai'
<AiChat agent="customers.account_assistant" />
To embed the picker standalone (e.g. in a custom toolbar):
import { ModelPicker } from '@saasframe/ui/ai'
import type { ModelPickerValue } from '@saasframe/ui/ai'
<ModelPicker
providers={providers} // ModelPickerProvider[] from the /models endpoint
value={pickerValue} // ModelPickerValue | null
onChange={handleChange} // (value: ModelPickerValue | null) => void
defaultProviderId="anthropic" // highlighted as default
defaultModelId="claude-haiku-4-5"
/>
The providers prop is an array of ModelPickerProvider objects, each containing a providerId, label, and models: ModelPickerProviderModel[]. The component is fully stateless — the parent owns the value state and must persist it if needed.
Tool packs
Tools are declared per module with defineAiTool and registered under a tool pack. Every tool advertises whether it mutates:
import { defineAiTool } from '@saasframe/ai-assistant'
import { z } from 'zod'
export const getPerson = defineAiTool({
name: 'customers.get_person',
description: 'Return a single person by id with linked addresses and tags.',
isMutation: false,
requiredFeatures: ['customers.view'],
inputSchema: z.object({ personId: z.string().uuid() }),
async handler(args, ctx) {
const em = ctx.container.resolve('em')
// ... load, scope by tenant, return a serializable object
return { person: { /* ... */ } }
},
})
Shipped packs
| Pack | Tools (examples) | When to use |
|---|---|---|
search.* | search.entities, search.customers | Cross-entity fulltext/vector search |
attachments.* | attachments.download, attachments.list | Read attachment metadata and fetch blobs |
meta.* | meta.describe_agent, meta.update_task_plan | Self-description and, when taskPlan.enabled, safe visible task-plan updates |
customers.* | customers.get_person, customers.list_deals, customers.update_deal_stage | CRM read + a single curated mutation |
catalog.* | catalog.list_products, catalog.update_product, catalog.bulk_update_products, catalog.apply_attribute_extraction, catalog.update_product_media_descriptions | Merchandising demo tools (read + four D18 writes) |
readOnlyWhen agent.readOnly === true (the default), the runtime strips any tool with isMutation: true from the model's tool surface. To unlock writes you MUST set readOnly: false AND declare a non-read-only mutationPolicy. Operators can then further downgrade — but never escalate — via the tenant settings page.
Default policy for write-capable agents
Agents shipped in packages/core that whitelist any isMutation: true tool default to mutationPolicy: 'confirm-required', not read-only. The intent: writes are allowed but every one needs explicit user approval through the mutation approval card. Per-tenant overrides can downgrade to read-only to lock writes back down without a redeploy. They cannot escalate above what the agent declares.
Visible task plans
For operator-facing chat agents, set taskPlan: { enabled: true } to expose the read-only meta.update_task_plan helper. The runtime injects prompt guidance telling the model to call it before domain tools on tool-using turns, and <AiChat> renders those agent-authored steps as the compact live plan above raw tool-call details.
This is a code-level agent contract, not a tenant setting in AI Agents. CRM/customer agents ship with it enabled by default; other agents stay quiet unless their AiAgentDefinition or an aiAgentExtensions entry opts in.
Keep labels short and user-facing:
taskPlan: { enabled: true },
allowedTools: ['catalog.search_products', 'catalog.get_product']
Prompt guidance should say that task-plan labels are progress UI, not hidden reasoning. Do not put chain-of-thought, scratchpad notes, or XML thinking tags in the plan. When you know which tool a step maps to, include toolName so the runtime can update that planned row from pending to running and done as the tool lifecycle streams.
Replacing or disabling a registered agent
Modules can replace or disable an agent registered by another module via three paths: extra aiAgentOverrides / aiToolOverrides exports on the module's existing ai-agents.ts / ai-tools.ts, inline on a ModuleEntry in apps/<app>/src/modules.ts, or programmatically via applyAiAgentOverrides({ ... }) / applyAiToolOverrides({ ... }) at boot. null disables; a definition replaces. See Overrides for the full contract, file layout, and resolution order.
Extending a registered agent
When a downstream module only needs to patch tools, prompt text, or starter prompts on an existing agent, export aiAgentExtensions instead of replacing the full agent:
import { defineAiAgentExtension } from '@saasframe/ai-assistant'
export const aiAgentExtensions = [
defineAiAgentExtension({
targetAgentId: 'catalog.catalog_assistant',
deleteAllowedTools: ['catalog.old_stats'],
appendAllowedTools: ['example.catalog_stats'],
appendSystemPrompt: 'Use example.catalog_stats when the operator asks for catalog metrics.',
deleteSuggestions: ['Old catalog stats'],
appendSuggestions: [
{ label: 'Show catalog stats', prompt: 'Show catalog stats' },
],
}),
]
Extensions run after replacement/disable overrides. They cannot resurrect a disabled agent; an extension whose targetAgentId is missing is skipped with a warning. Patch order is deterministic: replace* first, delete* second, append* last.
Supported patch fields:
| Field | Effect |
|---|---|
replaceAllowedTools / deleteAllowedTools / appendAllowedTools | Replace, remove, or append tool names in the agent whitelist. |
taskPlan | Opt the agent into or out of the visible planning helper, for example { enabled: true }. The registry adds/removes meta.update_task_plan automatically. |
replaceSystemPrompt / appendSystemPrompt | Replace the prompt or append an extra prompt paragraph. |
replaceSuggestions / deleteSuggestions / appendSuggestions | Replace, remove by label/prompt, or append starter prompts. |
suggestions | Backward-compatible alias for appendSuggestions. |
Concretely:
customers.account_assistant—confirm-required(whitelistscustomers.update_deal_stage+ comment/activity tools)catalog.merchandising_assistant—confirm-required(whitelists the four D18 mutation tools)catalog.catalog_assistant—read-only(genuinely read-only — no mutation tools)
Chat vs object execution mode
| Mode | Helper | Output | When to use |
|---|---|---|---|
chat (default) | runAiAgentText | Streamed text + UI parts | <AiChat> sheets, conversational assistants, multi-turn sessions |
object | runAiAgentObject | Validated JSON matching an output.schema | Single-shot structured extraction, batch background enrichment, headless callers |
Object-mode agents declare a Zod output.schema:
import { z } from 'zod'
export const attributeExtractor: AiAgentDefinition = {
id: 'catalog.attribute_extractor',
moduleId: 'catalog',
// ...
executionMode: 'object',
output: {
schemaName: 'CatalogAttributeExtraction',
schema: z.object({
productId: z.string().uuid(),
attributes: z.array(z.object({
key: z.string(),
value: z.string(),
})),
}),
},
}
The runtime delegates to Vercel AI SDK's generateObject / streamObject and returns the parsed result.
End-to-end example
// 1. Define the agent
// packages/core/src/modules/customers/ai-agents.ts
export const accountAssistant: AiAgentDefinition = { /* as above */ }
// 2. Define or whitelist the tools
// packages/core/src/modules/customers/ai-tools/get-person.ts
export const getPerson = defineAiTool({ /* as above */ })
// 3. Embed the chat UI
// packages/core/src/modules/customers/backend/customers/people/[id]/page.tsx
import { AiChat } from '@saasframe/ui/ai'
<AiChat
agent="customers.account_assistant"
pageContext={{ entityType: 'customers:person', recordId: params.id }}
/>
Where the runtime reads each field
checkAgentPolicyinlib/agent-policy.tsenforcesrequiredFeatures, tool whitelisting,readOnly/mutationPolicy, andacceptedMediaTypes.resolveAiAgentToolsinlib/agent-tools.tsfilters the registered tool pack down to the agent'sallowedToolsand wires the mutation interceptor.createModelFactoryinlib/model-factory.tspicks the LLM per the resolution order.runAiAgentText/runAiAgentObjectinlib/agent-runtime.tscompose the final request.tool-loader.tsregisters every tool's full typed shape —isMutation,displayName,loadBeforeRecord(s),tags— so the agent settings UI badges mutation tools correctly and the mutation interceptor fires.
When the runtime invokes a tool handler it injects the matching AiToolDefinition into the McpToolContext.tool slot, so handlers can build an AiToolExecutionContext for createAiApiOperationRunner without losing route-gate coverage.
Using the Vercel AI SDK natively
Most code should go through runAiAgentText / runAiAgentObject so policy, tool whitelisting, model resolution, mutation approvals, and tenant scoping are enforced for you. There are two escape hatches when you genuinely need raw AI SDK access — one fully bypassed, one wrapped.
Option A — Raw AI SDK (no agent wrapper)
Use this only for internal scripts, migrations, evaluation harnesses, or non-tenant contexts. Reuse the framework's model factory so provider keys, env overrides, and tenant settings still flow through.
import { generateText, generateObject, streamText, streamObject } from 'ai'
import { z } from 'zod'
import { createModelFactory } from '@saasframe/ai-assistant/modules/ai_assistant/lib/model-factory'
const factory = createModelFactory({ moduleId: 'catalog' })
const model = await factory.resolve()
// Text
const { text } = await generateText({ model, prompt: 'Summarize this...' })
// Structured object
const { object } = await generateObject({
model,
schema: z.object({ summary: z.string(), tags: z.array(z.string()) }),
prompt: 'Extract summary and tags from ...',
})
// Streaming
const stream = streamText({ model, prompt: '...' })
for await (const chunk of stream.textStream) { /* ... */ }
Optionally reuse the registered tool packs (still without agent policy):
import { resolveAiAgentTools } from '@saasframe/ai-assistant/modules/ai_assistant/lib/agent-tools'
const tools = await resolveAiAgentTools({
allowedTools: ['search.entities', 'attachments.list'],
container,
authContext,
})
const { text } = await generateText({ model, tools, prompt: '...', maxSteps: 5 })
What you lose by going raw:
checkAgentPolicy—requiredFeatures,allowedToolswhitelist,readOnly/mutationPolicy,acceptedMediaTypesare not enforced.- The mutation approval flow —
prepareMutation+ai_pending_actions+ approval cards do not run; never call mutation tools this way. - Page-context resolution —
resolvePageContextand tenant-scoped system-prompt composition are skipped. - Audit trail — runs are not associated with an agent id, so playground/settings overrides and per-agent telemetry do not apply.
- Per-tenant prompt and model overrides set in the AI Assistant settings UI.
- Attachment-to-model bridging (image/PDF parts produced by
acceptedMediaTypes).
Option B — Wrapped agent with a native SDK callback (recommended escape hatch)
When you want most of the wrapper's guarantees but need to reach into the raw AI SDK call (custom providerOptions, experimental_* flags, telemetry, additional onStepFinish logic, custom abortSignal plumbing, etc.), pass a generateText callback to runAiAgentText (or a generateObject callback to runAiAgentObject). The callback name mirrors the AI SDK function it ultimately invokes, so the call site stays coherent with the underlying SDK. The runtime still:
- runs
checkAgentPolicy(features, tool whitelist, mutation policy, media types), - resolves the model via
createModelFactoryand tenant overrides, - builds the tool map from
allowedTools, - composes the system prompt (including
resolvePageContext), - routes any mutation tool calls through
prepareMutation+ the approval contract, - composes the effective loop config from the agent's declaration, per-call overrides, and wrapper defaults,
and then hands the fully prepared options bag (PreparedAiSdkOptions) to your callback so you can call generateText / streamText (text runtime) or generateObject / streamObject (object runtime) directly. The streaming variants (streamText / streamObject) are reached from inside the same callback — the name describes which runtime family you are overriding, not which exact AI SDK function you must call.
import { runAiAgentText } from '@saasframe/ai-assistant'
import { streamText } from 'ai'
const response = await runAiAgentText({
agentId: 'customers.account_assistant',
container,
authContext,
messages,
generateText: async ({
model, tools, system, messages,
// loop primitives — see MUST rules below before omitting any
stopWhen,
prepareStep,
onStepFinish,
onStepStart,
onToolCallStart,
onToolCallFinish,
experimental_repairToolCall,
activeTools,
toolChoice,
abortSignal,
}) => {
return streamText({
model,
tools,
system,
messages,
stopWhen,
prepareStep,
onStepFinish,
onStepStart,
experimental_onToolCallStart: onToolCallStart,
experimental_onToolCallFinish: onToolCallFinish,
experimental_repairToolCall,
...(activeTools !== undefined ? { activeTools } : {}),
...(toolChoice !== undefined ? { toolChoice } : {}),
abortSignal,
experimental_telemetry: { isEnabled: true, functionId: 'customers.account_assistant' },
providerOptions: {
anthropic: { cacheControl: { type: 'ephemeral' } },
},
})
},
})
The matching hook on runAiAgentObject is named generateObject and additionally receives the resolved output.schema and the object-mode subset of loop primitives (maxSteps, onStepFinish, onStepStart, abortSignal).
MUST rules — security-critical fields you MUST NOT drop:
prepareStep— SECURITY-CRITICAL. The wrapper-ownedPrepareStepFunctionre-asserts the tool allowlist and mutation-approval wrapping on every step. Dropping it means mutation tools are no longer routed throughprepareMutationon steps 2+; the approval contract no longer applies. Never omitprepareStepunless you are deliberately removing mutation-approval guardrails for that call (and understand the consequences).stopWhen— dropping this removes the agent's loop policy and the R3 step-count fallback, turning the call into a single model invocation that never executes tool calls.tools,system,messages— dropping any of these bypasses the corresponding guardrail (tool allowlist, prompt composition, message history).
What you still lose with the generateText / generateObject callbacks:
- Custom transports (e.g. swapping the HTTP client) move you outside the model factory's provider routing; per-tenant API key overrides set in settings will not apply.
- UI streaming parts (
AiUiParttags consumed by<AiChat>) are produced by the default runtime path; if your callback returns a stream that does not emit those parts, the embedded chat UI degrades to plain text.
Pick Option B by default when you "just need one extra AI SDK option" — it keeps every other guardrail in place. Reach for Option A only when you are deliberately working outside the agent contract.
Agentic loop controls
The runtime always runs a tool-using loop (streamText with stopWhen). Spec 2026-04-28-ai-agents-agentic-loop-controls promotes that loop from "one step-count cap" to a first-class part of the agent contract — declarative loop block, per-call overrides, per-tenant operator budgets, runtime LoopTrace, and an opt-in ToolLoopAgent engine.
Resolution chain — per loop axis
Each axis (stopWhen, prepareStep, onStepFinish, repairToolCall, activeTools, toolChoice, maxSteps, budget) walks the same precedence the provider/model resolver uses:
- Per-request HTTP query (
?loopBudget=tight) — gated byallowRuntimeOverride. - Caller override —
runAiAgentText({ loop })/runAiAgentObject({ loop }). - Per-tenant settings override —
ai_agent_runtime_overrides.loop_*(see Settings → Loop policy overrides). SF_AI_<MODULE>_LOOP_*env shorthands (MAX_STEPS,BUDGET_TOKENS,BUDGET_MS).- Agent definition —
agent.loop(plus the deprecatedagent.maxStepsalias). - Wrapper default (
stepCountIs(10)for chat, unset for object).
Operator-tightening only: an override can never widen what code declared, and an env value can never widen what the operator allowed.
The loop block
import type { AiAgentDefinition } from '@saasframe/ai-assistant'
export const accountAssistant: AiAgentDefinition = {
// ...
loop: {
maxSteps: 12,
// Halt the loop the moment the mutation tool fires so the approval card surfaces right away.
stopWhen: [{ kind: 'hasToolCall', toolName: 'customers.update_deal_stage' }],
// Per-step model and/or active-tool reshaping. Composed with the wrapper-owned prepareStep.
prepareStep: buildAccountAssistantPrepareStep(),
// Repair common malformed tool-call shapes instead of failing the turn.
repairToolCall: async ({ toolCall, error, repair }) => repair(toolCall),
// Hard ceilings — operators can tighten further per tenant.
budget: { maxToolCalls: 12, maxWallClockMs: 60_000, maxTokens: 80_000 },
},
allowRuntimeOverride: true,
}
stopWhen accepts an array; the runtime translates { kind: 'stepCount', count } to stepCountIs(count) and { kind: 'hasToolCall', toolName } to hasToolCall(toolName). { kind: 'custom', stop } accepts a raw SDK predicate but is rejected when supplied via the JSON-only tenant override path.
Security guarantee — wrapper-owned prepareStep
The mutation-approval contract requires that every isMutation: true tool call is intercepted before execution. The runtime composes its own PrepareStepFunction ahead of yours:
- Re-narrows the per-step tool surface to
agent.allowedTools(∩loop.activeToolswhen set). - Re-checks the agent policy gate.
- Re-wraps every mutation tool's handler through
prepareMutationso writes land inai_pending_actions. - Then invokes your
loop.prepareStepon top of the guarded state.
mergeStepOverrides rejects user prepareStep returns that strip the prepareMutation wrappers with AgentPolicyError code loop_violates_mutation_policy. You cannot bypass mutation approvals from inside prepareStep — this is the security guarantee ToolLoopAgent also honors (the wrapper wires prepareStep at agent construction; see Choosing an execution engine).
Object-mode caveat
streamObject / generateObject accept maxSteps, onStepFinish, onStepStart, and abortSignal. They silently ignore prepareStep, repairToolCall, activeTools, toolChoice, and stopWhen on most providers. The runtime applies what the SDK accepts and emits one dev-time warning per agent listing the dropped primitives — never a silent drop.
LoopTrace and debug surfaces
Every completed turn emits an SSE loop-finish event carrying a LoopTrace:
- Steps with the model id used per step (
prepareStepswaps are visible here). - Tool calls per step with input/output shape and
repairToolCallattempts. - Total tokens summed from
usagefields. - The stop reason (
stepCountIs/hasToolCall/loop_budget_exceeded/finish-reason:stop|tool-calls|length/aborted).
<AiChat debug> and the Playground Debug panel render the trace. When the per-tenant kill switch is active, <AiChat> renders a LoopDisabledBanner and the dispatcher collapses the agent to a single model call.
Canonical reference
customers.deal_analyzer (+ sibling customers.deal_analyzer_tool_loop for the 'tool-loop-agent' engine) in packages/core/src/modules/customers/ai-agents.ts exercises every primitive — see Demo: customers.deal_analyzer agentic loop below.
Choosing an execution engine
The executionEngine field on AiAgentDefinition selects the underlying Vercel AI SDK dispatch strategy. The default ('stream-text') is unchanged from previous releases; 'tool-loop-agent' is opt-in.
| Feature | 'stream-text' (default) | 'tool-loop-agent' |
|---|---|---|
| SDK primitive | streamText(...) | ToolLoopAgent (Experimental_Agent) |
prepareStep | Supported — passed to streamText | Supported — wired at construction (settings.prepareStep), NOT via prepareCall |
stopWhen | Supported | Supported — wired at construction |
repairToolCall | Supported (experimental_repairToolCall) | Present in ToolLoopAgentSettings in current SDK, but behaviour parity across SDK versions is not guaranteed — prefer 'stream-text' when repair logic correctness is critical |
| Mutation-approval contract | Guaranteed — buildWrapperPrepareStep wraps all mutation tools | Guaranteed — same buildWrapperPrepareStep is wired at construction; prepareMutation runs identically |
| Multi-agent handoff (future) | Not planned | SDK-native; agents on 'tool-loop-agent' receive this feature first |
prepareCall hook | N/A | Per-turn narrowing of model, tools, stopWhen, activeTools, providerOptions only — prepareStep is not in the pick list |
Key rule for 'tool-loop-agent': the wrapper-owned prepareStep (mutation-approval enforcement) MUST be wired at agent construction time via settings.prepareStep. It cannot be threaded through prepareCall because prepareStep is not in prepareCall's Pick list. The runtime handles this automatically — agent authors who use the generateText escape-hatch MUST NOT reconstruct a ToolLoopAgent without forwarding preparedOptions.prepareStep.
To opt in to the ToolLoopAgent backend for an agent:
export const catalogToolLoopAssistant: AiAgentDefinition = {
id: 'catalog.tool_loop_assistant',
moduleId: 'catalog',
executionEngine: 'tool-loop-agent',
// ...rest of the definition
}
Omitting executionEngine (or setting it to 'stream-text') leaves the existing dispatch path completely unchanged.
Demo: customers.deal_analyzer agentic loop
The customers.deal_analyzer agent in packages/core/src/modules/customers/ai-agents.ts is the canonical reference implementation that exercises every loop primitive in one place.
What it demonstrates
| Primitive | How it is set | Effect |
|---|---|---|
| Env/default model inheritance | defaultProvider and defaultModel omitted | Model factory resolves the provider/model pair from env, tenant overrides, or runtime picker choices |
| Runtime model override | allowRuntimeOverride: true | <ModelPicker> visible in the chat composer toolbar |
| Loop step cap | loop.maxSteps: 12 | Prevents runaway multi-step loops |
| Stop-when | loop.stopWhen: [{ kind: 'hasToolCall', toolName: 'customers.update_deal_stage' }] | Loop halts immediately after the mutation tool call; mutation card surfaces right away |
| Per-step tool shaping | loop.prepareStep: buildDealAnalyzerPrepareStep() | Step 0 keeps mutation tools inactive; later steps can propose the approved mutation path without overriding the model |
| Budget | loop.budget: { maxToolCalls: 12, maxWallClockMs: 60_000 } | Hard wall-clock and tool-call ceiling |
| Both engines | executionEngine: 'stream-text' (default) + sibling customers.deal_analyzer_tool_loop with 'tool-loop-agent' | Proves mutation gate holds for both dispatch paths (TC-AI-AGENT-LOOP-006) |
uiParts | uiParts: ['saasframe:deal'] | Deal record cards auto-rendered in the chat |
Key files
- Tool:
packages/core/src/modules/customers/ai-tools/deal-analyzer-pack.ts—customers.analyze_dealsread-only tool (health score computation, bounded DB reads) - Agents:
packages/core/src/modules/customers/ai-agents.ts—dealAnalyzer+dealAnalyzerToolLoopdefinitions,buildDealAnalyzerPrepareStep() - UI widget:
packages/core/src/modules/customers/widgets/injection/ai-deal-analyzer-trigger/—<AiChat>embedded in Deals list:search-trailingslot - Injection table:
packages/core/src/modules/customers/widgets/injection-table.ts—data-table:customers.deals.list:search-trailingspot - Tests:
packages/core/src/modules/customers/__integration__/TC-AI-AGENT-DEAL-ANALYZER-001-007.spec.ts
Loop flow
- User opens the Deal Analyzer sheet on
/backend/customers/deals(or the playground). - Agent calls
customers.analyze_deals(step 0, Sonnet) — returns deals ranked by health score. - Agent identifies the highest-value stalled deal and calls
customers.update_deal_stage(step 1, Haiku). loop.stopWhenfires — the loop halts and the mutation-preview card surfaces.- Operator approves or rejects the stage move in the approval card.
The buildDealAnalyzerPrepareStep() function returns a PrepareStepFunction that creates a lazy model-factory singleton bound to null container (the factory only reads process.env and llmProviderRegistry, not the DI container). Step 0 resolves Sonnet for broad analysis; subsequent steps resolve Haiku for the cheaper mutation call.