feat(mcp): resume queued calculation jobs
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { queryMcpToolId } from "../runtime/product-mcp-tools.js";
|
||||
|
||||
const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
// Use only host-read native history. Ambiguous simultaneous identical calls fail
|
||||
// closed rather than assigning a second submission to the first call's job.
|
||||
export function resolveMatchingNativeCall(messages: unknown, toolName: string, args: Record<string, unknown>): string | null {
|
||||
if (!Array.isArray(messages)) return null;
|
||||
const ids = new Set<string>();
|
||||
for (const message of messages) {
|
||||
if (!record(message) || !Array.isArray(message.parts)) continue;
|
||||
for (const part of message.parts) {
|
||||
if (!record(part) || part.type !== "tool" || part.tool !== queryMcpToolId(toolName) || !record(part.state)) continue;
|
||||
if (!["running", "pending"].includes(String(part.state.status)) || !isDeepStrictEqual(part.state.input, args)) continue;
|
||||
if (typeof part.callID === "string" && part.callID.length > 0 && part.callID.length <= 256) ids.add(part.callID);
|
||||
}
|
||||
}
|
||||
return ids.size === 1 ? [...ids][0]! : null;
|
||||
}
|
||||
+20
-13
@@ -1,8 +1,8 @@
|
||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { createMcpHandler, McpServer, fromJsonSchema, type Tool } from "@modelcontextprotocol/server";
|
||||
import * as z from "zod/v4";
|
||||
import { getRuntimeSessionContext, type RuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||
import { LOCAL_QUERY_TOOLS, PRODUCT_MCP_TOOLS, queryMcpToolId } from "../runtime/product-mcp-tools.js";
|
||||
import { LOCAL_QUERY_TOOLS, PRODUCT_MCP_TOOLS, PRODUCT_MCP_QUEUED_TOOLS, queryMcpToolId } from "../runtime/product-mcp-tools.js";
|
||||
import type { McpQueryClient, QueryOwner } from "../product/mcp-query-client.js";
|
||||
import { localQueryDescriptions, localQueryInputs } from "./local-query-tools.js";
|
||||
import { allowedQueryTools } from "../runtime/query-mcp-policy.js";
|
||||
@@ -15,6 +15,7 @@ const productNames = new Set<string>(PRODUCT_MCP_TOOLS);
|
||||
|
||||
export function createQueryMcpHandler(options: {
|
||||
internalToken: string; productClient: Pick<McpQueryClient, "query"> | null; productTools: Tool[];
|
||||
resolveNativeCall?: (sessionId: string, toolName: string, args: Record<string, unknown>) => Promise<string | null>;
|
||||
resolveReader: (id: string) => Promise<QueryOwner | null>;
|
||||
executeLocal: (name: LocalQueryName, args: Record<string, unknown>, context: RuntimeSessionContext, signal: AbortSignal) => Promise<unknown>;
|
||||
artifactWorkspace?: (context: RuntimeSessionContext) => Promise<string>;
|
||||
@@ -37,10 +38,16 @@ export function createQueryMcpHandler(options: {
|
||||
if (!options.productClient) return rejected("Product MCP is disabled");
|
||||
const query = definition.name === "get_task_context" && args.report_revision === undefined && owner.context.reportRevision
|
||||
? { ...args, report_revision: owner.context.reportRevision } : args;
|
||||
// JSON-RPC IDs may restart on a native MCP reconnect. Use a host
|
||||
// request key, stable across this client's auth/transport retries,
|
||||
// so a later query cannot collide with a saved evidence receipt.
|
||||
const result = await options.productClient.query(id, owner, definition.name, query, randomUUID(), signal);
|
||||
// Native JSON-RPC IDs restart on reconnect. Resolve the persisted
|
||||
// native tool call from host history, never from model arguments.
|
||||
const nativeCallId = await options.resolveNativeCall?.(id, definition.name, args);
|
||||
const requiresIdentity = (PRODUCT_MCP_QUEUED_TOOLS as readonly string[]).includes(definition.name)
|
||||
&& (!["save_simulation_scenario", "create_sensor_placement"].includes(definition.name) || args.dry_run === false);
|
||||
if (requiresIdentity && !nativeCallId) return rejected("Native tool call identity unavailable; check calculation jobs before retrying");
|
||||
const callId = createHash("sha256").update(JSON.stringify([
|
||||
owner.context.actorKey, owner.context.projectKey, id, definition.name, nativeCallId ?? randomUUID(),
|
||||
])).digest("hex");
|
||||
const result = await options.productClient.query(id, owner, definition.name, query, callId, signal);
|
||||
if (["export_project_model", "export_sensor_placement"].includes(definition.name) && !result.isError) {
|
||||
if (!options.artifactWorkspace) return rejected("Export workspace unavailable");
|
||||
return await saveMcpArtifact(result, owner.context, await options.artifactWorkspace(owner.context));
|
||||
@@ -53,7 +60,12 @@ export function createQueryMcpHandler(options: {
|
||||
} catch (cause) {
|
||||
// Never return upstream bodies, headers or credentials across MCP.
|
||||
const status = cause && typeof cause === "object" && "status" in cause ? cause.status : undefined;
|
||||
return rejected(`Query unavailable${typeof status === "number" ? ` (${status})` : ""}`);
|
||||
const name = cause instanceof Error ? cause.name : undefined;
|
||||
const code = cause && typeof cause === "object" && "code" in cause ? cause.code : undefined;
|
||||
const category = name === "TimeoutError" || code === -32001 || status === 408 || status === 504 ? "Query timed out"
|
||||
: name === "AbortError" ? "Query cancelled"
|
||||
: cause instanceof TypeError || ["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EPIPE"].includes(String(code)) ? "Query network failure" : "Query unavailable";
|
||||
return rejected(`${category}${typeof status === "number" ? ` (${status})` : ""}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -77,12 +89,7 @@ const rejected = (message: string) => ({ content: [{ type: "text" as const, text
|
||||
export function startQueryMcpServer(options: Parameters<typeof createQueryMcpHandler>[0]) {
|
||||
const handler = createQueryMcpHandler(options);
|
||||
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, idleTimeout: 60,
|
||||
fetch(request, server) {
|
||||
// Native calls enforce a per-request deadline; an idle socket must not
|
||||
// disconnect a calculation before that deadline while no result is ready.
|
||||
server.timeout(request, 0);
|
||||
return handler.fetch(request);
|
||||
},
|
||||
fetch: (request) => handler.fetch(request),
|
||||
});
|
||||
return { url: `http://127.0.0.1:${server.port}/mcp`, executionTimeoutMs: handler.executionTimeoutMs,
|
||||
async close() { await handler.close(); await server.stop(true); } };
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { Tool } from "@modelcontextprotocol/client";
|
||||
// Absolute validation bound; the product catalog advertises the administrator's
|
||||
// effective maximum (one hour by default), never an identity or authorization grant.
|
||||
const ABSOLUTE_CALCULATION_SECONDS = 86_400;
|
||||
const CALCULATION_OVERHEAD_MS = 60_000;
|
||||
|
||||
export const DEFAULT_QUERY_REQUEST_TIMEOUT_MS = 35_000;
|
||||
export const MAX_NATIVE_QUERY_TIMEOUT_MS = ABSOLUTE_CALCULATION_SECONDS * 1000 + CALCULATION_OVERHEAD_MS + 5000;
|
||||
export const MAX_NATIVE_QUERY_TIMEOUT_MS = DEFAULT_QUERY_REQUEST_TIMEOUT_MS;
|
||||
|
||||
function timeoutSchema(tool: Tool) {
|
||||
const property = tool.inputSchema.properties?.timeout_seconds;
|
||||
@@ -22,12 +22,10 @@ export function queryTimeouts(tool: Tool, args: Record<string, unknown>) {
|
||||
throw Object.assign(new Error("Calculation timeout must be an integer within the advertised server limit"), { status: 400 });
|
||||
}
|
||||
const executionMs = seconds * 1000;
|
||||
return { executionMs, callMs: executionMs + 5000, requestMs: executionMs + CALCULATION_OVERHEAD_MS };
|
||||
// timeout_seconds controls the queued worker, never this short submission.
|
||||
return { executionMs, callMs: 30_000, requestMs: DEFAULT_QUERY_REQUEST_TIMEOUT_MS };
|
||||
}
|
||||
|
||||
export function nativeQueryTimeout(tools: Tool[]) {
|
||||
return Math.max(DEFAULT_QUERY_REQUEST_TIMEOUT_MS, ...tools.map((tool) => {
|
||||
const schema = timeoutSchema(tool);
|
||||
return schema ? queryTimeouts(tool, { timeout_seconds: schema.maximum }).requestMs + 5000 : DEFAULT_QUERY_REQUEST_TIMEOUT_MS;
|
||||
}));
|
||||
export function nativeQueryTimeout(_tools: Tool[]) {
|
||||
return DEFAULT_QUERY_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import type { McpQueryClient, QueryOwner } from "../product/mcp-query-client.js";
|
||||
|
||||
export type TaskCalculationWaiter = (sessionId: string, signal: AbortSignal) => Promise<string | null>;
|
||||
const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
// The host waits between short status requests while the task lease continues
|
||||
// heartbeating. No native model turn or long-lived MCP request performs polling.
|
||||
export function createTaskCalculationWaiter(
|
||||
client: Pick<McpQueryClient, "query">,
|
||||
resolveOwner: (sessionId: string) => QueryOwner | null,
|
||||
pause: (ms: number, signal: AbortSignal) => Promise<unknown> = (ms, signal) => delay(ms, undefined, { signal }),
|
||||
): TaskCalculationWaiter {
|
||||
const delivered = new Map<string, Set<string>>();
|
||||
return async (sessionId, signal) => {
|
||||
const owner = resolveOwner(sessionId);
|
||||
if (!owner || owner.context.purpose !== "task_execution") throw new Error("Calculation task owner unavailable");
|
||||
const seen = delivered.get(sessionId) ?? new Set<string>();
|
||||
delivered.set(sessionId, seen);
|
||||
const terminal = new Map<string, string>();
|
||||
for (;;) {
|
||||
signal.throwIfAborted();
|
||||
let pending = false;
|
||||
let pollMs = 5000;
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const result = await client.query(sessionId, owner, "list_calculation_jobs", { limit: 100, offset }, randomUUID(), signal);
|
||||
const body = result.structuredContent;
|
||||
if (result.isError || !record(body) || !Array.isArray(body.data)) throw new Error("Calculation status unavailable");
|
||||
for (const job of body.data) {
|
||||
if (!record(job) || typeof job.job_id !== "string" || typeof job.status !== "string") throw new Error("Calculation status invalid");
|
||||
if (["queued", "running", "cancel_requested"].includes(job.status)) {
|
||||
pending = true;
|
||||
if (typeof job.poll_after_ms === "number" && Number.isFinite(job.poll_after_ms)) pollMs = Math.min(pollMs, Math.max(1500, job.poll_after_ms));
|
||||
} else if (["succeeded", "failed", "timed_out", "cancelled", "interrupted"].includes(job.status)) {
|
||||
if (!seen.has(job.job_id)) terminal.set(job.job_id, job.status);
|
||||
} else throw new Error("Calculation status invalid");
|
||||
}
|
||||
if (!record(body.page) || typeof body.page.has_more !== "boolean") throw new Error("Calculation status paging invalid");
|
||||
if (!body.page.has_more) break;
|
||||
offset += 100;
|
||||
if (offset > 10000) throw new Error("Calculation status paging limit exceeded");
|
||||
}
|
||||
if (!pending) {
|
||||
if (!terminal.size) return null;
|
||||
for (const id of terminal.keys()) seen.add(id);
|
||||
return `后台计算已进入终态:${JSON.stringify([...terminal].map(([job_id, status]) => ({ job_id, status })))}。继续当前调查,使用 get_calculation_result 按 job_id、data_path、limit、offset 小页读取成功结果后形成判断;失败、取消、超时或中断应按执行异常处理,不得当作无异常或数据缺失。不要重复提交这些作业。`;
|
||||
}
|
||||
await pause(pollMs, signal);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TaskCalculationWaiter } from "../mcp/task-calculation-waiter.js";
|
||||
import { registerInteractionRequestRoutes } from "./interaction-request-routes.js";
|
||||
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
|
||||
import { Router, type NextFunction, type Request, type Response } from "express";
|
||||
@@ -33,6 +34,7 @@ import { registerArtifactRoutes } from "./artifact-routes.js";
|
||||
import type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js";
|
||||
|
||||
export type BuildChatRouterOptions = {
|
||||
waitTaskCalculations?: TaskCalculationWaiter;
|
||||
interactionRequestStore?: InteractionRequestStore;
|
||||
agentMessages?: AgentMessageCoordinator;
|
||||
frontendActionCoordinator: FrontendActionCoordinator;
|
||||
@@ -170,6 +172,7 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
|
||||
|
||||
registerResponseInputRoutes(chatRouter, { runtime, sessionMetadataStore, runState });
|
||||
registerChatStreamRoute(chatRouter, {
|
||||
waitTaskCalculations: options.waitTaskCalculations,
|
||||
interactionRequestStore: options.interactionRequestStore,
|
||||
agentMessages,
|
||||
frontendActionCoordinator,
|
||||
|
||||
@@ -77,7 +77,7 @@ type StreamPromptOptions = {
|
||||
onInboxEvent?: (type: string, data: { inboxID?: string; delivery?: "steer" | "queue" }) => void;
|
||||
waitForUserInput?: () => Promise<boolean>;
|
||||
beforePrompt?: () => Promise<void>;
|
||||
beforeIdle?: () => Promise<boolean>;
|
||||
beforeIdle?: () => Promise<boolean | string>;
|
||||
beforeRichAnswerClose?: () => Promise<boolean>;
|
||||
runtime: OpencodeRuntimeAdapter;
|
||||
sessionId: string;
|
||||
@@ -904,7 +904,7 @@ export const streamPromptResponse = async ({
|
||||
id: part.id,
|
||||
phase: "tool",
|
||||
status: normalizeToolStatus(part.state.status),
|
||||
title: getToolProgressTitle(part.tool, part.state.status),
|
||||
title: getToolProgressTitle(part.tool, part.state.status, part.state.status === "completed" ? part.state.output : undefined),
|
||||
tool: { name: part.tool, call_id: part.callID, input: sanitizeToolProgressInput(toolParams) },
|
||||
detail: buildToolProgressDetail(
|
||||
part.tool,
|
||||
@@ -912,6 +912,7 @@ export const streamPromptResponse = async ({
|
||||
toolParams,
|
||||
reason,
|
||||
part.state.status === "error" ? part.state.error : undefined,
|
||||
part.state.status === "completed" ? part.state.output : undefined,
|
||||
),
|
||||
});
|
||||
if (
|
||||
@@ -1022,12 +1023,13 @@ export const streamPromptResponse = async ({
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (beforeIdle && !await beforeIdle()) {
|
||||
const idleDecision = beforeIdle ? await beforeIdle() : true;
|
||||
if (idleDecision === false || typeof idleDecision === "string") {
|
||||
// A durable message won the race with completion. Continue within
|
||||
// the same managed session/lease, never create another task run.
|
||||
finalAnswer = undefined;
|
||||
sawResponseActivity = false;
|
||||
await runtime.prompt(sessionId, "请处理收信箱中新到达的调查协作消息,按需回复,然后完成本轮工作。", toRuntimeModel(model));
|
||||
await runtime.prompt(sessionId, typeof idleDecision === "string" ? idleDecision : "请处理收信箱中新到达的调查协作消息,按需回复,然后完成本轮工作。", toRuntimeModel(model));
|
||||
continue;
|
||||
}
|
||||
if (waitForUserInput && await waitForUserInput()) {
|
||||
|
||||
@@ -468,6 +468,7 @@ export const buildToolProgressDetail = (
|
||||
params: Record<string, unknown>,
|
||||
reason: string,
|
||||
error?: string,
|
||||
output?: string,
|
||||
) => {
|
||||
const toolName = toolLabels[tool] ?? tool;
|
||||
const reasonText = reason ? `;说明:${containsForbiddenPersistentContent(reason) ? "[redacted]" : reason}` : "";
|
||||
@@ -478,6 +479,8 @@ export const buildToolProgressDetail = (
|
||||
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
|
||||
}
|
||||
if (status === "completed") {
|
||||
const calculation = calculationProgress(output);
|
||||
if (calculation) return `${toolName}:${calculation.label};计算任务:${calculation.jobId}${reasonText}${paramsText}`;
|
||||
return `${toolName} 已执行完成${reasonText}${paramsText}`;
|
||||
}
|
||||
if (status === "pending") {
|
||||
@@ -486,8 +489,28 @@ export const buildToolProgressDetail = (
|
||||
return `${toolName} 正在执行${reasonText}${paramsText}`;
|
||||
};
|
||||
|
||||
export const getToolProgressTitle = (tool: string, status: string) => {
|
||||
const calculationProgress = (output?: string) => {
|
||||
if (!output || output.length > 100_000) return null;
|
||||
try {
|
||||
const value = JSON.parse(output);
|
||||
if (value?.semantics !== "task" || typeof value.data?.job_id !== "string" ||
|
||||
!/^[0-9a-f-]{36}$/i.test(value.data.job_id)) return null;
|
||||
const labels: Record<string, string> = {
|
||||
queued: "已提交,等待计算", running: "后台计算中", cancel_requested: "已请求取消,等待计算停止",
|
||||
succeeded: "计算已完成", failed: "计算失败", cancelled: "计算已取消",
|
||||
interrupted: "计算已中断", timed_out: "计算执行超时",
|
||||
};
|
||||
const label = labels[value.data.status];
|
||||
return label ? { label, jobId: value.data.job_id } : null;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
export const getToolProgressTitle = (tool: string, status: string, output?: string) => {
|
||||
const toolName = toolLabels[tool] ?? tool;
|
||||
if (status === "completed") {
|
||||
const calculation = calculationProgress(output);
|
||||
if (calculation) return `${toolName}:${calculation.label}`;
|
||||
}
|
||||
if (status === "completed") return `${toolName} 已完成`;
|
||||
if (status === "error") return `${toolName} 执行失败`;
|
||||
if (status === "pending") return `准备调用 ${toolName}`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TaskCalculationWaiter } from "../mcp/task-calculation-waiter.js";
|
||||
import { createResponsesProjection, responseRequestSchema, responsePrompt } from "../chat/responses-protocol.js";
|
||||
import { ResponseInputTracker, WaitingResponseInputQueue } from "../chat/response-inputs.js";
|
||||
import { reconcileInteractionDelivery } from "../sessions/interaction-delivery-reconciliation.js";
|
||||
@@ -252,6 +253,7 @@ export const buildFailedTaskExecutionPlan = (history: Record<string, unknown>[])
|
||||
};
|
||||
|
||||
type RegisterChatStreamRouteOptions = {
|
||||
waitTaskCalculations?: TaskCalculationWaiter;
|
||||
interactionRequestStore?: InteractionRequestStore;
|
||||
agentMessages?: AgentMessageCoordinator;
|
||||
frontendActionCoordinator: FrontendActionCoordinator;
|
||||
@@ -269,6 +271,7 @@ export const registerChatStreamRoute = (
|
||||
chatRouter: Router,
|
||||
{
|
||||
interactionRequestStore,
|
||||
waitTaskCalculations,
|
||||
frontendActionCoordinator,
|
||||
learningOrchestrator,
|
||||
memoryStore,
|
||||
@@ -1005,9 +1008,11 @@ export const registerChatStreamRoute = (
|
||||
if (messaging && (messaging.hasInputThisTurn(binding.sessionId) || !await messaging.closeInteractive(activeSessionRecord))) return false;
|
||||
return inputTracker.canClose();
|
||||
},
|
||||
beforeIdle: messaging ? async () => {
|
||||
if (sessionPurpose !== "task_execution") return messaging.closeInteractive(activeSessionRecord);
|
||||
if (await messaging.flush(binding.sessionId)) return false;
|
||||
beforeIdle: messaging || (sessionPurpose === "task_execution" && waitTaskCalculations) ? async () => {
|
||||
if (sessionPurpose !== "task_execution") return messaging ? messaging.closeInteractive(activeSessionRecord) : true;
|
||||
if (await messaging?.flush(binding.sessionId)) return false;
|
||||
const calculationResume = await waitTaskCalculations?.(binding.sessionId, abortController.signal);
|
||||
if (calculationResume) return calculationResume;
|
||||
await taskArtifactPublishQueue;
|
||||
if (taskLeaseFailure || !activeSessionRecord.taskId || !activeSessionRecord.runId) return true;
|
||||
taskCompleting = true;
|
||||
@@ -1018,13 +1023,13 @@ export const registerChatStreamRoute = (
|
||||
const task = await operationalTaskClient.completeTaskRun(binding.sessionId,
|
||||
activeSessionRecord.taskId, activeSessionRecord.runId, clientSessionId);
|
||||
taskRunFinalized = true;
|
||||
messaging.stop(binding.sessionId);
|
||||
messaging?.stop(binding.sessionId);
|
||||
await stopTaskHeartbeat();
|
||||
if (task.lifecycle_status === "failed") throw new Error("Server rejected incomplete investigation artifacts");
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "PENDING_AGENT_MESSAGES") {
|
||||
await messaging.flush(binding.sessionId);
|
||||
await messaging?.flush(binding.sessionId);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
@@ -1048,7 +1053,6 @@ export const registerChatStreamRoute = (
|
||||
publish("execution_plan", buildFailedTaskExecutionPlan(executionPlanHistory));
|
||||
}
|
||||
await taskArtifactPublishQueue;
|
||||
await stopTaskHeartbeat();
|
||||
if (
|
||||
sessionPurpose === "task_execution"
|
||||
&& activeSessionRecord.taskId
|
||||
@@ -1068,6 +1072,7 @@ export const registerChatStreamRoute = (
|
||||
taskRunFinalized = true;
|
||||
} else {
|
||||
try {
|
||||
if (await waitTaskCalculations?.(binding.sessionId, abortController.signal)) throw Object.assign(new Error("Calculation results require investigation continuation"), { code: "CALCULATION_CONTINUATION_REQUIRED" });
|
||||
const completedTask = await operationalTaskClient.completeTaskRun(
|
||||
binding.sessionId,
|
||||
activeSessionRecord.taskId,
|
||||
@@ -1096,6 +1101,7 @@ export const registerChatStreamRoute = (
|
||||
}
|
||||
}
|
||||
}
|
||||
await stopTaskHeartbeat();
|
||||
await persistQueue.catch((error) => {
|
||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||
});
|
||||
@@ -1153,6 +1159,7 @@ export const registerChatStreamRoute = (
|
||||
session_id: clientSessionId,
|
||||
title: sessionTitle,
|
||||
});
|
||||
await stopTaskHeartbeat();
|
||||
await persistQueue.catch((error) => {
|
||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export const PRODUCT_MCP_READ_TOOLS = [
|
||||
"get_mutation_status",
|
||||
"get_calculation_job",
|
||||
"get_calculation_result",
|
||||
"list_calculation_jobs",
|
||||
"export_project_model",
|
||||
"export_sensor_placement",
|
||||
"get_analysis_run",
|
||||
@@ -58,6 +61,7 @@ export const PRODUCT_MCP_ANALYSIS_TOOLS = [
|
||||
] as const;
|
||||
export const PRODUCT_MCP_WRITE_TOOLS = [
|
||||
"cancel_task_run",
|
||||
"cancel_calculation_job",
|
||||
"clean_scada_readings",
|
||||
"create_operational_task",
|
||||
"create_sensor_placement",
|
||||
@@ -81,3 +85,10 @@ export const LOCAL_QUERY_TOOLS = ["geocode", "session_search", "list_investigati
|
||||
export const LOCAL_QUERY_TOOL_IDS = LOCAL_QUERY_TOOLS.map(queryMcpToolId);
|
||||
export const QUERY_MCP_TOOL_IDS = [...PRODUCT_MCP_TOOL_IDS, ...LOCAL_QUERY_TOOL_IDS];
|
||||
export const QUERY_MCP_AUTOALLOW_TOOL_IDS = [...PRODUCT_MCP_READ_TOOL_IDS, ...PRODUCT_MCP_ANALYSIS_TOOLS.map(queryMcpToolId), ...LOCAL_QUERY_TOOL_IDS];
|
||||
|
||||
export const PRODUCT_MCP_QUEUED_TOOLS = [
|
||||
"run_burst_location", "run_leakage_identification", "run_burst_detection",
|
||||
"run_simulation", "run_burst_simulation", "run_contaminant_simulation",
|
||||
"analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation",
|
||||
"optimize_sensor_placement", "save_simulation_scenario", "create_sensor_placement",
|
||||
] as const;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { InteractionRequestStore } from "./sessions/interaction-request-store.js
|
||||
import { registerChatInteractionBridge } from "./routes/chat-interaction-bridge.js";
|
||||
import { writeQueryMcpConnection } from "./runtime/query-mcp-connection.js";
|
||||
import { startQueryMcpServer } from "./mcp/query-server.js";
|
||||
import { resolveMatchingNativeCall } from "./mcp/native-call-identity.js";
|
||||
import { createTaskCalculationWaiter } from "./mcp/task-calculation-waiter.js";
|
||||
import { createLocalQueryExecutor } from "./mcp/local-query-tools.js";
|
||||
import { createProductMcpClient } from "./product/mcp-query-client.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -225,6 +227,10 @@ app.get("/health", async (_req, res) => {
|
||||
app.use(
|
||||
"/api/v1",
|
||||
buildChatRouter({
|
||||
waitTaskCalculations: productMcpClient ? createTaskCalculationWaiter(productMcpClient, (id) => {
|
||||
const context = getRuntimeSessionContext(id);
|
||||
return context ? { context, runtimeSessionId: id, taskId: context.taskId } : null;
|
||||
}) : undefined,
|
||||
interactionRequestStore,
|
||||
agentMessages,
|
||||
frontendActionCoordinator,
|
||||
@@ -284,6 +290,16 @@ const warmupOpencodeRuntime = async () => {
|
||||
|
||||
const sandboxProbe = await probeLandlockSandbox();
|
||||
const queryMcp = startQueryMcpServer({
|
||||
resolveNativeCall: async (sessionId, toolName, args) => {
|
||||
// Native tool state can arrive just after the MCP request. Only use a
|
||||
// unique current call from host-read history, never a model-supplied key.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const callId = resolveMatchingNativeCall(await opencodeRuntime.messages(sessionId, 20), toolName, args);
|
||||
if (callId) return callId;
|
||||
if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
artifactWorkspace: (context) => opencodeRuntime.ensureSessionWorkspace(context.sessionId, context),
|
||||
internalToken, productClient: productMcpClient,
|
||||
productTools: productMcpClient ? await productMcpClient.prepare() : [],
|
||||
|
||||
Reference in New Issue
Block a user