From 59d7ac29751b74ee22ff111bc541f34ab3d8e546 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 22 Aug 2026 14:01:57 +0800 Subject: [PATCH 1/5] feat(adapters): annotate present-but-empty tool outputs (DeepSeek default) --- src/adapters/openai-chat.ts | 18 ++- src/adapters/openai-responses.ts | 38 +++++ src/config.ts | 1 + src/providers/derive.ts | 6 + src/providers/registry.ts | 9 ++ src/router.ts | 4 + src/types/provider.ts | 8 + tests/empty-tool-output-annotation.test.ts | 163 +++++++++++++++++++++ 8 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 tests/empty-tool-output-annotation.test.ts diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 7ad61f1b4d..bc9109f967 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -577,14 +577,24 @@ function isNativeOpenAIChatTarget(provider: OcxProviderConfig): boolean { } } +/** Wire text used when a present-but-empty tool result must stay visible to the model. */ +const EMPTY_TOOL_OUTPUT_ANNOTATION = + "[ocx] empty tool output: the tool ran but produced no stdout or return value; do not treat this as success, failure, or user-provided input."; + /** * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool" * content is text-only on every chat provider, so these ride in a follow-up user message instead of * being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https * URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64. */ -function toolResultTextForWire(content: string | OcxContentPart[]): string { - if (typeof content === "string") return content; +function toolResultTextForWire(content: string | OcxContentPart[], annotateEmpty = false): string { + // An empty content array is a present-but-empty result; `contentPartsToText` would + // otherwise fall back to the "[image]" marker and hide the emptiness from the model. + if (annotateEmpty && Array.isArray(content) && content.length === 0) return EMPTY_TOOL_OUTPUT_ANNOTATION; + if (typeof content === "string") { + if (annotateEmpty && content.trim() === "") return EMPTY_TOOL_OUTPUT_ANNOTATION; + return content; + } const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join(""); if (text) { const untransportableImages = content.filter((p) => p.type === "image" && !p.imageUrl).length; @@ -772,7 +782,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon out.push({ role: "tool", tool_call_id: toolCallId, - content: toolResultTextForWire(msg.content), + content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true), }); pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content)); pendingToolCalls.splice(matchIdx, 1); @@ -817,7 +827,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon out.push({ role: "tool", tool_call_id: toolCallId, - content: toolResultTextForWire(msg.content), + content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true), }); pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content)); flushToolResultImages(); diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 83b348028c..c5d14f9e1c 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -710,6 +710,41 @@ function toolOutputText(output: unknown): string { }).filter(Boolean).join("\n"); } +/** Wire text used when a present-but-empty tool output must stay visible to the model. */ +const EMPTY_TOOL_OUTPUT_ANNOTATION = + "[ocx] empty tool output: the tool ran but produced no stdout or return value; do not treat this as success, failure, or user-provided input."; + +/** True when a Responses tool output item is present but carries no usable content. */ +function isToolOutputEmpty(output: unknown): boolean { + if (typeof output === "string") return output.trim() === ""; + if (Array.isArray(output)) { + return output.every(part => { + if (!isPlainObject(part)) return true; + if (typeof part.text === "string" && part.text.trim() !== "") return false; + if (part.type === "refusal" && typeof part.refusal === "string" && part.refusal.trim() !== "") return false; + return true; + }); + } + return output === undefined || output === null; +} + +/** + * Rewrite present-but-empty tool outputs to an explicit annotation. Synthetic + * missing-result placeholders are non-empty and pass through untouched. No-op unless + * the provider opts in (`annotateEmptyToolOutputs`). + */ +function annotateEmptyResponsesToolOutputs(body: unknown, enabled: boolean): unknown { + if (!enabled || !isPlainObject(body) || !Array.isArray(body.input)) return body; + let changed = false; + const input = body.input.map(item => { + if (!isPlainObject(item) || (item.type !== "function_call_output" && item.type !== "custom_tool_call_output")) return item; + if (!isToolOutputEmpty(item.output)) return item; + changed = true; + return { ...item, output: EMPTY_TOOL_OUTPUT_ANNOTATION }; + }); + return changed ? { ...body, input } : body; +} + /** * Repair a forward-mode input array whose continuation context was lost. When the replay * expansion misses (proxy restart, unrecorded prior turn), previous_response_id is stripped @@ -1705,6 +1740,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (forward || stateless) { outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward); } + if (provider.annotateEmptyToolOutputs === true) { + outBody = annotateEmptyResponsesToolOutputs(outBody, true); + } if (provider.requiresAdjacentResponsesToolResults === true) { outBody = normalizeResponsesToolResultAdjacency(outBody); } diff --git a/src/config.ts b/src/config.ts index dcf34313a4..27f3efd9d3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -711,6 +711,7 @@ const providerConfigSchema = z.object({ responsesPath: z.string().min(1).optional(), statelessResponses: z.boolean().optional(), requiresAdjacentResponsesToolResults: z.boolean().optional(), + annotateEmptyToolOutputs: z.boolean().optional(), fastWire: fastWireSchema.nullable().optional(), supportsServiceTier: z.boolean().optional(), modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 63cd1c9388..cf79028bc0 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -254,6 +254,9 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(entry.requiresAdjacentResponsesToolResults !== undefined ? { requiresAdjacentResponsesToolResults: entry.requiresAdjacentResponsesToolResults } : {}), + ...(entry.annotateEmptyToolOutputs !== undefined + ? { annotateEmptyToolOutputs: entry.annotateEmptyToolOutputs } + : {}), ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}), @@ -474,6 +477,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.requiresAdjacentResponsesToolResults === undefined && seed.requiresAdjacentResponsesToolResults !== undefined) { prov.requiresAdjacentResponsesToolResults = seed.requiresAdjacentResponsesToolResults; } + if (prov.annotateEmptyToolOutputs === undefined && seed.annotateEmptyToolOutputs !== undefined) { + prov.annotateEmptyToolOutputs = seed.annotateEmptyToolOutputs; + } // Registry-only metadata (never seeded into saved config): backfill straight from // the entry so an explicit user value stays distinguishable from the default. if (prov.fastWire === undefined && entry.fastWire !== undefined) { diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 9fda85ba63..153fbf9fce 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -215,6 +215,11 @@ export interface ProviderRegistryEntry { * to stay contiguous. This is seeded/backfilled like other fixed wire capabilities. */ requiresAdjacentResponsesToolResults?: boolean; + /** + * When enabled, tool results that are present but empty are annotated on the wire. + * Seeded/backfilled like other fixed wire capabilities. + */ + annotateEmptyToolOutputs?: boolean; /** * Registry default for the provider's `service_tier` support; see * `OcxProviderConfig.supportsServiceTier`. Registry-only: backfilled (never @@ -1682,6 +1687,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // context splits a call from its result (#1292); parallel calls remain one // reasoning-bearing assistant batch rather than being split per pair (#1477). requiresAdjacentResponsesToolResults: true, + // DeepSeek exec tool results can be present-but-empty (a script that ran without + // calling text(...)); annotate them so routed models do not silently accept an + // empty result or re-issue the same call. + annotateEmptyToolOutputs: true, /* [Decision Log] - 목적: DeepSeek V4 thinking mode multi-turn/tool-call requests must replay prior assistant reasoning_content. - 대안 분석: Globally preserve reasoning_content for all OpenAI-compatible models; preserve it for legacy deepseek-reasoner too; mark only V4 thinking models in registry metadata. diff --git a/src/router.ts b/src/router.ts index 47a604d77c..fc40ebd264 100644 --- a/src/router.ts +++ b/src/router.ts @@ -347,6 +347,10 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider && registryEntry.requiresAdjacentResponsesToolResults !== undefined ? { requiresAdjacentResponsesToolResults: registryEntry.requiresAdjacentResponsesToolResults } : {}), + ...(provider.annotateEmptyToolOutputs === undefined + && registryEntry.annotateEmptyToolOutputs !== undefined + ? { annotateEmptyToolOutputs: registryEntry.annotateEmptyToolOutputs } + : {}), ...(provider.fastWire === undefined && registryEntry.fastWire !== undefined ? { fastWire: cloneFastWire(registryEntry.fastWire), diff --git a/src/types/provider.ts b/src/types/provider.ts index 3dfca58ddc..0967ef5688 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -182,6 +182,14 @@ export interface OcxProviderConfig { * preserved after it, and parallel calls stay together with the reasoning turn that produced them. */ requiresAdjacentResponsesToolResults?: boolean; + /** + * When enabled, a tool result that is present but empty (no usable text or content + * part) is rewritten to an explicit annotation before it reaches the upstream wire, + * so models do not silently accept an empty result or re-issue the same call + * Non-empty results and missing-result placeholders stay byte-identical. + * Seeded true for DeepSeek; absent keeps legacy behavior for every other provider. + */ + annotateEmptyToolOutputs?: boolean; /** * Provider fallback for canonical Fast capability over an OpenAI `service_tier` wire. * This pure tri-state feeds catalog publication, routing eligibility, compatibility diff --git a/tests/empty-tool-output-annotation.test.ts b/tests/empty-tool-output-annotation.test.ts new file mode 100644 index 0000000000..0c0cdffaa4 --- /dev/null +++ b/tests/empty-tool-output-annotation.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; +import { providerConfigSeed } from "../src/providers/derive"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { handleResponses } from "../src/server/responses/core"; +import type { OcxConfig, OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +const ANNOTATION = + "[ocx] empty tool output: the tool ran but produced no stdout or return value; do not treat this as success, failure, or user-provided input."; + +describe("annotateEmptyToolOutputs (DeepSeek default ON)", () => { + test("deepseek registry seed defaults the option to true", () => { + const seed = providerConfigSeed(getProviderRegistryEntry("deepseek")!); + expect(seed.annotateEmptyToolOutputs).toBe(true); + }); + + test("non-deepseek registry seed leaves the option unset", () => { + const seed = providerConfigSeed(getProviderRegistryEntry("cerebras")!); + expect(seed.annotateEmptyToolOutputs).toBeUndefined(); + }); +}); + +describe("openai-chat empty tool output annotation", () => { + function wire(provider: OcxProviderConfig, messages: OcxMessage[]): Array> { + const parsed: OcxParsedRequest = { + modelId: "test-model", + context: { messages }, + stream: false, + options: {}, + }; + const req = createOpenAIChatAdapter(provider).buildRequest(parsed) as { body: string }; + return (JSON.parse(req.body) as { messages: Array> }).messages; + } + + function toolCallTurn(emptyResult: string | unknown[]): OcxMessage[] { + return [ + { role: "user", content: "hi", timestamp: 0 }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "exec_command", arguments: {} }], + timestamp: 0, + }, + { role: "toolResult", toolCallId: "call_1", toolName: "exec_command", content: emptyResult as never, isError: false, timestamp: 0 }, + ]; + } + + const providerWithFlag: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "sk-test", + authMode: "key", + annotateEmptyToolOutputs: true, + }; + + const providerWithoutFlag: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "sk-test", + authMode: "key", + }; + + test("empty string result is annotated when enabled", () => { + const messages = wire(providerWithFlag, toolCallTurn("")); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe(ANNOTATION); + }); + + test("whitespace-only result is annotated when enabled", () => { + const messages = wire(providerWithFlag, toolCallTurn(" \n ")); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe(ANNOTATION); + }); + + test("empty content array is annotated when enabled", () => { + const messages = wire(providerWithFlag, toolCallTurn([])); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe(ANNOTATION); + }); + + test("non-empty result stays byte-identical when enabled", () => { + const messages = wire(providerWithFlag, toolCallTurn("real output")); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe("real output"); + }); + + test("empty result stays empty when the option is absent (legacy behavior)", () => { + const messages = wire(providerWithoutFlag, toolCallTurn("")); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe(""); + }); +}); + +describe("openai-responses empty tool output annotation", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetch; }); + + async function drive(config: OcxConfig, input: unknown[]): Promise<{ body: Record }> { + const requests: Array<{ body: Record }> = []; + globalThis.fetch = (async (inputUrl: RequestInfo | URL, init?: RequestInit) => { + requests.push({ body: JSON.parse(String(init?.body ?? "{}")) as Record }); + return Response.json({ id: "resp_test", object: "response", status: "completed", output: [] }); + }) as typeof fetch; + await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "test/model", input, stream: true }), + }), + config, + { model: "", provider: "" }, + ); + return requests[0] ?? { body: {} }; + } + + function responsesConfig(annotate: boolean | undefined): OcxConfig { + return { + port: 0, + defaultProvider: "test", + providers: { + test: { + adapter: "openai-responses", + baseUrl: "https://example.test", + apiKey: "sk-test", + authMode: "key", + ...(annotate === undefined ? {} : { annotateEmptyToolOutputs: annotate }), + }, + }, + } as unknown as OcxConfig; + } + + test("empty function_call_output is annotated when enabled", async () => { + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_1", output: "" }, + ]); + const input = body.input as Array>; + expect(input[0]).toMatchObject({ type: "function_call_output", call_id: "call_1" }); + expect(input[0].output).toBe(ANNOTATION); + }); + + test("empty custom_tool_call_output is annotated when enabled", async () => { + const { body } = await drive(responsesConfig(true), [ + { type: "custom_tool_call_output", call_id: "call_2", output: " " }, + ]); + const input = body.input as Array>; + expect(input[0].output).toBe(ANNOTATION); + }); + + test("non-empty output stays byte-identical when enabled", async () => { + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_3", output: "ok" }, + ]); + const input = body.input as Array>; + expect(input[0].output).toBe("ok"); + }); + + test("empty output stays empty when the option is absent", async () => { + const { body } = await drive(responsesConfig(undefined), [ + { type: "function_call_output", call_id: "call_4", output: "" }, + ]); + const input = body.input as Array>; + expect(input[0].output).toBe(""); + }); +}); From c432351108b48f039eded93b6d9fdfbad8b81452 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 22 Aug 2026 14:42:47 +0800 Subject: [PATCH 2/5] fix(adapters): annotate whitespace-only text-part arrays on the chat wire --- src/adapters/openai-chat.ts | 6 ++++++ tests/empty-tool-output-annotation.test.ts | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index bc9109f967..059ec6bc26 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -596,6 +596,12 @@ function toolResultTextForWire(content: string | OcxContentPart[], annotateEmpty return content; } const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join(""); + // A whitespace-only text-part array is the array twin of a blank string: the + // Responses adapter treats it as empty, so the Chat adapter must annotate it too + // instead of forwarding whitespace the model silently accepts (CodeRabbit). + if (annotateEmpty && content.every(part => part.type === "text") && text.trim() === "") { + return EMPTY_TOOL_OUTPUT_ANNOTATION; + } if (text) { const untransportableImages = content.filter((p) => p.type === "image" && !p.imageUrl).length; return `${text}${"[image]".repeat(untransportableImages)}`; diff --git a/tests/empty-tool-output-annotation.test.ts b/tests/empty-tool-output-annotation.test.ts index 0c0cdffaa4..3c57254b55 100644 --- a/tests/empty-tool-output-annotation.test.ts +++ b/tests/empty-tool-output-annotation.test.ts @@ -77,6 +77,27 @@ describe("openai-chat empty tool output annotation", () => { expect(tool?.content).toBe(ANNOTATION); }); + test("whitespace-only text-part array is annotated when enabled", () => { + const messages = wire(providerWithFlag, toolCallTurn([{ type: "text", text: " \n " }])); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe(ANNOTATION); + }); + + test("whitespace-only text-part array stays unchanged when the option is absent", () => { + const messages = wire(providerWithoutFlag, toolCallTurn([{ type: "text", text: " " }])); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe(" "); + }); + + test("image parts with whitespace text are not treated as empty", () => { + const messages = wire(providerWithFlag, toolCallTurn([ + { type: "text", text: " " }, + { type: "image", imageUrl: "data:image/png;base64,AAAA" }, + ])); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).not.toBe(ANNOTATION); + }); + test("non-empty result stays byte-identical when enabled", () => { const messages = wire(providerWithFlag, toolCallTurn("real output")); const tool = messages.find(m => m.role === "tool"); From a2b227a50a284333f42fb13e54ad8fd44cb639f2 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 22 Aug 2026 14:50:21 +0800 Subject: [PATCH 3/5] test(adapters): cover orphaned empty tool results on the chat wire --- tests/empty-tool-output-annotation.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/empty-tool-output-annotation.test.ts b/tests/empty-tool-output-annotation.test.ts index 3c57254b55..99fa8091e9 100644 --- a/tests/empty-tool-output-annotation.test.ts +++ b/tests/empty-tool-output-annotation.test.ts @@ -109,8 +109,25 @@ describe("openai-chat empty tool output annotation", () => { const tool = messages.find(m => m.role === "tool"); expect(tool?.content).toBe(""); }); + + test("orphaned empty result is annotated when enabled", () => { + const messages = wire(providerWithFlag, [ + { role: "toolResult", toolCallId: "call_orphan", toolName: "exec_command", content: "", isError: false, timestamp: 0 }, + ]); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe(ANNOTATION); + }); + + test("orphaned empty result stays empty when the option is absent", () => { + const messages = wire(providerWithoutFlag, [ + { role: "toolResult", toolCallId: "call_orphan", toolName: "exec_command", content: "", isError: false, timestamp: 0 }, + ]); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe(""); + }); }); + describe("openai-responses empty tool output annotation", () => { const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); From ff71a9e9d5ff801294de569785e89bbf6e7d16bc Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 22 Aug 2026 15:15:25 +0800 Subject: [PATCH 4/5] docs(types): end the annotateEmptyToolOutputs comment sentence --- src/types/provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/provider.ts b/src/types/provider.ts index 0967ef5688..390941a636 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -185,7 +185,7 @@ export interface OcxProviderConfig { /** * When enabled, a tool result that is present but empty (no usable text or content * part) is rewritten to an explicit annotation before it reaches the upstream wire, - * so models do not silently accept an empty result or re-issue the same call + * so models do not silently accept an empty result or re-issue the same call. * Non-empty results and missing-result placeholders stay byte-identical. * Seeded true for DeepSeek; absent keeps legacy behavior for every other provider. */ From eccffb8b76bdcbb3b3b58eaccd18d1be82c9c783 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sun, 23 Aug 2026 08:22:45 +0800 Subject: [PATCH 5/5] fix(adapters): never annotate non-text Responses tool outputs; shared emptiness contract; auth-cors boolean guard --- src/adapters/empty-tool-output-annotation.ts | 40 ++++++++++++ src/adapters/openai-chat.ts | 14 ++--- src/adapters/openai-responses.ts | 16 ++--- src/server/auth-cors.ts | 3 + tests/empty-tool-output-annotation.test.ts | 64 ++++++++++++++++++++ 5 files changed, 119 insertions(+), 18 deletions(-) create mode 100644 src/adapters/empty-tool-output-annotation.ts diff --git a/src/adapters/empty-tool-output-annotation.ts b/src/adapters/empty-tool-output-annotation.ts new file mode 100644 index 0000000000..1798c26b7b --- /dev/null +++ b/src/adapters/empty-tool-output-annotation.ts @@ -0,0 +1,40 @@ +/** + * Shared wire text and emptiness contract for present-but-empty tool outputs. + * + * Both the OpenAI Chat and Responses adapters use this module so the two wires + * cannot drift again: only a pure text/refusal part array whose joined content + * trims empty is "present but empty". Image, file, encrypted-content and any + * other non-text part is real output and is never replaced by the annotation. + */ + +/** Wire text used when a present-but-empty tool output must stay visible to the model. */ +export const EMPTY_TOOL_OUTPUT_ANNOTATION = + "[ocx] empty tool output: the tool ran but produced no stdout or return value; do not treat this as success, failure, or user-provided input."; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * True when every part is text/refusal and the joined text/refusal content trims + * empty. An empty array is the array twin of a blank string. Any image, file, + * encrypted-content or other non-text part makes the array non-empty so the + * model still receives the real payload. + */ +export function isWhitespaceOnlyTextPartArray(parts: readonly unknown[]): boolean { + if (parts.length === 0) return true; + let joined = ""; + for (const part of parts) { + if (!isPlainObject(part)) return false; + if (part.type === "text" && typeof part.text === "string") { + joined += part.text; + continue; + } + if (part.type === "refusal" && typeof part.refusal === "string") { + joined += part.refusal; + continue; + } + return false; + } + return joined.trim() === ""; +} diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 059ec6bc26..d245d1e493 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -8,6 +8,7 @@ import { isDebugEnabled } from "../lib/debug-settings"; import { isCyberPolicyCode } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { contentPartsToText } from "./image"; +import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation"; import { identifyRoutedModel } from "./identity"; import { peekReasoningForCall } from "../responses/reasoning-replay-cache"; import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge"; @@ -577,10 +578,6 @@ function isNativeOpenAIChatTarget(provider: OcxProviderConfig): boolean { } } -/** Wire text used when a present-but-empty tool result must stay visible to the model. */ -const EMPTY_TOOL_OUTPUT_ANNOTATION = - "[ocx] empty tool output: the tool ran but produced no stdout or return value; do not treat this as success, failure, or user-provided input."; - /** * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool" * content is text-only on every chat provider, so these ride in a follow-up user message instead of @@ -596,10 +593,11 @@ function toolResultTextForWire(content: string | OcxContentPart[], annotateEmpty return content; } const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join(""); - // A whitespace-only text-part array is the array twin of a blank string: the - // Responses adapter treats it as empty, so the Chat adapter must annotate it too - // instead of forwarding whitespace the model silently accepts (CodeRabbit). - if (annotateEmpty && content.every(part => part.type === "text") && text.trim() === "") { + // A whitespace-only text-part array is the array twin of a blank string; the + // shared emptiness contract (same module as the Responses adapter) annotates it + // instead of forwarding whitespace the model silently accepts. Image parts and + // any other non-text part keep the array non-empty. + if (annotateEmpty && isWhitespaceOnlyTextPartArray(content)) { return EMPTY_TOOL_OUTPUT_ANNOTATION; } if (text) { diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index c5d14f9e1c..cc359d77f7 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -19,6 +19,7 @@ import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-co import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat"; import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat"; import { openaiResponsesUrl } from "./openai-responses-url"; +import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation"; import { normalizeXaiResponsesWebSearch } from "./xai-web-search"; import { createAdapterTierMetadata, @@ -710,20 +711,15 @@ function toolOutputText(output: unknown): string { }).filter(Boolean).join("\n"); } -/** Wire text used when a present-but-empty tool output must stay visible to the model. */ -const EMPTY_TOOL_OUTPUT_ANNOTATION = - "[ocx] empty tool output: the tool ran but produced no stdout or return value; do not treat this as success, failure, or user-provided input."; - /** True when a Responses tool output item is present but carries no usable content. */ function isToolOutputEmpty(output: unknown): boolean { if (typeof output === "string") return output.trim() === ""; if (Array.isArray(output)) { - return output.every(part => { - if (!isPlainObject(part)) return true; - if (typeof part.text === "string" && part.text.trim() !== "") return false; - if (part.type === "refusal" && typeof part.refusal === "string" && part.refusal.trim() !== "") return false; - return true; - }); + // Mirror the Chat wire rule through the shared contract: only a pure + // text/refusal part array whose joined content trims empty is annotated. + // input_image, encrypted_content, input_file and any other non-text part is + // real output and must never be replaced. + return isWhitespaceOnlyTextPartArray(output); } return output === undefined || output === null; } diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 77ffa085c2..8b4aa2ddd5 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -624,6 +624,9 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (raw.responsesSnapshotRepair !== undefined && typeof raw.responsesSnapshotRepair !== "boolean") { return `provider ${name} responsesSnapshotRepair must be a boolean`; } + if (raw.annotateEmptyToolOutputs !== undefined && typeof raw.annotateEmptyToolOutputs !== "boolean") { + return `provider ${name} annotateEmptyToolOutputs must be a boolean`; + } const defaultMaxOutputError = positiveIntegerConfigError(raw.defaultMaxOutputTokens, "defaultMaxOutputTokens"); if (defaultMaxOutputError) return `provider ${name} ${defaultMaxOutputError}`; const maxOutputError = positiveIntegerRecordConfigError(raw.modelMaxOutputTokens, "modelMaxOutputTokens"); diff --git a/tests/empty-tool-output-annotation.test.ts b/tests/empty-tool-output-annotation.test.ts index 99fa8091e9..d6ba7311fb 100644 --- a/tests/empty-tool-output-annotation.test.ts +++ b/tests/empty-tool-output-annotation.test.ts @@ -198,4 +198,68 @@ describe("openai-responses empty tool output annotation", () => { const input = body.input as Array>; expect(input[0].output).toBe(""); }); + + test("whitespace-only text-part array is annotated when enabled", async () => { + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_5", output: [{ type: "text", text: " \n " }] }, + ]); + const input = body.input as Array>; + expect(input[0].output).toBe(ANNOTATION); + }); + + test("image-only output is never replaced when enabled", async () => { + const output = [{ type: "input_image", image_url: { url: "data:image/png;base64,AAAA" } }]; + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_6", output }, + ]); + const input = body.input as Array>; + expect(input[0].output).toEqual(output); + }); + + test("image plus whitespace text is never replaced when enabled", async () => { + const output = [ + { type: "text", text: " " }, + { type: "input_image", image_url: { url: "data:image/png;base64,AAAA" } }, + ]; + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_7", output }, + ]); + const input = body.input as Array>; + expect(input[0].output).toEqual(output); + }); + + test("encrypted_content output is never replaced when enabled", async () => { + const output = [{ type: "encrypted_content", data: "opaque-blob" }]; + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_8", output }, + ]); + const input = body.input as Array>; + expect(input[0].output).toEqual(output); + }); + + test("file_id-only output is never replaced when enabled", async () => { + const output = [{ type: "input_file", file_id: "file_123" }]; + const { body } = await drive(responsesConfig(true), [ + { type: "custom_tool_call_output", call_id: "call_9", output }, + ]); + const input = body.input as Array>; + expect(input[0].output).toEqual(output); + }); + + test("non-empty refusal output is never replaced when enabled", async () => { + const output = [{ type: "refusal", refusal: "I cannot do that" }]; + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_10", output }, + ]); + const input = body.input as Array>; + expect(input[0].output).toEqual(output); + }); + + test("whitespace-only refusal output is annotated when enabled", async () => { + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_11", output: [{ type: "refusal", refusal: " " }] }, + ]); + const input = body.input as Array>; + expect(input[0].output).toBe(ANNOTATION); + }); });