@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.
- 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/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
|
@@ -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
|
|
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
|
+
}
|
package/src/context/compactor.ts
CHANGED
|
@@ -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
|
|
1060
|
-
// collapsed, historical web-search results converted to text),
|
|
1061
|
-
//
|
|
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 [
|
|
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
|
-
|
|
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
|
}
|