@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.
@@ -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
- /** Convert an assistant message's content blocks to Responses input items. */
891
- private appendAssistantItems(result: unknown[], msg: Message): void {
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
- /** Convert a user message's content blocks to Responses input items. */
936
- private appendUserItems(result: unknown[], msg: Message): void {
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
- let textContent = tr.content;
953
- if (tr.contentBlocks && tr.contentBlocks.length > 0) {
954
- const extraText = tr.contentBlocks
955
- .filter(
956
- (cb): cb is Extract<ContentBlock, { type: "text" }> =>
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: tr.is_error ? `[ERROR] ${textContent}` : textContent,
993
+ output: serialized.payload,
973
994
  });
974
995
  }
975
996
 
976
- // Emit remaining content + any tool result images as a user message
977
- const userContent = [...otherBlocks, ...toolResultImages];
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
  },