diff --git a/CHANGELOG.md b/CHANGELOG.md index ec44699572c..6d6bc6abe3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents: filter silent heartbeat response-tool transcript artifacts out of embedded context snapshots so later user turns are not polluted by heartbeat no-op messages. (#83477) Thanks @fuller-stack-dev. +- Agents/OpenAI: log repeated strict tool-schema downgrade diagnostics once per provider/model/tool signature, reducing duplicate debug noise while preserving `strict=false` fallback behavior. Fixes #82930. (#82933) Thanks @galiniliev. - Agents/code mode: spell out the `exec` tool's JavaScript/TypeScript, no Node module, and catalog-bridge constraints in model-visible schema text so agents can use enabled tools without trial-and-error. (#84269) Thanks @Kaspre. - Codex: give `image_generate` dynamic-tool calls a 120s default watchdog when no per-call or configured image timeout is set, so image generation no longer falls back to the generic 30s bridge timeout. (#84254) Thanks @moritzmmayerhofer. - Codex: avoid duplicate dynamic tool terminal diagnostics while large diagnostic backlogs drain without blocking tool responses. (#82937) Thanks @galiniliev. diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index f80de756776..7a4c35b6dec 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -3015,6 +3015,82 @@ describe("openai transport stream", () => { expect(params.tools?.[0]?.strict).toBe(false); }); + it("deduplicates repeated OpenAI strict schema downgrade diagnostics", async () => { + const debug = vi.fn(); + const logger = { + subsystem: "openai-transport", + isEnabled: vi.fn((level: string, target?: string) => level === "debug" && target === "any"), + trace: vi.fn(), + debug, + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + raw: vi.fn(), + child: vi.fn(), + }; + logger.child.mockReturnValue(logger); + + vi.resetModules(); + vi.doMock("../logging/subsystem.js", async (importOriginal) => ({ + ...(await importOriginal()), + createSubsystemLogger: vi.fn(() => logger), + })); + + try { + const { buildOpenAIResponsesParams: isolatedBuildOpenAIResponsesParams } = + await import("./openai-transport-stream.js"); + const model = { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"openai-responses">; + const context = { + systemPrompt: "system", + messages: [], + tools: [ + { + name: "read", + description: "Read file", + parameters: { + type: "object", + additionalProperties: false, + properties: { path: { type: "string" } }, + required: [], + }, + }, + ], + } as never; + + const first = isolatedBuildOpenAIResponsesParams(model, context, undefined) as { + tools?: Array<{ strict?: boolean }>; + }; + const second = isolatedBuildOpenAIResponsesParams(model, context, undefined) as { + tools?: Array<{ strict?: boolean }>; + }; + + expect(first.tools?.[0]?.strict).toBe(false); + expect(second.tools?.[0]?.strict).toBe(false); + expect( + debug.mock.calls.filter( + ([message]) => + typeof message === "string" && + message.includes("tool schema strict mode downgraded to strict=false"), + ), + ).toHaveLength(1); + } finally { + vi.doUnmock("../logging/subsystem.js"); + vi.resetModules(); + } + }); + it("omits responses strict tool shaping for proxy-like OpenAI routes", () => { const params = buildOpenAIResponsesParams( { diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index 3ce8d063aab..2d64d9e311c 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -78,7 +78,9 @@ const AZURE_RESPONSES_FIRST_EVENT_TIMEOUT_MS = 30_000; const MODEL_STREAM_COOPERATIVE_YIELD_INTERVAL_MS = 12; const MODEL_STREAM_COOPERATIVE_YIELD_MAX_EVENTS = 64; const RESPONSE_FAILED_NO_DETAILS_MESSAGE = "Unknown error (no error details in response)"; +const MAX_OPENAI_STRICT_TOOL_DOWNGRADE_DIAGNOSTIC_KEYS = 256; const log = createSubsystemLogger("openai-transport"); +const loggedOpenAIStrictToolDowngradeDiagnosticKeys = new Set(); type ReplayableResponseOutputMessage = Omit & { id?: string }; type ReplayableResponseReasoningItem = Omit & { id?: string }; @@ -976,6 +978,9 @@ function resolveOpenAIStrictToolFlagWithDiagnostics( const strict = resolveOpenAIStrictToolFlagForInventory(tools, strictSetting); if (strictSetting === true && strict === false && log.isEnabled("debug", "any")) { const diagnostics = findOpenAIStrictToolSchemaDiagnostics(tools); + if (!shouldLogOpenAIStrictToolDowngradeDiagnostic(diagnostics, context)) { + return strict; + } const sample = diagnostics.slice(0, 5).map((entry) => ({ tool: entry.toolName ?? `tool[${entry.toolIndex}]`, violations: entry.violations.slice(0, 8), @@ -996,6 +1001,44 @@ function resolveOpenAIStrictToolFlagWithDiagnostics( return strict; } +function buildOpenAIStrictToolDowngradeDiagnosticKey( + diagnostics: ReturnType, + context: { transport: "responses" | "completions"; model: OpenAIModeModel }, +): string { + return createHash("sha256") + .update( + JSON.stringify({ + transport: context.transport, + provider: context.model.provider ?? null, + model: context.model.id ?? null, + diagnostics: diagnostics.map((entry) => ({ + toolIndex: entry.toolIndex, + toolName: entry.toolName ?? null, + violations: entry.violations, + })), + }), + ) + .digest("hex"); +} + +function shouldLogOpenAIStrictToolDowngradeDiagnostic( + diagnostics: ReturnType, + context: { transport: "responses" | "completions"; model: OpenAIModeModel }, +): boolean { + const key = buildOpenAIStrictToolDowngradeDiagnosticKey(diagnostics, context); + if (loggedOpenAIStrictToolDowngradeDiagnosticKeys.has(key)) { + return false; + } + if ( + loggedOpenAIStrictToolDowngradeDiagnosticKeys.size >= + MAX_OPENAI_STRICT_TOOL_DOWNGRADE_DIAGNOSTIC_KEYS + ) { + loggedOpenAIStrictToolDowngradeDiagnosticKeys.clear(); + } + loggedOpenAIStrictToolDowngradeDiagnosticKeys.add(key); + return true; +} + function createResponsesFirstEventTimeoutError(model: Model, timeoutMs: number): Error { return new Error( `Azure OpenAI Responses stream did not deliver a first event within ${timeoutMs}ms after HTTP streaming headers. ` +