diff --git a/docs/tools/code-mode/guest-api.md b/docs/tools/code-mode/guest-api.md index 19d7e9d9c1d8..08683814641c 100644 --- a/docs/tools/code-mode/guest-api.md +++ b/docs/tools/code-mode/guest-api.md @@ -215,9 +215,12 @@ const hits = await search({ query: "OpenClaw code mode" }); ``` Calling a native global or native catalog handle returns the normal tool's JSON `details` -value directly. MCP handles retain the native MCP result (`content`, optional -`structuredContent`, and optional `isError`). Exact catalog ids and raw `{ tool, result }` envelopes are not -guest-visible. +value directly. When a tool marks its result `isError: true` and its details carry no +non-empty `message` or `error` string, the value includes the tool's text content +as `message`, so guest code and the model can see why the call failed. MCP handles +retain the native MCP result (`content`, optional `structuredContent`, and +optional `isError`). Exact catalog ids and raw `{ tool, result }` envelopes are +not guest-visible. The `ls`, `find`, and `grep` tools include their bounded listing or search text in `content`, including empty-result messages and truncation notices. Directory diff --git a/src/agents/code-mode-bridge.ts b/src/agents/code-mode-bridge.ts index d64f4410d094..a29d18ff20f6 100644 --- a/src/agents/code-mode-bridge.ts +++ b/src/agents/code-mode-bridge.ts @@ -339,16 +339,12 @@ export async function runBridgeRequest(params: { yieldMs: Math.max(1, Math.min(1_000, Math.floor(params.remainingMs / 4))), }; } - const called = await params.runtime.callExactId(binding.id, input, { + value = await params.runtime.callExactValue(binding.id, input, { recoverySurface: "catalog", parentToolCallId: params.parentToolCallId, signal: params.signal, onUpdate: params.onUpdate, }); - value = - isRecord(called.result) && "details" in called.result - ? called.result.details - : called.result; break; } case "nodes": { diff --git a/src/agents/code-mode.contracts.test.ts b/src/agents/code-mode.contracts.test.ts index 9d9b3d7df133..1c55e96a1387 100644 --- a/src/agents/code-mode.contracts.test.ts +++ b/src/agents/code-mode.contracts.test.ts @@ -124,6 +124,36 @@ it("allows omitted native empty inputs but preserves required fields", async () expect(required.execute).toHaveBeenCalledOnce(); }); +it("keeps an explicit tool error's text when its details carry no message", async () => { + const h = createCodeModeHarness(); + const reason = "Start the Gateway with visitor-access enabled before managing visitors."; + const errorTool = (name: string, text: string, details: unknown) => + pluginToolWithExecute(name, "Fail", async () => ({ + content: [{ type: "text" as const, text }], + details, + isError: true, + })); + const tools = [ + errorTool("flag_only", reason, { error: true }), + errorTool("no_details", "Gateway unavailable.", undefined), + errorTool("blank_message", "Visitor store is locked.", { error: true, message: " " }), + errorTool("structured", "Rendered failure.", { status: "failed", error: "structured" }), + ]; + applyCodeModeCatalog({ ...h.ctx, tools: [...h.tools, ...tools] }); + const result = resultDetails( + await expectDefined(h.tools[0], "exec").execute("tool-error-text", { + code: "return [await flag_only(), await no_details(), await blank_message(), await structured()];", + }), + ); + expect(result.status, JSON.stringify(result)).toBe("completed"); + expect(result.value).toEqual([ + { error: true, message: reason }, + { message: "Gateway unavailable." }, + { error: true, message: "Visitor store is locked." }, + { status: "failed", error: "structured" }, + ]); +}); + it("merges actual root and multiple server files without skipping declaration errors", () => { const files = createMcpApiVirtualFiles( ["alpha", "beta", "index"].map((identifier) => ({ diff --git a/src/agents/tool-search-runtime.ts b/src/agents/tool-search-runtime.ts index fecb7fb763bc..536f4721a85b 100644 --- a/src/agents/tool-search-runtime.ts +++ b/src/agents/tool-search-runtime.ts @@ -9,6 +9,7 @@ import { } from "./agent-tools.before-tool-call.js"; import { runWithToolExecutionValidation } from "./agent-tools.execution-validation.js"; import { getChannelAgentToolMeta } from "./channel-tool-metadata.js"; +import { collectTextContentBlocks } from "./content-blocks.js"; import { setMcpCodeModeGuestResultFromAgentResult } from "./mcp-content.js"; import { captureAgentPluginRuntimeRefresh } from "./plugin-runtime-refresh.js"; import type { AgentToolResult } from "./runtime/index.js"; @@ -65,6 +66,11 @@ import type { } from "./tool-search-types.js"; import { textResult, ToolInputError } from "./tools/common.js"; +type ToolSearchExactCallOptions = Pick< + ToolSearchCallOptions, + "parentToolCallId" | "signal" | "onUpdate" | "recoverySurface" | "mcpNamespaceGuest" +>; + function describeEntry(entry: ToolSearchCatalogEntry) { return { ...compactToolSearchCatalogEntry(entry), @@ -412,14 +418,7 @@ export class ToolSearchRuntime { ); }; - callExactId = async ( - id: string, - input?: unknown, - options?: Pick< - ToolSearchCallOptions, - "parentToolCallId" | "signal" | "onUpdate" | "recoverySurface" | "mcpNamespaceGuest" - >, - ) => { + callExactId = async (id: string, input?: unknown, options?: ToolSearchExactCallOptions) => { const catalog = resolveCatalog(this.ctx); return await this.callEntry( findEntryByExactId(catalog, id, { ...options, codeModeSkills: this.ctx.codeModeSkills }), @@ -429,7 +428,10 @@ export class ToolSearchRuntime { }; callValue = async (id: string, input?: unknown, options?: ToolSearchCallOptions) => - unwrapToolResultValue((await this.call(id, input, options)).result); + projectToolResultValue((await this.call(id, input, options)).result); + + callExactValue = async (id: string, input?: unknown, options?: ToolSearchExactCallOptions) => + projectToolResultValue((await this.callExactId(id, input, options)).result); observeNetworkContent(parentToolCallId: string): void { const state = this.networkInvocations.get(parentToolCallId) ?? { active: 0, observed: false }; @@ -699,3 +701,36 @@ export function formatToolSearchControlError( function unwrapToolResultValue(result: AgentToolResult): unknown { return isRecord(result) && "details" in result ? result.details : result; } + +/** + * Project a target result into the value Code Mode guests receive. Output + * contracts validate the raw details; only the guest value gains error text. + */ +function projectToolResultValue(result: AgentToolResult): unknown { + if (!isRecord(result) || !("details" in result)) { + return result; + } + const { details } = result; + // An explicit error can explain itself only in model-facing text; keep that reason. + if (result.isError !== true || hasErrorMessage(details)) { + return details; + } + const message = collectTextContentBlocks(result.content) + .map((text) => text.trim()) + .filter(Boolean) + .join("\n"); + if (!message) { + return details; + } + if (details === undefined || details === null) { + return { message }; + } + return isRecord(details) ? { ...details, message } : details; +} + +function hasErrorMessage(details: unknown): boolean { + return ( + isRecord(details) && + [details.message, details.error].some((value) => typeof value === "string" && value.trim()) + ); +}