fix(gateway): bound Responses tool replay prompts

This commit is contained in:
Dallin Romney
2026-08-16 01:52:36 -07:00
parent f351cedfa1
commit 40e64971db
4 changed files with 77 additions and 4 deletions
+28
View File
@@ -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",
+13 -2
View File
@@ -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<typeof buildAgentPrompt>;
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[] = [];
+23
View File
@@ -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([
+13 -2
View File
@@ -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<ItemParam, { type: "message" }>;
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,
},
});
}