From 77a2d143eb59a49bf2418cc72438a34db0d775b6 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 1 Jun 2026 15:21:12 +0100 Subject: [PATCH 1/3] docs(ai-chat): add Tools page Document declaring tools on chat.agent: why it matters for toModelOutput across turns, static vs per-turn tools, the typed tools handed back on the run payload, typing messages via InferChatUIMessageFromTools, how config tools relate to skills and toStreamTextOptions, and the manual convertToModelMessages path for customAgent loops. --- docs/ai-chat/tools.mdx | 191 +++++++++++++++++++++++++++++++++++++++++ docs/docs.json | 1 + 2 files changed, 192 insertions(+) create mode 100644 docs/ai-chat/tools.mdx diff --git a/docs/ai-chat/tools.mdx b/docs/ai-chat/tools.mdx new file mode 100644 index 00000000000..593cefb8a15 --- /dev/null +++ b/docs/ai-chat/tools.mdx @@ -0,0 +1,191 @@ +--- +title: "Tools" +sidebarTitle: "Tools" +description: "Declare tools on chat.agent so toModelOutput survives across turns, get them back typed in run(), and type your messages from them." +--- + +import RcBanner from "/snippets/ai-chat-rc-banner.mdx"; + + + +`chat.agent` doesn't call the model for you — your tools still go to [`streamText`](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling) inside `run()`. But you should **also declare them on the agent config**: + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { streamText, stepCountIs, tool } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { z } from "zod"; + +const tools = { + searchDocs: tool({ + description: "Search the docs.", + inputSchema: z.object({ query: z.string() }), + execute: async ({ query }) => searchIndex(query), + }), +}; + +export const myChat = chat.agent({ + id: "my-chat", + tools, // ← declare here + run: async ({ messages, tools, signal }) => + streamText({ + ...chat.toStreamTextOptions({ tools }), // ← the same set, handed back on the payload + model: anthropic("claude-sonnet-4-5"), + messages, + abortSignal: signal, + stopWhen: stepCountIs(15), + }), +}); +``` + +Declaring `tools` on the config does two things you can't get by passing them to `streamText` alone: + +- It threads your tools into the SDK's internal message conversion, so each tool's [`toModelOutput`](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling#tomodeloutput) is re-applied when prior-turn history is re-converted (see [`toModelOutput` across turns](#tomodeloutput-across-turns)). +- It hands the resolved set back, typed, on the `run()` payload as `tools`, so you declare them once and don't re-import the map. + +## Where tools go + +There are three places a tool set shows up. Declare once, reuse: + +| Surface | What it's for | +| --- | --- | +| `chat.agent({ tools })` | Re-applies `toModelOutput` on prior-turn history; hands the set back typed on the `run()` payload. | +| `chat.toStreamTextOptions({ tools })` | Detects which tool calls need [HITL approval](/ai-chat/patterns/human-in-the-loop) (`needsApproval`) and merges any auto-injected [skill](/ai-chat/patterns/skills) tools. | +| `streamText({ tools })` | What the model actually calls. `chat.toStreamTextOptions({ tools })` already sets this — spread it instead of passing `tools` twice. | + +The canonical pattern: declare `tools` on the config, read them back from the `run()` payload, and pass that to `chat.toStreamTextOptions({ tools })`. One declaration flows everywhere. + + + Conversion only reads each tool's `inputSchema` and `toModelOutput` — never `execute`. If you keep heavy `execute` dependencies out of a module (for bundle reasons), you can declare a lightweight schema-only tool map on the config and add the executes where you call `streamText`. + + +## `toModelOutput` across turns + +`toModelOutput` transforms a tool's result before it enters the model's context — turning raw image bytes into an image content part, or compressing a long sub-agent transcript into a one-line summary. The full result still streams to the frontend; the model only sees the transformed version. + +The catch is multi-turn. After each turn, `chat.agent` persists the conversation as `UIMessage[]` and re-converts it to model messages at the start of the next turn. That conversion needs your tools to find each `toModelOutput`. **If you only pass tools to `streamText` and not to the config, the transform runs on turn 1 but is skipped on every later turn** — the raw output gets stringified back into the prompt instead, and the model loses the transformed view. + +Declaring `tools` on the config fixes this: the SDK threads them into the conversion, so `toModelOutput` is re-applied on every turn. + +```ts +const tools = { + renderChart: tool({ + description: "Render a chart and return it as an image.", + inputSchema: z.object({ spec: z.string() }), + execute: async ({ spec }) => renderToPng(spec), // raw bytes + // The model should see an image part, not base64 bytes: + toModelOutput: ({ output }) => ({ + type: "content", + value: [{ type: "media", mediaType: "image/png", data: output.base64 }], + }), + }), +}; + +export const chartChat = chat.agent({ + id: "chart-chat", + tools, // ← without this, the image is "remembered" on turn 1 and gone from turn 2 + run: async ({ messages, tools, signal }) => + streamText({ + ...chat.toStreamTextOptions({ tools }), + model: anthropic("claude-sonnet-4-5"), + messages, + abortSignal: signal, + stopWhen: stepCountIs(15), + }), +}); +``` + +## Static or per-turn tools + +`tools` accepts either a static `ToolSet` or a function that returns one per turn — for tools that depend on the user, a feature flag, or anything in the turn context: + +```ts +export const myChat = chat + .withClientData({ schema: z.object({ userId: z.string(), plan: z.string() }) }) + .agent({ + id: "my-chat", + tools: ({ clientData }) => ({ + searchDocs, + ...(clientData?.plan === "pro" ? { deepResearch } : {}), + }), + run: async ({ messages, tools, signal }) => + streamText({ + ...chat.toStreamTextOptions({ tools }), + model: anthropic("claude-sonnet-4-5"), + messages, + abortSignal: signal, + stopWhen: stepCountIs(15), + }), + }); +``` + +The function receives a `ResolveToolsEvent` and runs once per turn (after `clientData` is parsed): + +| Field | Type | Description | +| --- | --- | --- | +| `chatId` | `string` | The chat session ID. | +| `turn` | `number` | The current turn number (0-indexed). | +| `continuation` | `boolean` | Whether this run is continuing an existing chat. | +| `clientData` | `TClientData` | Parsed client data from the frontend. | + +The resolved set is what lands on the `run()` payload's `tools`. + +## Typed tools in `run()` + +The `run()` payload's `tools` is typed to whatever you declared, so you can pass it straight through without re-importing the map: + +```ts +run: async ({ messages, tools, signal }) => { + // `tools` is typed as your tool set, not a broad `ToolSet` + return streamText({ + ...chat.toStreamTextOptions({ tools }), + model: anthropic("claude-sonnet-4-5"), + messages, + abortSignal: signal, + }); +}; +``` + +When no `tools` are declared, the payload's `tools` is an empty object and behaves exactly as before — declaring tools is fully opt-in. + +## Typing messages from your tools + +To get typed tool parts (`tool-${name}` with typed input/output) on your `UIMessage` — in hooks like `onTurnComplete` and on the frontend — derive the message type from your tool set with `InferChatUIMessageFromTools`: + +```ts +import type { InferChatUIMessageFromTools } from "@trigger.dev/sdk/ai"; + +const tools = { searchDocs, renderChart }; + +export type ChatUiMessage = InferChatUIMessageFromTools; +``` + +This is shorthand for `UIMessage>`. Pin it on the agent with [`chat.withUIMessage()`](/ai-chat/types#custom-uimessage-with-chat-withuimessage) and reuse it on the client. If you also have custom `data-*` parts, build the `UIMessage` generic directly instead — see [Types](/ai-chat/types). + +## Skills + +[Agent skills](/ai-chat/patterns/skills) are auto-injected as tools (`loadSkill`, `readFile`, `bash`) by `chat.toStreamTextOptions()`. They're separate from your config `tools`: declare your own tools on the config (so their `toModelOutput` survives across turns), and let `toStreamTextOptions` merge the skill tools on top at call time. Skill tools don't define `toModelOutput`, so they don't need to be on the config. + +## Manual turn loops (`chat.customAgent`) + +The `tools` config option belongs to the managed [`chat.agent`](/ai-chat/backend#chat-agent). When you drive the loop yourself with [`chat.customAgent`](/ai-chat/backend#raw-task-primitives) (or build messages from `chat.history`), you own the conversion — so pass your tools to `convertToModelMessages` directly to get the same cross-turn `toModelOutput` behavior: + +```ts +import { convertToModelMessages, streamText } from "ai"; + +// Inside your loop, with `tools` in scope: +const uiMessages = chat.history.all(); +const messages = await convertToModelMessages(uiMessages, { + tools, + ignoreIncompleteToolCalls: true, +}); + +return streamText({ model: anthropic("claude-sonnet-4-5"), messages, tools }); +``` + +## Learn more + +- [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop) — tools that pause for approval. +- [Sub-agents](/ai-chat/patterns/sub-agents) — tools that delegate to other agents and compress their output with `toModelOutput`. +- [Tool result auditing](/ai-chat/patterns/tool-result-auditing) — logging tool results as they resolve. +- [AI SDK: Tools and tool calling](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling). diff --git a/docs/docs.json b/docs/docs.json index ee670bca2da..24c0339f3ed 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -107,6 +107,7 @@ "ai-chat/how-it-works", "ai-chat/backend", "ai-chat/lifecycle-hooks", + "ai-chat/tools", "ai-chat/frontend", "ai-chat/server-chat", "ai-chat/sessions", From 6c27ede5aa813fac53d6610b83c4bda880bf747d Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 1 Jun 2026 15:45:04 +0100 Subject: [PATCH 2/3] docs(ai-chat): unstack the callouts under chat.agent() Three callouts (Tip, Info, Warning) were stacked back-to-back right under the chat.agent() header before any content. Lead with the intro and first example instead: the toStreamTextOptions warning now sits with the example it's about, and the durable-Session note moved to its own short section. --- docs/ai-chat/backend.mdx | 36 +++++++++--------------------------- docs/ai-chat/tools.mdx | 26 +++++++++++++------------- 2 files changed, 22 insertions(+), 40 deletions(-) diff --git a/docs/ai-chat/backend.mdx b/docs/ai-chat/backend.mdx index 0c2de3784e0..6f65fb8df70 100644 --- a/docs/ai-chat/backend.mdx +++ b/docs/ai-chat/backend.mdx @@ -12,32 +12,6 @@ import RcBanner from "/snippets/ai-chat-rc-banner.mdx"; The highest-level approach. Handles message accumulation, stop signals, turn lifecycle, and auto-piping automatically. - - To fix a **custom** `UIMessage` subtype or typed client data schema, use the [ChatBuilder](/ai-chat/types#chatbuilder) via `chat.withUIMessage<...>()` and/or `chat.withClientData({ schema })`. Builder-level hooks can also be chained before `.agent()`. See [Types](/ai-chat/types). - - - - Every `chat.agent` conversation is backed by a durable Session — `externalId` is your `chatId`, `type` is `"chat.agent"`, `taskIdentifier` is the agent's task ID. The session is the run manager: it owns the chat's runs, persists across run lifecycles, and orchestrates handoffs (idle continuation, `chat.requestUpgrade`). You rarely need to touch the session directly (`chat.stream`, `chat.messages`, `chat.stopSignal` wrap everything), but `payload.sessionId` is available if you want to reach in — e.g. `sessions.open(payload.sessionId)` to write from a sub-agent or from outside the turn loop. - - - - **Always spread `chat.toStreamTextOptions()` into every `streamText` call.** It wires up the `prepareStep` callback that drives [compaction](/ai-chat/compaction), [steering](/ai-chat/pending-messages), and [background injection](/ai-chat/background-injection) — features that silently no-op if the spread is missing. It also injects the system prompt set via `chat.prompt()`, the resolved model (when a registry is provided), and telemetry metadata. - - Spread it **first** in the options object so any explicit overrides win: - - ```ts - streamText({ - ...chat.toStreamTextOptions(), // or: chat.toStreamTextOptions({ registry, tools }) — see below - messages, - abortSignal: signal, - // any explicit overrides go here - stopWhen: stepCountIs(15), - }); - ``` - - Examples in this doc keep the spread implicit for brevity, but you should include it in real code. - - ### Simple: return a StreamTextResult Return the `streamText` result from `run` and it's automatically piped to the frontend: @@ -51,7 +25,7 @@ export const simpleChat = chat.agent({ id: "simple-chat", run: async ({ messages, signal }) => { return streamText({ - ...chat.toStreamTextOptions(), // prepareStep, system, telemetry — see callout above + ...chat.toStreamTextOptions(), // prepareStep, system, telemetry (see note below) model: anthropic("claude-sonnet-4-5"), system: "You are a helpful assistant.", messages, @@ -62,6 +36,10 @@ export const simpleChat = chat.agent({ }); ``` + + **Always spread `chat.toStreamTextOptions()` first** (as above) so your explicit overrides win. It wires up the `prepareStep` callback behind [compaction](/ai-chat/compaction), [steering](/ai-chat/pending-messages), and [background injection](/ai-chat/background-injection), all of which silently no-op without it, and injects the system prompt from `chat.prompt()`, the resolved model (when you pass a `registry`), and telemetry metadata. Examples below keep the spread implicit for brevity, so include it in real code. + + ### Using chat.pipe() for complex flows For complex agent flows where `streamText` is called deep inside your code, use `chat.pipe()`. It works from **anywhere inside a task** — even nested function calls. @@ -173,6 +151,10 @@ await waitUntilComplete(); For piping streams from subtasks to the parent chat (via `target: "root"`), see the [Sub-agents pattern](/ai-chat/patterns/sub-agents). +### Backed by a Session + +Every `chat.agent` conversation is backed by a durable [Session](/ai-chat/sessions): `externalId` is your `chatId`, `type` is `"chat.agent"`, and `taskIdentifier` is the agent's task ID. The session is the run manager. It owns the chat's runs, persists across run lifecycles, and orchestrates handoffs (idle continuation, `chat.requestUpgrade`). You rarely touch it directly, since `chat.stream`, `chat.messages`, and `chat.stopSignal` wrap everything, but `payload.sessionId` is there when you need to reach in, e.g. `sessions.open(payload.sessionId)` to write from a sub-agent or from outside the turn loop. + ### Lifecycle hooks `chat.agent({ ... })` accepts hooks that fire in a fixed order around each turn, plus dedicated suspend/resume hooks. The full reference lives on its own page: diff --git a/docs/ai-chat/tools.mdx b/docs/ai-chat/tools.mdx index 593cefb8a15..fee57fed008 100644 --- a/docs/ai-chat/tools.mdx +++ b/docs/ai-chat/tools.mdx @@ -8,7 +8,7 @@ import RcBanner from "/snippets/ai-chat-rc-banner.mdx"; -`chat.agent` doesn't call the model for you — your tools still go to [`streamText`](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling) inside `run()`. But you should **also declare them on the agent config**: +`chat.agent` doesn't call the model for you. Your tools still go to [`streamText`](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling) inside `run()`. But you should **also declare them on the agent config**: ```ts import { chat } from "@trigger.dev/sdk/ai"; @@ -51,19 +51,19 @@ There are three places a tool set shows up. Declare once, reuse: | --- | --- | | `chat.agent({ tools })` | Re-applies `toModelOutput` on prior-turn history; hands the set back typed on the `run()` payload. | | `chat.toStreamTextOptions({ tools })` | Detects which tool calls need [HITL approval](/ai-chat/patterns/human-in-the-loop) (`needsApproval`) and merges any auto-injected [skill](/ai-chat/patterns/skills) tools. | -| `streamText({ tools })` | What the model actually calls. `chat.toStreamTextOptions({ tools })` already sets this — spread it instead of passing `tools` twice. | +| `streamText({ tools })` | What the model actually calls. `chat.toStreamTextOptions({ tools })` already sets this, so spread it instead of passing `tools` twice. | The canonical pattern: declare `tools` on the config, read them back from the `run()` payload, and pass that to `chat.toStreamTextOptions({ tools })`. One declaration flows everywhere. - Conversion only reads each tool's `inputSchema` and `toModelOutput` — never `execute`. If you keep heavy `execute` dependencies out of a module (for bundle reasons), you can declare a lightweight schema-only tool map on the config and add the executes where you call `streamText`. + Conversion only reads each tool's `inputSchema` and `toModelOutput`, never `execute`. If you keep heavy `execute` dependencies out of a module (for bundle reasons), you can declare a lightweight schema-only tool map on the config and add the executes where you call `streamText`. ## `toModelOutput` across turns -`toModelOutput` transforms a tool's result before it enters the model's context — turning raw image bytes into an image content part, or compressing a long sub-agent transcript into a one-line summary. The full result still streams to the frontend; the model only sees the transformed version. +`toModelOutput` transforms a tool's result before it enters the model's context, turning raw image bytes into an image content part, or compressing a long sub-agent transcript into a one-line summary. The full result still streams to the frontend; the model only sees the transformed version. -The catch is multi-turn. After each turn, `chat.agent` persists the conversation as `UIMessage[]` and re-converts it to model messages at the start of the next turn. That conversion needs your tools to find each `toModelOutput`. **If you only pass tools to `streamText` and not to the config, the transform runs on turn 1 but is skipped on every later turn** — the raw output gets stringified back into the prompt instead, and the model loses the transformed view. +The catch is multi-turn. After each turn, `chat.agent` persists the conversation as `UIMessage[]` and re-converts it to model messages at the start of the next turn. That conversion needs your tools to find each `toModelOutput`. **If you only pass tools to `streamText` and not to the config, the transform runs on turn 1 but is skipped on every later turn.** The raw output gets stringified back into the prompt instead, and the model loses the transformed view. Declaring `tools` on the config fixes this: the SDK threads them into the conversion, so `toModelOutput` is re-applied on every turn. @@ -97,7 +97,7 @@ export const chartChat = chat.agent({ ## Static or per-turn tools -`tools` accepts either a static `ToolSet` or a function that returns one per turn — for tools that depend on the user, a feature flag, or anything in the turn context: +`tools` accepts either a static `ToolSet` or a function that returns one per turn, for tools that depend on the user, a feature flag, or anything in the turn context: ```ts export const myChat = chat @@ -146,11 +146,11 @@ run: async ({ messages, tools, signal }) => { }; ``` -When no `tools` are declared, the payload's `tools` is an empty object and behaves exactly as before — declaring tools is fully opt-in. +When no `tools` are declared, the payload's `tools` is an empty object and behaves exactly as before, so declaring tools is fully opt-in. ## Typing messages from your tools -To get typed tool parts (`tool-${name}` with typed input/output) on your `UIMessage` — in hooks like `onTurnComplete` and on the frontend — derive the message type from your tool set with `InferChatUIMessageFromTools`: +To get typed tool parts (`tool-${name}` with typed input/output) on your `UIMessage`, in hooks like `onTurnComplete` and on the frontend, derive the message type from your tool set with `InferChatUIMessageFromTools`: ```ts import type { InferChatUIMessageFromTools } from "@trigger.dev/sdk/ai"; @@ -160,7 +160,7 @@ const tools = { searchDocs, renderChart }; export type ChatUiMessage = InferChatUIMessageFromTools; ``` -This is shorthand for `UIMessage>`. Pin it on the agent with [`chat.withUIMessage()`](/ai-chat/types#custom-uimessage-with-chat-withuimessage) and reuse it on the client. If you also have custom `data-*` parts, build the `UIMessage` generic directly instead — see [Types](/ai-chat/types). +This is shorthand for `UIMessage>`. Pin it on the agent with [`chat.withUIMessage()`](/ai-chat/types#custom-uimessage-with-chat-withuimessage) and reuse it on the client. If you also have custom `data-*` parts, build the `UIMessage` generic directly instead. See [Types](/ai-chat/types). ## Skills @@ -168,7 +168,7 @@ This is shorthand for `UIMessage Date: Mon, 1 Jun 2026 15:55:07 +0100 Subject: [PATCH 3/3] docs(ai-chat): document the tools option across the chat.agent guide Adds a tools row to ChatAgentOptions and a tools field to the run payload in the reference, plus ResolveToolsEvent and InferChatUIMessageFromTools. Corrects the sub-agents guide so the toModelOutput compression survives across turns (declare tools on the config; convertToModelMessages with tools in customAgent loops). Adds a happy-path Tools section to the backend page, collapses the manual UIMessage triple in types, aligns the HITL and skills examples, and adds a changelog entry plus Tools links from overview and quick start. --- docs/ai-chat/backend.mdx | 23 ++++++++++++++++ docs/ai-chat/changelog.mdx | 24 +++++++++++++++++ docs/ai-chat/overview.mdx | 3 +++ docs/ai-chat/patterns/human-in-the-loop.mdx | 7 +++-- docs/ai-chat/patterns/skills.mdx | 2 ++ docs/ai-chat/patterns/sub-agents.mdx | 13 +++++++--- .../ai-chat/patterns/tool-result-auditing.mdx | 2 +- docs/ai-chat/quick-start.mdx | 1 + docs/ai-chat/reference.mdx | 26 +++++++++++++++++++ docs/ai-chat/types.mdx | 10 +++++-- 10 files changed, 103 insertions(+), 8 deletions(-) diff --git a/docs/ai-chat/backend.mdx b/docs/ai-chat/backend.mdx index 6f65fb8df70..ccf86ef03b5 100644 --- a/docs/ai-chat/backend.mdx +++ b/docs/ai-chat/backend.mdx @@ -155,6 +155,29 @@ For piping streams from subtasks to the parent chat (via `target: "root"`), see Every `chat.agent` conversation is backed by a durable [Session](/ai-chat/sessions): `externalId` is your `chatId`, `type` is `"chat.agent"`, and `taskIdentifier` is the agent's task ID. The session is the run manager. It owns the chat's runs, persists across run lifecycles, and orchestrates handoffs (idle continuation, `chat.requestUpgrade`). You rarely touch it directly, since `chat.stream`, `chat.messages`, and `chat.stopSignal` wrap everything, but `payload.sessionId` is there when you need to reach in, e.g. `sessions.open(payload.sessionId)` to write from a sub-agent or from outside the turn loop. +### Tools + +Declare your tools on the agent config, then read them back (typed) from the `run()` payload. Declaring them on the config, not just on `streamText`, is what lets the SDK re-apply each tool's `toModelOutput` when it re-converts history on later turns. + +```ts +const tools = { searchDocs }; + +export const myChat = chat.agent({ + id: "my-chat", + tools, + run: async ({ messages, tools, signal }) => + streamText({ + ...chat.toStreamTextOptions({ tools }), + model: anthropic("claude-sonnet-4-5"), + messages, + abortSignal: signal, + stopWhen: stepCountIs(15), + }), +}); +``` + +See [Tools](/ai-chat/tools) for `toModelOutput` across turns, per-turn dynamic tools, the typed run payload, and how config tools relate to skills. + ### Lifecycle hooks `chat.agent({ ... })` accepts hooks that fire in a fixed order around each turn, plus dedicated suspend/resume hooks. The full reference lives on its own page: diff --git a/docs/ai-chat/changelog.mdx b/docs/ai-chat/changelog.mdx index 08a1832ff60..511f8335ddb 100644 --- a/docs/ai-chat/changelog.mdx +++ b/docs/ai-chat/changelog.mdx @@ -4,6 +4,30 @@ sidebarTitle: "Changelog" description: "Pre-release updates for AI chat agents." --- + + +## `tools` option on `chat.agent`: `toModelOutput` survives across turns + +`chat.agent` now takes a `tools` option. Until now tools only went to `streamText` inside `run()`, which meant the SDK had no tools when it re-converted the persisted `UIMessage` history at the start of each turn. Any tool with a `toModelOutput` (raw image bytes turned into an image content part, or a sub-agent transcript compressed to a summary) had its transform applied on turn 1 and skipped from turn 2 onward, so the raw output got stringified back into the prompt. + +Declare your tools on the config and the SDK threads them into that conversion, so `toModelOutput` is re-applied every turn. The resolved set is handed back, typed, on the `run()` payload as `tools`, so you declare them once: + +```ts +const tools = { searchDocs, renderChart }; + +export const myChat = chat.agent({ + tools, + run: async ({ messages, tools, signal }) => + streamText({ ...chat.toStreamTextOptions({ tools }), messages, abortSignal: signal }), +}); +``` + +`tools` also accepts a per-turn function (`(event) => ToolSet`) for tools that depend on the user or a feature flag. Only `inputSchema` and `toModelOutput` are read during conversion, never `execute`. No behavior change for agents that don't declare `tools`. + +A new `InferChatUIMessageFromTools` helper derives the chat `UIMessage` type (with typed tool parts) directly from a tool set. See the new [Tools](/ai-chat/tools) guide. + + + ## HITL continuations — slim wire by default + field-level merge diff --git a/docs/ai-chat/overview.mdx b/docs/ai-chat/overview.mdx index 30de8ad1baa..fe5771a0e63 100644 --- a/docs/ai-chat/overview.mdx +++ b/docs/ai-chat/overview.mdx @@ -78,6 +78,9 @@ Three primitives, related but distinct: `chat.agent` options, lifecycle hooks, and the raw-task primitives. + + Declare tools so `toModelOutput` survives across turns, typed in `run()`. + HITL approvals, branching, sub-agents, OOM/crash recovery. diff --git a/docs/ai-chat/patterns/human-in-the-loop.mdx b/docs/ai-chat/patterns/human-in-the-loop.mdx index e53182fc954..7a8028bf85b 100644 --- a/docs/ai-chat/patterns/human-in-the-loop.mdx +++ b/docs/ai-chat/patterns/human-in-the-loop.mdx @@ -69,11 +69,12 @@ const askUser = tool({ export const myChat = chat.agent({ id: "my-chat", - run: async ({ messages, signal }) => { + tools: { askUser }, + run: async ({ messages, tools, signal }) => { return streamText({ model: anthropic("claude-sonnet-4-5"), messages, - tools: { askUser }, + tools, abortSignal: signal, stopWhen: stepCountIs(15), }); @@ -81,6 +82,8 @@ export const myChat = chat.agent({ }); ``` +Declaring `tools` on the config (and reading them back from the payload) is the recommended shape for any agent with tools. See [Tools](/ai-chat/tools). + ## Frontend: render the question and collect the answer Two pieces on the client: diff --git a/docs/ai-chat/patterns/skills.mdx b/docs/ai-chat/patterns/skills.mdx index 23151399147..9afba99a5d9 100644 --- a/docs/ai-chat/patterns/skills.mdx +++ b/docs/ai-chat/patterns/skills.mdx @@ -185,6 +185,8 @@ return streamText({ Your tools win on name conflicts. (Pick names that don't collide with `loadSkill` / `readFile` / `bash` to keep things predictable.) +Also declare those same tools on the agent's [`tools`](/ai-chat/tools) config. `toStreamTextOptions` merges them with the skill tools for the model call, while the config option threads them into history re-conversion so any `toModelOutput` survives across turns. The auto-injected skill tools (`loadSkill` / `readFile` / `bash`) don't define `toModelOutput`, so they don't need to be on the config. + ## Bundling Bundling is **built-in to the CLI** — there's no extension to import. When you run `trigger deploy` or `trigger dev`: diff --git a/docs/ai-chat/patterns/sub-agents.mdx b/docs/ai-chat/patterns/sub-agents.mdx index 6fd85d8cd6e..84a52b9479b 100644 --- a/docs/ai-chat/patterns/sub-agents.mdx +++ b/docs/ai-chat/patterns/sub-agents.mdx @@ -205,6 +205,10 @@ toModelOutput: ({ output: message }) => { This is important for token efficiency: the sub-agent might use 100K tokens exploring and reasoning, but the parent LLM only consumes the summary. + + `toModelOutput` only runs when the SDK has your tools at conversion time. On a multi-turn parent, the SDK re-converts the persisted history at the start of each turn, so you must declare the sub-agent tool on the agent config (`chat.agent({ tools })`) for the compression to survive. Without it, the summary holds on turn 1 but turn 2 onward re-ingests the full sub-agent output. In a `chat.customAgent` loop you own the conversion, so pass the tools to `convertToModelMessages(uiMessages, { tools })` yourself. See [Tools: toModelOutput across turns](/ai-chat/tools#tomodeloutput-across-turns). + + ## ChatStream.messages() The `messages()` method on `ChatStream` wraps the AI SDK's `readUIMessageStream`. It reads the raw `UIMessageChunk` stream and yields complete `UIMessage` snapshots — each containing all parts received so far. @@ -237,13 +241,16 @@ Sub-agent tools work inside both `chat.agent()` (managed) and `chat.customAgent( ```ts // Managed agent with sub-agent tool +const tools = { research: researchTool }; + export const myAgent = chat.agent({ id: "orchestrator", - run: async ({ messages, stopSignal }) => { + tools, // declare here so toModelOutput survives across turns + run: async ({ messages, tools, stopSignal }) => { return streamText({ model: anthropic("claude-sonnet-4-6"), messages, - tools: { research: researchTool }, + tools, abortSignal: stopSignal, stopWhen: stepCountIs(15), }); @@ -251,7 +258,7 @@ export const myAgent = chat.agent({ }); ``` -For `chat.customAgent()`, define the tool and sub-agent Map inside the `run` closure so they survive across turns. +For `chat.customAgent()`, define the tool and sub-agent Map inside the `run` closure so they survive across turns. Since you own the turn loop there, convert history with your tools in scope so `toModelOutput` is re-applied each turn: `convertToModelMessages(uiMessages, { tools })`. See [Tools: manual turn loops](/ai-chat/tools#manual-turn-loops-chatcustomagent). ## Streaming progress from a subtask to the parent chat diff --git a/docs/ai-chat/patterns/tool-result-auditing.mdx b/docs/ai-chat/patterns/tool-result-auditing.mdx index c79da0e6808..493c8037486 100644 --- a/docs/ai-chat/patterns/tool-result-auditing.mdx +++ b/docs/ai-chat/patterns/tool-result-auditing.mdx @@ -8,7 +8,7 @@ import RcBanner from "/snippets/ai-chat-rc-banner.mdx"; -When a chat agent uses tools (especially [human-in-the-loop](/ai-chat/patterns/human-in-the-loop) tools that wait on `addToolOutput` from the frontend), you often need to fire side effects exactly once per resolved tool call: +When a chat agent uses [tools](/ai-chat/tools) (especially [human-in-the-loop](/ai-chat/patterns/human-in-the-loop) tools that wait on `addToolOutput` from the frontend), you often need to fire side effects exactly once per resolved tool call: - **Audit logs** — record every tool result for compliance. - **Billing** — charge per tool invocation. diff --git a/docs/ai-chat/quick-start.mdx b/docs/ai-chat/quick-start.mdx index 4d1e204dbec..3407ffb6e4f 100644 --- a/docs/ai-chat/quick-start.mdx +++ b/docs/ai-chat/quick-start.mdx @@ -151,6 +151,7 @@ These steps assume you already have a Trigger.dev project with the SDK installed ## Next steps - [Backend](/ai-chat/backend) — Lifecycle hooks, persistence, session iterator, raw task primitives +- [Tools](/ai-chat/tools): Declare tools so `toModelOutput` survives across turns, typed in `run()` - [Frontend](/ai-chat/frontend) — Session management, client data, reconnection - [Types](/ai-chat/types) — `chat.withUIMessage`, `InferChatUIMessage`, and related typing - [`chat.local`](/ai-chat/chat-local) — Per-run typed state across hooks, run, tools, subtasks diff --git a/docs/ai-chat/reference.mdx b/docs/ai-chat/reference.mdx index 82cc28610b7..2f7981d61c8 100644 --- a/docs/ai-chat/reference.mdx +++ b/docs/ai-chat/reference.mdx @@ -45,6 +45,7 @@ Options for `chat.agent()`. | `compaction` | `ChatAgentCompactionOptions` | — | Automatic context compaction. See [Compaction](/ai-chat/compaction) | | `pendingMessages` | `PendingMessagesOptions` | — | Mid-execution message injection. See [Pending Messages](/ai-chat/pending-messages) | | `prepareMessages` | `(event: PrepareMessagesEvent) => ModelMessage[]` | — | Transform model messages before use (cache breaks, context injection, etc.) | +| `tools` | `ToolSet \| ((event: ResolveToolsEvent) => ToolSet \| Promise)` | — | Tools for this agent. Threads each tool's `toModelOutput` through cross-turn history re-conversion, and hands the resolved set back on the run payload. Static set or per-turn function. See [Tools](/ai-chat/tools). | | `maxTurns` | `number` | `100` | Max conversational turns per run | | `turnTimeout` | `string` | `"1h"` | How long to wait for next message | | `idleTimeoutInSeconds` | `number` | `30` | Seconds to stay idle before suspending | @@ -87,6 +88,7 @@ The payload passed to the `run` function. | -------------- | ------------------------------------------ | -------------------------------------------------------------------- | | `ctx` | `TaskRunContext` | Full task run context — same as `task` `run`’s `{ ctx }` | | `messages` | `ModelMessage[]` | Model-ready messages — pass directly to `streamText` | +| `tools` | `ToolSet` | Resolved tools declared on the agent config (empty object when none). Pass straight to `streamText`. See [Tools](/ai-chat/tools). | | `chatId` | `string` | Your conversation ID (the session's `externalId`) | | `sessionId` | `string` | Friendly ID of the backing Session (`session_*`). Use with `sessions.open()` for advanced cases. Always set — every chat.agent run is bound to a Session. | | `trigger` | `"submit-message" \| "regenerate-message"` | What triggered the request | @@ -191,6 +193,17 @@ Passed to the `onValidateMessages` callback. | `turn` | `number` | Turn number (0-indexed) | | `trigger` | `"submit-message" \| "regenerate-message" \| "preload" \| "close"` | The trigger type for this turn | +## ResolveToolsEvent + +Passed to the `tools` function form on `chat.agent`, once per turn, to resolve the tool set for that turn. See [Tools](/ai-chat/tools#static-or-per-turn-tools). + +| Field | Type | Description | +| -------------- | --------------------------- | ------------------------------------------------- | +| `chatId` | `string` | Chat session ID | +| `turn` | `number` | Turn number (0-indexed) | +| `continuation` | `boolean` | Whether this run is continuing an existing chat | +| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | + ## HydrateMessagesEvent Passed to the `hydrateMessages` callback. See [hydrateMessages](/ai-chat/lifecycle-hooks#hydratemessages). @@ -532,6 +545,19 @@ type Msg = InferChatUIMessage; Use with `useChat({ transport })` when using [`chat.withUIMessage`](/ai-chat/types). For agents defined with plain `chat.agent()` (no custom generic), this resolves to the base `UIMessage`. +## `InferChatUIMessageFromTools` + +Type helper: derives the chat `UIMessage` type (with typed `tool-${name}` parts) directly from a tool set. Shorthand for `UIMessage>`. + +```ts +import type { InferChatUIMessageFromTools } from "@trigger.dev/sdk/ai"; + +const tools = { search, readFile }; +type ChatUiMessage = InferChatUIMessageFromTools; +``` + +Pin it on the agent with [`chat.withUIMessage()`](/ai-chat/types) and reuse it on the client. See [Tools](/ai-chat/tools#typing-messages-from-your-tools). + ## AI helpers (`ai` from `@trigger.dev/sdk/ai`) | Export | Status | Description | diff --git a/docs/ai-chat/types.mdx b/docs/ai-chat/types.mdx index 0afbc7b1c8d..c966ce6bffd 100644 --- a/docs/ai-chat/types.mdx +++ b/docs/ai-chat/types.mdx @@ -48,6 +48,10 @@ type MyChatDataTypes = UIDataTypes & { export type MyChatUIMessage = UIMessage; ``` + + If you don't need custom `data-*` parts, [`InferChatUIMessageFromTools`](/ai-chat/tools#typing-messages-from-your-tools) from `@trigger.dev/sdk/ai` collapses the tools half into one line (it's shorthand for `UIMessage>`). + + Task-backed tools should use AI SDK [`tool()`](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling) with `execute: ai.toolExecute(schemaTask)` where needed — see [Task-backed AI tools](/tasks/schemaTask#task-backed-ai-tools). ### Backend: `chat.withUIMessage(...).agent(...)` @@ -82,6 +86,7 @@ export const myChat = chat }) .agent({ id: "my-chat", + tools: myTools, onTurnStart: async ({ uiMessages, writer }) => { // uiMessages is MyChatUIMessage[] — custom data parts are typed writer.write({ @@ -89,11 +94,12 @@ export const myChat = chat data: { status: "preparing" }, }); }, - run: async ({ messages, signal }) => { + run: async ({ messages, tools, signal }) => { + // `tools` is myTools, typed, handed back on the payload return streamText({ model: anthropic("claude-sonnet-4-5"), messages, - tools: myTools, + tools, abortSignal: signal, stopWhen: stepCountIs(15), });