Agents
Agents are reusable model configurations: identity (system prompt), model, tools, memory binding, and optional structured output. agent.run() / agent.stream() return the final response. Loop control is AI SDK stopWhen (default stepCountIs(20)). Tool calls and results still emit events and persist to the transcript. Pass stopWhen: stepCountIs(1) when a workflow wants to own each model step.
createAgent
Section titled “createAgent”Registry modules import { adl } from "#adl" (src/adl.ts) and call adl.createAgent:
import { hasToolCall, stepCountIs, tool } from "@agent-dev-lab/core";import { openai } from "@ai-sdk/openai";import { z } from "zod";
import { adl } from "#adl";
export const researcher = adl.createAgent({ id: "researcher",
systemPrompt: adl.createTemplate({ path: "./researcher.md", from: import.meta.url, inputData: z.object({}), }),
model: openai("gpt-4o"), // optional when createAdlRuntime({ defaults: { model } }) is set
tools: { search: tool({ description: "Search for papers", inputSchema: z.object({ query: z.string() }), execute: async ({ query }) => ({ papers: [] as string[] }), }), },
outputSchema: z.object({ summary: z.string() }),
// AI SDK stopWhen — default is stepCountIs(20). // stopWhen: stepCountIs(1), // one model step (workflow-owned loops) // stopWhen: hasToolCall("done"), // stopWhen: ({ steps }) => steps.some((step) => step.text.includes("ANSWER:")),
// Optional: a workflow names the conversation after the first reply. // titleWorkflow: conversationTitle,
// memory.store defaults to runtime stores.message when omitted});id is the registry key listed in adl.config agents array.
Install a model provider package (e.g. @ai-sdk/openai) in your project for model.
Conversation Titles
Section titled “Conversation Titles”Optional titleWorkflow is a workflow that names the conversation after the first successful episode on a new memoryScope. It does not run on follow-up turns. The workflow is typed: it receives the transcript and must return { title: string }. Title generation is best-effort — failures do not fail the conversation turn.
Pin those types with generics (Zod on the workflow is optional):
import type { ConversationTitleInput, ConversationTitleOutput } from "@agent-dev-lab/core";import { z } from "zod";
const namer = adl.createAgent({ id: "conversation-title-namer", systemPrompt: "Reply with a short conversation title.", outputSchema: z.object({ title: z.string() }),});
export const conversationTitle = adl.createWorkflow< ConversationTitleInput, ConversationTitleOutput>({ id: "conversation-title", async run(input, ctx) { const episode = await namer.run({ memoryScope: ctx.memoryScopeWithSuffix("namer"), user: `Write a short title.\n\n${format(input.messages)}`, }).result; return { title: episode.output.title }; },});
export const researcher = adl.createAgent({ id: "researcher", systemPrompt: "You are a research assistant.", titleWorkflow: conversationTitle,});Keep the title workflow out of the adl.config workflows array if you do not want those runs listed in the inspection UI. Keep any inner title-namer agent out of the agents array unless you want those helper conversations listed. The runtime starts the title workflow with { isolated: true } so it is a separate persisted run and is not nested inside another workflow’s tree.
Title generation uses a re-entrancy guard so a titleWorkflow that itself calls agent.run does not start another auto-title.
System Prompt
Section titled “System Prompt”Declared as a template ref or static string. On a new memoryScope, the resolved text is persisted as the first stored message and passed to the AI SDK via the instructions option (not messages, which avoids the SDK’s system-in-messages warning). Later episodes on that scope reuse the pinned copy, so a hot-reload of the live definition does not change an in-flight conversation.
Calling the same agent again on that scope is the normal conversation pattern — no warning, the pin stays:
await researcher.run({ memoryScope: "notes", user: "First turn" }).result;await researcher.run({ memoryScope: "notes", user: "Follow-up" }).result;A different agent with a different system prompt is the conflict case: the runner keeps the pinned prompt and console.warns. Prompts are not stacked — the AI SDK instructions option is a single string, and two identities in one blob usually fight each other. Pass suppressSystemPromptConflictWarning: true to silence the warning, or systemPromptConflict: "use-current" to apply this agent’s prompt for that episode only (the stored pin is not rewritten):
await researcher.run({ memoryScope: "notes", user: "Draft the section" }).result;
await editor.run({ memoryScope: "notes", user: "Tighten the draft", suppressSystemPromptConflictWarning: true,});The inspection UI shows the pinned stored prompt when one exists, and overlays the live agent.systemPrompt inspect result for empty / not-yet-pinned conversations (isErr when a template cannot render). Stray system messages in caller messages are dropped before the model call.
Volatile turn context belongs in user messages, not in the system prompt.
Structured Output
Section titled “Structured Output”| Level | Field | Behavior |
|---|---|---|
| Agent default | adl.createAgent({ outputSchema }) | Every run / stream uses structured output unless overridden |
| Per call | agent.run({ outputSchema }) | Overrides agent default for one episode |
Implementation uses streamText — the same path for run and stream. Agent is generic over TOutput (inferred from outputSchema, defaulting to string). AgentRunResult.output is that type: the parsed object when a schema is set, or the episode text when it is not.
A schema with no tools sends output (Output.object) on that call, so the provider asks for JSON. A schema with tools does not. Sending a JSON response format and tools on the same request makes some models skip the tools and return a minimal object that still validates (Zod .default() values fill the rest). The tool loop runs as an ordinary text-and-tools call. If it stops with text that already matches the schema, that text is the result. If it stops with other text, one follow-up call with no tools and output set turns the transcript into the object. That follow-up is an extra model request beyond stopWhen, and a user message asking for the JSON object is stored ahead of it. If stopWhen ends the loop on a tool call, the episode fails and the follow-up does not run.
The parsed object is the AI SDK’s complete parse of that final answer text (result.output). If it is not JSON matching the schema, the episode fails with NoObjectGeneratedError. The model’s text is on the error’s text field and on the agent_failed event. Zod .default() values apply only after a JSON object has parsed. A prose answer, or a partial parse left over from an earlier step, does not become a successful all-defaults result.
What Agents Do Not Carry
Section titled “What Agents Do Not Carry”- Memory pipeline — deferred; v1 uses load/append/save directly.
Calling an Agent
Section titled “Calling an Agent”agent.run and agent.stream share one input shape (AgentRunInput) and one streamText path. Each call is one episode: load a conversation (if any), append this turn, then streamText with AI SDK stopWhen (default stepCountIs(20)).
| API | Caller Sees | Runner Behavior |
|---|---|---|
agent.run | AgentRunHandle (result, cancel) | Drains stream internally; observers still get agent_text_delta |
agent.stream | AgentStreamHandle with SDK streams | Exposes textStream / fullStream; same persistence on finish |
The intended loop is the same agent, many times, on the same conversation. A new conversation is a new scope (or an omitted one). Passing a different agent onto an existing conversation is supported — see System Prompt. From the CLI, adl agent run <id> --input "…" is one episode with a string user message (optional --scope). Agents whose tools are a ToolProvider take --tool-context / -c as JSON for toolProviderContext; omit it to pass undefined — there is no cwd default. adl agent list marks those agents with (tool-context).
import type { ModelMessage, ToolProvider, ToolSet } from "@agent-dev-lab/core";import type { z } from "zod";
type AgentRunInput = { memoryScope?: string; context?: unknown; user?: string; messages?: ModelMessage[]; outputSchema?: z.ZodType<unknown>; stopWhen?: StopCondition | StopCondition[]; // AI SDK stopWhen; default stepCountIs(20) maxRetries?: number; // request retries before streaming; omit for the SDK default (2) streamRetries?: number; // retries after streaming starts; omit to leave them off systemPromptConflict?: "keep-pinned" | "use-current"; suppressSystemPromptConflictWarning?: boolean; workflow?: { workflowRunId: string; stepId: string | null }; tools?: ToolSet | ToolProvider<ToolSet>; toolProviderContext?: unknown; tags?: string[];};Inside a workflow step, workflowRunId / stepId are picked up from the active context — omit workflow unless you are linking a standalone call.
tools overrides the agent’s tools for this episode (ToolSet or ToolProvider). toolProviderContext is the raw value passed into a provider’s getTools — the framework never validates it.
tags labels this episode. The inspection UI shows them in a Tags footer (no run-list filter). Every agent.run also records automatic version: / commit: provenance unless you pass a tag with the same prefix or set createAdlRuntime({ version: false }). See Run tags.
maxRetries and streamRetries are set on the agent or on the call (the call wins, including 0). Omit both to keep the current defaults: maxRetries is the AI SDK default (2), and streamRetries is off.
maxRetries is forwarded to streamText. It retries a retryable failure of the request itself, before any response body is read.
streamRetries is forwarded to streamText, which reruns the current model step when the provider stream emits an error chunk. ADL also turns a provider stream that closes after the response has started (HTTP 200, isRetryable) into such an error chunk, because that drop otherwise rejects the stream and the SDK never retries it. Set a positive integer, for example 2; it is one budget for both cases. Earlier completed steps stay. 0 turns this off.
Text streams as it arrives, so a dropped attempt’s partial text can already be in textStream, fullStream, and agent_text_delta before the retry. The SDK holds back tool calls until an attempt finishes, and it leaves the dropped attempt out of result.text, structured output, and the stored transcript. An error chunk that is not retried fails the episode, even when partial text came before it. streamRetries needs a model object: with a string model id the call fails with INVALID_INPUT.
Turn Input
Section titled “Turn Input”This episode’s new turns come from user and/or messages. Both are optional. Together they append onto whatever is already stored for the scope (empty when the scope is new or omitted):
- stored transcript
{ role: "user", content: user }whenuseris set- each entry in
messages - the model reply (persisted on success)
// Convenience: one user stringawait researcher.run({ memoryScope: "notes", user: "Summarize this" }).result;
// Explicit list — works with or without a scopeawait researcher.run({ memoryScope: "notes", messages: [ { role: "user", content: "Here is extra context." }, { role: "assistant", content: "Noted." }, { role: "user", content: "Continue from there." }, ],}).result;Stray system messages in messages are dropped before the model call (the agent’s system prompt is passed via the AI SDK instructions option).
memoryScope
Section titled “memoryScope”memoryScope is the conversation key in MessageStore — an opaque string you choose:
`run:${runId}:step:outline`;`user:${userId}:chat:${chatId}`;| Call | Effect |
|---|---|
Same agent + same memoryScope | Shared history — the intended conversation loop |
New or omitted memoryScope | New conversation |
Different agent + existing memoryScope | Same transcript; system-prompt conflict rules apply (above) |
memoryScope + messages | The list is appended onto the stored transcript |
memoryScope is optional. When omitted, the runner allocates a random id (on the handle and AgentRunResult) so a one-shot user / messages call still persists. Follow-up stopWhen steps reuse that same generated id. The next agent.run will not see that transcript unless you pass the id back.
This is conversation memory, not workflow resume. The runner loads, appends this turn, and saves. It does not require a workflow or the same workflowRunId. Step retry (skip completed ctx.step outputs) is a separate WorkflowStore path — see Workflows — Resumability. On a retried step that calls agent.run again, both can apply.
Inside a workflow, ctx.memoryScopeWithSuffix(suffix) builds ${rootWorkflowRunId}:${suffix} for the nest-tree root (shared across phases). ctx.runLocalScope(suffix) builds ${workflowRunId}:${suffix} for that run only — see Nested and Isolated Runs.
That transcript is one list per memoryScope, and it only grows by append. MessageStore is that contract: retry copies the list, and the inspection UI splits turns by its length. A store that summarizes, drops, reorders, or is not a replaceable list is not supported.
Run Context
Section titled “Run Context”Optional context on agent.run() forwards to tool execute via AI SDK experimental_context:
const handle = literatureReview.run({ topic: "CRISPR delivery" });const runId = handle.workflowRunId;
await researcher.run({ memoryScope: "scope-1", user: "Summarize this", context: { resourceId: "user-42", runId },});context is not stored in MessageStore and is not sent to the model unless a tool or workflow copies it into a message.
Per-Run Flow
Section titled “Per-Run Flow”- Resolve
memoryScope(caller value, or a random id) store.load(memoryScope)(leading pin extracted; straysystemmessages dropped)- If
user/messages→ append to the stored transcript - Resolve
systemPrompt(pinned copy, unlesssystemPromptConflict: "use-current") → pass as theinstructionsoption tostreamText streamTextwith AI SDKstopWhen, plusmaxRetries/streamRetrieswhen set — forward text deltas and tool call/result events; persist after each step- Return
AgentRunResult(text/outputare the final response;turnsis the SDK step count)
Tool Calls and Persistence
Section titled “Tool Calls and Persistence”ADL persists only AI SDK ModelMessage lists. Tool usage round-trips through SDK message shape:
- Assistant parts with
tool-call - Tool role messages with
tool-result
After run(), the store already includes every request from the turn (result.newMessages). Tool calls also emit agent_tool_call / agent_tool_result during the stream.
const { result } = researcher.run({ memoryScope: "scope-1", user: "What is ADL?",});const { text, turns } = await result;Default stopWhen is stepCountIs(20): the SDK continues after tool results until a non-tool finish or the step cap. Override on the agent or the call (stopWhen: stepCountIs(1)) when a workflow should drive each model request as its own ctx.step. Custom conditions receive { steps } — see AI SDK loop control.
Agents and Workflows as Tools
Section titled “Agents and Workflows as Tools”// agents/orchestrator.ts — register tools on an agent that runs inside a workflowimport { adl } from "#adl";import { z } from "zod";
import { researcher } from "./researcher";import { literatureReview } from "../workflows/literature-review";
const literatureReviewTool = adl.createToolFromWorkflow(literatureReview, { description: "Run the full literature review workflow",});
const researcherTool = adl.createToolFromAgent(researcher, { description: "Run one research episode", inputSchema: z.object({ threadId: z.string(), query: z.string(), }), mapRun: (toolArgs, { ctx }) => ({ memoryScope: ctx?.memoryScopeWithSuffix(`tool:${toolArgs.threadId}`) ?? `tool:${toolArgs.threadId}`, user: toolArgs.query, }),});
const askAsWorkflow = adl.createWorkflowFromAgent(researcher, { id: "ask-researcher" });await askAsWorkflow.run("What is CRISPR?").result;createToolFromAgent / createToolFromWorkflow return an AI SDK Tool<TInput, TOutput>. Agent tools use the agent’s TOutput (inferred from outputSchema, otherwise string). Pass inputSchema so mapRun / mapInput receive typed arguments instead of a catch-all object.
These helpers work outside a workflow. mapRun receives ctx only when the tool runs inside a workflow body or step. createWorkflowFromAgent wraps an agent as a workflow that takes a string user message.
AgentDefinition.tools also accepts a ToolProvider when the tool set depends on the call (sandbox root, credentials). Optional sandboxed file/bash/fetchUrl tools are in @agent-dev-lab/tools.
Events
Section titled “Events”The runner emits run events via RunRecorder: agent_started, agent_text_delta, agent_messages_committed, agent_finished, agent_failed, and agent_warning (for example system-prompt conflicts). See RunEvent and WorkflowStore in the API reference.