@@ -102,7 +102,16 @@ const METADATA_KEY = "tool.execute.options";
102102 * stopped/aborted conversations with partial tool parts.
103103 */
104104function toModelMessages ( messages : UIMessage [ ] ) : Promise < ModelMessage [ ] > {
105- return convertToModelMessages ( messages , { ignoreIncompleteToolCalls : true } ) ;
105+ // Pass the resolved per-turn `tools` (if any) so the AI SDK can look up each
106+ // tool's `toModelOutput` and re-apply it to prior-turn tool results. Without
107+ // `tools` it falls back to JSON-stringifying the raw output (TRI-10149). The
108+ // conditional spread keeps the options object byte-identical to the no-tools
109+ // path when nothing was declared.
110+ const tools = locals . get ( chatResolvedToolsKey ) ;
111+ return convertToModelMessages ( messages , {
112+ ignoreIncompleteToolCalls : true ,
113+ ...( tools ? { tools } : { } ) ,
114+ } ) ;
106115}
107116
108117export type ToolCallExecutionOptions = {
@@ -1425,7 +1434,10 @@ export type ChatTaskSignals = {
14251434 * The full payload passed to a `chatAgent` run function.
14261435 * Extends `ChatTaskPayload` (the wire payload) with abort signals.
14271436 */
1428- export type ChatTaskRunPayload < TClientData = unknown > = ChatTaskPayload < TClientData > &
1437+ export type ChatTaskRunPayload <
1438+ TClientData = unknown ,
1439+ TTools extends ToolSet = ToolSet ,
1440+ > = ChatTaskPayload < TClientData > &
14291441 ChatTaskSignals & {
14301442 /**
14311443 * Task run context — same object as the `ctx` passed to a standard `task({ run })` handler’s second argument.
@@ -1436,6 +1448,21 @@ export type ChatTaskRunPayload<TClientData = unknown> = ChatTaskPayload<TClientD
14361448 previousTurnUsage ?: LanguageModelUsage ;
14371449 /** Cumulative token usage across all completed turns so far. */
14381450 totalUsage : LanguageModelUsage ;
1451+ /**
1452+ * The resolved tool set for this turn, the same `tools` you declared on
1453+ * `chat.agent({ tools })` (or the result of the per-turn `tools` function).
1454+ * Pass straight to `streamText({ tools })` so you don't redeclare them:
1455+ *
1456+ * ```ts
1457+ * run: ({ messages, tools, signal }) =>
1458+ * streamText({ model, messages, tools, abortSignal: signal })
1459+ * ```
1460+ *
1461+ * Declaring `tools` on the config is also what lets the SDK re-run each
1462+ * tool's `toModelOutput` when it re-converts prior-turn history (see the
1463+ * `tools` option on `chat.agent`). Empty object when no `tools` were declared.
1464+ */
1465+ tools : TTools ;
14391466 } ;
14401467
14411468// Input streams for bidirectional chat communication
@@ -2366,6 +2393,20 @@ const chatPrepareMessagesKey =
23662393 locals . create < ( event : PrepareMessagesEvent < unknown > ) => ModelMessage [ ] | Promise < ModelMessage [ ] > > (
23672394 "chat.prepareMessages"
23682395 ) ;
2396+ /**
2397+ * @internal The raw `tools` option from `chat.agent({ tools })`, either a
2398+ * static `ToolSet` or a per-turn function. Set once at boot.
2399+ */
2400+ const chatToolsOptionKey = locals . create <
2401+ ToolSet | ( ( event : ResolveToolsEvent < unknown > ) => ToolSet | Promise < ToolSet > )
2402+ > ( "chat.toolsOption" ) ;
2403+ /**
2404+ * @internal The concrete `ToolSet` resolved for the current turn. Read by
2405+ * `toModelMessages` so `convertToModelMessages` can re-run `toModelOutput` on
2406+ * prior-turn tool results. Unset when no `tools` were declared (preserves the
2407+ * exact pre-feature conversion behavior).
2408+ */
2409+ const chatResolvedToolsKey = locals . create < ToolSet > ( "chat.resolvedTools" ) ;
23692410
23702411/** @internal Flag set by `chat.requestUpgrade()` to exit the loop after the current turn. */
23712412const chatUpgradeRequestedKey = locals . create < boolean > ( "chat.upgradeRequested" ) ;
@@ -2626,6 +2667,25 @@ export type PrepareMessagesEvent<TClientData = unknown> = {
26262667 clientData ?: TClientData ;
26272668} ;
26282669
2670+ /**
2671+ * Event passed to the per-turn `tools` function form on `chat.agent`.
2672+ *
2673+ * Use this when the active tool set depends on per-turn context (the user, a
2674+ * feature flag, etc.). Return the `ToolSet` to use for converting this turn's
2675+ * history. Only `inputSchema` and `toModelOutput` are read during conversion,
2676+ * so a lightweight map (no `execute`) is fine.
2677+ */
2678+ export type ResolveToolsEvent < TClientData = unknown > = {
2679+ /** The chat session ID. */
2680+ chatId : string ;
2681+ /** The current turn number (0-indexed). */
2682+ turn : number ;
2683+ /** Whether this run is continuing an existing chat. */
2684+ continuation : boolean ;
2685+ /** Custom data from the frontend. */
2686+ clientData ?: TClientData ;
2687+ } ;
2688+
26292689/**
26302690 * Data shape for `data-compaction` stream chunks emitted during compaction.
26312691 * Use to type the `data` field when rendering compaction parts in the frontend.
@@ -2800,6 +2860,41 @@ async function applyPrepareMessages(
28002860 ) ;
28012861}
28022862
2863+ /**
2864+ * Resolve the `tools` option into a concrete `ToolSet` and cache it in locals so
2865+ * `toModelMessages` can pass it to `convertToModelMessages`. For the function
2866+ * form, invokes the user function with the given context (or the current turn
2867+ * context when no override is passed). Pass an `override` for the boot-time
2868+ * history conversion, which runs before the per-turn context exists and uses
2869+ * the run/continuation payload's `clientData`.
2870+ *
2871+ * Fails closed: a throwing resolver propagates rather than carrying a prior
2872+ * turn's set forward. The function form can gate capabilities by user or flag,
2873+ * so reusing stale tools would leak capabilities. No-op when no `tools` were
2874+ * declared.
2875+ * @internal
2876+ */
2877+ async function resolveTurnTools (
2878+ override ?: { chatId : string ; turn : number ; continuation : boolean ; clientData : unknown }
2879+ ) : Promise < void > {
2880+ const option = locals . get ( chatToolsOptionKey ) ;
2881+ if ( ! option ) return ;
2882+
2883+ if ( typeof option !== "function" ) {
2884+ locals . set ( chatResolvedToolsKey , option ) ;
2885+ return ;
2886+ }
2887+
2888+ const ctx = override ?? locals . get ( chatTurnContextKey ) ;
2889+ const resolved = await option ( {
2890+ chatId : ctx ?. chatId ?? "" ,
2891+ turn : ctx ?. turn ?? 0 ,
2892+ continuation : ctx ?. continuation ?? false ,
2893+ clientData : ctx ?. clientData ,
2894+ } ) ;
2895+ locals . set ( chatResolvedToolsKey , resolved ) ;
2896+ }
2897+
28032898/**
28042899 * Read the current compaction state. Returns the summary and base message count
28052900 * if compaction has occurred in this turn, or `undefined` if not.
@@ -4250,6 +4345,7 @@ export type ChatAgentOptions<
42504345 TClientDataSchema extends TaskSchema | undefined = undefined ,
42514346 TUIMessage extends UIMessage = UIMessage ,
42524347 TActionSchema extends TaskSchema | undefined = undefined ,
4348+ TTools extends ToolSet = ToolSet ,
42534349> = Omit <
42544350 TaskOptions <
42554351 TIdentifier ,
@@ -4360,6 +4456,41 @@ export type ChatAgentOptions<
43604456 >
43614457 ) => Promise < unknown > | unknown ;
43624458
4459+ /**
4460+ * The tools available to this agent.
4461+ *
4462+ * `chat.agent` doesn't call the model for you. Your tools still go to
4463+ * `streamText({ tools })` inside `run()`. Declaring them here additionally
4464+ * lets the SDK re-run each tool's
4465+ * [`toModelOutput`](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#tomodeloutput)
4466+ * when it re-converts persisted history on later turns. Without this, the
4467+ * AI SDK has no `tools` to look up `toModelOutput` against, so a tool's
4468+ * transformed result (e.g. raw image bytes → an image content part, or a
4469+ * sub-agent summary) silently degrades to its raw JSON output from turn 2
4470+ * onward.
4471+ *
4472+ * Only `inputSchema` and `toModelOutput` are read during conversion (never
4473+ * `execute`), so you may pass a lightweight map if you keep heavy execute
4474+ * deps out of this module.
4475+ *
4476+ * Pass either a static `ToolSet` or a function of per-turn context (for
4477+ * tools that depend on the user, a feature flag, etc.). The resolved set is
4478+ * available on the `run()` payload as `tools`.
4479+ *
4480+ * @example
4481+ * ```ts
4482+ * const tools = { read_file, search };
4483+ * chat.agent({
4484+ * tools,
4485+ * run: async ({ messages, tools, signal }) =>
4486+ * streamText({ model, messages, tools, abortSignal: signal }),
4487+ * });
4488+ * ```
4489+ */
4490+ tools ?:
4491+ | TTools
4492+ | ( ( event : ResolveToolsEvent < inferSchemaOut < TClientDataSchema > > ) => TTools | Promise < TTools > ) ;
4493+
43634494 /**
43644495 * The run function for the chat task.
43654496 *
@@ -4370,7 +4501,9 @@ export type ChatAgentOptions<
43704501 * **Auto-piping:** If this function returns a value with `.toUIMessageStream()`,
43714502 * the stream is automatically piped to the frontend.
43724503 */
4373- run : ( payload : ChatTaskRunPayload < inferSchemaOut < TClientDataSchema > > ) => Promise < unknown > ;
4504+ run : (
4505+ payload : ChatTaskRunPayload < inferSchemaOut < TClientDataSchema > , TTools >
4506+ ) => Promise < unknown > ;
43744507
43754508 /**
43764509 * Called once at the start of every run boot — for the initial run, for
@@ -4951,8 +5084,9 @@ function chatAgent<
49515084 TClientDataSchema extends TaskSchema | undefined = undefined ,
49525085 TUIMessage extends UIMessage = UIMessage ,
49535086 TActionSchema extends TaskSchema | undefined = undefined ,
5087+ TTools extends ToolSet = ToolSet ,
49545088> (
4955- options : ChatAgentOptions < TIdentifier , TClientDataSchema , TUIMessage , TActionSchema >
5089+ options : ChatAgentOptions < TIdentifier , TClientDataSchema , TUIMessage , TActionSchema , TTools >
49565090) : Task < TIdentifier , ChatTaskWirePayload < TUIMessage , inferSchemaIn < TClientDataSchema > > , unknown > {
49575091 const {
49585092 run : userRun ,
@@ -4971,6 +5105,7 @@ function chatAgent<
49715105 compaction,
49725106 pendingMessages : pendingMessagesConfig ,
49735107 prepareMessages,
5108+ tools : toolsOption ,
49745109 onTurnComplete,
49755110 maxTurns = 100 ,
49765111 turnTimeout = "1h" ,
@@ -5049,6 +5184,25 @@ function chatAgent<
50495184 locals . set ( chatPrepareMessagesKey , prepareMessages ) ;
50505185 }
50515186
5187+ if ( toolsOption ) {
5188+ // Cast: the option's function form is typed against the parsed
5189+ // `clientData` (`ResolveToolsEvent<inferSchemaOut<...>>`), but the
5190+ // locals key uses the erased `ResolveToolsEvent<unknown>`. The runtime
5191+ // value is identical; this mirrors how `prepareMessages` is stored.
5192+ locals . set (
5193+ chatToolsOptionKey ,
5194+ toolsOption as
5195+ | ToolSet
5196+ | ( ( event : ResolveToolsEvent < unknown > ) => ToolSet | Promise < ToolSet > )
5197+ ) ;
5198+ // Static tools are usable immediately. The function form is resolved
5199+ // just before the boot history conversion (with the payload's
5200+ // clientData) and again per-turn (see resolveTurnTools).
5201+ if ( typeof toolsOption !== "function" ) {
5202+ locals . set ( chatResolvedToolsKey , toolsOption ) ;
5203+ }
5204+ }
5205+
50525206 if ( compaction ) {
50535207 locals . set (
50545208 chatAgentCompactionKey ,
@@ -5438,6 +5592,29 @@ function chatAgent<
54385592 }
54395593
54405594 if ( accumulatedUIMessages . length > 0 ) {
5595+ // Resolve a function-form `tools` with the run/continuation payload's
5596+ // clientData so this conversion of the restored history applies each
5597+ // tool's toModelOutput (static tools were already seeded above). This
5598+ // only re-renders saved history, so it fails open: a resolver hiccup
5599+ // logs and converts without tools rather than blocking the resume.
5600+ // Per-turn resolveTurnTools still fails closed for live turns.
5601+ if ( typeof toolsOption === "function" ) {
5602+ try {
5603+ await resolveTurnTools ( {
5604+ chatId : payload . chatId ,
5605+ turn : 0 ,
5606+ continuation : payload . continuation ?? false ,
5607+ clientData : parseClientData
5608+ ? await parseClientData ( payload . metadata )
5609+ : payload . metadata ,
5610+ } ) ;
5611+ } catch ( error ) {
5612+ logger . warn (
5613+ "chat.agent: tools() resolver threw at boot; restored history converted without toModelOutput" ,
5614+ { error : error instanceof Error ? error . message : String ( error ) }
5615+ ) ;
5616+ }
5617+ }
54415618 try {
54425619 accumulatedMessages = await toModelMessages ( accumulatedUIMessages ) ;
54435620 } catch ( error ) {
@@ -5958,6 +6135,11 @@ function chatAgent<
59586135 clientData,
59596136 } ) ;
59606137
6138+ // Resolve the per-turn `tools` set now that turn context
6139+ // (incl. parsed clientData) exists, so every toModelMessages
6140+ // call this turn can re-apply tool `toModelOutput`.
6141+ await resolveTurnTools ( ) ;
6142+
59616143 // Per-turn stop controller (reset each turn)
59626144 const stopController = new AbortController ( ) ;
59636145 currentStopController = stopController ;
@@ -6613,6 +6795,7 @@ function chatAgent<
66136795 previousTurnUsage,
66146796 totalUsage : cumulativeUsage ,
66156797 ctx,
6798+ tools : locals . get ( chatResolvedToolsKey ) ?? { } ,
66166799 signal : combinedSignal ,
66176800 cancelSignal,
66186801 stopSignal,
@@ -7512,11 +7695,11 @@ export interface ChatBuilder<
75127695 * (backwards compatible).
75137696 */
75147697 agent : [ TClientDataSchema ] extends [ undefined ]
7515- ? < TId extends string , TInfer extends TaskSchema | undefined = undefined , TAction extends TaskSchema | undefined = undefined > (
7516- options : ChatAgentOptions < TId , TInfer , TUIMessage , TAction >
7698+ ? < TId extends string , TInfer extends TaskSchema | undefined = undefined , TAction extends TaskSchema | undefined = undefined , TTools extends ToolSet = ToolSet > (
7699+ options : ChatAgentOptions < TId , TInfer , TUIMessage , TAction , TTools >
75177700 ) => Task < TId , ChatTaskWirePayload < TUIMessage , inferSchemaIn < TInfer > > , unknown >
7518- : < TId extends string , TAction extends TaskSchema | undefined = undefined > (
7519- options : Omit < ChatAgentOptions < TId , TClientDataSchema , TUIMessage , TAction > , "clientDataSchema" >
7701+ : < TId extends string , TAction extends TaskSchema | undefined = undefined , TTools extends ToolSet = ToolSet > (
7702+ options : Omit < ChatAgentOptions < TId , TClientDataSchema , TUIMessage , TAction , TTools > , "clientDataSchema" >
75207703 ) => Task < TId , ChatTaskWirePayload < TUIMessage , inferSchemaIn < TClientDataSchema > > , unknown > ;
75217704
75227705 /**
@@ -9145,7 +9328,11 @@ function chatLocal<T extends Record<string, unknown>>(options: { id: string }):
91459328// the browser graph. Re-exported here so `@trigger.dev/sdk/ai` consumers
91469329// still see them.
91479330import type { InferChatClientData , InferChatUIMessage } from "./ai-shared.js" ;
9148- export type { InferChatClientData , InferChatUIMessage } from "./ai-shared.js" ;
9331+ export type {
9332+ InferChatClientData ,
9333+ InferChatUIMessage ,
9334+ InferChatUIMessageFromTools ,
9335+ } from "./ai-shared.js" ;
91499336
91509337/**
91519338 * Options for {@link createChatStartSessionAction}.
0 commit comments