Skip to content

Commit 45bafb0

Browse files
committed
fix(sdk): fall back to the agent's tools in the managed streamText
An onAction handler has no tools in scope the way run() does, so a regenerated answer built with the bound streamText could call nothing. Omitting tools now falls back to chat.agent({ tools }); naming tools still replaces the set for that call. onAction also receives tools.
1 parent 76cdc4d commit 45bafb0

4 files changed

Lines changed: 76 additions & 4 deletions

File tree

docs/ai-chat/actions.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export const myChat = chat.agent({
5858

5959
The `messages` argument is captured before `onAction` runs, so a handler that mutates `chat.history` and then passes `messages` straight through sends the model the state it just changed. Rebuild from `chat.history.all()` after the mutation.
6060

61-
Build the response with the `streamText` from `onAction`'s own argument, the same one `run()` receives. It carries the agent's system prompt, skill tools, resolved model and telemetry, so a regenerated answer is produced under the same configuration as every other turn. The `streamText` imported from `ai` carries none of that, and the reply still looks fine, which is what makes the difference easy to miss.
61+
Build the response with the `streamText` from `onAction`'s own argument, the same one `run()` receives. It carries the agent's system prompt, config tools, skill tools, resolved model and telemetry, so a regenerated answer is produced under the same configuration as every other turn. The `streamText` imported from `ai` carries none of that, and the reply still looks fine, which is what makes the difference easy to miss.
6262

6363
```ts
6464
onAction: async ({ action, streamText }) => {

docs/ai-chat/tools.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ There are three places a tool set shows up. Declare once, reuse:
4646
| Surface | What it's for |
4747
| --- | --- |
4848
| `chat.agent({ tools })` | Re-applies `toModelOutput` on prior-turn history; hands the set back typed on the `run()` payload. |
49-
| `streamText({ tools })` on the `run` argument's `streamText` | What the model actually calls. Detects which calls need [HITL approval](/ai-chat/patterns/human-in-the-loop) (`needsApproval`) and merges the auto-injected [skill](/ai-chat/patterns/skills) tools on top. |
49+
| `streamText({ tools })` on the `run` argument's `streamText` | What the model actually calls. Detects which calls need [HITL approval](/ai-chat/patterns/human-in-the-loop) (`needsApproval`) and merges the auto-injected [skill](/ai-chat/patterns/skills) tools on top. Naming `tools` replaces the config set for that call, so you can narrow it; omitting `tools` falls back to the config set. |
5050
| `chat.toStreamTextOptions({ tools })` | The same job by hand, for a [custom agent](#manual-turn-loops-chatcustomagent), which has no `run` argument. |
5151

5252
The canonical pattern: declare `tools` on the config, read them back from the `run()` payload, and pass that set to the `streamText` the payload also carries.

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

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4773,6 +4773,8 @@ type ManagedStreamTextConfig = {
47734773
system?: ToStreamTextOptionsOptions["system"];
47744774
cacheControl?: ToStreamTextOptionsOptions["cacheControl"];
47754775
systemProviderOptions?: ToStreamTextOptionsOptions["systemProviderOptions"];
4776+
/** The agent's resolved tools, used when the call site names no `tools` of its own. */
4777+
tools?: ToolSet;
47764778
};
47774779

47784780
/**
@@ -4786,7 +4788,13 @@ function buildManagedStreamTextOptions(
47864788
options: Record<string, unknown>,
47874789
config: ManagedStreamTextConfig
47884790
): Record<string, unknown> {
4789-
const { registry, system: agentSystem, cacheControl, systemProviderOptions } = config;
4791+
const {
4792+
registry,
4793+
system: agentSystem,
4794+
cacheControl,
4795+
systemProviderOptions,
4796+
tools: agentTools,
4797+
} = config;
47904798

47914799
/**
47924800
* Only the three keys that collide are intercepted. Everything else, telemetry
@@ -4806,7 +4814,13 @@ function buildManagedStreamTextOptions(
48064814
system: (callerSystem as ToStreamTextOptionsOptions["system"]) ?? agentSystem,
48074815
cacheControl,
48084816
systemProviderOptions,
4809-
tools: tools as Record<string, Tool> | undefined,
4817+
/**
4818+
* A call site that names `tools` replaces the agent's set rather than
4819+
* adding to it, so narrowing the tools for one call still works. Omitting
4820+
* `tools` falls back to the agent's, which is what an `onAction`
4821+
* regenerate needs: without it a regenerated answer can call nothing.
4822+
*/
4823+
tools: (tools ?? agentTools) as Record<string, Tool> | undefined,
48104824
});
48114825

48124826
const promptSystem = locals.get(chatPromptKey)?.text;
@@ -4862,6 +4876,8 @@ function createBoundStreamText(
48624876
system: agentSystem,
48634877
cacheControl: agentCacheControl,
48644878
systemProviderOptions: agentSystemProviderOptions,
4879+
/** Read per call, so per-turn tools resolved after binding are included. */
4880+
tools: locals.get(chatResolvedToolsKey),
48654881
}) as any
48664882
);
48674883

@@ -5562,6 +5578,8 @@ export type ActionEvent<
55625578
uiMessages: TUIM[];
55635579
/** The accumulated model messages (after hydration, if set). */
55645580
messages: ModelMessage[];
5581+
/** The agent's resolved tools, the same set `run()` receives. */
5582+
tools: ToolSet;
55655583
/**
55665584
* `streamText` with the agent's managed options already applied, the same one
55675585
* `run()` receives: the prompt from `chat.prompt.set()` or
@@ -8004,6 +8022,7 @@ function chatAgent<
80048022
clientData,
80058023
uiMessages: accumulatedUIMessages,
80068024
messages: accumulatedMessages,
8025+
tools: locals.get(chatResolvedToolsKey) ?? {},
80078026
streamText: createBoundStreamText(
80088027
promptRegistry,
80098028
agentSystem,

packages/trigger-sdk/test/bound-streamtext.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,59 @@ describe("the streamText handed to onAction", () => {
503503
await harness.close();
504504
}
505505
});
506+
507+
it("carries the agent's tools into a regenerated answer", async () => {
508+
/**
509+
* A regenerate that can call nothing is not the same agent answering
510+
* again. `onAction` has no `tools` in scope the way `run()` does, so
511+
* omitting `tools` has to fall back to the agent's set rather than to
512+
* none.
513+
*/
514+
const turnModel = new MockLanguageModelV3({
515+
doStream: async () => ({ stream: textStream("first answer") }),
516+
});
517+
const actionModel = new MockLanguageModelV3({
518+
doStream: async () => ({ stream: textStream("regenerated answer") }),
519+
});
520+
521+
const agentOnlyTool = tool({
522+
description: "declared only on chat.agent({ tools })",
523+
inputSchema: z.object({ a: z.string() }),
524+
execute: async () => "a",
525+
});
526+
527+
const agent = chat.agent({
528+
id: "bound-streamtext-onaction-tools",
529+
tools: { agentOnlyTool },
530+
actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("regenerate") })]),
531+
onAction: async ({ action, streamText }) => {
532+
if (action.type !== "regenerate") return;
533+
chat.history.slice(0, -1);
534+
return streamText({
535+
model: actionModel,
536+
messages: await convertToModelMessages(chat.history.all()),
537+
});
538+
},
539+
run: async ({ messages, tools, signal, streamText }) =>
540+
streamText({ model: turnModel, messages, tools, abortSignal: signal }),
541+
});
542+
543+
const harness = mockChatAgent(agent, { chatId: "bound-streamtext-onaction-tools" });
544+
545+
try {
546+
await harness.sendMessage({ id: "u1", role: "user", parts: [{ type: "text", text: "ask" }] });
547+
await new Promise((r) => setTimeout(r, 40));
548+
549+
await harness.sendAction({ type: "regenerate" });
550+
await new Promise((r) => setTimeout(r, 60));
551+
552+
expect(actionModel.doStreamCalls).toHaveLength(1);
553+
const names = (actionModel.doStreamCalls[0]!.tools ?? []).map((t) => t.name);
554+
expect(names).toEqual(["agentOnlyTool"]);
555+
} finally {
556+
await harness.close();
557+
}
558+
});
506559
});
507560

508561
describe("a structured system message", () => {

0 commit comments

Comments
 (0)