Skip to content

Commit e9e2ec1

Browse files
authored
fix(sdk): re-apply tool toModelOutput across chat.agent turns (#3790)
## Summary `chat.agent` now takes a `tools` option. Until now tools only went to `streamText` inside `run()`, so 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 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 JSON-stringified back into the prompt and the model lost the transformed view. Declaring `tools` on the config threads them into that conversion, so `toModelOutput` runs on every turn. The resolved set is handed back, typed, on the `run()` payload as `tools`: ```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 for tools that depend on the user or a feature flag. Only `inputSchema` and `toModelOutput` are read during conversion, never `execute`. Also exports `InferChatUIMessageFromTools<typeof tools>` to derive the chat `UIMessage` type from a tool set. No behavior change for agents that don't declare `tools`.
1 parent e21b68c commit e9e2ec1

7 files changed

Lines changed: 494 additions & 14 deletions

File tree

.changeset/chat-agent-tools.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Add a `tools` option to `chat.agent`. Declaring your tools here threads them into the SDK's internal `convertToModelMessages`, so each tool's `toModelOutput` is re-applied when prior-turn history is re-converted.
6+
7+
```ts
8+
chat.agent({
9+
tools: { readFile, search },
10+
run: async ({ messages, tools, signal }) =>
11+
streamText({ model, messages, tools, abortSignal: signal }),
12+
});
13+
```
14+
15+
Also exports `InferChatUIMessageFromTools<typeof tools>` to derive the chat `UIMessage` type (typed tool parts) directly from a tool set.

packages/trigger-sdk/src/v3/ai-shared.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import type { Task, AnyTask } from "@trigger.dev/core/v3";
19-
import type { ModelMessage, UIMessage } from "ai";
19+
import type { InferUITools, ModelMessage, ToolSet, UIDataTypes, UIMessage } from "ai";
2020

2121
/**
2222
* Message-part `type` value for the pending-message data part the agent
@@ -199,6 +199,26 @@ export type InferChatUIMessage<TTask extends AnyTask> = TTask extends Task<
199199
? TUIM
200200
: UIMessage;
201201

202+
/**
203+
* Derive the chat `UIMessage` type for a given tool set. The tool-part types
204+
* (`tool-${name}` with typed input/output) are inferred from the tools. Use
205+
* this to declare the message type from your tools (e.g. to pass to
206+
* `chat.withUIMessage<...>()` or to type the frontend) without hand-writing
207+
* the `UIMessage<unknown, UIDataTypes, InferUITools<...>>` triple.
208+
*
209+
* @example
210+
* ```ts
211+
* import type { InferChatUIMessageFromTools } from "@trigger.dev/sdk/ai";
212+
* const tools = { search, readFile };
213+
* type ChatUiMessage = InferChatUIMessageFromTools<typeof tools>;
214+
* ```
215+
*/
216+
export type InferChatUIMessageFromTools<TTools extends ToolSet> = UIMessage<
217+
unknown,
218+
UIDataTypes,
219+
InferUITools<TTools>
220+
>;
221+
202222
/**
203223
* Upsert an incoming wire message into the customer's DB-backed chain
204224
* inside a `hydrateMessages` hook. Returns `true` iff the chain was

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 196 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,16 @@ const METADATA_KEY = "tool.execute.options";
102102
* stopped/aborted conversations with partial tool parts.
103103
*/
104104
function 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

108117
export 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. */
23712412
const 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.
91479330
import 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

Comments
 (0)