mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(agents): guard CLI prompt tool names
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"sonnet-4-6": "claude-sonnet-4-6",
|
||||
};
|
||||
|
||||
function sanitizeCliPromptTools(tools: readonly AgentTool[]): AgentTool[] {
|
||||
return buildMcpToolSchema([...tools] as Parameters<typeof buildMcpToolSchema>[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()))
|
||||
|
||||
Reference in New Issue
Block a user