@trigger.dev/sdk 4.4.5 → 4.5.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commonjs/v3/agentSkillsRuntime.d.ts +28 -0
- package/dist/commonjs/v3/agentSkillsRuntime.js +163 -0
- package/dist/commonjs/v3/agentSkillsRuntime.js.map +1 -0
- package/dist/commonjs/v3/ai-shared.d.ts +173 -0
- package/dist/commonjs/v3/ai-shared.js +25 -0
- package/dist/commonjs/v3/ai-shared.js.map +1 -0
- package/dist/commonjs/v3/ai.d.ts +2823 -5
- package/dist/commonjs/v3/ai.js +6197 -13
- package/dist/commonjs/v3/ai.js.map +1 -1
- package/dist/commonjs/v3/auth.d.ts +9 -0
- package/dist/commonjs/v3/auth.js.map +1 -1
- package/dist/commonjs/v3/chat-client.d.ts +301 -0
- package/dist/commonjs/v3/chat-client.js +624 -0
- package/dist/commonjs/v3/chat-client.js.map +1 -0
- package/dist/commonjs/v3/chat-react.d.ts +155 -0
- package/dist/commonjs/v3/chat-react.js +330 -0
- package/dist/commonjs/v3/chat-react.js.map +1 -0
- package/dist/commonjs/v3/chat-server.d.ts +206 -0
- package/dist/commonjs/v3/chat-server.js +737 -0
- package/dist/commonjs/v3/chat-server.js.map +1 -0
- package/dist/commonjs/v3/chat-server.test.d.ts +1 -0
- package/dist/commonjs/v3/chat-server.test.js +518 -0
- package/dist/commonjs/v3/chat-server.test.js.map +1 -0
- package/dist/commonjs/v3/chat-tab-coordinator.d.ts +65 -0
- package/dist/commonjs/v3/chat-tab-coordinator.js +235 -0
- package/dist/commonjs/v3/chat-tab-coordinator.js.map +1 -0
- package/dist/commonjs/v3/chat-tab-coordinator.test.d.ts +1 -0
- package/dist/commonjs/v3/chat-tab-coordinator.test.js +140 -0
- package/dist/commonjs/v3/chat-tab-coordinator.test.js.map +1 -0
- package/dist/commonjs/v3/chat.d.ts +437 -0
- package/dist/commonjs/v3/chat.js +968 -0
- package/dist/commonjs/v3/chat.js.map +1 -0
- package/dist/commonjs/v3/chat.test.d.ts +1 -0
- package/dist/commonjs/v3/chat.test.js +1180 -0
- package/dist/commonjs/v3/chat.test.js.map +1 -0
- package/dist/commonjs/v3/createStartSessionAction.test.d.ts +1 -0
- package/dist/commonjs/v3/createStartSessionAction.test.js +113 -0
- package/dist/commonjs/v3/createStartSessionAction.test.js.map +1 -0
- package/dist/commonjs/v3/deployments.d.ts +26 -0
- package/dist/commonjs/v3/deployments.js +37 -0
- package/dist/commonjs/v3/deployments.js.map +1 -0
- package/dist/commonjs/v3/index.d.ts +6 -3
- package/dist/commonjs/v3/index.js +7 -1
- package/dist/commonjs/v3/index.js.map +1 -1
- package/dist/commonjs/v3/runs.d.ts +22 -7
- package/dist/commonjs/v3/runs.js +1 -0
- package/dist/commonjs/v3/runs.js.map +1 -1
- package/dist/commonjs/v3/sessions.d.ts +228 -0
- package/dist/commonjs/v3/sessions.js +664 -0
- package/dist/commonjs/v3/sessions.js.map +1 -0
- package/dist/commonjs/v3/sessions.test.d.ts +1 -0
- package/dist/commonjs/v3/sessions.test.js +154 -0
- package/dist/commonjs/v3/sessions.test.js.map +1 -0
- package/dist/commonjs/v3/shared.d.ts +24 -2
- package/dist/commonjs/v3/shared.js +189 -1
- package/dist/commonjs/v3/shared.js.map +1 -1
- package/dist/commonjs/v3/skill.d.ts +99 -0
- package/dist/commonjs/v3/skill.js +155 -0
- package/dist/commonjs/v3/skill.js.map +1 -0
- package/dist/commonjs/v3/skills.d.ts +2 -0
- package/dist/commonjs/v3/skills.js +6 -0
- package/dist/commonjs/v3/skills.js.map +1 -0
- package/dist/commonjs/v3/streams.js +127 -19
- package/dist/commonjs/v3/streams.js.map +1 -1
- package/dist/commonjs/v3/tasks.d.ts +2 -1
- package/dist/commonjs/v3/tasks.js +1 -0
- package/dist/commonjs/v3/tasks.js.map +1 -1
- package/dist/commonjs/v3/test/index.d.ts +3 -0
- package/dist/commonjs/v3/test/index.js +18 -0
- package/dist/commonjs/v3/test/index.js.map +1 -0
- package/dist/commonjs/v3/test/mock-chat-agent.d.ts +259 -0
- package/dist/commonjs/v3/test/mock-chat-agent.js +468 -0
- package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -0
- package/dist/commonjs/v3/test/setup-catalog.d.ts +1 -0
- package/dist/commonjs/v3/test/setup-catalog.js +18 -0
- package/dist/commonjs/v3/test/setup-catalog.js.map +1 -0
- package/dist/commonjs/v3/test/test-session-handle.d.ts +53 -0
- package/dist/commonjs/v3/test/test-session-handle.js +256 -0
- package/dist/commonjs/v3/test/test-session-handle.js.map +1 -0
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/v3/agentSkillsRuntime.d.ts +28 -0
- package/dist/esm/v3/agentSkillsRuntime.js +136 -0
- package/dist/esm/v3/agentSkillsRuntime.js.map +1 -0
- package/dist/esm/v3/ai-shared.d.ts +173 -0
- package/dist/esm/v3/ai-shared.js +22 -0
- package/dist/esm/v3/ai-shared.js.map +1 -0
- package/dist/esm/v3/ai.d.ts +2823 -5
- package/dist/esm/v3/ai.js +6187 -14
- package/dist/esm/v3/ai.js.map +1 -1
- package/dist/esm/v3/auth.d.ts +9 -0
- package/dist/esm/v3/auth.js.map +1 -1
- package/dist/esm/v3/chat-client.d.ts +301 -0
- package/dist/esm/v3/chat-client.js +619 -0
- package/dist/esm/v3/chat-client.js.map +1 -0
- package/dist/esm/v3/chat-react.d.ts +155 -0
- package/dist/esm/v3/chat-react.js +325 -0
- package/dist/esm/v3/chat-react.js.map +1 -0
- package/dist/esm/v3/chat-server.d.ts +206 -0
- package/dist/esm/v3/chat-server.js +734 -0
- package/dist/esm/v3/chat-server.js.map +1 -0
- package/dist/esm/v3/chat-server.test.d.ts +1 -0
- package/dist/esm/v3/chat-server.test.js +516 -0
- package/dist/esm/v3/chat-server.test.js.map +1 -0
- package/dist/esm/v3/chat-tab-coordinator.d.ts +65 -0
- package/dist/esm/v3/chat-tab-coordinator.js +231 -0
- package/dist/esm/v3/chat-tab-coordinator.js.map +1 -0
- package/dist/esm/v3/chat-tab-coordinator.test.d.ts +1 -0
- package/dist/esm/v3/chat-tab-coordinator.test.js +138 -0
- package/dist/esm/v3/chat-tab-coordinator.test.js.map +1 -0
- package/dist/esm/v3/chat.d.ts +437 -0
- package/dist/esm/v3/chat.js +961 -0
- package/dist/esm/v3/chat.js.map +1 -0
- package/dist/esm/v3/chat.test.d.ts +1 -0
- package/dist/esm/v3/chat.test.js +1178 -0
- package/dist/esm/v3/chat.test.js.map +1 -0
- package/dist/esm/v3/createStartSessionAction.test.d.ts +1 -0
- package/dist/esm/v3/createStartSessionAction.test.js +111 -0
- package/dist/esm/v3/createStartSessionAction.test.js.map +1 -0
- package/dist/esm/v3/deployments.d.ts +26 -0
- package/dist/esm/v3/deployments.js +34 -0
- package/dist/esm/v3/deployments.js.map +1 -0
- package/dist/esm/v3/index.d.ts +6 -3
- package/dist/esm/v3/index.js +4 -1
- package/dist/esm/v3/index.js.map +1 -1
- package/dist/esm/v3/runs.d.ts +15 -0
- package/dist/esm/v3/runs.js +1 -0
- package/dist/esm/v3/runs.js.map +1 -1
- package/dist/esm/v3/sessions.d.ts +228 -0
- package/dist/esm/v3/sessions.js +656 -0
- package/dist/esm/v3/sessions.js.map +1 -0
- package/dist/esm/v3/sessions.test.d.ts +1 -0
- package/dist/esm/v3/sessions.test.js +152 -0
- package/dist/esm/v3/sessions.test.js.map +1 -0
- package/dist/esm/v3/shared.d.ts +24 -2
- package/dist/esm/v3/shared.js +188 -1
- package/dist/esm/v3/shared.js.map +1 -1
- package/dist/esm/v3/skill.d.ts +99 -0
- package/dist/esm/v3/skill.js +128 -0
- package/dist/esm/v3/skill.js.map +1 -0
- package/dist/esm/v3/skills.d.ts +2 -0
- package/dist/esm/v3/skills.js +2 -0
- package/dist/esm/v3/skills.js.map +1 -0
- package/dist/esm/v3/streams.js +127 -20
- package/dist/esm/v3/streams.js.map +1 -1
- package/dist/esm/v3/tasks.d.ts +2 -1
- package/dist/esm/v3/tasks.js +2 -1
- package/dist/esm/v3/tasks.js.map +1 -1
- package/dist/esm/v3/test/index.d.ts +3 -0
- package/dist/esm/v3/test/index.js +13 -0
- package/dist/esm/v3/test/index.js.map +1 -0
- package/dist/esm/v3/test/mock-chat-agent.d.ts +259 -0
- package/dist/esm/v3/test/mock-chat-agent.js +465 -0
- package/dist/esm/v3/test/mock-chat-agent.js.map +1 -0
- package/dist/esm/v3/test/setup-catalog.d.ts +1 -0
- package/dist/esm/v3/test/setup-catalog.js +16 -0
- package/dist/esm/v3/test/setup-catalog.js.map +1 -0
- package/dist/esm/v3/test/test-session-handle.d.ts +53 -0
- package/dist/esm/v3/test/test-session-handle.js +251 -0
- package/dist/esm/v3/test/test-session-handle.js.map +1 -0
- package/dist/esm/version.js +1 -1
- package/package.json +87 -6
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side helpers for the `chat.agent` head-start flow — a
|
|
3
|
+
* customer's warm process (Next.js route handler, Express, etc.)
|
|
4
|
+
* gets the conversation moving while the heavy chat.agent run boots
|
|
5
|
+
* in parallel. Mid-turn, ownership of the durable stream hands over
|
|
6
|
+
* to the agent.
|
|
7
|
+
*
|
|
8
|
+
* The `chat.headStart({ agentId, run })` entry point returns a
|
|
9
|
+
* Next.js-style POST handler. Inside the customer's `run` callback
|
|
10
|
+
* they call `streamText` themselves, spreading
|
|
11
|
+
* `chat.toStreamTextOptions({ tools })` to inherit handover wiring.
|
|
12
|
+
* The handler runs `streamText` step 1 in the customer's process
|
|
13
|
+
* while the chat.agent run boots in parallel; on `tool-calls` the
|
|
14
|
+
* agent run picks up tool execution and continues, on pure-text the
|
|
15
|
+
* agent run exits clean without an LLM call.
|
|
16
|
+
*
|
|
17
|
+
* Two-layer naming: customer-facing surface is "head start"
|
|
18
|
+
* (describes the *benefit* — fast first-turn TTFC). The internal
|
|
19
|
+
* protocol still uses "handover" (describes the *mechanism* — the
|
|
20
|
+
* conversation hands off mid-turn from the warm process to the
|
|
21
|
+
* agent). Customers see `chat.headStart`, `HeadStartSession`, etc.
|
|
22
|
+
* The wire format and run-loop locals stay on `handover` /
|
|
23
|
+
* `handover-prepare` / `handover-skip`.
|
|
24
|
+
*
|
|
25
|
+
* Cooperative ordering only — handler stops writing to `session.out`
|
|
26
|
+
* before sending the `handover` chunk on `session.in`. No S2 fencing.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ HARD CONSTRAINT — bundle isolation
|
|
29
|
+
*
|
|
30
|
+
* This module is the customer-facing boundary for the route handler.
|
|
31
|
+
* The whole TTFC win comes from the customer's process being
|
|
32
|
+
* lightweight while the heavy agent run boots in parallel. **The
|
|
33
|
+
* route-handler bundle must not include heavy tool execute deps**:
|
|
34
|
+
* E2B, puppeteer/playwright, native bindings, the trigger SDK
|
|
35
|
+
* runtime, turndown, image processing libs, anything that pulls
|
|
36
|
+
* weight or pulls `node:` builtins.
|
|
37
|
+
*
|
|
38
|
+
* "Schema-only" tools must live in a module that imports only `ai`
|
|
39
|
+
* (for `tool()`) and `zod`. The agent task module imports those
|
|
40
|
+
* schemas and adds execute fns elsewhere — that's where the heavy
|
|
41
|
+
* deps live, and it's never reached by the route handler bundle.
|
|
42
|
+
*
|
|
43
|
+
* Runtime "strip executes" helpers (anything that takes a tool
|
|
44
|
+
* catalog with executes and removes them) DO NOT solve this. The
|
|
45
|
+
* import chain is resolved at bundle/build time, so importing the
|
|
46
|
+
* full catalog drags every dep in regardless of what the SDK does
|
|
47
|
+
* with the value at runtime.
|
|
48
|
+
*
|
|
49
|
+
* IMPORTANT (internal): this module must NOT import from `./ai.ts`.
|
|
50
|
+
* `ai.ts` statically imports `agentSkillsRuntime` (which uses `node:`
|
|
51
|
+
* builtins unfit for some serverless runtimes) and the heavy task
|
|
52
|
+
* runtime. Allowed imports: `./ai-shared.js`, `./chat-client.js`,
|
|
53
|
+
* `@trigger.dev/core/v3` (api client), `ai` (types + lightweight
|
|
54
|
+
* helpers like `stepCountIs` / `convertToModelMessages`).
|
|
55
|
+
*/
|
|
56
|
+
import { SessionStreamInstance, TRIGGER_CONTROL_SUBTYPE, apiClientManager, } from "@trigger.dev/core/v3";
|
|
57
|
+
import { convertToModelMessages, generateId as generateAssistantMessageId, stepCountIs, } from "ai";
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Public API
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
export const chat = {
|
|
62
|
+
/**
|
|
63
|
+
* Returns a Next.js-style POST handler for the chat.agent
|
|
64
|
+
* head-start flow. Customer mounts it as
|
|
65
|
+
* `export const { POST } = chat.headStart({...})` (or
|
|
66
|
+
* `export const POST = chat.headStart({...})`).
|
|
67
|
+
*
|
|
68
|
+
* Pair with the browser transport's `headStart: "/api/chat"`
|
|
69
|
+
* option so the first message of a brand-new chat lands here
|
|
70
|
+
* before the agent run boots.
|
|
71
|
+
*/
|
|
72
|
+
headStart(opts) {
|
|
73
|
+
return async (req) => {
|
|
74
|
+
const session = await openHandoverSession({
|
|
75
|
+
req,
|
|
76
|
+
agentId: opts.agentId,
|
|
77
|
+
idleTimeoutInSeconds: opts.idleTimeoutInSeconds,
|
|
78
|
+
});
|
|
79
|
+
const helper = {
|
|
80
|
+
toStreamTextOptions(spreadOpts) {
|
|
81
|
+
return session.buildStreamTextOptions(spreadOpts);
|
|
82
|
+
},
|
|
83
|
+
session: session.handle,
|
|
84
|
+
};
|
|
85
|
+
const result = await opts.run({
|
|
86
|
+
messages: session.uiMessages,
|
|
87
|
+
signal: session.combinedSignal,
|
|
88
|
+
chat: helper,
|
|
89
|
+
});
|
|
90
|
+
return session.handle.handoverResponse(result);
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
/**
|
|
94
|
+
* Lower-level primitive for power users who want to call
|
|
95
|
+
* `streamText` themselves outside the `run` callback shape — custom
|
|
96
|
+
* transforms, non-AI-SDK code paths, or manual control over the
|
|
97
|
+
* response. Same wiring `chat.headStart` builds on internally.
|
|
98
|
+
*/
|
|
99
|
+
openSession(opts) {
|
|
100
|
+
return openHandoverSession(opts).then((s) => s.handle);
|
|
101
|
+
},
|
|
102
|
+
/**
|
|
103
|
+
* Wrap a Web Fetch handler — `(req: Request) => Promise<Response>` —
|
|
104
|
+
* as a Node `http` listener — `(req: IncomingMessage, res: ServerResponse) => Promise<void>`.
|
|
105
|
+
*
|
|
106
|
+
* Use this to mount `chat.headStart` (or any other Web Fetch
|
|
107
|
+
* handler) inside Node-only frameworks like Express, Fastify, Koa,
|
|
108
|
+
* or raw `node:http`. Web-native frameworks (Next.js App Router,
|
|
109
|
+
* Hono, SvelteKit, Remix, Workers, Bun, Deno, etc.) don't need
|
|
110
|
+
* this — they pass `Request` objects directly.
|
|
111
|
+
*
|
|
112
|
+
* Streams the response body chunk-by-chunk to the Node response,
|
|
113
|
+
* so the `chat.headStart` SSE chunks reach the browser as they
|
|
114
|
+
* arrive (no buffering). Aborts the underlying handler if the
|
|
115
|
+
* client closes the connection.
|
|
116
|
+
*
|
|
117
|
+
* Type-only import of `node:http` types — no runtime dep on `node:http`,
|
|
118
|
+
* so this stays safe to bundle into edge / Workers builds (the
|
|
119
|
+
* function just won't be called there).
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* import express from "express";
|
|
124
|
+
* import { chat } from "@trigger.dev/sdk/chat-server";
|
|
125
|
+
*
|
|
126
|
+
* const handler = chat.headStart({
|
|
127
|
+
* agentId: "my-chat",
|
|
128
|
+
* run: async ({ chat: helper }) => streamText({ ... }),
|
|
129
|
+
* });
|
|
130
|
+
*
|
|
131
|
+
* const app = express();
|
|
132
|
+
* app.post("/api/chat", chat.toNodeListener(handler));
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
toNodeListener,
|
|
136
|
+
};
|
|
137
|
+
async function openHandoverSession(opts) {
|
|
138
|
+
const wirePayload = (await opts.req.json());
|
|
139
|
+
const chatId = wirePayload.chatId;
|
|
140
|
+
if (!chatId) {
|
|
141
|
+
throw new Error("[chat.handover] request body missing `chatId`");
|
|
142
|
+
}
|
|
143
|
+
// Slim wire — head-start ships full history via `headStartMessages` (not
|
|
144
|
+
// `message`/`messages`) because the route handler runs on the customer's
|
|
145
|
+
// own HTTP endpoint and isn't subject to the 512 KiB `/in/append` cap.
|
|
146
|
+
// The full UIMessage[] flows through `wirePayload` into the auto-trigger
|
|
147
|
+
// `basePayload` below, where the agent run boot consumes it on first turn.
|
|
148
|
+
const uiMessages = (wirePayload.headStartMessages ?? []);
|
|
149
|
+
// `convertToModelMessages` is async — resolve once up front so the
|
|
150
|
+
// synchronous `toStreamTextOptions` builder can hand back a fully
|
|
151
|
+
// formed object. AI SDK's `streamText` validates `messages` as a
|
|
152
|
+
// `ModelMessage[]` synchronously and rejects a Promise.
|
|
153
|
+
const modelMessages = await convertToModelMessages(uiMessages);
|
|
154
|
+
const apiClient = resolveApiClient();
|
|
155
|
+
const idleTimeoutInSeconds = opts.idleTimeoutInSeconds ?? 60;
|
|
156
|
+
// Create the session and trigger the chat.agent's `handover-prepare`
|
|
157
|
+
// run atomically. `createSession` is idempotent on `(env, externalId
|
|
158
|
+
// = chatId)` and the auto-triggered run uses `triggerConfig.
|
|
159
|
+
// basePayload` as the wire payload — so a single round-trip both
|
|
160
|
+
// ensures the session exists and starts the agent booting with the
|
|
161
|
+
// right trigger.
|
|
162
|
+
//
|
|
163
|
+
// Awaited intentionally: subsequent writes to `session.out` (the
|
|
164
|
+
// tee from the customer's `streamText` to S2) need the session to
|
|
165
|
+
// exist, and the handover signal at end-of-step-1 needs the agent
|
|
166
|
+
// run to be there to consume it. The added latency (~one round trip
|
|
167
|
+
// to the control plane) is bounded; the agent's compute boot still
|
|
168
|
+
// overlaps with LLM TTFB.
|
|
169
|
+
const created = await apiClient.createSession({
|
|
170
|
+
type: "chat.agent",
|
|
171
|
+
externalId: chatId,
|
|
172
|
+
taskIdentifier: opts.agentId,
|
|
173
|
+
triggerConfig: {
|
|
174
|
+
basePayload: {
|
|
175
|
+
...wirePayload,
|
|
176
|
+
chatId,
|
|
177
|
+
trigger: "handover-prepare",
|
|
178
|
+
idleTimeoutInSeconds,
|
|
179
|
+
},
|
|
180
|
+
idleTimeoutInSeconds,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
const sessionPublicAccessToken = created.publicAccessToken;
|
|
184
|
+
// Combined abort signal: request lifecycle OR an internal timeout
|
|
185
|
+
// mirroring the agent's idle wait so a hung handler doesn't sit
|
|
186
|
+
// forever.
|
|
187
|
+
const abortController = new AbortController();
|
|
188
|
+
const requestAbort = opts.req.signal;
|
|
189
|
+
if (requestAbort) {
|
|
190
|
+
if (requestAbort.aborted)
|
|
191
|
+
abortController.abort();
|
|
192
|
+
else
|
|
193
|
+
requestAbort.addEventListener("abort", () => abortController.abort(), { once: true });
|
|
194
|
+
}
|
|
195
|
+
const idleTimer = setTimeout(() => abortController.abort(new Error("chat.handover: idle timeout")), idleTimeoutInSeconds * 1000);
|
|
196
|
+
const buildStreamTextOptions = (spreadOpts) => {
|
|
197
|
+
// The customer spreads this object into their `streamText` call
|
|
198
|
+
// and then adds `model`, `system`, etc. on top. We set the four
|
|
199
|
+
// keys handover correctness depends on:
|
|
200
|
+
//
|
|
201
|
+
// - `messages`: the wire payload's UIMessages, converted
|
|
202
|
+
// (Promise resolved upfront so the spread is synchronous)
|
|
203
|
+
// - `tools`: customer's schema-only tool set
|
|
204
|
+
// - `stopWhen`: `stepCountIs(1)` — step 1 only. Agent run picks
|
|
205
|
+
// up tool execution and step 2+ after the handover signal.
|
|
206
|
+
// - `abortSignal`: combined request-lifecycle + idle timeout
|
|
207
|
+
//
|
|
208
|
+
// The customer's `StreamTextResult` exposes `finishReason` and
|
|
209
|
+
// `response.messages` directly, so we don't need to install an
|
|
210
|
+
// `onStepFinish` capture hook — we read those off the result in
|
|
211
|
+
// `handoverWhenDone`.
|
|
212
|
+
return {
|
|
213
|
+
messages: modelMessages,
|
|
214
|
+
tools: spreadOpts?.tools,
|
|
215
|
+
stopWhen: stepCountIs(1),
|
|
216
|
+
abortSignal: abortController.signal,
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
// Tee a UIMessage stream into session.out via S2 direct-write,
|
|
220
|
+
// batched. `SessionStreamInstance` calls `initializeSessionStream`
|
|
221
|
+
// once to fetch S2 credentials, then pipes via `StreamsWriterV2`'s
|
|
222
|
+
// `BatchTransform` — one S2 append per ~200ms of chunks instead of
|
|
223
|
+
// one HTTP round-trip per UIMessageChunk.
|
|
224
|
+
let sessionWriter = null;
|
|
225
|
+
const tee = (stream) => {
|
|
226
|
+
const [a, b] = stream.tee();
|
|
227
|
+
sessionWriter = new SessionStreamInstance({
|
|
228
|
+
apiClient,
|
|
229
|
+
baseUrl: apiClient.baseUrl,
|
|
230
|
+
sessionId: chatId, // Sessions are addressable by externalId (chatId).
|
|
231
|
+
io: "out",
|
|
232
|
+
source: b,
|
|
233
|
+
signal: abortController.signal,
|
|
234
|
+
});
|
|
235
|
+
return a;
|
|
236
|
+
};
|
|
237
|
+
/** Wait for the teed S2 writer to drain. Called before signaling handover. */
|
|
238
|
+
const flushSessionWriter = async () => {
|
|
239
|
+
if (!sessionWriter)
|
|
240
|
+
return;
|
|
241
|
+
try {
|
|
242
|
+
await sessionWriter.wait();
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// Drop write errors — the customer's response stream is the
|
|
246
|
+
// source of truth for what the user sees. Durability/resume
|
|
247
|
+
// best-effort.
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
const handover = async (args) => {
|
|
251
|
+
const chunk = {
|
|
252
|
+
kind: "handover",
|
|
253
|
+
partialAssistantMessage: args.partialAssistantMessage,
|
|
254
|
+
messageId: args.messageId,
|
|
255
|
+
isFinal: args.isFinal,
|
|
256
|
+
};
|
|
257
|
+
await apiClient.appendToSessionStream(chatId, "in", JSON.stringify(chunk));
|
|
258
|
+
};
|
|
259
|
+
/**
|
|
260
|
+
* Sent only on dispatch error (handler aborted before producing a
|
|
261
|
+
* `finishReason`). Normal pure-text and tool-call finishes go
|
|
262
|
+
* through `handover()` with the appropriate `isFinal` flag.
|
|
263
|
+
*/
|
|
264
|
+
const handoverSkip = async () => {
|
|
265
|
+
const chunk = { kind: "handover-skip" };
|
|
266
|
+
await apiClient.appendToSessionStream(chatId, "in", JSON.stringify(chunk));
|
|
267
|
+
};
|
|
268
|
+
// A stable assistant messageId for this turn. The customer's
|
|
269
|
+
// `toUIMessageStream` is configured to emit its `start` chunk with
|
|
270
|
+
// this id, the handover signal carries it to the agent, and the
|
|
271
|
+
// agent's post-handover `toUIMessageStream` reuses it — so all
|
|
272
|
+
// chunks (customer's step 1 + agent's step 2) merge into one
|
|
273
|
+
// assistant message on the browser side.
|
|
274
|
+
const turnMessageId = generateAssistantMessageId();
|
|
275
|
+
let resolveDecision;
|
|
276
|
+
const decisionPromise = new Promise((resolve) => {
|
|
277
|
+
resolveDecision = resolve;
|
|
278
|
+
});
|
|
279
|
+
const handoverWhenDone = async (result) => {
|
|
280
|
+
// Owns idle-timer cleanup via the finally below, so both the
|
|
281
|
+
// sugar (`handoverResponse`) and the escape-hatch
|
|
282
|
+
// (`chat.openSession()` → `handle.handoverWhenDone(...)`) clean up
|
|
283
|
+
// the timer the same way.
|
|
284
|
+
try {
|
|
285
|
+
// `result.finishReason` is a Promise<FinishReason> on the AI SDK
|
|
286
|
+
// result. Wait for the stream to settle, then dispatch.
|
|
287
|
+
const finishReason = await result.finishReason;
|
|
288
|
+
// Drain the S2 tee so any in-flight handler writes (last
|
|
289
|
+
// `tool-input-available` parts, the synthetic `finish-step` for
|
|
290
|
+
// pure-text) are visible before the agent reads from session.out
|
|
291
|
+
// / session.in. Cooperative ordering — agent doesn't read past
|
|
292
|
+
// these unless we've finished writing them.
|
|
293
|
+
await flushSessionWriter();
|
|
294
|
+
const responseMessages = (await result.response).messages;
|
|
295
|
+
if (finishReason === "tool-calls") {
|
|
296
|
+
// Reshape pending tool-calls into AI SDK's tool-approval round
|
|
297
|
+
// so the agent's `streamText` resumes by executing them
|
|
298
|
+
// before the step-2 LLM call.
|
|
299
|
+
const reshaped = reshapeForHandoverResume(responseMessages);
|
|
300
|
+
await handover({
|
|
301
|
+
partialAssistantMessage: reshaped,
|
|
302
|
+
messageId: turnMessageId,
|
|
303
|
+
isFinal: false,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
// Pure-text (or any non-tool-calls) finish — customer's step 1
|
|
308
|
+
// IS the final response. The agent runs the turn-loop hooks
|
|
309
|
+
// (`onChatStart`, `onTurnStart`, `onTurnComplete`, etc.) using
|
|
310
|
+
// this partial as the response, but skips the LLM call. That
|
|
311
|
+
// way persistence (`onTurnComplete` writing to DB), self-
|
|
312
|
+
// review, and any post-turn work all fire normally.
|
|
313
|
+
await handover({
|
|
314
|
+
partialAssistantMessage: responseMessages,
|
|
315
|
+
messageId: turnMessageId,
|
|
316
|
+
isFinal: true,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
resolveDecision({ kind: "handover" });
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
// Dispatch failed before we could send the handover signal.
|
|
323
|
+
// Tell the agent to exit clean (no hooks fire) and close the
|
|
324
|
+
// response stream so it doesn't hang waiting for agent chunks.
|
|
325
|
+
resolveDecision({ kind: "handover-skip" });
|
|
326
|
+
try {
|
|
327
|
+
await handoverSkip();
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// best-effort
|
|
331
|
+
}
|
|
332
|
+
throw err;
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
clearTimeout(idleTimer);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
/**
|
|
339
|
+
* Build a single ReadableStream that:
|
|
340
|
+
* 1. Forwards the customer's `streamText` chunks (step 1) directly
|
|
341
|
+
* to the response — same low-latency path as before.
|
|
342
|
+
* 2. After step 1 ends and the dispatch decision lands:
|
|
343
|
+
* - `handover-skip`: closes the response immediately. The agent
|
|
344
|
+
* run exits without writing more chunks.
|
|
345
|
+
* - `handover`: subscribes to `session.out` from the sequence
|
|
346
|
+
* ID where the customer's tee left off, forwarding the agent
|
|
347
|
+
* run's chunks (tool-output-available, step 2 LLM text,
|
|
348
|
+
* `finish-step`, etc.) until `trigger:turn-complete`.
|
|
349
|
+
*
|
|
350
|
+
* The browser sees one continuous SSE response per first turn, just
|
|
351
|
+
* like a normal `streamText` would produce.
|
|
352
|
+
*/
|
|
353
|
+
const stitchHandoverStream = (customerBranch) => {
|
|
354
|
+
return new ReadableStream({
|
|
355
|
+
async start(controller) {
|
|
356
|
+
try {
|
|
357
|
+
// Phase 1: forward customer's chunks.
|
|
358
|
+
const reader = customerBranch.getReader();
|
|
359
|
+
try {
|
|
360
|
+
while (true) {
|
|
361
|
+
const { done, value } = await reader.read();
|
|
362
|
+
if (done)
|
|
363
|
+
break;
|
|
364
|
+
controller.enqueue(value);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
reader.releaseLock();
|
|
369
|
+
}
|
|
370
|
+
// Phase 2a: wait for handoverWhenDone to decide.
|
|
371
|
+
const decision = await decisionPromise;
|
|
372
|
+
if (decision.kind === "handover-skip") {
|
|
373
|
+
controller.close();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
// Phase 2b: agent is taking over. Resume from session.out
|
|
377
|
+
// starting AFTER the customer tee's last write, so we don't
|
|
378
|
+
// re-emit chunks the browser already saw.
|
|
379
|
+
const writeResult = sessionWriter
|
|
380
|
+
? await sessionWriter.wait().catch(() => undefined)
|
|
381
|
+
: undefined;
|
|
382
|
+
const customerLastEventId = writeResult?.lastEventId;
|
|
383
|
+
// Capture the latest S2 event id seen on session.out via
|
|
384
|
+
// `onPart`. After the stream closes we emit it to the
|
|
385
|
+
// browser as a `trigger:session-state` control chunk so the
|
|
386
|
+
// transport can hydrate `state.lastEventId` for turn 2's
|
|
387
|
+
// subscribe — without it, turn 2 reads session.out from the
|
|
388
|
+
// start and replays turn 1 to the user.
|
|
389
|
+
//
|
|
390
|
+
// The agent's `turn-complete` control record is now header-
|
|
391
|
+
// form on S2 (see `client-protocol.mdx`), so the
|
|
392
|
+
// `for await (const chunk of agentStream)` loop below NEVER
|
|
393
|
+
// sees it as a data chunk — `subscribeToSessionStream` routes
|
|
394
|
+
// it to `onControl`. Use that to know when to stop and
|
|
395
|
+
// synthesise the data-chunk shape the browser bridge still
|
|
396
|
+
// expects (this HTTP response stream is NOT S2 and keeps the
|
|
397
|
+
// legacy chunk shape for the customer-server-to-browser hop).
|
|
398
|
+
let latestEventId;
|
|
399
|
+
let turnComplete = false;
|
|
400
|
+
// Dedicated abort signal for this agent subscription. Aborted
|
|
401
|
+
// from `onControl` the moment turn-complete fires so the
|
|
402
|
+
// `for await` loop below exits immediately instead of blocking
|
|
403
|
+
// until S2's long-poll closes (~60s). Combined with the outer
|
|
404
|
+
// `abortController.signal` via `AbortSignal.any` so a request-
|
|
405
|
+
// wide abort still tears the subscription down.
|
|
406
|
+
const subscriptionAbort = new AbortController();
|
|
407
|
+
const agentStream = await apiClient.subscribeToSessionStream(chatId, "out", {
|
|
408
|
+
...(customerLastEventId != null
|
|
409
|
+
? { lastEventId: customerLastEventId }
|
|
410
|
+
: {}),
|
|
411
|
+
signal: AbortSignal.any([abortController.signal, subscriptionAbort.signal]),
|
|
412
|
+
onPart: (part) => {
|
|
413
|
+
if (part.id)
|
|
414
|
+
latestEventId = part.id;
|
|
415
|
+
},
|
|
416
|
+
onControl: (event) => {
|
|
417
|
+
if (event.subtype === TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
|
|
418
|
+
turnComplete = true;
|
|
419
|
+
// Synthesise the data-chunk shape for the browser
|
|
420
|
+
// bridge. The customer-server-to-browser response is
|
|
421
|
+
// not S2; it keeps the legacy chunk shape so the
|
|
422
|
+
// browser's transport can recognise turn-complete the
|
|
423
|
+
// same way it always has.
|
|
424
|
+
controller.enqueue({
|
|
425
|
+
type: "trigger:turn-complete",
|
|
426
|
+
});
|
|
427
|
+
// Stop the SSE read now. Without this the `for await`
|
|
428
|
+
// can't see the control event (control records are
|
|
429
|
+
// never enqueued into the data stream) and would idle
|
|
430
|
+
// until S2's long-poll timeout closes the connection.
|
|
431
|
+
subscriptionAbort.abort();
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
try {
|
|
436
|
+
for await (const chunk of agentStream) {
|
|
437
|
+
// Data records only — control records are routed via
|
|
438
|
+
// `onControl` above and trigger the subscription abort.
|
|
439
|
+
controller.enqueue(chunk);
|
|
440
|
+
if (turnComplete)
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
// AbortError from `subscriptionAbort` is the expected exit
|
|
446
|
+
// path once turn-complete fires; surface anything else.
|
|
447
|
+
const isAbort = err instanceof Error && err.name === "AbortError";
|
|
448
|
+
if (!isAbort || !turnComplete)
|
|
449
|
+
throw err;
|
|
450
|
+
}
|
|
451
|
+
// Final control chunk: hand the browser transport the
|
|
452
|
+
// `lastEventId` it should use for the next turn's
|
|
453
|
+
// session.out subscribe. Filtered out before reaching the
|
|
454
|
+
// AI SDK on the browser side.
|
|
455
|
+
if (latestEventId != null) {
|
|
456
|
+
controller.enqueue({
|
|
457
|
+
type: "trigger:session-state",
|
|
458
|
+
lastEventId: latestEventId,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
controller.close();
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
controller.error(err);
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
cancel() {
|
|
468
|
+
// Browser closed the connection. Trigger the abort so any
|
|
469
|
+
// pending session.out subscription stops too.
|
|
470
|
+
abortController.abort();
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
};
|
|
474
|
+
const handoverResponse = (result) => {
|
|
475
|
+
// `generateMessageId` makes the customer's `start` chunk carry
|
|
476
|
+
// `turnMessageId`, so the browser-side AI SDK keys the assistant
|
|
477
|
+
// message by it. The agent's post-handover stream emits chunks
|
|
478
|
+
// with the same id (passed via the handover signal) — both sides
|
|
479
|
+
// merge into one message on the browser.
|
|
480
|
+
const teed = tee(result.toUIMessageStream({
|
|
481
|
+
generateMessageId: () => turnMessageId,
|
|
482
|
+
}));
|
|
483
|
+
// `handoverWhenDone` re-throws on dispatch failure for visibility,
|
|
484
|
+
// but the recovery (resolveDecision + handoverSkip) has already run
|
|
485
|
+
// by then and `stitchHandoverStream` closes the response cleanly via
|
|
486
|
+
// `decisionPromise`. The user-facing path is fine; we only suppress
|
|
487
|
+
// the unhandled-rejection so processes started with
|
|
488
|
+
// `--unhandled-rejections=throw` don't crash on what is effectively
|
|
489
|
+
// a logged failure with no further action to take.
|
|
490
|
+
// (Idle-timer cleanup lives inside `handoverWhenDone` itself.)
|
|
491
|
+
void handoverWhenDone(result).catch(() => { });
|
|
492
|
+
const stitched = stitchHandoverStream(teed);
|
|
493
|
+
// Encode UIMessageChunks as SSE for the AI SDK transport on the
|
|
494
|
+
// browser. AI SDK's `toUIMessageStreamResponse()` does this same
|
|
495
|
+
// thing internally; replicate the format here so we don't have
|
|
496
|
+
// to bridge through the SDK's response helper.
|
|
497
|
+
const encoder = new TextEncoder();
|
|
498
|
+
const sseStream = stitched.pipeThrough(new TransformStream({
|
|
499
|
+
transform(chunk, controller) {
|
|
500
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
|
501
|
+
},
|
|
502
|
+
}));
|
|
503
|
+
return new Response(sseStream, {
|
|
504
|
+
headers: {
|
|
505
|
+
"Content-Type": "text/event-stream",
|
|
506
|
+
"X-Vercel-AI-UI-Message-Stream": "v1",
|
|
507
|
+
"Cache-Control": "no-cache, no-transform",
|
|
508
|
+
Connection: "keep-alive",
|
|
509
|
+
// Browser transport reads these to hydrate session state
|
|
510
|
+
// for subsequent (non-handover) turns. Once the browser has
|
|
511
|
+
// the PAT it talks directly to `session.in` / `session.out`
|
|
512
|
+
// without going back through the handler.
|
|
513
|
+
"X-Trigger-Chat-Id": chatId,
|
|
514
|
+
"X-Trigger-Chat-Access-Token": sessionPublicAccessToken,
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
};
|
|
518
|
+
const handle = {
|
|
519
|
+
chatId,
|
|
520
|
+
tee,
|
|
521
|
+
handoverWhenDone,
|
|
522
|
+
handoverResponse,
|
|
523
|
+
handover,
|
|
524
|
+
handoverSkip,
|
|
525
|
+
};
|
|
526
|
+
return {
|
|
527
|
+
uiMessages,
|
|
528
|
+
combinedSignal: abortController.signal,
|
|
529
|
+
handle,
|
|
530
|
+
buildStreamTextOptions,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
function resolveApiClient() {
|
|
534
|
+
// Reuse the SDK's standard apiClientManager so customers configure
|
|
535
|
+
// base URL + secret key the same way as for `tasks.trigger(...)`.
|
|
536
|
+
const client = apiClientManager.clientOrThrow();
|
|
537
|
+
return client;
|
|
538
|
+
}
|
|
539
|
+
/** @internal — exposed via `chat.toNodeListener`. */
|
|
540
|
+
function toNodeListener(webHandler) {
|
|
541
|
+
return async function nodeListener(req, res) {
|
|
542
|
+
const abort = new AbortController();
|
|
543
|
+
res.on("close", () => abort.abort());
|
|
544
|
+
try {
|
|
545
|
+
const url = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
|
|
546
|
+
const method = req.method ?? "GET";
|
|
547
|
+
const hasBody = method !== "GET" && method !== "HEAD";
|
|
548
|
+
// Read full body upfront. Chat wire payloads are small (sub-KB
|
|
549
|
+
// typically) so accumulating avoids the duplex-stream ceremony
|
|
550
|
+
// some Node versions need for streaming request bodies into
|
|
551
|
+
// a Web Request.
|
|
552
|
+
let body;
|
|
553
|
+
if (hasBody) {
|
|
554
|
+
const chunks = [];
|
|
555
|
+
for await (const chunk of req) {
|
|
556
|
+
chunks.push(chunk);
|
|
557
|
+
}
|
|
558
|
+
if (chunks.length > 0) {
|
|
559
|
+
let total = 0;
|
|
560
|
+
for (const c of chunks)
|
|
561
|
+
total += c.length;
|
|
562
|
+
const merged = new Uint8Array(total);
|
|
563
|
+
let offset = 0;
|
|
564
|
+
for (const c of chunks) {
|
|
565
|
+
merged.set(c, offset);
|
|
566
|
+
offset += c.length;
|
|
567
|
+
}
|
|
568
|
+
body = merged.buffer.slice(merged.byteOffset, merged.byteOffset + merged.byteLength);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// Flatten Node header values: arrays → comma-joined (per RFC 7230 §3.2.2).
|
|
572
|
+
const webHeaders = new Headers();
|
|
573
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
574
|
+
if (value == null)
|
|
575
|
+
continue;
|
|
576
|
+
if (Array.isArray(value)) {
|
|
577
|
+
for (const v of value)
|
|
578
|
+
webHeaders.append(name, v);
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
webHeaders.set(name, value);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const webReq = new Request(url, {
|
|
585
|
+
method,
|
|
586
|
+
headers: webHeaders,
|
|
587
|
+
body,
|
|
588
|
+
signal: abort.signal,
|
|
589
|
+
});
|
|
590
|
+
const webRes = await webHandler(webReq);
|
|
591
|
+
res.statusCode = webRes.status;
|
|
592
|
+
// `Headers.forEach` exposes the value comma-joined for multi-valued
|
|
593
|
+
// headers, which `setHeader` accepts. Set-Cookie is handled separately
|
|
594
|
+
// via `getSetCookie()` to preserve multiple values.
|
|
595
|
+
webRes.headers.forEach((value, key) => {
|
|
596
|
+
if (key.toLowerCase() === "set-cookie")
|
|
597
|
+
return;
|
|
598
|
+
res.setHeader(key, value);
|
|
599
|
+
});
|
|
600
|
+
const setCookies = typeof webRes.headers.getSetCookie === "function"
|
|
601
|
+
? webRes.headers.getSetCookie()
|
|
602
|
+
: [];
|
|
603
|
+
if (setCookies.length > 0) {
|
|
604
|
+
res.setHeader("set-cookie", setCookies);
|
|
605
|
+
}
|
|
606
|
+
if (!webRes.body) {
|
|
607
|
+
res.end();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
// Pipe the Web Response body to the Node response. On client
|
|
611
|
+
// disconnect (`abort.signal`), cancel the reader so a pending
|
|
612
|
+
// `read()` rejects and we exit the loop instead of blocking on
|
|
613
|
+
// a stream that will never produce more chunks.
|
|
614
|
+
const reader = webRes.body.getReader();
|
|
615
|
+
const onAbort = () => {
|
|
616
|
+
reader.cancel(abort.signal.reason).catch(() => { });
|
|
617
|
+
};
|
|
618
|
+
if (abort.signal.aborted)
|
|
619
|
+
onAbort();
|
|
620
|
+
else
|
|
621
|
+
abort.signal.addEventListener("abort", onAbort, { once: true });
|
|
622
|
+
try {
|
|
623
|
+
while (true) {
|
|
624
|
+
const { done, value } = await reader.read();
|
|
625
|
+
if (done)
|
|
626
|
+
break;
|
|
627
|
+
res.write(value);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
// Reader was cancelled (client disconnect). Silently end.
|
|
632
|
+
}
|
|
633
|
+
finally {
|
|
634
|
+
abort.signal.removeEventListener("abort", onAbort);
|
|
635
|
+
}
|
|
636
|
+
res.end();
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
if (!res.headersSent) {
|
|
640
|
+
res.statusCode = 500;
|
|
641
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
642
|
+
res.end(err instanceof Error ? err.message : "Internal error");
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
res.end();
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Reshape a step-1 partial so the agent's `streamText` resumes by
|
|
652
|
+
* executing pending tool-calls before the next LLM call.
|
|
653
|
+
*
|
|
654
|
+
* When the customer's handler runs `streamText` with schema-only tools
|
|
655
|
+
* (no `execute` fns) and `stopWhen: stepCountIs(1)`, the LLM emits
|
|
656
|
+
* tool-calls but AI SDK can't execute them — the partial we ship is
|
|
657
|
+
* `[{ assistant: text + tool-call }]`. Splicing that as-is onto the
|
|
658
|
+
* agent's accumulator and calling `streamText` throws
|
|
659
|
+
* `MissingToolResultsError` synchronously inside
|
|
660
|
+
* `convertToLanguageModelPrompt`.
|
|
661
|
+
*
|
|
662
|
+
* AI SDK's documented escape hatch for "external party decides what
|
|
663
|
+
* to do with a tool-call, then SDK executes" is the tool-approval
|
|
664
|
+
* round. By appending a `tool-approval-request` part to the assistant
|
|
665
|
+
* message and a trailing `tool` message with a matching
|
|
666
|
+
* `tool-approval-response { approved: true }`, AI SDK:
|
|
667
|
+
* 1. Suppresses `MissingToolResultsError` for approved tool-calls
|
|
668
|
+
* (`convert-to-language-model-prompt.ts:135-144`).
|
|
669
|
+
* 2. Hits its initial-tool-execution branch
|
|
670
|
+
* (`stream-text.ts:1342-1486`) on the next `streamText` call,
|
|
671
|
+
* runs the agent-side `execute` fns, and synthesizes
|
|
672
|
+
* `tool-result` parts before the step-2 LLM call.
|
|
673
|
+
*
|
|
674
|
+
* If the customer's tools already had `execute` fns (rare for the
|
|
675
|
+
* handover use case but valid), the partial already contains a
|
|
676
|
+
* `tool-result` per tool-call — we leave those alone and only inject
|
|
677
|
+
* approvals for genuinely-pending calls.
|
|
678
|
+
*
|
|
679
|
+
* `collectToolApprovals` only scans the LAST message
|
|
680
|
+
* (`collect-tool-approvals.ts:30-37`), so the synthesized tool message
|
|
681
|
+
* must end up at the tail of the partial. The agent's run-loop
|
|
682
|
+
* splices the partial onto the end of the accumulator, which keeps
|
|
683
|
+
* this invariant.
|
|
684
|
+
*/
|
|
685
|
+
function reshapeForHandoverResume(responseMessages) {
|
|
686
|
+
// First pass: gather the set of tool-call IDs that already have a
|
|
687
|
+
// matching tool-result. Those are "complete" — leave them alone.
|
|
688
|
+
const completedToolCallIds = new Set();
|
|
689
|
+
for (const message of responseMessages) {
|
|
690
|
+
if (message.role !== "tool" || typeof message.content === "string")
|
|
691
|
+
continue;
|
|
692
|
+
for (const part of message.content) {
|
|
693
|
+
if (part.type === "tool-result" && part.toolCallId) {
|
|
694
|
+
completedToolCallIds.add(part.toolCallId);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
// Second pass: clone the messages, appending a tool-approval-request
|
|
699
|
+
// alongside each pending tool-call. Collect the matching responses.
|
|
700
|
+
const approvalResponses = [];
|
|
701
|
+
let approvalCounter = 0;
|
|
702
|
+
const reshaped = responseMessages.map((message) => {
|
|
703
|
+
if (message.role !== "assistant" || typeof message.content === "string") {
|
|
704
|
+
return message;
|
|
705
|
+
}
|
|
706
|
+
const newContent = [...message.content];
|
|
707
|
+
for (const part of message.content) {
|
|
708
|
+
if (part.type === "tool-call" &&
|
|
709
|
+
part.toolCallId &&
|
|
710
|
+
!completedToolCallIds.has(part.toolCallId)) {
|
|
711
|
+
const approvalId = `handover-approval-${++approvalCounter}`;
|
|
712
|
+
newContent.push({
|
|
713
|
+
type: "tool-approval-request",
|
|
714
|
+
approvalId,
|
|
715
|
+
toolCallId: part.toolCallId,
|
|
716
|
+
});
|
|
717
|
+
approvalResponses.push({
|
|
718
|
+
type: "tool-approval-response",
|
|
719
|
+
approvalId,
|
|
720
|
+
approved: true,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return { ...message, content: newContent };
|
|
725
|
+
});
|
|
726
|
+
if (approvalResponses.length > 0) {
|
|
727
|
+
reshaped.push({
|
|
728
|
+
role: "tool",
|
|
729
|
+
content: approvalResponses,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
return reshaped;
|
|
733
|
+
}
|
|
734
|
+
//# sourceMappingURL=chat-server.js.map
|