@@ -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,40 @@ async function applyPrepareMessages(
28002860 ) ;
28012861}
28022862
2863+ /**
2864+ * Resolve the `tools` option for the current turn and cache it in locals so
2865+ * `toModelMessages` can pass it to `convertToModelMessages`. For the function
2866+ * form, invokes the user function once per turn with the current turn context
2867+ * (which already has parsed `clientData`). No-op when no `tools` were declared.
2868+ * A throwing tools function logs and leaves the previously-resolved value in
2869+ * place rather than killing the turn.
2870+ * @internal
2871+ */
2872+ async function resolveTurnTools ( ) : Promise < void > {
2873+ const option = locals . get ( chatToolsOptionKey ) ;
2874+ if ( ! option ) return ;
2875+
2876+ if ( typeof option !== "function" ) {
2877+ locals . set ( chatResolvedToolsKey , option ) ;
2878+ return ;
2879+ }
2880+
2881+ const turnCtx = locals . get ( chatTurnContextKey ) ;
2882+ try {
2883+ const resolved = await option ( {
2884+ chatId : turnCtx ?. chatId ?? "" ,
2885+ turn : turnCtx ?. turn ?? 0 ,
2886+ continuation : turnCtx ?. continuation ?? false ,
2887+ clientData : turnCtx ?. clientData ,
2888+ } ) ;
2889+ locals . set ( chatResolvedToolsKey , resolved ) ;
2890+ } catch ( error ) {
2891+ logger . warn ( "chat.agent: tools() resolver threw; reusing previous tools for this turn" , {
2892+ error : error instanceof Error ? error . message : String ( error ) ,
2893+ } ) ;
2894+ }
2895+ }
2896+
28032897/**
28042898 * Read the current compaction state. Returns the summary and base message count
28052899 * if compaction has occurred in this turn, or `undefined` if not.
@@ -4250,6 +4344,7 @@ export type ChatAgentOptions<
42504344 TClientDataSchema extends TaskSchema | undefined = undefined ,
42514345 TUIMessage extends UIMessage = UIMessage ,
42524346 TActionSchema extends TaskSchema | undefined = undefined ,
4347+ TTools extends ToolSet = ToolSet ,
42534348> = Omit <
42544349 TaskOptions <
42554350 TIdentifier ,
@@ -4360,6 +4455,41 @@ export type ChatAgentOptions<
43604455 >
43614456 ) => Promise < unknown > | unknown ;
43624457
4458+ /**
4459+ * The tools available to this agent.
4460+ *
4461+ * `chat.agent` doesn't call the model for you. Your tools still go to
4462+ * `streamText({ tools })` inside `run()`. Declaring them here additionally
4463+ * lets the SDK re-run each tool's
4464+ * [`toModelOutput`](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#tomodeloutput)
4465+ * when it re-converts persisted history on later turns. Without this, the
4466+ * AI SDK has no `tools` to look up `toModelOutput` against, so a tool's
4467+ * transformed result (e.g. raw image bytes → an image content part, or a
4468+ * sub-agent summary) silently degrades to its raw JSON output from turn 2
4469+ * onward.
4470+ *
4471+ * Only `inputSchema` and `toModelOutput` are read during conversion (never
4472+ * `execute`), so you may pass a lightweight map if you keep heavy execute
4473+ * deps out of this module.
4474+ *
4475+ * Pass either a static `ToolSet` or a function of per-turn context (for
4476+ * tools that depend on the user, a feature flag, etc.). The resolved set is
4477+ * available on the `run()` payload as `tools`.
4478+ *
4479+ * @example
4480+ * ```ts
4481+ * const tools = { read_file, search };
4482+ * chat.agent({
4483+ * tools,
4484+ * run: async ({ messages, tools, signal }) =>
4485+ * streamText({ model, messages, tools, abortSignal: signal }),
4486+ * });
4487+ * ```
4488+ */
4489+ tools ?:
4490+ | TTools
4491+ | ( ( event : ResolveToolsEvent < inferSchemaOut < TClientDataSchema > > ) => TTools | Promise < TTools > ) ;
4492+
43634493 /**
43644494 * The run function for the chat task.
43654495 *
@@ -4370,7 +4500,9 @@ export type ChatAgentOptions<
43704500 * **Auto-piping:** If this function returns a value with `.toUIMessageStream()`,
43714501 * the stream is automatically piped to the frontend.
43724502 */
4373- run : ( payload : ChatTaskRunPayload < inferSchemaOut < TClientDataSchema > > ) => Promise < unknown > ;
4503+ run : (
4504+ payload : ChatTaskRunPayload < inferSchemaOut < TClientDataSchema > , TTools >
4505+ ) => Promise < unknown > ;
43744506
43754507 /**
43764508 * Called once at the start of every run boot — for the initial run, for
@@ -4951,8 +5083,9 @@ function chatAgent<
49515083 TClientDataSchema extends TaskSchema | undefined = undefined ,
49525084 TUIMessage extends UIMessage = UIMessage ,
49535085 TActionSchema extends TaskSchema | undefined = undefined ,
5086+ TTools extends ToolSet = ToolSet ,
49545087> (
4955- options : ChatAgentOptions < TIdentifier , TClientDataSchema , TUIMessage , TActionSchema >
5088+ options : ChatAgentOptions < TIdentifier , TClientDataSchema , TUIMessage , TActionSchema , TTools >
49565089) : Task < TIdentifier , ChatTaskWirePayload < TUIMessage , inferSchemaIn < TClientDataSchema > > , unknown > {
49575090 const {
49585091 run : userRun ,
@@ -4971,6 +5104,7 @@ function chatAgent<
49715104 compaction,
49725105 pendingMessages : pendingMessagesConfig ,
49735106 prepareMessages,
5107+ tools : toolsOption ,
49745108 onTurnComplete,
49755109 maxTurns = 100 ,
49765110 turnTimeout = "1h" ,
@@ -5049,6 +5183,25 @@ function chatAgent<
50495183 locals . set ( chatPrepareMessagesKey , prepareMessages ) ;
50505184 }
50515185
5186+ if ( toolsOption ) {
5187+ // Cast: the option's function form is typed against the parsed
5188+ // `clientData` (`ResolveToolsEvent<inferSchemaOut<...>>`), but the
5189+ // locals key uses the erased `ResolveToolsEvent<unknown>`. The runtime
5190+ // value is identical; this mirrors how `prepareMessages` is stored.
5191+ locals . set (
5192+ chatToolsOptionKey ,
5193+ toolsOption as
5194+ | ToolSet
5195+ | ( ( event : ResolveToolsEvent < unknown > ) => ToolSet | Promise < ToolSet > )
5196+ ) ;
5197+ // Static tools are usable immediately (e.g. the boot-time history
5198+ // seed before the first turn context exists). The function form is
5199+ // resolved per-turn once `clientData` is parsed (see resolveTurnTools).
5200+ if ( typeof toolsOption !== "function" ) {
5201+ locals . set ( chatResolvedToolsKey , toolsOption ) ;
5202+ }
5203+ }
5204+
50525205 if ( compaction ) {
50535206 locals . set (
50545207 chatAgentCompactionKey ,
@@ -5958,6 +6111,11 @@ function chatAgent<
59586111 clientData,
59596112 } ) ;
59606113
6114+ // Resolve the per-turn `tools` set now that turn context
6115+ // (incl. parsed clientData) exists, so every toModelMessages
6116+ // call this turn can re-apply tool `toModelOutput`.
6117+ await resolveTurnTools ( ) ;
6118+
59616119 // Per-turn stop controller (reset each turn)
59626120 const stopController = new AbortController ( ) ;
59636121 currentStopController = stopController ;
@@ -6613,6 +6771,7 @@ function chatAgent<
66136771 previousTurnUsage,
66146772 totalUsage : cumulativeUsage ,
66156773 ctx,
6774+ tools : locals . get ( chatResolvedToolsKey ) ?? { } ,
66166775 signal : combinedSignal ,
66176776 cancelSignal,
66186777 stopSignal,
@@ -7512,11 +7671,11 @@ export interface ChatBuilder<
75127671 * (backwards compatible).
75137672 */
75147673 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 >
7674+ ? < TId extends string , TInfer extends TaskSchema | undefined = undefined , TAction extends TaskSchema | undefined = undefined , TTools extends ToolSet = ToolSet > (
7675+ options : ChatAgentOptions < TId , TInfer , TUIMessage , TAction , TTools >
75177676 ) => 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" >
7677+ : < TId extends string , TAction extends TaskSchema | undefined = undefined , TTools extends ToolSet = ToolSet > (
7678+ options : Omit < ChatAgentOptions < TId , TClientDataSchema , TUIMessage , TAction , TTools > , "clientDataSchema" >
75207679 ) => Task < TId , ChatTaskWirePayload < TUIMessage , inferSchemaIn < TClientDataSchema > > , unknown > ;
75217680
75227681 /**
@@ -9145,7 +9304,11 @@ function chatLocal<T extends Record<string, unknown>>(options: { id: string }):
91459304// the browser graph. Re-exported here so `@trigger.dev/sdk/ai` consumers
91469305// still see them.
91479306import type { InferChatClientData , InferChatUIMessage } from "./ai-shared.js" ;
9148- export type { InferChatClientData , InferChatUIMessage } from "./ai-shared.js" ;
9307+ export type {
9308+ InferChatClientData ,
9309+ InferChatUIMessage ,
9310+ InferChatUIMessageFromTools ,
9311+ } from "./ai-shared.js" ;
91499312
91509313/**
91519314 * Options for {@link createChatStartSessionAction}.
0 commit comments