Skip to content

Commit fa5e2a8

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 8c48927 commit fa5e2a8

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
@@ -4788,6 +4788,8 @@ type ManagedStreamTextConfig = {
47884788
system?: ToStreamTextOptionsOptions["system"];
47894789
cacheControl?: ToStreamTextOptionsOptions["cacheControl"];
47904790
systemProviderOptions?: ToStreamTextOptionsOptions["systemProviderOptions"];
4791+
/** The agent's resolved tools, used when the call site names no `tools` of its own. */
4792+
tools?: ToolSet;
47914793
};
47924794

47934795
/**
@@ -4801,7 +4803,13 @@ function buildManagedStreamTextOptions(
48014803
options: Record<string, unknown>,
48024804
config: ManagedStreamTextConfig
48034805
): Record<string, unknown> {
4804-
const { registry, system: agentSystem, cacheControl, systemProviderOptions } = config;
4806+
const {
4807+
registry,
4808+
system: agentSystem,
4809+
cacheControl,
4810+
systemProviderOptions,
4811+
tools: agentTools,
4812+
} = config;
48054813

48064814
/**
48074815
* Only the three keys that collide are intercepted. Everything else, telemetry
@@ -4821,7 +4829,13 @@ function buildManagedStreamTextOptions(
48214829
system: (callerSystem as ToStreamTextOptionsOptions["system"]) ?? agentSystem,
48224830
cacheControl,
48234831
systemProviderOptions,
4824-
tools: tools as Record<string, Tool> | undefined,
4832+
/**
4833+
* A call site that names `tools` replaces the agent's set rather than
4834+
* adding to it, so narrowing the tools for one call still works. Omitting
4835+
* `tools` falls back to the agent's, which is what an `onAction`
4836+
* regenerate needs: without it a regenerated answer can call nothing.
4837+
*/
4838+
tools: (tools ?? agentTools) as Record<string, Tool> | undefined,
48254839
});
48264840

48274841
const promptSystem = locals.get(chatPromptKey)?.text;
@@ -4877,6 +4891,8 @@ function createBoundStreamText(
48774891
system: agentSystem,
48784892
cacheControl: agentCacheControl,
48794893
systemProviderOptions: agentSystemProviderOptions,
4894+
/** Read per call, so per-turn tools resolved after binding are included. */
4895+
tools: locals.get(chatResolvedToolsKey),
48804896
}) as any
48814897
);
48824898

@@ -5577,6 +5593,8 @@ export type ActionEvent<
55775593
uiMessages: TUIM[];
55785594
/** The accumulated model messages (after hydration, if set). */
55795595
messages: ModelMessage[];
5596+
/** The agent's resolved tools, the same set `run()` receives. */
5597+
tools: ToolSet;
55805598
/**
55815599
* `streamText` with the agent's managed options already applied, the same one
55825600
* `run()` receives: the prompt from `chat.prompt.set()` or
@@ -8019,6 +8037,7 @@ function chatAgent<
80198037
clientData,
80208038
uiMessages: accumulatedUIMessages,
80218039
messages: accumulatedMessages,
8040+
tools: locals.get(chatResolvedToolsKey) ?? {},
80228041
streamText: createBoundStreamText(
80238042
promptRegistry,
80248043
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)