Scope ToolHub resolve cache by task

This commit is contained in:
musi
2026-07-06 21:56:55 +08:00
parent 1da1723530
commit ad0d9db795
7 changed files with 259 additions and 102 deletions
+1 -1
View File
@@ -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,
+13 -1
View File
@@ -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 [];
@@ -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";
+48 -85
View File
@@ -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<SearchMessage> {
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<number, {
function: {
arguments: string;
name: string;
};
id: string;
type: "function";
}>();
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<K, V>(map: Map<K, V>): 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;
}
+1 -3
View File
@@ -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<string, unknown>): 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 = {
+2
View File
@@ -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"
});
});
+194 -11
View File
@@ -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") {