diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index 5078f2f4fffd..21c7f18d5ae3 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites import { createDeferred } from "../../test/helpers/promise.js"; import { createClientToolNameConflictError } from "../agents/agent-tool-definition-adapter.js"; import { FailoverError } from "../agents/failover-error.js"; +import { DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS } from "../agents/tool-result-limits.js"; import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; import { resetConfigRuntimeState } from "../config/config.js"; @@ -1875,6 +1876,33 @@ describe("OpenResponses HTTP API (e2e)", () => { expect(agentCommandMock).toHaveBeenCalledTimes(1); }); + it("rejects structured tool output that exceeds the model-visible prompt budget", async () => { + agentCommandMock.mockClear(); + const res = await postResponses(enabledPort, { + model: "openclaw", + input: [ + { + type: "function_call_output", + call_id: "call_oversized", + output: [ + { + type: "input_text", + text: "x".repeat(DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS + 1), + }, + ], + }, + ], + }); + + const body = (await res.json()) as { error?: { message?: string; type?: string } }; + expect(res.status).toBe(400); + expect(body.error?.type).toBe("invalid_request_error"); + expect(body.error?.message).toContain( + `${DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS}-character live tool-result limit`, + ); + expect(agentCommandMock).not.toHaveBeenCalled(); + }); + it.each([ { name: "an unknown reasoning summary discriminant", diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 64b2f178f411..ee9ed470d3cf 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -95,7 +95,7 @@ import { type ToolChoiceConstraint, } from "./openai-tool-choice.js"; import { wrapUntrustedFileContent } from "./openresponses-file-content.js"; -import { buildAgentPrompt } from "./openresponses-prompt.js"; +import { buildAgentPrompt, OpenResponsesToolOutputTooLargeError } from "./openresponses-prompt.js"; import { createAssistantOutputItem, createFunctionCallOutputItem } from "./openresponses-shape.js"; type OpenResponsesHttpOptions = { @@ -509,7 +509,18 @@ export async function handleOpenResponsesHttpRequest( return true; } - const prompt = buildAgentPrompt(payload.input); + let prompt: ReturnType; + try { + prompt = buildAgentPrompt(payload.input); + } catch (err) { + if (err instanceof OpenResponsesToolOutputTooLargeError) { + sendJson(res, 400, { + error: { message: err.message, type: "invalid_request_error" }, + }); + return true; + } + throw err; + } // Count URL sources request-wide, but replay media only from the current user turn. let images: ImageContent[] = []; diff --git a/src/gateway/openresponses-parity.test.ts b/src/gateway/openresponses-parity.test.ts index dadf295cee44..42a81c6614be 100644 --- a/src/gateway/openresponses-parity.test.ts +++ b/src/gateway/openresponses-parity.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS } from "../agents/tool-result-limits.js"; import { IMAGE_ONLY_USER_MESSAGE } from "./agent-prompt.js"; import { CreateResponseBodySchema } from "./open-responses.schema.js"; import { wrapUntrustedFileContent } from "./openresponses-file-content.js"; @@ -88,6 +89,28 @@ describe("OpenResponses aggregate behavior", () => { expect(result.message).toContain("Summarize it"); }); + it("enforces the canonical live tool-result prompt budget", () => { + expect( + buildAgentPrompt([ + { + type: "function_call_output", + call_id: "call-max", + output: "x".repeat(DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS), + }, + ]).message, + ).toHaveLength(DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS); + + expect(() => + buildAgentPrompt([ + { + type: "function_call_output", + call_id: "call-oversized", + output: "x".repeat(DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS + 1), + }, + ]), + ).toThrow(`${DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS}-character live tool-result limit`); + }); + it("preserves attachment-only turn placeholders", () => { expect( buildAgentPrompt([ diff --git a/src/gateway/openresponses-prompt.ts b/src/gateway/openresponses-prompt.ts index f1fc37d8fc0f..903c053d9996 100644 --- a/src/gateway/openresponses-prompt.ts +++ b/src/gateway/openresponses-prompt.ts @@ -1,4 +1,6 @@ // Prompt adapter from OpenAI Responses input items to OpenClaw agent messages. +import { estimateToolResultTextChars } from "../agents/embedded-agent-runner/tool-result-text-budget.js"; +import { DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS } from "../agents/tool-result-limits.js"; import { buildAgentMessageFromConversationEntries, type ConversationEntry, @@ -9,6 +11,8 @@ import type { ContentPart, ItemParam } from "./open-responses.schema.js"; const FILE_ONLY_USER_MESSAGE = "User sent file(s) with no text."; type ResponseMessageItem = Extract; +export class OpenResponsesToolOutputTooLargeError extends Error {} + function extractTextContent(content: string | ContentPart[]): string { if (typeof content === "string") { return content; @@ -101,12 +105,19 @@ export function buildAgentPrompt(input: string | ItemParam[]): { }); } else if (item.type === "function_call_output") { // Structured SDK tool output remains transport data, not permission to - // fetch its file or image URLs; serialize every accepted part losslessly. + // fetch its file or image URLs. The HTTP boundary does not yet know the + // effective model context, so use the canonical low-context live-result cap. + const body = typeof item.output === "string" ? item.output : JSON.stringify(item.output); + if (estimateToolResultTextChars(body) > DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS) { + throw new OpenResponsesToolOutputTooLargeError( + `Function call output exceeds the ${DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS}-character live tool-result limit.`, + ); + } conversationEntries.push({ role: "tool", entry: { sender: `Tool:${item.call_id}`, - body: typeof item.output === "string" ? item.output : JSON.stringify(item.output), + body, }, }); }