@vellumai/assistant 0.11.2-staging.2 → 0.11.2

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.
@@ -15,6 +15,7 @@ import { ApiError, GoogleGenAI } from "@google/genai";
15
15
  import { computeRetryDelay, sleep } from "../../../../util/retry.js";
16
16
  import { CostTracker } from "./cost-tracker.js";
17
17
  import type { MapOutput, SegmentMapResult } from "./gemini-map.js";
18
+ import { resolveMediaAnalysisModel } from "./media-analysis-model.js";
18
19
 
19
20
  // ---------------------------------------------------------------------------
20
21
  // Types
@@ -76,7 +77,7 @@ export async function analyzeVideoDirectly(
76
77
  mimeType: string,
77
78
  onProgress?: (msg: string) => void,
78
79
  ): Promise<MapOutput> {
79
- const model = options.model ?? "gemini-2.5-flash";
80
+ const model = resolveMediaAnalysisModel(options.model);
80
81
  const maxRetries = options.maxRetries ?? 3;
81
82
 
82
83
  // Check file size before uploading
@@ -0,0 +1,12 @@
1
+ import { resolveModelIntent } from "../../../../providers/model-intents.js";
2
+
3
+ /**
4
+ * Resolves the Gemini model used for media analysis. When no override is
5
+ * supplied (the automatic background-processing path), the default comes
6
+ * from the catalog-backed vision intent, which is validated against the
7
+ * provider catalog at module load. Execution and cache identity must both
8
+ * resolve through this function so they agree on the model.
9
+ */
10
+ export function resolveMediaAnalysisModel(model?: string): string {
11
+ return model ?? resolveModelIntent("gemini", "vision-optimized");
12
+ }
@@ -18,6 +18,7 @@
18
18
  * On any parse or resolution failure we abort the compaction and return
19
19
  * `compacted: false` — never silently lose messages.
20
20
  */
21
+ import { repairHistory } from "../agent/history-repair/history-repair.js";
21
22
  import { optimizeImageForTransport } from "../agent/image-optimize.js";
22
23
  import type { CompactionConfig } from "../config/schemas/compaction.js";
23
24
  import type { LLMCallSite } from "../config/schemas/llm.js";
@@ -1056,9 +1057,14 @@ function extractTextFromResponse(content: ContentBlock[]): string {
1056
1057
 
1057
1058
  // Build the outbound message list for a compaction provider call: apply the
1058
1059
  // same pre-send sanitization bundle as the agent loop's model calls
1059
- // (`preModelCallSanitize` old tool-result media stripped, AX trees
1060
- // collapsed, historical web-search results converted to text), then append
1061
- // the summarization instruction at the tail.
1060
+ // (`preModelCallSanitize`: old tool-result media stripped, AX trees
1061
+ // collapsed, historical web-search results converted to text), run the
1062
+ // deterministic history repair over the sanitized projection, then append
1063
+ // the summarization instruction at the tail. The repair pass downgrades any
1064
+ // orphaned `tool_result` (its `tool_use` outside the request, e.g. cut off
1065
+ // by front truncation) to plain text and merges consecutive same-role runs,
1066
+ // so the request always satisfies the provider's pairing validation. A
1067
+ // well-formed history passes through repair structurally unchanged.
1062
1068
  //
1063
1069
  // Matching the loop's projection matters for two reasons. First, the summary
1064
1070
  // call's prefix stays byte-aligned with the agent's warm prompt cache — an
@@ -1076,7 +1082,10 @@ function buildCompactionRequest(
1076
1082
  history: Message[],
1077
1083
  instruction: Message,
1078
1084
  ): Message[] {
1079
- return [...preModelCallSanitize(history), instruction];
1085
+ return [
1086
+ ...repairHistory(preModelCallSanitize(history)).messages,
1087
+ instruction,
1088
+ ];
1080
1089
  }
1081
1090
 
1082
1091
  // Token headroom a compaction summary call reserves on top of its history: room
@@ -1129,6 +1138,34 @@ function truncateHistoryToBudget(args: {
1129
1138
  if (dropCount === 0) {
1130
1139
  return messages;
1131
1140
  }
1141
+ // Advance the cut to a pair-safe boundary. The budget loop stops wherever
1142
+ // the estimate first fits, which can land between an assistant `tool_use`
1143
+ // and its user `tool_result` and leave an orphaned `tool_result` opening
1144
+ // the retained portion (rejected by providers that validate pairing).
1145
+ // Walk forward to the next clean user boundary, never dropping the final
1146
+ // message (mirroring the budget loop's own bound). When no boundary exists
1147
+ // the requested cut stands; the request-build repair pass downgrades any
1148
+ // orphaned results so the outbound call remains valid.
1149
+ const requestedDropCount = dropCount;
1150
+ if (!isForwardCutBoundary(messages, dropCount)) {
1151
+ for (let i = dropCount + 1; i < messages.length; i++) {
1152
+ if (isForwardCutBoundary(messages, i)) {
1153
+ dropCount = i;
1154
+ break;
1155
+ }
1156
+ }
1157
+ }
1158
+ if (dropCount !== requestedDropCount) {
1159
+ log.info(
1160
+ {
1161
+ requestedDropCount,
1162
+ pairSafeDropCount: dropCount,
1163
+ budgetTokens,
1164
+ totalMessages: messages.length,
1165
+ },
1166
+ "Advanced compaction summary-call front truncation to a pair-safe boundary",
1167
+ );
1168
+ }
1132
1169
  log.info(
1133
1170
  { dropCount, budgetTokens, totalMessages: messages.length },
1134
1171
  "Compaction summary input exceeds context window — truncating from front",
@@ -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
- result.push(this.toOpenAIAssistantMessage(msg));
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
- let textContent = tr.content;
981
- if (tr.contentBlocks && tr.contentBlocks.length > 0) {
982
- const extraText = tr.contentBlocks
983
- .filter(
984
- (cb): cb is Extract<ContentBlock, { type: "text" }> =>
985
- cb.type === "text",
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
- .map((cb) => cb.text);
988
- if (extraText.length > 0) {
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: tr.is_error ? `[ERROR] ${textContent}` : textContent,
1020
+ content: serialized.payload,
1010
1021
  });
1011
1022
  }
1012
1023
 
1013
- // Emit remaining content + any tool result media as a user message.
1014
- // Media from tool results (e.g. browser_screenshot, audio a tool read)
1015
- // must go in a user message because OpenAI-compatible APIs don't
1016
- // support media parts in tool messages.
1017
- const userContent = [...otherBlocks, ...toolResultMedia];
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
  }