@copilotkit/channels-core 0.4.1-canary.perfall1 → 0.5.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/README.md +7 -8
- package/dist/canonical-run-loop.test.d.ts +2 -0
- package/dist/canonical-run-loop.test.d.ts.map +1 -0
- package/dist/canonical-run-loop.test.js +453 -0
- package/dist/codec.d.ts +2 -3
- package/dist/codec.d.ts.map +1 -1
- package/dist/create-channel.d.ts +110 -4
- package/dist/create-channel.d.ts.map +1 -1
- package/dist/create-channel.js +258 -92
- package/dist/create-channel.test.js +552 -29
- package/dist/delivery-error.d.ts +17 -0
- package/dist/delivery-error.d.ts.map +1 -0
- package/dist/delivery-error.js +22 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/managed-v1-await-choice-guard.test.d.ts +2 -0
- package/dist/managed-v1-await-choice-guard.test.d.ts.map +1 -0
- package/dist/managed-v1-await-choice-guard.test.js +52 -0
- package/dist/platform-adapter.d.ts +74 -11
- package/dist/platform-adapter.d.ts.map +1 -1
- package/dist/run-loop.d.ts +25 -6
- package/dist/run-loop.d.ts.map +1 -1
- package/dist/run-loop.js +289 -39
- package/dist/run-loop.test.js +37 -0
- package/dist/sanitize-agent-events.d.ts +24 -0
- package/dist/sanitize-agent-events.d.ts.map +1 -0
- package/dist/sanitize-agent-events.js +88 -0
- package/dist/sanitize-agent-events.test.d.ts +2 -0
- package/dist/sanitize-agent-events.test.d.ts.map +1 -0
- package/dist/sanitize-agent-events.test.js +194 -0
- package/dist/source-platform.test.d.ts +2 -0
- package/dist/source-platform.test.d.ts.map +1 -0
- package/dist/source-platform.test.js +149 -0
- package/dist/testing/fake-adapter.d.ts +8 -1
- package/dist/testing/fake-adapter.d.ts.map +1 -1
- package/dist/testing/fake-adapter.js +30 -1
- package/dist/testing/fake-agent.d.ts +5 -0
- package/dist/testing/fake-agent.d.ts.map +1 -1
- package/dist/testing/fake-agent.js +12 -0
- package/dist/thread-promise-contract.test.d.ts +2 -0
- package/dist/thread-promise-contract.test.d.ts.map +1 -0
- package/dist/thread-promise-contract.test.js +37 -0
- package/dist/thread.d.ts +5 -0
- package/dist/thread.d.ts.map +1 -1
- package/dist/thread.js +338 -243
- package/package.json +4 -4
package/dist/run-loop.js
CHANGED
|
@@ -1,4 +1,179 @@
|
|
|
1
|
+
import { EventType } from "@ag-ui/client";
|
|
1
2
|
import { parseToolArgs, stringifyHandlerResult } from "./tools.js";
|
|
3
|
+
import { isChannelDeliveryTerminatedError } from "./delivery-error.js";
|
|
4
|
+
/**
|
|
5
|
+
* Merge two subscriber callback results while preserving either subscriber's
|
|
6
|
+
* request to stop propagation.
|
|
7
|
+
*/
|
|
8
|
+
function mergeSubscriberResults(firstResult, secondResult) {
|
|
9
|
+
if ((firstResult === undefined || firstResult === null) &&
|
|
10
|
+
(secondResult === undefined || secondResult === null)) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
...(typeof firstResult === "object" ? firstResult : {}),
|
|
15
|
+
...(typeof secondResult === "object" ? secondResult : {}),
|
|
16
|
+
stopPropagation: (typeof firstResult === "object" &&
|
|
17
|
+
firstResult !== null &&
|
|
18
|
+
"stopPropagation" in firstResult &&
|
|
19
|
+
firstResult.stopPropagation === true) ||
|
|
20
|
+
(typeof secondResult === "object" &&
|
|
21
|
+
secondResult !== null &&
|
|
22
|
+
"stopPropagation" in secondResult &&
|
|
23
|
+
secondResult.stopPropagation === true),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Invoke canonical ingestion before rendering, but always invoke both. A
|
|
28
|
+
* renderer failure therefore cannot prevent the same event from reaching the
|
|
29
|
+
* runner.
|
|
30
|
+
*/
|
|
31
|
+
async function invokeSubscriberPair(rendererCallback, ingestionCallback, params, onRendererError, isRendererClosed) {
|
|
32
|
+
let rendererResult;
|
|
33
|
+
let ingestionResult;
|
|
34
|
+
let rendererError;
|
|
35
|
+
let ingestionError;
|
|
36
|
+
let rendererFailed = false;
|
|
37
|
+
let ingestionFailed = false;
|
|
38
|
+
try {
|
|
39
|
+
ingestionResult = await ingestionCallback?.(params);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
ingestionFailed = true;
|
|
43
|
+
ingestionError = error;
|
|
44
|
+
}
|
|
45
|
+
if (!isRendererClosed?.()) {
|
|
46
|
+
try {
|
|
47
|
+
rendererResult = await rendererCallback?.(params);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
rendererFailed = true;
|
|
51
|
+
rendererError = error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (ingestionFailed)
|
|
55
|
+
throw ingestionError;
|
|
56
|
+
if (rendererFailed) {
|
|
57
|
+
if (!onRendererError)
|
|
58
|
+
throw rendererError;
|
|
59
|
+
onRendererError(rendererError);
|
|
60
|
+
}
|
|
61
|
+
return mergeSubscriberResults(rendererResult, ingestionResult);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Return one stamped event object for every callback generated from the same
|
|
65
|
+
* raw AG-UI event.
|
|
66
|
+
*/
|
|
67
|
+
function canonicalizeSubscriberParams(params, options, stampedEvents) {
|
|
68
|
+
if (typeof params !== "object" ||
|
|
69
|
+
params === null ||
|
|
70
|
+
!("event" in params) ||
|
|
71
|
+
typeof params.event !== "object" ||
|
|
72
|
+
params.event === null ||
|
|
73
|
+
!("type" in params.event)) {
|
|
74
|
+
return params;
|
|
75
|
+
}
|
|
76
|
+
const event = params.event;
|
|
77
|
+
if (options.canonicalRun &&
|
|
78
|
+
(event.type === EventType.RUN_STARTED ||
|
|
79
|
+
event.type === EventType.RUN_FINISHED ||
|
|
80
|
+
event.type === EventType.RUN_ERROR)) {
|
|
81
|
+
if (event.type === EventType.RUN_ERROR) {
|
|
82
|
+
options.onInnerRunError?.(event);
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
if (!options.canonicalRun)
|
|
87
|
+
return params;
|
|
88
|
+
let stamped = stampedEvents.get(event);
|
|
89
|
+
if (!stamped) {
|
|
90
|
+
stamped = {
|
|
91
|
+
...event,
|
|
92
|
+
threadId: options.canonicalRun.threadId,
|
|
93
|
+
runId: options.canonicalRun.runId,
|
|
94
|
+
};
|
|
95
|
+
stampedEvents.set(event, stamped);
|
|
96
|
+
}
|
|
97
|
+
return { ...params, event: stamped };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Fan one agent event stream out to both the provider renderer and a canonical
|
|
101
|
+
* runner subscriber. The proxy covers every current and future subscriber
|
|
102
|
+
* callback without keeping a second callback-name registry in Channels.
|
|
103
|
+
*/
|
|
104
|
+
export function mergeAgentSubscribers(first, second, options = {}) {
|
|
105
|
+
const stampedEvents = new WeakMap();
|
|
106
|
+
return new Proxy({}, {
|
|
107
|
+
get(_target, property) {
|
|
108
|
+
const firstCallback = first[property];
|
|
109
|
+
const secondCallback = second[property];
|
|
110
|
+
if (typeof firstCallback !== "function" &&
|
|
111
|
+
typeof secondCallback !== "function") {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
return async (params) => {
|
|
115
|
+
const canonicalParams = canonicalizeSubscriberParams(params, options, stampedEvents);
|
|
116
|
+
if (canonicalParams === undefined)
|
|
117
|
+
return undefined;
|
|
118
|
+
return invokeSubscriberPair(typeof firstCallback === "function"
|
|
119
|
+
? firstCallback
|
|
120
|
+
: undefined, typeof secondCallback === "function"
|
|
121
|
+
? secondCallback
|
|
122
|
+
: undefined, canonicalParams, options.onRendererError, options.isRendererClosed);
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** Convert an inner AG-UI error event into the error rejected by the outer run. */
|
|
128
|
+
function errorFromInnerRun(event) {
|
|
129
|
+
const error = new Error(event.message);
|
|
130
|
+
error.name = "ChannelInnerRunError";
|
|
131
|
+
if (event.code)
|
|
132
|
+
error.code = event.code;
|
|
133
|
+
return error;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Emit one canonical lifecycle event through the same ingestion-first fanout
|
|
137
|
+
* used by streamed events.
|
|
138
|
+
*/
|
|
139
|
+
async function emitCanonicalLifecycleEvent(event, result, args, onRendererError, isRendererClosed) {
|
|
140
|
+
const canonicalRun = args.canonicalRun;
|
|
141
|
+
if (!canonicalRun)
|
|
142
|
+
return;
|
|
143
|
+
const input = {
|
|
144
|
+
threadId: canonicalRun.threadId,
|
|
145
|
+
runId: canonicalRun.runId,
|
|
146
|
+
messages: args.agent.messages,
|
|
147
|
+
state: args.agent.state,
|
|
148
|
+
tools: [...args.toolDescriptors],
|
|
149
|
+
context: [...args.context],
|
|
150
|
+
forwardedProps: {},
|
|
151
|
+
};
|
|
152
|
+
const baseParams = {
|
|
153
|
+
messages: args.agent.messages,
|
|
154
|
+
state: args.agent.state,
|
|
155
|
+
agent: args.agent,
|
|
156
|
+
input,
|
|
157
|
+
};
|
|
158
|
+
const ingestion = args.subscriber ?? {};
|
|
159
|
+
const renderer = args.renderer.subscriber;
|
|
160
|
+
const eventParams = { ...baseParams, event };
|
|
161
|
+
await invokeSubscriberPair(renderer.onEvent, ingestion.onEvent, eventParams, onRendererError, isRendererClosed);
|
|
162
|
+
if (event.type === EventType.RUN_STARTED) {
|
|
163
|
+
await invokeSubscriberPair(renderer.onRunStartedEvent, ingestion.onRunStartedEvent, eventParams, onRendererError, isRendererClosed);
|
|
164
|
+
}
|
|
165
|
+
else if (event.type === EventType.RUN_FINISHED) {
|
|
166
|
+
await invokeSubscriberPair(renderer.onRunFinishedEvent, ingestion.onRunFinishedEvent, {
|
|
167
|
+
...baseParams,
|
|
168
|
+
event,
|
|
169
|
+
outcome: "success",
|
|
170
|
+
result,
|
|
171
|
+
}, onRendererError, isRendererClosed);
|
|
172
|
+
}
|
|
173
|
+
else if (event.type === EventType.RUN_ERROR) {
|
|
174
|
+
await invokeSubscriberPair(renderer.onRunErrorEvent, ingestion.onRunErrorEvent, eventParams, onRendererError, isRendererClosed);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
2
177
|
/**
|
|
3
178
|
* Drive the agent, executing frontend-tool calls and re-invoking until the
|
|
4
179
|
* agent stops calling them (or we hit the iteration cap). On a captured
|
|
@@ -8,55 +183,130 @@ import { parseToolArgs, stringifyHandlerResult } from "./tools.js";
|
|
|
8
183
|
*/
|
|
9
184
|
export async function runAgentLoop(args) {
|
|
10
185
|
const { agent, renderer, tools, toolDescriptors, context, makeToolCtx, handleInterrupt, isAborted, initialResume, } = args;
|
|
186
|
+
let innerRunError;
|
|
187
|
+
let hasDeliveryError = false;
|
|
188
|
+
let deliveryError;
|
|
189
|
+
const deferRendererError = (error) => {
|
|
190
|
+
if (hasDeliveryError)
|
|
191
|
+
return;
|
|
192
|
+
hasDeliveryError = true;
|
|
193
|
+
deliveryError = error;
|
|
194
|
+
};
|
|
195
|
+
const isRendererClosed = () => hasDeliveryError;
|
|
196
|
+
const subscriber = args.subscriber || args.canonicalRun
|
|
197
|
+
? mergeAgentSubscribers(renderer.subscriber, args.subscriber ?? {}, args.canonicalRun
|
|
198
|
+
? {
|
|
199
|
+
canonicalRun: args.canonicalRun,
|
|
200
|
+
onInnerRunError: (event) => {
|
|
201
|
+
innerRunError ??= errorFromInnerRun(event);
|
|
202
|
+
},
|
|
203
|
+
onRendererError: deferRendererError,
|
|
204
|
+
isRendererClosed,
|
|
205
|
+
}
|
|
206
|
+
: {})
|
|
207
|
+
: renderer.subscriber;
|
|
11
208
|
const maxIterations = args.maxIterations ?? 6;
|
|
12
209
|
const executed = new Set();
|
|
13
210
|
let resume = initialResume;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
else {
|
|
20
|
-
await agent.runAgent({ tools: toolDescriptors, context: context }, renderer.subscriber);
|
|
21
|
-
}
|
|
22
|
-
if (isAborted?.())
|
|
23
|
-
return { iterations: i + 1, interrupted: false };
|
|
24
|
-
const pending = renderer.getPendingInterrupt();
|
|
25
|
-
if (pending) {
|
|
26
|
-
renderer.clearPendingInterrupt();
|
|
27
|
-
if (handleInterrupt)
|
|
28
|
-
await handleInterrupt(pending);
|
|
29
|
-
// ack-first: picker posted; thread.resume re-enters later
|
|
30
|
-
return { iterations: i + 1, interrupted: true };
|
|
31
|
-
}
|
|
32
|
-
const calls = renderer
|
|
33
|
-
.getCapturedToolCalls()
|
|
34
|
-
.filter((c) => tools.has(c.toolCallName) && !executed.has(c.toolCallId));
|
|
35
|
-
if (calls.length === 0)
|
|
36
|
-
return { iterations: i + 1, interrupted: false };
|
|
37
|
-
ensureAssistantToolCallMessage(agent, calls);
|
|
38
|
-
for (const call of calls) {
|
|
39
|
-
const tool = tools.get(call.toolCallName);
|
|
40
|
-
let result;
|
|
41
|
-
const parsed = await parseToolArgs(tool.parameters, call.toolCallArgs);
|
|
42
|
-
if (!parsed.ok) {
|
|
43
|
-
result = JSON.stringify({
|
|
44
|
-
error: `invalid arguments: ${parsed.error}`,
|
|
45
|
-
});
|
|
211
|
+
const executeIterations = async () => {
|
|
212
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
213
|
+
if (resume) {
|
|
214
|
+
await agent.runAgent({ forwardedProps: { command: resume } }, subscriber);
|
|
215
|
+
resume = undefined;
|
|
46
216
|
}
|
|
47
217
|
else {
|
|
48
|
-
|
|
49
|
-
|
|
218
|
+
await agent.runAgent({ tools: toolDescriptors, context: context }, subscriber);
|
|
219
|
+
}
|
|
220
|
+
if (innerRunError)
|
|
221
|
+
throw innerRunError;
|
|
222
|
+
if (isAborted?.())
|
|
223
|
+
return { iterations: i + 1, interrupted: false };
|
|
224
|
+
const pending = renderer.getPendingInterrupt();
|
|
225
|
+
if (pending) {
|
|
226
|
+
renderer.clearPendingInterrupt();
|
|
227
|
+
if (handleInterrupt)
|
|
228
|
+
await handleInterrupt(pending);
|
|
229
|
+
// ack-first: picker posted; thread.resume re-enters later
|
|
230
|
+
return { iterations: i + 1, interrupted: true };
|
|
231
|
+
}
|
|
232
|
+
const calls = renderer
|
|
233
|
+
.getCapturedToolCalls()
|
|
234
|
+
.filter((c) => tools.has(c.toolCallName) && !executed.has(c.toolCallId));
|
|
235
|
+
if (calls.length === 0)
|
|
236
|
+
return { iterations: i + 1, interrupted: false };
|
|
237
|
+
ensureAssistantToolCallMessage(agent, calls);
|
|
238
|
+
for (const call of calls) {
|
|
239
|
+
const tool = tools.get(call.toolCallName);
|
|
240
|
+
let result;
|
|
241
|
+
const parsed = await parseToolArgs(tool.parameters, call.toolCallArgs);
|
|
242
|
+
if (!parsed.ok) {
|
|
243
|
+
result = JSON.stringify({
|
|
244
|
+
error: `invalid arguments: ${parsed.error}`,
|
|
245
|
+
});
|
|
50
246
|
}
|
|
51
|
-
|
|
52
|
-
|
|
247
|
+
else {
|
|
248
|
+
await args.canonicalRun?.beforeToolCall?.();
|
|
249
|
+
try {
|
|
250
|
+
result = stringifyHandlerResult(await tool.handler(parsed.value, makeToolCtx(call)));
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
if (isChannelDeliveryTerminatedError(err)) {
|
|
254
|
+
// The provider already terminalled this delivery. Freeze renderer
|
|
255
|
+
// fanout before the canonical RUN_ERROR is ingested, then stop the
|
|
256
|
+
// loop instead of inviting the model to emit through a closed path.
|
|
257
|
+
deferRendererError(err);
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
result = JSON.stringify({ error: err.message });
|
|
261
|
+
}
|
|
53
262
|
}
|
|
263
|
+
pushToolResult(agent, call.toolCallId, result);
|
|
264
|
+
executed.add(call.toolCallId);
|
|
54
265
|
}
|
|
55
|
-
pushToolResult(agent, call.toolCallId, result);
|
|
56
|
-
executed.add(call.toolCallId);
|
|
57
266
|
}
|
|
267
|
+
return { iterations: maxIterations, interrupted: false };
|
|
268
|
+
};
|
|
269
|
+
if (!args.canonicalRun) {
|
|
270
|
+
return executeIterations();
|
|
271
|
+
}
|
|
272
|
+
const startEvent = {
|
|
273
|
+
type: EventType.RUN_STARTED,
|
|
274
|
+
threadId: args.canonicalRun.threadId,
|
|
275
|
+
runId: args.canonicalRun.runId,
|
|
276
|
+
};
|
|
277
|
+
try {
|
|
278
|
+
await emitCanonicalLifecycleEvent(startEvent, undefined, args, deferRendererError, isRendererClosed);
|
|
279
|
+
const result = await executeIterations();
|
|
280
|
+
const finishedEvent = {
|
|
281
|
+
type: EventType.RUN_FINISHED,
|
|
282
|
+
threadId: args.canonicalRun.threadId,
|
|
283
|
+
runId: args.canonicalRun.runId,
|
|
284
|
+
};
|
|
285
|
+
await emitCanonicalLifecycleEvent(finishedEvent, result, args, deferRendererError, isRendererClosed);
|
|
286
|
+
return hasDeliveryError ? { ...result, deliveryError } : result;
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
const runError = {
|
|
290
|
+
type: EventType.RUN_ERROR,
|
|
291
|
+
threadId: args.canonicalRun.threadId,
|
|
292
|
+
runId: args.canonicalRun.runId,
|
|
293
|
+
message: error instanceof Error ? error.message : String(error),
|
|
294
|
+
...(typeof error === "object" &&
|
|
295
|
+
error !== null &&
|
|
296
|
+
"code" in error &&
|
|
297
|
+
typeof error.code === "string"
|
|
298
|
+
? { code: error.code }
|
|
299
|
+
: {}),
|
|
300
|
+
};
|
|
301
|
+
try {
|
|
302
|
+
await emitCanonicalLifecycleEvent(runError, undefined, args, deferRendererError, isRendererClosed);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// Preserve the original run failure after canonical ingestion got its
|
|
306
|
+
// chance to record the terminal error.
|
|
307
|
+
}
|
|
308
|
+
throw error;
|
|
58
309
|
}
|
|
59
|
-
return { iterations: maxIterations, interrupted: false };
|
|
60
310
|
}
|
|
61
311
|
/**
|
|
62
312
|
* If the agent's latest message isn't already the assistant message that
|
package/dist/run-loop.test.js
CHANGED
|
@@ -48,6 +48,43 @@ describe("runAgentLoop", () => {
|
|
|
48
48
|
expect(toolResult).toBeDefined();
|
|
49
49
|
expect(toolResult.toolCallId).toBe("t1");
|
|
50
50
|
});
|
|
51
|
+
it("keeps ordinary tool failures recoverable by the agent", async () => {
|
|
52
|
+
const renderer = makeFakeRunRenderer();
|
|
53
|
+
const tool = {
|
|
54
|
+
name: "recoverable",
|
|
55
|
+
description: "Fail without closing the delivery.",
|
|
56
|
+
parameters: z.object({}),
|
|
57
|
+
handler: () => {
|
|
58
|
+
throw new Error("try something else");
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
const agent = new FakeAgent([
|
|
62
|
+
(sub) => {
|
|
63
|
+
sub.onToolCallEndEvent?.({
|
|
64
|
+
event: { toolCallId: "t1" },
|
|
65
|
+
toolCallName: "recoverable",
|
|
66
|
+
toolCallArgs: {},
|
|
67
|
+
});
|
|
68
|
+
sub.onRunFinishedEvent?.({ event: {} });
|
|
69
|
+
},
|
|
70
|
+
(sub) => {
|
|
71
|
+
sub.onRunFinishedEvent?.({ event: {} });
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
await runAgentLoop({
|
|
75
|
+
agent,
|
|
76
|
+
renderer,
|
|
77
|
+
tools: new Map([["recoverable", tool]]),
|
|
78
|
+
toolDescriptors,
|
|
79
|
+
context,
|
|
80
|
+
makeToolCtx: () => ({ thread: {}, platform: "fake" }),
|
|
81
|
+
});
|
|
82
|
+
expect(agent.runAgentCalls).toBe(2);
|
|
83
|
+
expect(agent.messages.find(({ role }) => role === "tool")).toMatchObject({
|
|
84
|
+
toolCallId: "t1",
|
|
85
|
+
content: JSON.stringify({ error: "try something else" }),
|
|
86
|
+
});
|
|
87
|
+
});
|
|
51
88
|
it("posts the picker via handleInterrupt and returns without running tools", async () => {
|
|
52
89
|
const renderer = makeFakeRunRenderer();
|
|
53
90
|
const tools = new Map();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AbstractAgent } from "@ag-ui/client";
|
|
2
|
+
/**
|
|
3
|
+
* Make an agent tolerate the AG-UI event streams real agents emit.
|
|
4
|
+
*
|
|
5
|
+
* `@ag-ui/langgraph` emits a `TOOL_CALL_START` whose `parentMessageId` is
|
|
6
|
+
* `null` — notably the tool call that triggers an interrupt. The AG-UI schema
|
|
7
|
+
* declares that field optional but never nullable, so `HttpAgent`'s transform
|
|
8
|
+
* re-validates the streamed event, Zod rejects it ("Expected string, received
|
|
9
|
+
* null"), and a single rejected event aborts the entire run. That breaks
|
|
10
|
+
* LangGraph interrupts / human-in-the-loop on every Channel platform.
|
|
11
|
+
*
|
|
12
|
+
* The fix is applied at the transport rather than by replacing the event
|
|
13
|
+
* transform, which keeps everything else the stock path does — protobuf
|
|
14
|
+
* content-type negotiation, `AbortError` → `RUN_ERROR`, and strict validation
|
|
15
|
+
* of every field except the coerced one. Agents that do not stream over HTTP
|
|
16
|
+
* are returned untouched: nothing re-validates their events, so there is
|
|
17
|
+
* nothing to coerce.
|
|
18
|
+
*
|
|
19
|
+
* Channels apply this by default; opt out with
|
|
20
|
+
* `createChannel({ sanitizeAgentEvents: false })`. Remove it once
|
|
21
|
+
* `@ag-ui/langgraph` stops emitting the null (CopilotKit OSS-691).
|
|
22
|
+
*/
|
|
23
|
+
export declare function sanitizeAgentEventStream<T extends AbstractAgent>(agent: T): T;
|
|
24
|
+
//# sourceMappingURL=sanitize-agent-events.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize-agent-events.d.ts","sourceRoot":"","sources":["../src/sanitize-agent-events.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AA8DnD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,wBAAwB,CAAC,CAAC,SAAS,aAAa,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,CA0B7E"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coerce `"<field>": null` to `""` on the wire. Targeted on purpose: we only
|
|
3
|
+
* touch fields where a `null` is known to come through from `@ag-ui/langgraph`
|
|
4
|
+
* and would otherwise trip strict client-side validation.
|
|
5
|
+
*
|
|
6
|
+
* Matching the quoted key means an escaped occurrence inside a JSON string
|
|
7
|
+
* (`\"parentMessageId\":null`, e.g. nested in a `rawEvent` payload) is left
|
|
8
|
+
* alone — the backslash breaks the match.
|
|
9
|
+
*/
|
|
10
|
+
const NULLABLE_ID_FIELDS = /("(?:parentMessageId)"\s*:\s*)null/g;
|
|
11
|
+
/** Marks a transport we have already wrapped, so re-entry is a no-op. */
|
|
12
|
+
const SANITIZED = Symbol.for("copilotkit.channels.sanitizedTransport");
|
|
13
|
+
/**
|
|
14
|
+
* Rewrite SSE frames as they stream, coercing the known nullable-string fields.
|
|
15
|
+
*
|
|
16
|
+
* Buffering on the `\n\n` frame delimiter is what makes this safe: a `null`
|
|
17
|
+
* split across two transport chunks is still matched, and every other byte —
|
|
18
|
+
* comments, keep-alives, unrelated fields — is re-emitted verbatim.
|
|
19
|
+
*/
|
|
20
|
+
function coerceNullableIds() {
|
|
21
|
+
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
22
|
+
const encoder = new TextEncoder();
|
|
23
|
+
let buffered = "";
|
|
24
|
+
const coerce = (frame) => encoder.encode(frame.replace(NULLABLE_ID_FIELDS, '$1""'));
|
|
25
|
+
return new TransformStream({
|
|
26
|
+
transform(chunk, controller) {
|
|
27
|
+
buffered += decoder.decode(chunk, { stream: true });
|
|
28
|
+
const frames = buffered.split("\n\n");
|
|
29
|
+
buffered = frames.pop() ?? "";
|
|
30
|
+
for (const frame of frames)
|
|
31
|
+
controller.enqueue(coerce(`${frame}\n\n`));
|
|
32
|
+
},
|
|
33
|
+
flush(controller) {
|
|
34
|
+
buffered += decoder.decode();
|
|
35
|
+
if (buffered)
|
|
36
|
+
controller.enqueue(coerce(buffered));
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** Does this agent stream its events over HTTP, and therefore re-validate them? */
|
|
41
|
+
function hasHttpTransport(agent) {
|
|
42
|
+
const candidate = agent;
|
|
43
|
+
return (typeof candidate.fetch === "function" && typeof candidate.url === "string");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Make an agent tolerate the AG-UI event streams real agents emit.
|
|
47
|
+
*
|
|
48
|
+
* `@ag-ui/langgraph` emits a `TOOL_CALL_START` whose `parentMessageId` is
|
|
49
|
+
* `null` — notably the tool call that triggers an interrupt. The AG-UI schema
|
|
50
|
+
* declares that field optional but never nullable, so `HttpAgent`'s transform
|
|
51
|
+
* re-validates the streamed event, Zod rejects it ("Expected string, received
|
|
52
|
+
* null"), and a single rejected event aborts the entire run. That breaks
|
|
53
|
+
* LangGraph interrupts / human-in-the-loop on every Channel platform.
|
|
54
|
+
*
|
|
55
|
+
* The fix is applied at the transport rather than by replacing the event
|
|
56
|
+
* transform, which keeps everything else the stock path does — protobuf
|
|
57
|
+
* content-type negotiation, `AbortError` → `RUN_ERROR`, and strict validation
|
|
58
|
+
* of every field except the coerced one. Agents that do not stream over HTTP
|
|
59
|
+
* are returned untouched: nothing re-validates their events, so there is
|
|
60
|
+
* nothing to coerce.
|
|
61
|
+
*
|
|
62
|
+
* Channels apply this by default; opt out with
|
|
63
|
+
* `createChannel({ sanitizeAgentEvents: false })`. Remove it once
|
|
64
|
+
* `@ag-ui/langgraph` stops emitting the null (CopilotKit OSS-691).
|
|
65
|
+
*/
|
|
66
|
+
export function sanitizeAgentEventStream(agent) {
|
|
67
|
+
if (!hasHttpTransport(agent))
|
|
68
|
+
return agent;
|
|
69
|
+
const transport = agent.fetch;
|
|
70
|
+
if (transport[SANITIZED])
|
|
71
|
+
return agent;
|
|
72
|
+
const sanitized = async (input, init) => {
|
|
73
|
+
const response = await transport(input, init);
|
|
74
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
75
|
+
if (!response.ok ||
|
|
76
|
+
!response.body ||
|
|
77
|
+
!contentType.includes("text/event-stream"))
|
|
78
|
+
return response;
|
|
79
|
+
return new Response(response.body.pipeThrough(coerceNullableIds()), {
|
|
80
|
+
status: response.status,
|
|
81
|
+
statusText: response.statusText,
|
|
82
|
+
headers: response.headers,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
sanitized[SANITIZED] = true;
|
|
86
|
+
agent.fetch = sanitized;
|
|
87
|
+
return agent;
|
|
88
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize-agent-events.test.d.ts","sourceRoot":"","sources":["../src/sanitize-agent-events.test.ts"],"names":[],"mappings":""}
|