Skip to content

Commit 30393ca

Browse files
committed
fix(sdk): re-apply tool toModelOutput across chat.agent turns
chat.agent now takes a `tools` option. Declaring tools there threads them into the SDK's internal convertToModelMessages, so each tool's toModelOutput is re-applied when prior-turn history is re-converted. Without it, a transformed result (e.g. an image content part or a sub-agent summary) degraded to raw JSON from the second turn onward. Accepts a static tool set or a per-turn function, and reads only inputSchema/toModelOutput (never execute). The resolved set is handed back, typed, on the run payload so it can go straight to streamText. No behavior change for agents that don't declare tools.
1 parent 9211032 commit 30393ca

7 files changed

Lines changed: 448 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: 172 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,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.
91479306
import 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

Comments
 (0)