diff --git a/src/agents/cli-runner.helpers.test.ts b/src/agents/cli-runner.helpers.test.ts index db199ff841ce..4f4330eaa0ad 100644 --- a/src/agents/cli-runner.helpers.test.ts +++ b/src/agents/cli-runner.helpers.test.ts @@ -8,6 +8,7 @@ import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { escapeRegExp } from "../shared/regexp.js"; import { buildCliArgs, + buildSystemPrompt, loadPromptRefImages, prepareCliPromptImagePayload, resolveCliRunQueueKey, @@ -219,6 +220,62 @@ describe("buildCliArgs", () => { }); }); +describe("buildSystemPrompt", () => { + it("skips unreadable tool names while preserving healthy CLI prompt tools", () => { + let reads = 0; + const unreadableTool = Object.defineProperty( + { + description: "bad experimental tool", + parameters: { type: "object", properties: {} }, + execute: async () => ({ text: "bad" }), + }, + "name", + { + get() { + throw new Error("fuzzplugin cli prompt tool name getter exploded"); + }, + }, + ); + const singleReadTool = { + description: "single read", + parameters: { type: "object", properties: {} }, + execute: async () => ({ text: "ok" }), + get name() { + reads += 1; + if (reads > 1) { + throw new Error("cli prompt tool name read twice"); + } + return " single_read "; + }, + }; + + const systemPrompt = buildSystemPrompt({ + workspaceDir: "/tmp", + modelDisplay: "codex/gpt-5.5", + tools: [ + unreadableTool, + { + name: " ", + description: "blank", + parameters: {}, + execute: async () => ({ text: "ok" }), + }, + singleReadTool, + { + name: "sessions_spawn", + description: "delegate work", + parameters: { type: "object", properties: {} }, + execute: async () => ({ text: "ok" }), + }, + ] as never, + }); + + expect(reads).toBe(1); + expect(systemPrompt).toContain("single_read"); + expect(systemPrompt).toContain("sessions_spawn"); + }); +}); + describe("writeCliImages", () => { it("uses stable hashed file paths so repeated image hydration reuses the same path", async () => { const workspaceDir = await fs.mkdtemp( diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 9c8fd472b264..94dcee6ce8c2 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -124,7 +124,7 @@ export function buildCliAgentSystemPrompt(params: { surface: "cli_backend", }), runtimeInfo, - toolNames: params.tools.map((tool) => tool.name), + toolNames: collectReadableCliToolNames(params.tools), skillsPrompt: params.skillsPrompt, userTimezone, userTime, @@ -135,6 +135,26 @@ export function buildCliAgentSystemPrompt(params: { export const buildSystemPrompt = buildCliAgentSystemPrompt; +function collectReadableCliToolNames(tools: readonly AgentTool[]): string[] { + const names: string[] = []; + for (const tool of tools) { + try { + const rawName = tool.name; + if (typeof rawName !== "string") { + continue; + } + const name = rawName.trim(); + if (name) { + names.push(name); + } + } catch { + // Runtime schema projection owns invalid-tool diagnostics; prompt setup must + // not let one unreadable tool descriptor abort the whole CLI turn. + } + } + return names; +} + export function normalizeCliModel(modelId: string, backend: CliBackendConfig): string { const trimmed = modelId.trim(); if (!trimmed) { diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index d6252429776f..ed18c80f32bd 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -1161,9 +1161,57 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { })); const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer); const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig); + const unreadableNameTool = Object.defineProperty( + { + name: "broken_name", + label: "Broken Name", + description: "bad name", + parameters: { type: "object", properties: {} }, + execute: vi.fn(), + }, + "name", + { + get() { + throw new Error("loopback tool name getter exploded"); + }, + }, + ); + const unreadableDescriptionTool = Object.defineProperty( + { + name: "memory_describe", + label: "Memory Describe", + description: "bad description", + parameters: { type: "object", properties: {} }, + execute: vi.fn(), + }, + "description", + { + get() { + throw new Error("loopback tool description getter exploded"); + }, + }, + ); + const unreadableParametersTool = Object.defineProperty( + { + name: "memory_bad_schema", + label: "Bad Schema", + description: "bad schema", + parameters: { type: "object", properties: {} }, + execute: vi.fn(), + }, + "parameters", + { + get() { + throw new Error("loopback tool parameters getter exploded"); + }, + }, + ); const resolveMcpLoopbackScopedTools = vi.fn(() => ({ agentId: "main", tools: [ + unreadableNameTool, + unreadableDescriptionTool, + unreadableParametersTool, { name: "memory_search", label: "Memory Search", @@ -1229,12 +1277,16 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { sourceReplyDeliveryMode: undefined, }); expect(context.systemPrompt).toContain("## Memory Recall"); - expect(context.systemPrompt).toContain("tools=memory_search"); + expect(context.systemPrompt).toContain("tools=memory_describe,memory_search"); expect(context.systemPromptReport.tools.entries.map((entry) => entry.name)).toEqual([ + "memory_describe", "memory_search", ]); + expect(context.systemPromptReport.tools.entries[0]?.summaryChars).toBe( + "memory_describe".length, + ); expect(context.promptToolNamesHash).toBe( - hashCliSessionText(JSON.stringify(["memory_search"])), + hashCliSessionText(JSON.stringify(["memory_describe", "memory_search"])), ); expect(context.reusableCliSession).toEqual({ invalidatedReason: "system-prompt" }); } finally { diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 90cd9bdd9fa0..7663affff3ca 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -13,6 +13,7 @@ import { resolveMcpLoopbackBearerToken, } from "../../gateway/mcp-http.loopback-runtime.js"; import { resolveMcpLoopbackScopedTools } from "../../gateway/mcp-http.runtime.js"; +import { buildMcpToolSchema } from "../../gateway/mcp-http.schema.js"; import { isClaudeCliProvider } from "../../plugin-sdk/anthropic-cli.js"; import type { CliBackendAuthEpochMode, @@ -61,6 +62,7 @@ import { composeSystemPromptWithHookContext } from "../embedded-agent-runner/run import { buildCurrentInboundPrompt } from "../embedded-agent-runner/run/runtime-context-prompt.js"; import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js"; import { applyPluginTextReplacements } from "../plugin-text-transforms.js"; +import type { AgentTool } from "../runtime/index.js"; import { ensureSystemPromptCacheBoundary } from "../system-prompt-cache-boundary.js"; import { buildSystemPromptReport } from "../system-prompt-report.js"; import { appendModelIdentitySystemPrompt, buildModelIdentityPromptLine } from "../system-prompt.js"; @@ -107,6 +109,19 @@ const CLAUDE_CLI_CONTEXT_MODEL_ALIASES: Record = { "sonnet-4-6": "claude-sonnet-4-6", }; +function sanitizeCliPromptTools(tools: readonly AgentTool[]): AgentTool[] { + return buildMcpToolSchema([...tools] as Parameters[0]).map( + (tool) => + ({ + name: tool.name, + label: tool.name, + description: tool.description ?? "", + parameters: tool.inputSchema, + execute: async () => ({ content: [], details: {} }), + }) as unknown as AgentTool, + ); +} + function resolveClaudeCliContextModelId(modelId: string): string { const trimmed = modelId.trim(); const lower = trimmed.toLowerCase(); @@ -356,7 +371,7 @@ export async function prepareCliRunContext( ...(preparedBackendEnv ? { env: preparedBackendEnv } : {}), ...(preparedCleanup ? { cleanup: preparedCleanup } : {}), }; - const promptTools = + const rawPromptTools = bundleMcpEnabled && mcpLoopbackRuntime ? prepareDeps.resolveMcpLoopbackScopedTools({ cfg: params.config ?? getRuntimeConfig(), @@ -372,6 +387,7 @@ export async function prepareCliRunContext( senderIsOwner: params.senderIsOwner, }).tools : []; + const promptTools = sanitizeCliPromptTools(rawPromptTools); const promptToolNamesHash = bundleMcpEnabled && mcpLoopbackRuntime ? hashCliSessionText(JSON.stringify(promptTools.map((tool) => tool.name).toSorted()))