@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
|
@@ -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
|
+
}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
normalizeOpenAIAPIError,
|
|
27
27
|
} from "./api-error-normalization.js";
|
|
28
28
|
import { detectOpenAICompatibleContextOverflow } from "./chat-completions-provider.js";
|
|
29
|
+
import { serializeToolResult } from "./orphaned-tool-result.js";
|
|
29
30
|
|
|
30
31
|
const log = getLogger("openai-responses");
|
|
31
32
|
|
|
@@ -758,11 +759,16 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
758
759
|
messages = resolveMediaReferences(messages);
|
|
759
760
|
const result: unknown[] = [];
|
|
760
761
|
|
|
762
|
+
// `call_id`s emitted as `function_call` items earlier in this request.
|
|
763
|
+
// The API rejects a `function_call_output` whose `call_id` has no
|
|
764
|
+
// preceding `function_call`, so tool results are only serialized as
|
|
765
|
+
// outputs when their call was emitted first (backward matches only).
|
|
766
|
+
const emittedCallIds = new Set<string>();
|
|
761
767
|
for (const msg of messages) {
|
|
762
768
|
if (msg.role === "assistant") {
|
|
763
|
-
this.appendAssistantItems(result, msg);
|
|
769
|
+
this.appendAssistantItems(result, msg, emittedCallIds);
|
|
764
770
|
} else {
|
|
765
|
-
this.appendUserItems(result, msg);
|
|
771
|
+
this.appendUserItems(result, msg, emittedCallIds);
|
|
766
772
|
}
|
|
767
773
|
}
|
|
768
774
|
|
|
@@ -887,8 +893,16 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
887
893
|
}
|
|
888
894
|
}
|
|
889
895
|
|
|
890
|
-
/**
|
|
891
|
-
|
|
896
|
+
/**
|
|
897
|
+
* Convert an assistant message's content blocks to Responses input items.
|
|
898
|
+
* Every `function_call` emitted is recorded in `emittedCallIds` so the
|
|
899
|
+
* user-item conversion can pair `tool_result` blocks against it.
|
|
900
|
+
*/
|
|
901
|
+
private appendAssistantItems(
|
|
902
|
+
result: unknown[],
|
|
903
|
+
msg: Message,
|
|
904
|
+
emittedCallIds: Set<string>,
|
|
905
|
+
): void {
|
|
892
906
|
const textParts: string[] = [];
|
|
893
907
|
|
|
894
908
|
for (const block of msg.content) {
|
|
@@ -912,6 +926,7 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
912
926
|
name: block.name,
|
|
913
927
|
arguments: JSON.stringify(block.input),
|
|
914
928
|
});
|
|
929
|
+
emittedCallIds.add(block.id);
|
|
915
930
|
break;
|
|
916
931
|
case "server_tool_use":
|
|
917
932
|
textParts.push(`[Web search: ${block.name}]`);
|
|
@@ -932,8 +947,18 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
932
947
|
}
|
|
933
948
|
}
|
|
934
949
|
|
|
935
|
-
/**
|
|
936
|
-
|
|
950
|
+
/**
|
|
951
|
+
* Convert a user message's content blocks to Responses input items.
|
|
952
|
+
* A `tool_result` whose `tool_use_id` was not emitted as a `function_call`
|
|
953
|
+
* earlier in this request (`emittedCallIds`) is orphaned; the API rejects
|
|
954
|
+
* an unmatched `function_call_output`, so its content is degraded into the
|
|
955
|
+
* plain user message instead of dropped.
|
|
956
|
+
*/
|
|
957
|
+
private appendUserItems(
|
|
958
|
+
result: unknown[],
|
|
959
|
+
msg: Message,
|
|
960
|
+
emittedCallIds: Set<string>,
|
|
961
|
+
): void {
|
|
937
962
|
// Separate tool results from other blocks
|
|
938
963
|
const toolResults = msg.content.filter(
|
|
939
964
|
(b): b is Extract<ContentBlock, { type: "tool_result" }> =>
|
|
@@ -948,33 +973,34 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
948
973
|
|
|
949
974
|
// Emit tool results as function_call_output items
|
|
950
975
|
const toolResultImages: ContentBlock[] = [];
|
|
976
|
+
const orphanedResultBlocks: ContentBlock[] = [];
|
|
951
977
|
for (const tr of toolResults) {
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
cb.type === "text",
|
|
958
|
-
)
|
|
959
|
-
.map((cb) => cb.text);
|
|
960
|
-
if (extraText.length > 0) {
|
|
961
|
-
textContent = textContent + "\n" + extraText.join("\n");
|
|
962
|
-
}
|
|
963
|
-
for (const cb of tr.contentBlocks) {
|
|
964
|
-
if (cb.type === "image") {
|
|
965
|
-
toolResultImages.push(cb);
|
|
966
|
-
}
|
|
978
|
+
// This transport carries images only; the text payload and the orphan
|
|
979
|
+
// decision are the shared cross-transport rule.
|
|
980
|
+
for (const cb of tr.contentBlocks ?? []) {
|
|
981
|
+
if (cb.type === "image") {
|
|
982
|
+
toolResultImages.push(cb);
|
|
967
983
|
}
|
|
968
984
|
}
|
|
985
|
+
const serialized = serializeToolResult(tr, emittedCallIds);
|
|
986
|
+
if (serialized.kind === "orphaned") {
|
|
987
|
+
orphanedResultBlocks.push(serialized.block);
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
969
990
|
result.push({
|
|
970
991
|
type: "function_call_output",
|
|
971
992
|
call_id: tr.tool_use_id,
|
|
972
|
-
output:
|
|
993
|
+
output: serialized.payload,
|
|
973
994
|
});
|
|
974
995
|
}
|
|
975
996
|
|
|
976
|
-
// Emit remaining content
|
|
977
|
-
|
|
997
|
+
// Emit remaining content, degraded orphaned results, and any tool result
|
|
998
|
+
// images as a user message
|
|
999
|
+
const userContent = [
|
|
1000
|
+
...otherBlocks,
|
|
1001
|
+
...orphanedResultBlocks,
|
|
1002
|
+
...toolResultImages,
|
|
1003
|
+
];
|
|
978
1004
|
if (userContent.length > 0) {
|
|
979
1005
|
result.push(this.toResponsesUserMessage(userContent));
|
|
980
1006
|
}
|
|
@@ -756,8 +756,14 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
756
756
|
}),
|
|
757
757
|
responseBody: z.object({
|
|
758
758
|
success: z.boolean(),
|
|
759
|
-
type: z.string(),
|
|
760
|
-
name: z.string(),
|
|
759
|
+
type: z.string().optional(),
|
|
760
|
+
name: z.string().optional(),
|
|
761
|
+
error: z
|
|
762
|
+
.string()
|
|
763
|
+
.optional()
|
|
764
|
+
.describe(
|
|
765
|
+
"Why the secret was not stored (e.g. provider-side API key validation failed). Present only when success is false.",
|
|
766
|
+
),
|
|
761
767
|
}),
|
|
762
768
|
handler: handleAddSecret,
|
|
763
769
|
},
|