diff --git a/CHANGELOG.md b/CHANGELOG.md index abdfc29e716..ec9e34cc922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/tools: anchor trusted local `MEDIA:` tool-result passthrough on the exact raw name of this run's registered built-in tools, and reject client tool definitions whose names normalize-collide with a built-in or with another client tool in the same request (`400 invalid_request_error` on both JSON and SSE paths), so a client-supplied tool named like a built-in can no longer inherit its local-media trust. (#67303) + ## 2026.4.15-beta.1 ### Changes diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 58638523b31..6c4144f5a07 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -29,6 +29,7 @@ import { resolveProviderTextTransforms, transformProviderSystemPrompt, } from "../../../plugins/provider-runtime.js"; +import { getPluginToolMeta } from "../../../plugins/tools.js"; import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js"; import { normalizeOptionalString } from "../../../shared/string-coerce.js"; @@ -86,7 +87,11 @@ import { import { subscribeEmbeddedPiSession } from "../../pi-embedded-subscribe.js"; import { createPreparedEmbeddedPiSettingsManager } from "../../pi-project-settings.js"; import { applyPiAutoCompactionGuard } from "../../pi-settings.js"; -import { toClientToolDefinitions } from "../../pi-tool-definition-adapter.js"; +import { + createClientToolNameConflictError, + findClientToolNameConflicts, + toClientToolDefinitions, +} from "../../pi-tool-definition-adapter.js"; import { createOpenClawCodingTools, resolveToolLoopDetectionConfig } from "../../pi-tools.js"; import { wrapStreamFnTextTransforms } from "../../plugin-text-transforms.js"; import { describeProviderRequestRoutingSummary } from "../../provider-attribution.js"; @@ -962,6 +967,37 @@ export async function runEmbeddedAttempt( cfg: params.config, agentId: sessionAgentId, }); + // Exact raw names of every tool registered for this run, including + // bundled/plugin tools. Used as the raw-name set for the trusted local + // MEDIA: passthrough gate: a normalized alias is not sufficient — the + // emitted tool name must match an exact registration of this run. + const builtinToolNames = new Set( + effectiveTools.flatMap((tool) => { + const name = (tool.name ?? "").trim(); + return name ? [name] : []; + }), + ); + // Admission-time conflict check only against non-plugin core tools, to + // preserve prior behavior where client tools may coexist with unrelated + // plugin tool names. MEDIA passthrough is still gated by the raw-name + // set above, so a client tool that normalize-collides with a plugin + // tool cannot inherit the plugin's local-media trust. + const coreBuiltinToolNames = new Set( + effectiveTools.flatMap((tool) => { + const name = (tool.name ?? "").trim(); + if (!name || getPluginToolMeta(tool)) { + return []; + } + return [name]; + }), + ); + const clientToolNameConflicts = findClientToolNameConflicts({ + tools: clientTools ?? [], + existingToolNames: coreBuiltinToolNames, + }); + if (clientToolNameConflicts.length > 0) { + throw createClientToolNameConflictError(clientToolNameConflicts); + } const clientToolDefs = clientTools ? toClientToolDefinitions( clientTools, @@ -1529,6 +1565,7 @@ export async function runEmbeddedAttempt( sessionKey: sandboxSessionKey, sessionId: params.sessionId, agentId: sessionAgentId, + builtinToolNames, internalEvents: params.internalEvents, }), ); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts index 39f01d19dcb..41c2fc5cec0 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts @@ -10,6 +10,7 @@ function createMockContext(overrides?: { shouldEmitToolOutput?: boolean; onToolResult?: ReturnType; toolResultFormat?: "markdown" | "plain"; + builtinToolNames?: ReadonlySet; }): EmbeddedPiSubscribeContext { const onToolResult = overrides?.onToolResult ?? vi.fn(); return { @@ -39,6 +40,7 @@ function createMockContext(overrides?: { deterministicApprovalPromptSent: false, }, log: { debug: vi.fn(), warn: vi.fn() }, + builtinToolNames: overrides?.builtinToolNames, shouldEmitToolResult: vi.fn(() => false), shouldEmitToolOutput: vi.fn(() => overrides?.shouldEmitToolOutput ?? false), emitToolSummary: vi.fn(), @@ -173,6 +175,46 @@ describe("handleToolExecutionEnd media emission", () => { expect(ctx.state.pendingToolMediaUrls).toEqual([]); }); + it("does NOT emit local media for case-variant collisions with trusted built-ins", async () => { + const ctx = createMockContext({ + shouldEmitToolOutput: false, + onToolResult: vi.fn(), + builtinToolNames: new Set(["web_search"]), + }); + + await handleToolExecutionEnd(ctx, { + type: "tool_execution_end", + toolName: "Web_Search", + toolCallId: "tc-1", + isError: false, + result: { + content: [{ type: "text", text: "MEDIA:/tmp/secret.png" }], + }, + }); + + expect(ctx.state.pendingToolMediaUrls).toEqual([]); + }); + + it("still emits remote media for case-variant collisions with trusted built-ins", async () => { + const ctx = createMockContext({ + shouldEmitToolOutput: false, + onToolResult: vi.fn(), + builtinToolNames: new Set(["web_search"]), + }); + + await handleToolExecutionEnd(ctx, { + type: "tool_execution_end", + toolName: "Web_Search", + toolCallId: "tc-1", + isError: false, + result: { + content: [{ type: "text", text: "MEDIA:https://example.com/file.png" }], + }, + }); + + expect(ctx.state.pendingToolMediaUrls).toEqual(["https://example.com/file.png"]); + }); + it("emits remote media for MCP-provenance results", async () => { const onToolResult = vi.fn(); const ctx = createMockContext({ shouldEmitToolOutput: false, onToolResult }); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index db600eb0b77..7f765fb5f07 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -433,12 +433,13 @@ function readExecApprovalUnavailableDetails(result: unknown): { async function emitToolResultOutput(params: { ctx: ToolHandlerContext; toolName: string; + rawToolName: string; meta?: string; isToolError: boolean; result: unknown; sanitizedResult: unknown; }) { - const { ctx, toolName, meta, isToolError, result, sanitizedResult } = params; + const { ctx, toolName, rawToolName, meta, isToolError, result, sanitizedResult } = params; const hasStructuredMedia = result && typeof result === "object" && @@ -511,10 +512,10 @@ async function emitToolResultOutput(params: { ctx.shouldEmitToolOutput() || shouldEmitCompactToolOutput({ toolName, result, outputText }); if (shouldEmitOutput) { if (outputText) { - ctx.emitToolOutput(toolName, meta, outputText, result); + ctx.emitToolOutput(rawToolName, meta, outputText, result); if (ctx.params.toolResultFormat === "plain") { emittedToolOutputMediaUrls = await collectEmittedToolOutputMediaUrls( - toolName, + rawToolName, outputText, result, ); @@ -533,7 +534,12 @@ async function emitToolResultOutput(params: { if (!mediaReply) { return; } - const mediaUrls = filterToolResultMediaUrls(toolName, mediaReply.mediaUrls, result); + const mediaUrls = filterToolResultMediaUrls( + rawToolName, + mediaReply.mediaUrls, + result, + ctx.builtinToolNames, + ); const pendingMediaUrls = mediaReply.audioAsVoice || emittedToolOutputMediaUrls.length === 0 ? mediaUrls @@ -779,7 +785,8 @@ export async function handleToolExecutionEnd( result?: unknown; }, ) { - const toolName = normalizeToolName(evt.toolName); + const rawToolName = evt.toolName; + const toolName = normalizeToolName(rawToolName); const toolCallId = evt.toolCallId; const runId = ctx.params.runId; const isError = evt.isError; @@ -1099,7 +1106,15 @@ export async function handleToolExecutionEnd( `embedded run tool end: runId=${ctx.params.runId} tool=${toolName} toolCallId=${toolCallId}`, ); - await emitToolResultOutput({ ctx, toolName, meta, isToolError, result, sanitizedResult }); + await emitToolResultOutput({ + ctx, + toolName, + rawToolName, + meta, + isToolError, + result, + sanitizedResult, + }); // Run after_tool_call plugin hook (fire-and-forget) const hookRunnerAfter = ctx.hookRunner ?? (await loadHookRunnerGlobal()).getGlobalHookRunner(); diff --git a/src/agents/pi-embedded-subscribe.handlers.types.ts b/src/agents/pi-embedded-subscribe.handlers.types.ts index 60858ee98d3..542e59b524a 100644 --- a/src/agents/pi-embedded-subscribe.handlers.types.ts +++ b/src/agents/pi-embedded-subscribe.handlers.types.ts @@ -93,6 +93,7 @@ export type EmbeddedPiSubscribeContext = { blockChunking?: BlockReplyChunking; blockChunker: EmbeddedBlockChunker | null; hookRunner?: HookRunner; + builtinToolNames?: ReadonlySet; noteLastAssistant: (msg: AgentMessage) => void; shouldEmitToolResult: () => boolean; @@ -179,6 +180,7 @@ export type ToolHandlerContext = { state: ToolHandlerState; log: EmbeddedSubscribeLogger; hookRunner?: HookRunner; + builtinToolNames?: ReadonlySet; flushBlockReplyBuffer: () => void | Promise; shouldEmitToolResult: () => boolean; shouldEmitToolOutput: () => boolean; diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts index 38e9d99f6ec..cb52ad68b02 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts @@ -209,6 +209,35 @@ describe("subscribeEmbeddedPiSession", () => { expect(onPartialReply).not.toHaveBeenCalled(); }); + it("blocks local MEDIA urls from case-variant tool names in verbose output", async () => { + const onToolResult = vi.fn(); + const { emit } = createSubscribedHarness({ + runId: "run", + onToolResult, + verboseLevel: "full", + builtinToolNames: new Set(["web_search"]), + }); + + emitToolRun({ + emit, + toolName: "Web_Search", + toolCallId: "tool-1", + isError: false, + result: { + content: [{ type: "text", text: "Fetched page\nMEDIA:/tmp/secret.png" }], + }, + }); + + await vi.waitFor(() => { + expect(onToolResult).toHaveBeenCalled(); + }); + const payload = onToolResult.mock.calls.at(-1)?.[0] as + | { text?: string; mediaUrls?: string[] } + | undefined; + expect(payload?.text ?? "").toContain("Fetched page"); + expect(payload?.mediaUrls).toBeUndefined(); + }); + it("attaches media from internal completion events even when assistant omits MEDIA lines", async () => { const onBlockReply = vi.fn(); const { emit } = createSubscribedHarness({ diff --git a/src/agents/pi-embedded-subscribe.tools.media.test.ts b/src/agents/pi-embedded-subscribe.tools.media.test.ts index 1d67a9048fb..4a1f8a6370f 100644 --- a/src/agents/pi-embedded-subscribe.tools.media.test.ts +++ b/src/agents/pi-embedded-subscribe.tools.media.test.ts @@ -277,6 +277,67 @@ describe("extractToolResultMediaPaths", () => { expect(isToolResultMediaTrusted("music_generate")).toBe(true); }); + it("blocks trusted-media aliases that are not exact registered built-ins", () => { + expect(filterToolResultMediaUrls("bash", ["/etc/passwd"], undefined, new Set(["exec"]))).toEqual( + [], + ); + expect( + filterToolResultMediaUrls( + "Web_Search", + ["/etc/passwd"], + undefined, + new Set(["web_search"]), + ), + ).toEqual([]); + }); + + it("keeps local media for exact registered built-in tool names", () => { + expect( + filterToolResultMediaUrls( + "web_search", + ["/tmp/screenshot.png"], + undefined, + new Set(["web_search"]), + ), + ).toEqual(["/tmp/screenshot.png"]); + }); + + it("keeps local media for bundled plugin tool names registered in this run", () => { + // music_generate is a bundled-plugin trusted tool; when the runner + // registers it for this run, its raw name must be allowed through the + // exact-name gate just like a core built-in. + expect( + filterToolResultMediaUrls( + "music_generate", + ["/tmp/song.mp3"], + undefined, + new Set(["music_generate"]), + ), + ).toEqual(["/tmp/song.mp3"]); + }); + + it("strips local media for plugin-name collisions when the plugin is not registered", () => { + expect( + filterToolResultMediaUrls( + "Music_Generate", + ["/etc/passwd"], + undefined, + new Set(["music_generate"]), + ), + ).toEqual([]); + }); + + it("still allows remote media for colliding aliases", () => { + expect( + filterToolResultMediaUrls( + "bash", + ["/etc/passwd", "https://example.com/file.png"], + undefined, + new Set(["exec"]), + ), + ).toEqual(["https://example.com/file.png"]); + }); + it("does not trust local MEDIA paths for MCP-provenance results", () => { expect( filterToolResultMediaUrls("browser", ["/tmp/screenshot.png"], { diff --git a/src/agents/pi-embedded-subscribe.tools.ts b/src/agents/pi-embedded-subscribe.tools.ts index 93fba78b373..3ce04c5427c 100644 --- a/src/agents/pi-embedded-subscribe.tools.ts +++ b/src/agents/pi-embedded-subscribe.tools.ts @@ -211,11 +211,24 @@ export function filterToolResultMediaUrls( toolName: string | undefined, mediaUrls: string[], result?: unknown, + builtinToolNames?: ReadonlySet, ): string[] { if (mediaUrls.length === 0) { return mediaUrls; } if (isToolResultMediaTrusted(toolName, result)) { + // When the current run provides its exact registered tool names (core + // built-ins plus bundled/trusted plugin tools), require the raw emitted + // tool name to match one of them before allowing local MEDIA: paths. + // This blocks normalized aliases and case-variant collisions such as + // "Bash" -> "bash" or "Web_Search" -> "web_search" from inheriting a + // registered tool's media trust. + if (builtinToolNames !== undefined) { + const registeredName = toolName?.trim(); + if (!registeredName || !builtinToolNames.has(registeredName)) { + return mediaUrls.filter((url) => HTTP_URL_RE.test(url.trim())); + } + } return mediaUrls; } return mediaUrls.filter((url) => HTTP_URL_RE.test(url.trim())); diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 2de19270ad2..1fddc10b216 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -415,7 +415,12 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar return; } const { text: cleanedText, mediaUrls } = parseReplyDirectives(message); - const filteredMediaUrls = filterToolResultMediaUrls(toolName, mediaUrls ?? [], result); + const filteredMediaUrls = filterToolResultMediaUrls( + toolName, + mediaUrls ?? [], + result, + params.builtinToolNames, + ); if (!cleanedText && filteredMediaUrls.length === 0) { return; } @@ -724,6 +729,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar blockChunking, blockChunker, hookRunner: params.hookRunner, + builtinToolNames: params.builtinToolNames, noteLastAssistant, shouldEmitToolResult, shouldEmitToolOutput, diff --git a/src/agents/pi-embedded-subscribe.types.ts b/src/agents/pi-embedded-subscribe.types.ts index 9673ae5f651..2f49a377592 100644 --- a/src/agents/pi-embedded-subscribe.types.ts +++ b/src/agents/pi-embedded-subscribe.types.ts @@ -39,5 +39,11 @@ export type SubscribeEmbeddedPiSessionParams = { sessionId?: string; /** Agent identity for hook context — resolved from session config in attempt.ts. */ agentId?: string; + /** + * Exact raw names of non-plugin OpenClaw tools registered for this run. + * When provided, MEDIA: passthrough requires an exact match instead of only + * a normalized-name collision with a trusted built-in. + */ + builtinToolNames?: ReadonlySet; internalEvents?: AgentInternalEvent[]; }; diff --git a/src/agents/pi-tool-definition-adapter.test.ts b/src/agents/pi-tool-definition-adapter.test.ts index 1bc8ad94db4..af88ef37ef4 100644 --- a/src/agents/pi-tool-definition-adapter.test.ts +++ b/src/agents/pi-tool-definition-adapter.test.ts @@ -2,7 +2,14 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { describe, expect, it } from "vitest"; import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js"; -import { toClientToolDefinitions, toToolDefinitions } from "./pi-tool-definition-adapter.js"; +import { + CLIENT_TOOL_NAME_CONFLICT_PREFIX, + createClientToolNameConflictError, + findClientToolNameConflicts, + isClientToolNameConflictError, + toClientToolDefinitions, + toToolDefinitions, +} from "./pi-tool-definition-adapter.js"; type ToolExecute = ReturnType[number]["execute"]; const extensionContext = {} as Parameters[4]; @@ -177,3 +184,29 @@ describe("toClientToolDefinitions – param coercion", () => { expect(calledWith).toEqual({ action: "search", params: { q: "test", page: 1 } }); }); }); + +describe("client tool name conflict checks", () => { + it("detects collisions with existing built-in names after normalization", () => { + expect( + findClientToolNameConflicts({ + tools: [makeClientTool("Web_Search"), makeClientTool("exec")], + existingToolNames: ["web_search", "read"], + }), + ).toEqual(["Web_Search"]); + }); + + it("detects duplicate client tool names after normalization", () => { + expect( + findClientToolNameConflicts({ + tools: [makeClientTool("Weather"), makeClientTool("weather")], + }), + ).toEqual(["Weather", "weather"]); + }); + + it("wraps conflict errors with a stable prefix", () => { + const err = createClientToolNameConflictError(["exec", "Web_Search"]); + expect(err.message).toBe(`${CLIENT_TOOL_NAME_CONFLICT_PREFIX} exec, Web_Search`); + expect(isClientToolNameConflictError(err)).toBe(true); + expect(isClientToolNameConflictError(new Error("other failure"))).toBe(false); + }); +}); diff --git a/src/agents/pi-tool-definition-adapter.ts b/src/agents/pi-tool-definition-adapter.ts index b6ae8f7cd2c..0aa4c609e2f 100644 --- a/src/agents/pi-tool-definition-adapter.ts +++ b/src/agents/pi-tool-definition-adapter.ts @@ -169,6 +169,50 @@ function splitToolExecuteArgs(args: ToolExecuteArgsAny): { }; } +export const CLIENT_TOOL_NAME_CONFLICT_PREFIX = "client tool name conflict:"; + +export function findClientToolNameConflicts(params: { + tools: ClientToolDefinition[]; + existingToolNames?: Iterable; +}): string[] { + const existingNormalized = new Set(); + for (const name of params.existingToolNames ?? []) { + const trimmed = name.trim(); + if (trimmed) { + existingNormalized.add(normalizeToolName(trimmed)); + } + } + + const conflicts = new Set(); + const seenClientNames = new Map(); + for (const tool of params.tools) { + const rawName = (tool.function?.name ?? "").trim(); + if (!rawName) { + continue; + } + const normalizedName = normalizeToolName(rawName); + if (existingNormalized.has(normalizedName)) { + conflicts.add(rawName); + } + const priorClientName = seenClientNames.get(normalizedName); + if (priorClientName) { + conflicts.add(priorClientName); + conflicts.add(rawName); + continue; + } + seenClientNames.set(normalizedName, rawName); + } + return Array.from(conflicts); +} + +export function createClientToolNameConflictError(conflicts: string[]): Error { + return new Error(`${CLIENT_TOOL_NAME_CONFLICT_PREFIX} ${conflicts.join(", ")}`); +} + +export function isClientToolNameConflictError(err: unknown): err is Error { + return err instanceof Error && err.message.startsWith(CLIENT_TOOL_NAME_CONFLICT_PREFIX); +} + export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] { return tools.map((tool) => { const name = tool.name || "tool"; diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index 80477e1e036..a4aef589d3d 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import http from "node:http"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js"; import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; import { emitAgentEvent } from "../infra/agent-events.js"; @@ -323,6 +324,21 @@ describe("OpenResponses HTTP API (e2e)", () => { expect(agentCommand).toHaveBeenCalledTimes(0); await ensureResponseConsumed(resInvalidOverride); + agentCommand.mockClear(); + agentCommand.mockRejectedValueOnce(createClientToolNameConflictError(["exec"])); + const resToolConflict = await postResponses(port, { + model: "openclaw", + input: "hi", + tools: WEATHER_TOOL, + }); + expect(resToolConflict.status).toBe(400); + const toolConflictJson = (await resToolConflict.json()) as { + error?: { code?: string; message?: string }; + }; + expect(toolConflictJson.error?.code).toBe("invalid_request_error"); + expect(toolConflictJson.error?.message).toBe("invalid tool configuration"); + await ensureResponseConsumed(resToolConflict); + mockAgentOnce([{ text: "hello" }]); const resUser = await postResponses(port, { user: "alice", diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index d9d6906abf3..b340f1a5e70 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -10,6 +10,7 @@ import { createHash, randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { ImageContent } from "../agents/command/types.js"; import type { ClientToolDefinition } from "../agents/pi-embedded-runner/run/params.js"; +import { isClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js"; import { createDefaultDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.types.js"; import { agentCommandFromIngress } from "../commands/agent.js"; @@ -779,6 +780,17 @@ export async function handleOpenResponsesHttpRequest( return true; } logWarn(`openresponses: non-stream response failed: ${String(err)}`); + if (isClientToolNameConflictError(err)) { + const response = createResponseResource({ + id: responseId, + model, + status: "failed", + output: [], + error: { code: "invalid_request_error", message: "invalid tool configuration" }, + }); + sendJson(res, 400, response); + return true; + } const response = createResponseResource({ id: responseId, model, @@ -1101,6 +1113,24 @@ export async function handleOpenResponsesHttpRequest( logWarn(`openresponses: streaming response failed: ${String(err)}`); finalUsage = finalUsage ?? createEmptyUsage(); + if (isClientToolNameConflictError(err)) { + const errorResponse = createResponseResource({ + id: responseId, + model, + status: "failed", + output: [], + error: { code: "invalid_request_error", message: "invalid tool configuration" }, + usage: finalUsage, + }); + + writeSseEvent(res, { type: "response.failed", response: errorResponse }); + emitAgentEvent({ + runId: responseId, + stream: "lifecycle", + data: { phase: "error" }, + }); + return; + } const errorResponse = createResponseResource({ id: responseId, model,