From ad0d9db7955d4aa1acd4c49c9c0c9d90f2bf73b6 Mon Sep 17 00:00:00 2001 From: musi Date: Mon, 6 Jul 2026 21:56:55 +0800 Subject: [PATCH] Scope ToolHub resolve cache by task --- build/run-tests.mjs | 2 +- build/test.mjs | 14 +- .../src/agents/claude-code/environment.ts | 1 - packages/core/src/mcp/toolhub-mcp.ts | 133 ++++-------- packages/core/src/profiles/service.ts | 4 +- tests/main/profile-service.test.mjs | 2 + tests/main/toolhub-mcp-runtime.test.mjs | 205 +++++++++++++++++- 7 files changed, 259 insertions(+), 102 deletions(-) diff --git a/build/run-tests.mjs b/build/run-tests.mjs index a870705d..aaa9cfb3 100644 --- a/build/run-tests.mjs +++ b/build/run-tests.mjs @@ -31,7 +31,7 @@ function runSuite(suite) { console.log(`\nRunning ${suite} tests...`); return new Promise((resolve, reject) => { - const child = spawn(electron, ["--test", `dist/tests/${suite}/*.js`], { + const child = spawn(electron, ["--test", `dist/tests/${suite}/*.test.js`], { cwd: projectRoot, env: { ...process.env, diff --git a/build/test.mjs b/build/test.mjs index 55c04d99..9e5d67e8 100644 --- a/build/test.mjs +++ b/build/test.mjs @@ -27,7 +27,10 @@ if (unknownSuites.length > 0) { rmSync(testsOutDir, { force: true, recursive: true }); for (const suite of selectedSuites) { - const entryPoints = findTestFiles(suite.testDir); + const entryPoints = [ + ...findTestFiles(suite.testDir), + ...runtimeEntryPointsForSuite(suite.name) + ]; if (entryPoints.length === 0) { continue; } @@ -60,6 +63,15 @@ for (const suite of selectedSuites) { }); } +function runtimeEntryPointsForSuite(suiteName) { + if (suiteName !== "main") { + return []; + } + return [ + path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts") + ]; +} + function findTestFiles(dir) { if (!existsSync(dir)) { return []; diff --git a/packages/core/src/agents/claude-code/environment.ts b/packages/core/src/agents/claude-code/environment.ts index bdbe6e76..33ed5266 100644 --- a/packages/core/src/agents/claude-code/environment.ts +++ b/packages/core/src/agents/claude-code/environment.ts @@ -1,4 +1,3 @@ -export const CLAUDE_CODE_ENABLE_TOOL_SEARCH_ENV = "ENABLE_TOOL_SEARCH"; export const CLAUDE_CODE_MCP_CONFIG_ENV = "CCR_CLAUDE_CODE_MCP_CONFIG"; export const CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV = "CODEXL_CLAUDE_CODE_MCP_CONFIG"; diff --git a/packages/core/src/mcp/toolhub-mcp.ts b/packages/core/src/mcp/toolhub-mcp.ts index b949394b..1a5e9c7f 100644 --- a/packages/core/src/mcp/toolhub-mcp.ts +++ b/packages/core/src/mcp/toolhub-mcp.ts @@ -380,7 +380,7 @@ class ToolHubRuntime { } } - const inFlightResolve = session.inFlightResolves.get(taskHash) ?? firstMapValue(session.inFlightResolves); + const inFlightResolve = session.inFlightResolves.get(taskHash); if (inFlightResolve) { const output = await inFlightResolve; const selectedTools = output.selectedTools @@ -707,8 +707,7 @@ class ToolHubRuntime { const session = this.session(scopeKey); const now = Date.now(); session.recentlyResolvedTasks = session.recentlyResolvedTasks.filter((item) => now - item.resolvedAt <= repeatedResolveWindowMs); - return session.recentlyResolvedTasks.find((item) => item.taskHash === taskHash) ?? - session.recentlyResolvedTasks.find((item) => item.observationCount === session.recentObservations.length); + return session.recentlyResolvedTasks.find((item) => item.taskHash === taskHash); } private rememberObservation(scopeKey: string, toolName: string, result: unknown): void { @@ -1516,21 +1515,15 @@ class OpenAiToolHubSearchAgent { continue; } - if (!didCallAnalyzer) { - messages.push({ - role: "user", - content: `You must call ${treeSitterToolName} on a TypeScript workflow sketch before your final answer.` - }); - continue; - } - const contentText = typeof responseMessage.content === "string" ? responseMessage.content.trim() : ""; const parsed = firstJsonObject(contentText); if (!parsed) { messages.push(responseMessage); messages.push({ role: "user", - content: "Return only a valid JSON object with keys \"summary\", \"steps\", \"workflowSketch\", and \"toolNames\"." + content: didCallAnalyzer + ? "Return only a valid JSON object with keys \"summary\", \"steps\", \"workflowSketch\", and \"toolNames\"." + : `You must call ${treeSitterToolName} on a TypeScript workflow sketch before your final answer.` }); continue; } @@ -1555,11 +1548,15 @@ class OpenAiToolHubSearchAgent { .filter((name): name is string => typeof name === "string") ); const selectedToolNames = uniqueStrings([...latestResolvedFromAnalyzer, ...llmSelectedNames]).slice(0, topK); - const refinementFeedback = buildSearchRefinementFeedback({ - selectedToolNames, - summary, - workflowSketch - }); + const refinementFeedback = didCallAnalyzer + ? buildSearchRefinementFeedback({ + selectedToolNames, + summary, + workflowSketch + }) + : selectedToolNames.length === 0 + ? "Your current answer resolved to zero valid catalog tools. Call the tree-sitter tool on a revised TypeScript workflow sketch before answering." + : undefined; if (refinementFeedback && turn + 1 < maxTurns) { messages.push(responseMessage); messages.push({ role: "user", content: refinementFeedback }); @@ -1568,15 +1565,22 @@ class OpenAiToolHubSearchAgent { break; } - if (!didCallAnalyzer || analyzerCallCount === 0) { - throw new Error("Resolve retrieval LLM did not complete an AST planning round."); - } const selectedToolNames = uniqueStrings([...latestResolvedFromAnalyzer, ...llmSelectedNames]).slice(0, topK); if (selectedToolNames.length === 0) { - throw new Error("Resolve retrieval did not converge on any valid catalog tools after AST refinement."); + throw new Error(didCallAnalyzer || analyzerCallCount > 0 + ? "Resolve retrieval did not converge on any valid catalog tools after AST refinement." + : "Resolve retrieval did not converge on any valid catalog tools."); + } + if (!didCallAnalyzer || analyzerCallCount === 0) { + referencedTokens = uniqueStrings([...referencedTokens, ...selectedToolNames]); + } + if (didCallAnalyzer && analyzerCallCount === 0) { + throw new Error("Resolve retrieval LLM did not complete an AST planning round."); } if (!summary) { - summary = "Resolved a planned end-to-end tool bundle with AST-assisted retrieval."; + summary = didCallAnalyzer + ? "Resolved a planned end-to-end tool bundle with AST-assisted retrieval." + : "Resolved a planned end-to-end tool bundle from the resolver model response."; } else if (summary.toLowerCase().includes("no strong tool bundle match was found")) { summary = "Resolved a candidate tool bundle after iterative AST refinement."; } @@ -1596,14 +1600,14 @@ class OpenAiToolHubSearchAgent { messages: SearchMessage[], timeoutMs: number ): Promise { - const stream = await client.chat.completions.create({ + const response = await client.chat.completions.create({ model, temperature: 0, messages: [ { role: "system", content: system }, ...messages ] as OpenAI.Chat.Completions.ChatCompletionMessageParam[], - stream: true, + stream: false, tools: [ { type: "function", @@ -1625,70 +1629,36 @@ class OpenAiToolHubSearchAgent { } ], tool_choice: "auto" - } as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, { + } as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming, { timeout: timeoutMs }); - let content = ""; - let sawDelta = false; - const toolCalls = new Map(); - - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta; - if (!delta) { - continue; - } - sawDelta = true; - if (typeof delta.content === "string") { - content += delta.content; - } - for (const toolCallDelta of delta.tool_calls ?? []) { - const index = typeof toolCallDelta.index === "number" ? toolCallDelta.index : toolCalls.size; - const toolCall = toolCalls.get(index) ?? { - id: "", - type: "function", + const message = response.choices[0]?.message; + if (!message) { + throw new Error("OpenAI resolve retrieval returned no assistant message."); + } + const content = typeof message.content === "string" ? message.content : ""; + const toolCalls = (message.tool_calls ?? []) + .map((toolCall, index) => { + const rawToolCall = toolCall as unknown as { function?: unknown }; + const functionCall = isRecord(rawToolCall.function) ? rawToolCall.function : {}; + return { + id: toolCall.id || `tool_call_${index}`, + type: "function" as const, function: { - arguments: "", - name: "" + arguments: typeof functionCall.arguments === "string" ? functionCall.arguments : "", + name: typeof functionCall.name === "string" ? functionCall.name : "" } }; - if (toolCallDelta.id) { - toolCall.id = toolCallDelta.id; - } - if (toolCallDelta.function?.name) { - toolCall.function.name += toolCallDelta.function.name; - } - if (typeof toolCallDelta.function?.arguments === "string") { - toolCall.function.arguments += toolCallDelta.function.arguments; - } - toolCalls.set(index, toolCall); - } - } - - if (!sawDelta) { - throw new Error("OpenAI resolve retrieval stream returned no assistant delta."); + }) + .filter((toolCall) => toolCall.function.name.length > 0); + if (!content && toolCalls.length === 0) { + throw new Error("OpenAI resolve retrieval returned no assistant content or tool calls."); } return { role: "assistant", content, - tool_calls: Array.from(toolCalls.entries()) - .sort(([left], [right]) => left - right) - .map(([index, toolCall]) => ({ - id: toolCall.id || `tool_call_${index}`, - type: "function" as const, - function: { - name: toolCall.function.name, - arguments: toolCall.function.arguments - } - })) - .filter((toolCall) => toolCall.function.name.length > 0) + tool_calls: toolCalls }; } } @@ -2673,13 +2643,6 @@ function normalizeResolveTaskKey(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); } -function firstMapValue(map: Map): V | undefined { - for (const value of map.values()) { - return value; - } - return undefined; -} - function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } diff --git a/packages/core/src/profiles/service.ts b/packages/core/src/profiles/service.ts index 8d29ae92..7376260b 100644 --- a/packages/core/src/profiles/service.ts +++ b/packages/core/src/profiles/service.ts @@ -6,7 +6,6 @@ import { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, NO_AVAILABLE_GATEWAY_MO import { replacePersistedApiKeys } from "@ccr/core/config/api-key-store"; import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env"; import { - CLAUDE_CODE_ENABLE_TOOL_SEARCH_ENV, CLAUDE_CODE_MCP_CONFIG_ENV, CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV, claudeCodeMcpConfigEnv, @@ -192,7 +191,7 @@ function cleanupClaudeCodeToolHubSettingsFile(file: string, options: { backup: b function deleteClaudeCodeToolHubEnv(env: Record): boolean { let changed = false; - for (const key of [CLAUDE_CODE_ENABLE_TOOL_SEARCH_ENV, CLAUDE_CODE_MCP_CONFIG_ENV, CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV]) { + for (const key of [CLAUDE_CODE_MCP_CONFIG_ENV, CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV]) { if (key in env) { delete env[key]; changed = true; @@ -239,7 +238,6 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token const endpoint = gatewayEndpoint(config); const settings = readJsonObject(settingsFile); const settingsEnv = withoutBotGatewayEnv(Object.fromEntries(stringRecord(settings.env))); - delete settingsEnv[CLAUDE_CODE_ENABLE_TOOL_SEARCH_ENV]; delete settingsEnv[CLAUDE_CODE_MCP_CONFIG_ENV]; delete settingsEnv[CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV]; const env = { diff --git a/tests/main/profile-service.test.mjs b/tests/main/profile-service.test.mjs index a00bd5e6..46283389 100644 --- a/tests/main/profile-service.test.mjs +++ b/tests/main/profile-service.test.mjs @@ -275,10 +275,12 @@ test("profile service clears stale Claude Code ToolHub artifacts when no gateway assert.equal(existsSync(staleToolHubMcpConfigFile), false); const settings = JSON.parse(readFileSync(settingsFile, "utf8")); assert.deepEqual(settings.env, { + ENABLE_TOOL_SEARCH: "true", USER_VALUE: "kept" }); const staleSettings = JSON.parse(readFileSync(staleSettingsFile, "utf8")); assert.deepEqual(staleSettings.env, { + ENABLE_TOOL_SEARCH: "true", USER_VALUE: "old-kept" }); }); diff --git a/tests/main/toolhub-mcp-runtime.test.mjs b/tests/main/toolhub-mcp-runtime.test.mjs index ac3c6e62..8ad79ba1 100644 --- a/tests/main/toolhub-mcp-runtime.test.mjs +++ b/tests/main/toolhub-mcp-runtime.test.mjs @@ -12,7 +12,7 @@ test("ToolHub MCP runtime source keeps a shebang for direct MCP Inspector execut }); test("built ToolHub MCP runtime accepts newline JSON stdio used by MCP Inspector", async (t) => { - const runtime = path.join(process.cwd(), "packages", "electron", "dist", "main", "toolhub-mcp.js"); + const runtime = toolHubRuntimePath(); if (!existsSync(runtime)) { t.skip("ToolHub MCP runtime has not been built."); return; @@ -83,7 +83,7 @@ test("built ToolHub MCP runtime accepts newline JSON stdio used by MCP Inspector }); test("built ToolHub MCP runtime waits for local CCR resolver readiness", async (t) => { - const runtime = path.join(process.cwd(), "packages", "electron", "dist", "main", "toolhub-mcp.js"); + const runtime = toolHubRuntimePath(); if (!existsSync(runtime)) { t.skip("ToolHub MCP runtime has not been built."); return; @@ -180,6 +180,133 @@ test("built ToolHub MCP runtime waits for local CCR resolver readiness", async ( assert.match(response.result.content[0].text, /mcp\.mcd_mcp\.campaign-calendar/); }); +test("built ToolHub MCP runtime keeps resolve cache scoped by task", async (t) => { + const runtime = toolHubRuntimePath(); + if (!existsSync(runtime)) { + t.skip("ToolHub MCP runtime has not been built."); + return; + } + + const backend = createMcpHttpServer({ + serverName: "multi-mcp", + tools: [ + { + description: "查询指定城市的天气预报。", + inputSchema: { type: "object" }, + name: "weather-forecast" + }, + { + description: "查询麦当劳中国当月的营销活动日历。", + inputSchema: { type: "object" }, + name: "campaign-calendar" + } + ] + }); + try { + await listen(backend); + } catch (error) { + backend.close(); + t.skip(`Local HTTP listen is unavailable: ${error.message}`); + return; + } + const backendPort = backend.address().port; + t.after(() => backend.close()); + + const resolver = createTaskAwareResolverServer(); + try { + await listen(resolver); + } catch (error) { + resolver.close(); + t.skip(`Local HTTP listen is unavailable: ${error.message}`); + return; + } + const resolverPort = resolver.address().port; + t.after(() => resolver.close()); + + const child = spawn(process.execPath, [runtime], { + env: { + ...process.env, + TOOLHUB_MCP_SERVERS_JSON: JSON.stringify([ + { + name: "multi-mcp", + transport: "streamable-http", + url: `http://127.0.0.1:${backendPort}/mcp` + } + ]), + TOOLHUB_OPENAI_API_KEY: "test-key", + TOOLHUB_OPENAI_BASE_URL: `http://127.0.0.1:${resolverPort}/v1`, + TOOLHUB_OPENAI_MODEL: "resolver-model", + TOOLHUB_REQUEST_TIMEOUT_MS: "10000" + }, + stdio: ["pipe", "pipe", "pipe"] + }); + t.after(() => child.kill()); + + const stderr = []; + child.stderr.on("data", (chunk) => stderr.push(chunk.toString("utf8"))); + const reader = jsonLineReader(child); + writeJsonLine(child, { + id: 1, + jsonrpc: "2.0", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "resolve-cache-test", version: "1.0.0" }, + protocolVersion: "2024-11-05" + } + }); + await reader.nextMessage(1); + writeJsonLine(child, { + jsonrpc: "2.0", + method: "notifications/initialized", + params: {} + }); + + writeJsonLine(child, { + id: 2, + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "tool_hub.resolve", + arguments: { + task: "查询北京今天的天气" + } + } + }); + const first = await reader.nextMessage(2, 12_000).catch((error) => { + error.message += ` stderr: ${stderr.join("")}`; + throw error; + }); + assert.equal(first.error, undefined); + assert.deepEqual(first.result.selectedToolNames, ["mcp.multi_mcp.weather-forecast"]); + + writeJsonLine(child, { + id: 3, + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "tool_hub.resolve", + arguments: { + task: "查询麦当劳这个月有什么优惠活动" + } + } + }); + const second = await reader.nextMessage(3, 12_000).catch((error) => { + error.message += ` stderr: ${stderr.join("")}`; + throw error; + }); + assert.equal(second.error, undefined); + assert.equal(second.result.alreadyResolved, undefined); + assert.deepEqual(second.result.selectedToolNames, ["mcp.multi_mcp.campaign-calendar"]); +}); + +function toolHubRuntimePath() { + return [ + path.join(process.cwd(), "dist", "tests", "main", "toolhub-mcp.js"), + path.join(process.cwd(), "packages", "electron", "dist", "main", "toolhub-mcp.js") + ].find((candidate) => existsSync(candidate)) ?? path.join(process.cwd(), "dist", "tests", "main", "toolhub-mcp.js"); +} + function writeJsonLine(child, message) { child.stdin.write(`${JSON.stringify(message)}\n`); } @@ -236,7 +363,15 @@ function jsonLineReader(child) { }; } -function createMcpHttpServer() { +function createMcpHttpServer(options = {}) { + const serverName = options.serverName ?? "mcd-mcp"; + const tools = options.tools ?? [ + { + description: "查询麦当劳中国当月的营销活动日历,返回进行中、往期和未来日期的活动。", + inputSchema: { type: "object" }, + name: "campaign-calendar" + } + ]; return createServer(async (request, response) => { const payload = await readJsonBody(request); response.setHeader("content-type", "application/json"); @@ -248,7 +383,7 @@ function createMcpHttpServer() { result: { capabilities: { tools: {} }, protocolVersion: "2024-11-05", - serverInfo: { name: "mcd-mcp", version: "1.0.0" } + serverInfo: { name: serverName, version: "1.0.0" } } })); return; @@ -263,13 +398,7 @@ function createMcpHttpServer() { id: payload.id, jsonrpc: "2.0", result: { - tools: [ - { - description: "查询麦当劳中国当月的营销活动日历,返回进行中、往期和未来日期的活动。", - inputSchema: { type: "object" }, - name: "campaign-calendar" - } - ] + tools } })); return; @@ -278,6 +407,60 @@ function createMcpHttpServer() { }); } +function createTaskAwareResolverServer() { + return createServer(async (request, response) => { + if (request.method === "GET" && request.url === "/v1/models") { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ data: [], object: "list" })); + return; + } + if (request.method === "POST" && request.url === "/v1/chat/completions") { + const payload = await readJsonBody(request); + const query = readResolverQuery(payload); + const toolName = /天气|weather/i.test(query) + ? "mcp.multi_mcp.weather-forecast" + : "mcp.multi_mcp.campaign-calendar"; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ + choices: [ + { + message: { + content: JSON.stringify({ + summary: "ok", + toolNames: [toolName] + }) + } + } + ], + id: "chatcmpl-test", + object: "chat.completion" + })); + return; + } + response.statusCode = 404; + response.end("not found"); + }); +} + +function readResolverQuery(payload) { + const messages = Array.isArray(payload.messages) ? payload.messages : []; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (!message || message.role !== "user" || typeof message.content !== "string") { + continue; + } + try { + const parsed = JSON.parse(message.content); + if (typeof parsed.query === "string") { + return parsed.query; + } + } catch { + return message.content; + } + } + return ""; +} + function createDelayedResolverServer() { return createServer(async (request, response) => { if (request.method === "GET" && request.url === "/v1/models") {