@vellumai/assistant 0.11.2-staging.1 → 0.11.2-staging.3
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/openapi.yaml +5 -2
- package/package.json +1 -1
- package/src/__tests__/compactor-truncation-pairing.test.ts +368 -0
- package/src/__tests__/history-repair.test.ts +39 -0
- package/src/agent/history-repair/history-repair.ts +8 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +1 -1
- package/src/config/bundled-skills/media-processing/TOOLS.json +1 -1
- package/src/config/bundled-skills/media-processing/__tests__/media-analysis-default-model.test.ts +177 -0
- package/src/config/bundled-skills/media-processing/services/gemini-map.ts +4 -3
- package/src/config/bundled-skills/media-processing/services/gemini-video.ts +2 -1
- package/src/config/bundled-skills/media-processing/services/media-analysis-model.ts +12 -0
- package/src/context/compactor.ts +41 -4
- package/src/live-voice/__tests__/activity-label.test.ts +27 -0
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +125 -0
- package/src/live-voice/activity-label.ts +28 -0
- package/src/live-voice/live-voice-session.ts +128 -17
- package/src/live-voice/protocol.ts +12 -0
- package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +478 -0
- package/src/providers/openai/chat-completions-provider.ts +46 -30
- package/src/providers/openai/orphaned-tool-result.ts +79 -0
- package/src/providers/openai/responses-provider.ts +50 -24
- package/src/runtime/routes/secret-routes.ts +8 -2
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialization guard for orphaned tool results on the OpenAI providers.
|
|
3
|
+
*
|
|
4
|
+
* Both transports validate pairing server-side: the Responses API rejects a
|
|
5
|
+
* `function_call_output` whose `call_id` was not emitted as a `function_call`
|
|
6
|
+
* earlier in the request, and the Chat Completions API rejects a tool-role
|
|
7
|
+
* message whose `tool_call_id` is not in a preceding assistant message's
|
|
8
|
+
* `tool_calls`. A `tool_result` with no backward match in the request (e.g.
|
|
9
|
+
* its `tool_use` truncated away) is therefore degraded into plain user text
|
|
10
|
+
* with an `[orphaned tool result]` prefix, preserving the information while
|
|
11
|
+
* keeping the request valid. Paired results serialize unchanged.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from "bun:test";
|
|
14
|
+
|
|
15
|
+
import type { Message } from "../../types.js";
|
|
16
|
+
import { OpenAIChatCompletionsProvider } from "../chat-completions-provider.js";
|
|
17
|
+
import { OpenAIResponsesProvider } from "../responses-provider.js";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Responses transport harness
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
type ResponsesStreamEvent = { type: string; [key: string]: unknown };
|
|
24
|
+
|
|
25
|
+
const RESPONSES_OK_EVENTS: ResponsesStreamEvent[] = [
|
|
26
|
+
{ type: "response.output_text.delta", delta: "ok" },
|
|
27
|
+
{
|
|
28
|
+
type: "response.completed",
|
|
29
|
+
response: {
|
|
30
|
+
model: "gpt-5.2",
|
|
31
|
+
status: "completed",
|
|
32
|
+
output: [],
|
|
33
|
+
usage: { input_tokens: 10, output_tokens: 2 },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function makeEventStream(
|
|
39
|
+
events: ResponsesStreamEvent[],
|
|
40
|
+
): AsyncIterable<ResponsesStreamEvent> {
|
|
41
|
+
return {
|
|
42
|
+
async *[Symbol.asyncIterator]() {
|
|
43
|
+
for (const event of events) {
|
|
44
|
+
yield event;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Swap `responses.create` for a canned stream that records request params. */
|
|
51
|
+
function stubResponsesCreate(provider: OpenAIResponsesProvider): {
|
|
52
|
+
input: () => unknown[];
|
|
53
|
+
} {
|
|
54
|
+
let captured: unknown;
|
|
55
|
+
const inner = provider as unknown as {
|
|
56
|
+
client: {
|
|
57
|
+
responses: {
|
|
58
|
+
create: (
|
|
59
|
+
params: unknown,
|
|
60
|
+
) => Promise<AsyncIterable<ResponsesStreamEvent>>;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
inner.client.responses.create = async (params) => {
|
|
65
|
+
captured = params;
|
|
66
|
+
return makeEventStream(RESPONSES_OK_EVENTS);
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
input: () => (captured as { input: unknown[] }).input,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Chat Completions transport harness
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
type ChatChunk = {
|
|
78
|
+
choices: Array<{
|
|
79
|
+
delta: { content?: string | null };
|
|
80
|
+
finish_reason?: string | null;
|
|
81
|
+
}>;
|
|
82
|
+
usage?: { prompt_tokens: number; completion_tokens: number };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const CHAT_OK_CHUNKS: ChatChunk[] = [
|
|
86
|
+
{
|
|
87
|
+
choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
|
|
88
|
+
usage: { prompt_tokens: 10, completion_tokens: 2 },
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
function makeChunkStream(chunks: ChatChunk[]): AsyncIterable<ChatChunk> {
|
|
93
|
+
return {
|
|
94
|
+
async *[Symbol.asyncIterator]() {
|
|
95
|
+
for (const chunk of chunks) {
|
|
96
|
+
yield chunk;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type ChatMessageParam = {
|
|
103
|
+
role: string;
|
|
104
|
+
content: string | Array<Record<string, unknown>>;
|
|
105
|
+
tool_call_id?: string;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Swap chat `create` for a canned stream that records request params. */
|
|
109
|
+
function stubChatCreate(provider: OpenAIChatCompletionsProvider): {
|
|
110
|
+
messages: () => ChatMessageParam[];
|
|
111
|
+
} {
|
|
112
|
+
let captured: unknown;
|
|
113
|
+
const inner = provider as unknown as {
|
|
114
|
+
client: {
|
|
115
|
+
chat: {
|
|
116
|
+
completions: {
|
|
117
|
+
create: (params: unknown) => Promise<AsyncIterable<ChatChunk>>;
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
inner.client.chat.completions.create = async (params) => {
|
|
123
|
+
captured = params;
|
|
124
|
+
return makeChunkStream(CHAT_OK_CHUNKS);
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
messages: () => (captured as { messages: ChatMessageParam[] }).messages,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Fixtures
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Persisted tool id shapes: `call_` is the Responses-native pass-through
|
|
137
|
+
* (conversations that always ran on this transport), `toolu_` is an
|
|
138
|
+
* Anthropic-shaped history routed to an OpenAI call site (cross-provider
|
|
139
|
+
* routing, e.g. a compaction call over imported history). The guard must be
|
|
140
|
+
* id-format-agnostic, so every case runs across both.
|
|
141
|
+
*/
|
|
142
|
+
const ID_SHAPES = [
|
|
143
|
+
{ label: "call_ ids", pairedId: "call_paired", orphanId: "call_orphan" },
|
|
144
|
+
{ label: "toolu_ ids", pairedId: "toolu_paired", orphanId: "toolu_orphan" },
|
|
145
|
+
] as const;
|
|
146
|
+
|
|
147
|
+
/** History whose tool_result is paired with a preceding tool_use. */
|
|
148
|
+
function pairedHistory(id: string): Message[] {
|
|
149
|
+
return [
|
|
150
|
+
{ role: "user", content: [{ type: "text", text: "Read the file" }] },
|
|
151
|
+
{
|
|
152
|
+
role: "assistant",
|
|
153
|
+
content: [
|
|
154
|
+
{
|
|
155
|
+
type: "tool_use",
|
|
156
|
+
id,
|
|
157
|
+
name: "file_read",
|
|
158
|
+
input: { path: "/tmp/a" },
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
role: "user",
|
|
164
|
+
content: [
|
|
165
|
+
{
|
|
166
|
+
type: "tool_result",
|
|
167
|
+
tool_use_id: id,
|
|
168
|
+
content: "file contents",
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* History whose leading tool_result has no matching tool_use anywhere in
|
|
177
|
+
* the request (the shape a pairing-blind front truncation produces).
|
|
178
|
+
*/
|
|
179
|
+
function orphanHistory(id: string): Message[] {
|
|
180
|
+
return [
|
|
181
|
+
{
|
|
182
|
+
role: "user",
|
|
183
|
+
content: [
|
|
184
|
+
{
|
|
185
|
+
type: "tool_result",
|
|
186
|
+
tool_use_id: id,
|
|
187
|
+
content: "stranded output",
|
|
188
|
+
},
|
|
189
|
+
{ type: "text", text: "continue from here" },
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
{ role: "assistant", content: [{ type: "text", text: "continuing" }] },
|
|
193
|
+
{ role: "user", content: [{ type: "text", text: "thanks" }] },
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A tool_result that arrives BEFORE its tool_use in the request order. The
|
|
199
|
+
* API contract only accepts backward matches, so this is orphaned too.
|
|
200
|
+
*/
|
|
201
|
+
function forwardReferenceHistory(): Message[] {
|
|
202
|
+
return [
|
|
203
|
+
{
|
|
204
|
+
role: "user",
|
|
205
|
+
content: [
|
|
206
|
+
{
|
|
207
|
+
type: "tool_result",
|
|
208
|
+
tool_use_id: "call_later",
|
|
209
|
+
content: "premature output",
|
|
210
|
+
},
|
|
211
|
+
{ type: "text", text: "odd ordering" },
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
role: "assistant",
|
|
216
|
+
content: [
|
|
217
|
+
{
|
|
218
|
+
type: "tool_use",
|
|
219
|
+
id: "call_later",
|
|
220
|
+
name: "file_read",
|
|
221
|
+
input: { path: "/tmp/b" },
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
role: "user",
|
|
227
|
+
content: [
|
|
228
|
+
{
|
|
229
|
+
type: "tool_result",
|
|
230
|
+
tool_use_id: "call_later",
|
|
231
|
+
content: "real output",
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Responses transport
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
describe("OpenAIResponsesProvider orphan tool_result guard", () => {
|
|
243
|
+
for (const shape of ID_SHAPES) {
|
|
244
|
+
test(`emits function_call_output for a paired tool_result (${shape.label})`, async () => {
|
|
245
|
+
const provider = new OpenAIResponsesProvider("sk-test", "gpt-5.2");
|
|
246
|
+
const stub = stubResponsesCreate(provider);
|
|
247
|
+
|
|
248
|
+
await provider.sendMessage(pairedHistory(shape.pairedId));
|
|
249
|
+
|
|
250
|
+
const input = stub.input() as Array<Record<string, unknown>>;
|
|
251
|
+
expect(input).toHaveLength(3);
|
|
252
|
+
expect(input[1]).toEqual({
|
|
253
|
+
type: "function_call",
|
|
254
|
+
call_id: shape.pairedId,
|
|
255
|
+
name: "file_read",
|
|
256
|
+
arguments: '{"path":"/tmp/a"}',
|
|
257
|
+
});
|
|
258
|
+
expect(input[2]).toEqual({
|
|
259
|
+
type: "function_call_output",
|
|
260
|
+
call_id: shape.pairedId,
|
|
261
|
+
output: "file contents",
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test(`degrades an orphaned tool_result to user text instead of function_call_output (${shape.label})`, async () => {
|
|
266
|
+
const provider = new OpenAIResponsesProvider("sk-test", "gpt-5.2");
|
|
267
|
+
const stub = stubResponsesCreate(provider);
|
|
268
|
+
|
|
269
|
+
await provider.sendMessage(orphanHistory(shape.orphanId));
|
|
270
|
+
|
|
271
|
+
const input = stub.input() as Array<Record<string, unknown>>;
|
|
272
|
+
// No function_call_output items at all: the only tool_result was
|
|
273
|
+
// orphaned.
|
|
274
|
+
expect(input.some((item) => item.type === "function_call_output")).toBe(
|
|
275
|
+
false,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// The orphan's content is preserved as prefixed text inside the user
|
|
279
|
+
// message, alongside the message's own text.
|
|
280
|
+
const firstUser = input[0] as {
|
|
281
|
+
type: string;
|
|
282
|
+
role: string;
|
|
283
|
+
content: Array<{ type: string; text?: string }>;
|
|
284
|
+
};
|
|
285
|
+
expect(firstUser.type).toBe("message");
|
|
286
|
+
expect(firstUser.role).toBe("user");
|
|
287
|
+
const texts = firstUser.content.map((part) => part.text ?? "");
|
|
288
|
+
expect(texts).toContain("continue from here");
|
|
289
|
+
expect(texts).toContain("[orphaned tool result] stranded output");
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
test("treats a forward-referencing tool_result as orphaned, keeps the backward match", async () => {
|
|
294
|
+
const provider = new OpenAIResponsesProvider("sk-test", "gpt-5.2");
|
|
295
|
+
const stub = stubResponsesCreate(provider);
|
|
296
|
+
|
|
297
|
+
await provider.sendMessage(forwardReferenceHistory());
|
|
298
|
+
|
|
299
|
+
const input = stub.input() as Array<Record<string, unknown>>;
|
|
300
|
+
const outputs = input.filter(
|
|
301
|
+
(item) => item.type === "function_call_output",
|
|
302
|
+
);
|
|
303
|
+
// Only the tool_result AFTER the function_call serializes as an output.
|
|
304
|
+
expect(outputs).toEqual([
|
|
305
|
+
{
|
|
306
|
+
type: "function_call_output",
|
|
307
|
+
call_id: "call_later",
|
|
308
|
+
output: "real output",
|
|
309
|
+
},
|
|
310
|
+
]);
|
|
311
|
+
// The premature result is degraded into the first user message.
|
|
312
|
+
const firstUser = input[0] as {
|
|
313
|
+
content: Array<{ type: string; text?: string }>;
|
|
314
|
+
};
|
|
315
|
+
const texts = firstUser.content.map((part) => part.text ?? "");
|
|
316
|
+
expect(texts).toContain("[orphaned tool result] premature output");
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// Chat Completions transport
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
describe("OpenAIChatCompletionsProvider orphan tool_result guard", () => {
|
|
325
|
+
function makeProvider(): OpenAIChatCompletionsProvider {
|
|
326
|
+
return new OpenAIChatCompletionsProvider("sk-test", "test-model", {
|
|
327
|
+
providerName: "openai",
|
|
328
|
+
providerLabel: "OpenAI",
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
for (const shape of ID_SHAPES) {
|
|
333
|
+
test(`emits a tool message for a paired tool_result (${shape.label})`, async () => {
|
|
334
|
+
const provider = makeProvider();
|
|
335
|
+
const stub = stubChatCreate(provider);
|
|
336
|
+
|
|
337
|
+
await provider.sendMessage(pairedHistory(shape.pairedId));
|
|
338
|
+
|
|
339
|
+
const sent = stub.messages();
|
|
340
|
+
const toolMessages = sent.filter((message) => message.role === "tool");
|
|
341
|
+
expect(toolMessages).toEqual([
|
|
342
|
+
{
|
|
343
|
+
role: "tool",
|
|
344
|
+
tool_call_id: shape.pairedId,
|
|
345
|
+
content: "file contents",
|
|
346
|
+
},
|
|
347
|
+
]);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test(`degrades an orphaned tool_result to user text instead of a tool message (${shape.label})`, async () => {
|
|
351
|
+
const provider = makeProvider();
|
|
352
|
+
const stub = stubChatCreate(provider);
|
|
353
|
+
|
|
354
|
+
await provider.sendMessage(orphanHistory(shape.orphanId));
|
|
355
|
+
|
|
356
|
+
const sent = stub.messages();
|
|
357
|
+
expect(sent.some((message) => message.role === "tool")).toBe(false);
|
|
358
|
+
|
|
359
|
+
const firstUser = sent.find((message) => message.role === "user");
|
|
360
|
+
expect(firstUser).toBeDefined();
|
|
361
|
+
const content = firstUser!.content;
|
|
362
|
+
const texts = Array.isArray(content)
|
|
363
|
+
? content.map((part) => (part.text as string) ?? "")
|
|
364
|
+
: [content];
|
|
365
|
+
expect(texts).toContain("continue from here");
|
|
366
|
+
expect(texts).toContain("[orphaned tool result] stranded output");
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
test("treats a forward-referencing tool_result as orphaned, keeps the backward match", async () => {
|
|
371
|
+
const provider = makeProvider();
|
|
372
|
+
const stub = stubChatCreate(provider);
|
|
373
|
+
|
|
374
|
+
await provider.sendMessage(forwardReferenceHistory());
|
|
375
|
+
|
|
376
|
+
const sent = stub.messages();
|
|
377
|
+
const toolMessages = sent.filter((message) => message.role === "tool");
|
|
378
|
+
expect(toolMessages).toEqual([
|
|
379
|
+
{
|
|
380
|
+
role: "tool",
|
|
381
|
+
tool_call_id: "call_later",
|
|
382
|
+
content: "real output",
|
|
383
|
+
},
|
|
384
|
+
]);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// Cross-transport agreement
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
describe("orphan degradation agrees across both OpenAI transports", () => {
|
|
393
|
+
/** Every degraded orphan text a request carried, in request order. */
|
|
394
|
+
async function responsesOrphanTexts(history: Message[]): Promise<string[]> {
|
|
395
|
+
const provider = new OpenAIResponsesProvider("sk-test", "gpt-5.2");
|
|
396
|
+
const stub = stubResponsesCreate(provider);
|
|
397
|
+
await provider.sendMessage(history);
|
|
398
|
+
const input = stub.input() as Array<Record<string, unknown>>;
|
|
399
|
+
return input
|
|
400
|
+
.flatMap((item) =>
|
|
401
|
+
Array.isArray(item.content) ? (item.content as unknown[]) : [],
|
|
402
|
+
)
|
|
403
|
+
.map((part) => (part as { text?: string }).text ?? "")
|
|
404
|
+
.filter((text) => text.startsWith("[orphaned"));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function chatOrphanTexts(history: Message[]): Promise<string[]> {
|
|
408
|
+
const provider = new OpenAIChatCompletionsProvider(
|
|
409
|
+
"sk-test",
|
|
410
|
+
"test-model",
|
|
411
|
+
{
|
|
412
|
+
providerName: "openai",
|
|
413
|
+
providerLabel: "OpenAI",
|
|
414
|
+
},
|
|
415
|
+
);
|
|
416
|
+
const stub = stubChatCreate(provider);
|
|
417
|
+
await provider.sendMessage(history);
|
|
418
|
+
return (
|
|
419
|
+
stub
|
|
420
|
+
.messages()
|
|
421
|
+
// A user message carrying a single text part serializes as a plain
|
|
422
|
+
// string on this transport rather than a one-element array.
|
|
423
|
+
.flatMap((message) =>
|
|
424
|
+
Array.isArray(message.content)
|
|
425
|
+
? (message.content as unknown[]).map(
|
|
426
|
+
(part) => (part as { text?: string }).text ?? "",
|
|
427
|
+
)
|
|
428
|
+
: [String(message.content ?? "")],
|
|
429
|
+
)
|
|
430
|
+
.filter((text) => text.startsWith("[orphaned"))
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// The detection rule and the degraded wording live in one shared helper
|
|
435
|
+
// (`serializeToolResult`), so the two transports cannot drift into
|
|
436
|
+
// different markers for the same input. Byte equality is the assertion:
|
|
437
|
+
// re-inlining the rule in one converter and editing it there fails here,
|
|
438
|
+
// which is the regression this pins.
|
|
439
|
+
test("both transports degrade the same orphan to byte-identical text", async () => {
|
|
440
|
+
const history = orphanHistory("call_orphan");
|
|
441
|
+
|
|
442
|
+
const [fromResponses, fromChat] = await Promise.all([
|
|
443
|
+
responsesOrphanTexts(history),
|
|
444
|
+
chatOrphanTexts(history),
|
|
445
|
+
]);
|
|
446
|
+
|
|
447
|
+
expect(fromResponses).toEqual(["[orphaned tool result] stranded output"]);
|
|
448
|
+
expect(fromChat).toEqual(fromResponses);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("both transports carry an executor failure into the same degraded text", async () => {
|
|
452
|
+
// `is_error` prefixing is part of the shared payload rule, so it must
|
|
453
|
+
// survive degradation identically on both transports.
|
|
454
|
+
const history: Message[] = [
|
|
455
|
+
{
|
|
456
|
+
role: "user",
|
|
457
|
+
content: [
|
|
458
|
+
{
|
|
459
|
+
type: "tool_result",
|
|
460
|
+
tool_use_id: "call_orphan",
|
|
461
|
+
content: "boom",
|
|
462
|
+
is_error: true,
|
|
463
|
+
},
|
|
464
|
+
],
|
|
465
|
+
},
|
|
466
|
+
{ role: "assistant", content: [{ type: "text", text: "continuing" }] },
|
|
467
|
+
{ role: "user", content: [{ type: "text", text: "thanks" }] },
|
|
468
|
+
];
|
|
469
|
+
|
|
470
|
+
const [fromResponses, fromChat] = await Promise.all([
|
|
471
|
+
responsesOrphanTexts(history),
|
|
472
|
+
chatOrphanTexts(history),
|
|
473
|
+
]);
|
|
474
|
+
|
|
475
|
+
expect(fromResponses).toEqual(["[orphaned tool result] [ERROR] boom"]);
|
|
476
|
+
expect(fromChat).toEqual(fromResponses);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
OPENAI_COMPAT_MAX_INLINE_AUDIO_BYTES,
|
|
47
47
|
openAIInputAudioFormat,
|
|
48
48
|
} from "./input-audio.js";
|
|
49
|
+
import { serializeToolResult } from "./orphaned-tool-result.js";
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* Detect OpenAI-compatible context-overflow signals on an `OpenAI.APIError`.
|
|
@@ -956,9 +957,19 @@ export class OpenAIChatCompletionsProvider implements Provider {
|
|
|
956
957
|
});
|
|
957
958
|
}
|
|
958
959
|
|
|
960
|
+
// Tool-call ids emitted in assistant messages earlier in this request.
|
|
961
|
+
// The API rejects a tool-role message whose `tool_call_id` has no
|
|
962
|
+
// preceding assistant `tool_calls` entry, so tool results are only
|
|
963
|
+
// serialized as tool messages when their call was emitted first
|
|
964
|
+
// (backward matches only).
|
|
965
|
+
const emittedToolCallIds = new Set<string>();
|
|
959
966
|
for (const msg of messages) {
|
|
960
967
|
if (msg.role === "assistant") {
|
|
961
|
-
|
|
968
|
+
const assistantMessage = this.toOpenAIAssistantMessage(msg);
|
|
969
|
+
for (const toolCall of assistantMessage.tool_calls ?? []) {
|
|
970
|
+
emittedToolCallIds.add(toolCall.id);
|
|
971
|
+
}
|
|
972
|
+
result.push(assistantMessage);
|
|
962
973
|
} else {
|
|
963
974
|
// User messages may contain tool_result blocks mixed with text/image
|
|
964
975
|
const toolResults = msg.content.filter(
|
|
@@ -975,46 +986,51 @@ export class OpenAIChatCompletionsProvider implements Provider {
|
|
|
975
986
|
// Emit tool results as separate tool-role messages
|
|
976
987
|
// OpenAI's API only supports string content in tool messages, so media
|
|
977
988
|
// from contentBlocks is collected and injected as a user message below.
|
|
989
|
+
// A tool_result whose id has no preceding assistant tool call in this
|
|
990
|
+
// request is orphaned; its content is degraded into the user message
|
|
991
|
+
// instead of being sent as a rejectable tool message.
|
|
978
992
|
const toolResultMedia: ContentBlock[] = [];
|
|
993
|
+
const orphanedResultBlocks: ContentBlock[] = [];
|
|
979
994
|
for (const tr of toolResults) {
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
995
|
+
// Media this transport can carry: images always, plus inline audio
|
|
996
|
+
// when the model accepts it. The text payload and the orphan
|
|
997
|
+
// decision are the shared cross-transport rule.
|
|
998
|
+
for (const cb of tr.contentBlocks ?? []) {
|
|
999
|
+
if (cb.type === "image") {
|
|
1000
|
+
toolResultMedia.push(cb);
|
|
1001
|
+
} else if (
|
|
1002
|
+
audioInputEnabled &&
|
|
1003
|
+
cb.type === "file" &&
|
|
1004
|
+
isOpenAICompatInlineAudio(
|
|
1005
|
+
cb.source.media_type,
|
|
1006
|
+
mediaSourceByteLength(cb.source),
|
|
986
1007
|
)
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
textContent = textContent + "\n" + extraText.join("\n");
|
|
990
|
-
}
|
|
991
|
-
for (const cb of tr.contentBlocks) {
|
|
992
|
-
if (cb.type === "image") {
|
|
993
|
-
toolResultMedia.push(cb);
|
|
994
|
-
} else if (
|
|
995
|
-
audioInputEnabled &&
|
|
996
|
-
cb.type === "file" &&
|
|
997
|
-
isOpenAICompatInlineAudio(
|
|
998
|
-
cb.source.media_type,
|
|
999
|
-
mediaSourceByteLength(cb.source),
|
|
1000
|
-
)
|
|
1001
|
-
) {
|
|
1002
|
-
toolResultMedia.push(cb);
|
|
1003
|
-
}
|
|
1008
|
+
) {
|
|
1009
|
+
toolResultMedia.push(cb);
|
|
1004
1010
|
}
|
|
1005
1011
|
}
|
|
1012
|
+
const serialized = serializeToolResult(tr, emittedToolCallIds);
|
|
1013
|
+
if (serialized.kind === "orphaned") {
|
|
1014
|
+
orphanedResultBlocks.push(serialized.block);
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1006
1017
|
result.push({
|
|
1007
1018
|
role: "tool",
|
|
1008
1019
|
tool_call_id: tr.tool_use_id,
|
|
1009
|
-
content:
|
|
1020
|
+
content: serialized.payload,
|
|
1010
1021
|
});
|
|
1011
1022
|
}
|
|
1012
1023
|
|
|
1013
|
-
// Emit remaining content
|
|
1014
|
-
// Media from tool results (e.g.
|
|
1015
|
-
// must go in a user message
|
|
1016
|
-
// support media parts in tool
|
|
1017
|
-
|
|
1024
|
+
// Emit remaining content, degraded orphaned results, and any tool
|
|
1025
|
+
// result media as a user message. Media from tool results (e.g.
|
|
1026
|
+
// browser_screenshot, audio a tool read) must go in a user message
|
|
1027
|
+
// because OpenAI-compatible APIs don't support media parts in tool
|
|
1028
|
+
// messages.
|
|
1029
|
+
const userContent = [
|
|
1030
|
+
...otherBlocks,
|
|
1031
|
+
...orphanedResultBlocks,
|
|
1032
|
+
...toolResultMedia,
|
|
1033
|
+
];
|
|
1018
1034
|
if (userContent.length > 0) {
|
|
1019
1035
|
result.push(this.toOpenAIUserMessage(userContent, audioInputEnabled));
|
|
1020
1036
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared wire-serialization rule for OpenAI tool results.
|
|
3
|
+
*
|
|
4
|
+
* Both OpenAI transports reject a tool result whose call was never emitted
|
|
5
|
+
* earlier in the same request: chat-completions rejects a `tool`-role message
|
|
6
|
+
* whose `tool_call_id` has no preceding assistant `tool_calls` entry, and the
|
|
7
|
+
* Responses API rejects a `function_call_output` whose `call_id` has no
|
|
8
|
+
* preceding `function_call`. Both therefore serialize a result as its native
|
|
9
|
+
* paired item only on a backward match, and degrade an unmatched one into
|
|
10
|
+
* plain text carried by the accompanying user message.
|
|
11
|
+
*
|
|
12
|
+
* The text payload and the orphan decision are identical across the two
|
|
13
|
+
* transports, so they live here; only the paired wire shape (and which media
|
|
14
|
+
* kinds each transport accepts alongside it) differs, and that stays at the
|
|
15
|
+
* call sites.
|
|
16
|
+
*
|
|
17
|
+
* Distinct from `agent/history-repair`'s `[orphaned <type> for <id>]:` marker,
|
|
18
|
+
* which operates one layer up on persisted history: it repairs the stored
|
|
19
|
+
* conversation shape (naming the id, and covering `web_search_tool_result`
|
|
20
|
+
* too) before any transport is chosen. This marker means "the call never
|
|
21
|
+
* reached this request's wire payload", so the id it would name is absent by
|
|
22
|
+
* construction. Keeping the two readable apart tells an operator which layer
|
|
23
|
+
* degraded the block.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { ContentBlock, ToolResultContent } from "../types.js";
|
|
27
|
+
|
|
28
|
+
/** Text prefix marking a tool result whose call is absent from the request. */
|
|
29
|
+
const ORPHANED_MARKER = "[orphaned tool result]";
|
|
30
|
+
|
|
31
|
+
/** Text prefix marking a tool result the executor reported as failed. */
|
|
32
|
+
const ERROR_MARKER = "[ERROR]";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* How a tool result should be serialized for an OpenAI transport:
|
|
36
|
+
*
|
|
37
|
+
* - `paired`: its call was emitted earlier in this request, so the transport
|
|
38
|
+
* emits its native tool-output item carrying `payload`.
|
|
39
|
+
* - `orphaned`: no matching call, so `block` is folded into the user message
|
|
40
|
+
* instead of being sent as a rejectable tool-output item.
|
|
41
|
+
*/
|
|
42
|
+
export type ToolResultSerialization =
|
|
43
|
+
| { kind: "paired"; payload: string }
|
|
44
|
+
| { kind: "orphaned"; block: ContentBlock };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Flatten a tool result's text (its own `content` plus any text in
|
|
48
|
+
* `contentBlocks`), mark executor failures, and decide whether the request
|
|
49
|
+
* can carry it as a paired tool-output item.
|
|
50
|
+
*
|
|
51
|
+
* `emittedCallIds` holds the ids already emitted as calls earlier in the same
|
|
52
|
+
* request, so membership is a backward match by construction. Media in
|
|
53
|
+
* `contentBlocks` is not consulted: each transport accepts a different set and
|
|
54
|
+
* collects it separately.
|
|
55
|
+
*/
|
|
56
|
+
export function serializeToolResult(
|
|
57
|
+
toolResult: ToolResultContent,
|
|
58
|
+
emittedCallIds: ReadonlySet<string>,
|
|
59
|
+
): ToolResultSerialization {
|
|
60
|
+
let text = toolResult.content;
|
|
61
|
+
const extraText = (toolResult.contentBlocks ?? [])
|
|
62
|
+
.filter(
|
|
63
|
+
(cb): cb is Extract<ContentBlock, { type: "text" }> => cb.type === "text",
|
|
64
|
+
)
|
|
65
|
+
.map((cb) => cb.text);
|
|
66
|
+
if (extraText.length > 0) {
|
|
67
|
+
text = text + "\n" + extraText.join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const payload = toolResult.is_error ? `${ERROR_MARKER} ${text}` : text;
|
|
71
|
+
|
|
72
|
+
if (!emittedCallIds.has(toolResult.tool_use_id)) {
|
|
73
|
+
return {
|
|
74
|
+
kind: "orphaned",
|
|
75
|
+
block: { type: "text", text: `${ORPHANED_MARKER} ${payload}` },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return { kind: "paired", payload };
|
|
79
|
+
}
|