feat: upgrade investigation and presentation runtime
This commit is contained in:
+45
-30
@@ -7,7 +7,7 @@ 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";
|
||||
import { localEvidenceResult } from "./evidence-result.js";
|
||||
import { nativeQueryTimeout, queryTimeouts } from "./query-timeouts.js";
|
||||
import { boundedQuery, MAX_NATIVE_QUERY_TIMEOUT_MS, modelQueryTool, nativeQueryTimeout, queryTimeouts } from "./query-timeouts.js";
|
||||
import { saveMcpArtifact } from "./artifact-files.js";
|
||||
|
||||
export type LocalQueryName = typeof LOCAL_QUERY_TOOLS[number];
|
||||
@@ -24,39 +24,46 @@ export function createQueryMcpHandler(options: {
|
||||
inputSchema: z.toJSONSchema(localQueryInputs[name], { io: "input" }) as Tool["inputSchema"], annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: name === "geocode" } }))];
|
||||
const handler = createMcpHandler(() => {
|
||||
const server = new McpServer({ name: "tjwater-agent-queries", version: "1.0.0" });
|
||||
for (const definition of definitions) {
|
||||
for (const originalDefinition of definitions) {
|
||||
const definition = modelQueryTool(originalDefinition);
|
||||
server.registerTool(definition.name, { ...definition, inputSchema: fromJsonSchema<Record<string, unknown>>(definition.inputSchema as Parameters<typeof fromJsonSchema>[0]), outputSchema: definition.outputSchema ? fromJsonSchema(definition.outputSchema) : undefined }, async (args, request) => {
|
||||
const id = request.mcpReq._meta?.["ai.opencode/sessionID"];
|
||||
if (typeof id !== "string" || !id || id.length > 128) return rejected("Authenticated native session metadata required");
|
||||
const direct = getRuntimeSessionContext(id);
|
||||
const owner = direct ? { context: direct, runtimeSessionId: id, taskId: direct.taskId } : await options.resolveReader(id);
|
||||
if (!owner || !allowedQueryTools(owner.context, !direct).includes(queryMcpToolId(definition.name))) return rejected("Query unavailable for this session");
|
||||
try {
|
||||
const budget = queryTimeouts(definition, args);
|
||||
const budget = queryTimeouts(originalDefinition);
|
||||
const signal = AbortSignal.any([request.mcpReq.signal, AbortSignal.timeout(budget.requestMs)]);
|
||||
if (productNames.has(definition.name)) {
|
||||
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;
|
||||
// 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));
|
||||
return await boundedQuery(async () => {
|
||||
const direct = getRuntimeSessionContext(id);
|
||||
const owner = direct ? { context: direct, runtimeSessionId: id, taskId: direct.taskId } : await options.resolveReader(id);
|
||||
signal.throwIfAborted();
|
||||
if (!owner || !allowedQueryTools(owner.context, !direct).includes(queryMcpToolId(definition.name))) return rejected("Query unavailable for this session");
|
||||
if (productNames.has(definition.name)) {
|
||||
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;
|
||||
// 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);
|
||||
signal.throwIfAborted();
|
||||
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 boundedQuery((boundedSignal) => options.productClient!.query(id, owner, definition.name, query, callId, boundedSignal), signal);
|
||||
if (["export_project_model", "export_sensor_placement"].includes(definition.name) && !result.isError) {
|
||||
if (!options.artifactWorkspace) return rejected("Export workspace unavailable");
|
||||
const workspace = await options.artifactWorkspace(owner.context);
|
||||
signal.throwIfAborted();
|
||||
return await saveMcpArtifact(result, owner.context, workspace);
|
||||
}
|
||||
return localEvidenceResult(result);
|
||||
}
|
||||
return localEvidenceResult(result);
|
||||
}
|
||||
const value = await options.executeLocal(definition.name as LocalQueryName, args, owner.context, signal);
|
||||
signal.throwIfAborted();
|
||||
return { content: [{ type: "text", text: JSON.stringify(value) }] };
|
||||
const value = await boundedQuery((boundedSignal) => options.executeLocal(definition.name as LocalQueryName, args, owner.context, boundedSignal), signal);
|
||||
signal.throwIfAborted();
|
||||
return { content: [{ type: "text", text: JSON.stringify(value) }] };
|
||||
}, signal);
|
||||
} catch (cause) {
|
||||
// Never return upstream bodies, headers or credentials across MCP.
|
||||
const status = cause && typeof cause === "object" && "status" in cause ? cause.status : undefined;
|
||||
@@ -88,8 +95,16 @@ 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) => handler.fetch(request),
|
||||
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, // Bun caps socket idleTimeout at 255 seconds. Enforce a finite five-minute
|
||||
// HTTP response deadline explicitly so the advertised 300s budget can finish.
|
||||
idleTimeout: 0,
|
||||
fetch: async (request) => {
|
||||
try {
|
||||
return await boundedQuery((signal) => handler.fetch(new Request(request, { signal })), AbortSignal.any([request.signal, AbortSignal.timeout(MAX_NATIVE_QUERY_TIMEOUT_MS)]));
|
||||
} catch {
|
||||
return new Response("Query timed out", { status: 504 });
|
||||
}
|
||||
},
|
||||
});
|
||||
return { url: `http://127.0.0.1:${server.port}/mcp`, executionTimeoutMs: handler.executionTimeoutMs,
|
||||
async close() { await handler.close(); await server.stop(true); } };
|
||||
|
||||
+40
-21
@@ -1,31 +1,50 @@
|
||||
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;
|
||||
// HTTP return budgets never cancel a queued calculation job.
|
||||
const MAX_SERVER_REQUEST_SECONDS = 240;
|
||||
const HOST_RETURN_GRACE_SECONDS = 60;
|
||||
export const DEFAULT_QUERY_REQUEST_TIMEOUT_MS = 180_000;
|
||||
export const MAX_NATIVE_QUERY_TIMEOUT_MS = 300_000;
|
||||
const MAX_HOST_REQUEST_TIMEOUT_MS = 270_000;
|
||||
|
||||
export const DEFAULT_QUERY_REQUEST_TIMEOUT_MS = 35_000;
|
||||
export const MAX_NATIVE_QUERY_TIMEOUT_MS = DEFAULT_QUERY_REQUEST_TIMEOUT_MS;
|
||||
|
||||
function timeoutSchema(tool: Tool) {
|
||||
const property = tool.inputSchema.properties?.timeout_seconds;
|
||||
return property && typeof property === "object" && !Array.isArray(property) ? property as Record<string, unknown> : undefined;
|
||||
export function modelQueryTool(tool: Tool): Tool {
|
||||
const inputSchema = structuredClone(tool.inputSchema);
|
||||
delete inputSchema.properties?.timeout_seconds;
|
||||
inputSchema.required = inputSchema.required?.filter((key) => key !== "timeout_seconds");
|
||||
return { ...tool, inputSchema };
|
||||
}
|
||||
|
||||
export function queryTimeouts(tool: Tool, args: Record<string, unknown>) {
|
||||
const schema = timeoutSchema(tool);
|
||||
if (!schema) return { executionMs: 30_000, callMs: 30_000, requestMs: DEFAULT_QUERY_REQUEST_TIMEOUT_MS };
|
||||
const seconds = args.timeout_seconds === undefined ? schema.default ?? 120 : args.timeout_seconds;
|
||||
const maximum = schema.maximum;
|
||||
if (typeof maximum !== "number" || !Number.isInteger(maximum) || maximum < 1 || maximum > ABSOLUTE_CALCULATION_SECONDS ||
|
||||
typeof seconds !== "number" || !Number.isInteger(seconds) || seconds < 1 || seconds > maximum) {
|
||||
throw Object.assign(new Error("Calculation timeout must be an integer within the advertised server limit"), { status: 400 });
|
||||
export function queryTimeouts(tool: Tool, _args: Record<string, unknown> = {}) {
|
||||
const valid = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= MAX_SERVER_REQUEST_SECONDS;
|
||||
const metadata = tool._meta?.["io.tjwater/request-timeout"];
|
||||
const schema = tool.inputSchema.properties?.timeout_seconds;
|
||||
let seconds = 120;
|
||||
if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
|
||||
const budget = metadata as Record<string, unknown>;
|
||||
if (valid(budget.defaultSeconds) && valid(budget.maxSeconds) && budget.defaultSeconds <= budget.maxSeconds) seconds = budget.defaultSeconds;
|
||||
} else if (schema && typeof schema === "object" && !Array.isArray(schema)) {
|
||||
const legacy = schema as Record<string, unknown>;
|
||||
if (valid(legacy.default) && (legacy.maximum === undefined || (valid(legacy.maximum) && legacy.default <= legacy.maximum))) seconds = legacy.default;
|
||||
}
|
||||
const executionMs = seconds * 1000;
|
||||
// timeout_seconds controls the queued worker, never this short submission.
|
||||
return { executionMs, callMs: 30_000, requestMs: DEFAULT_QUERY_REQUEST_TIMEOUT_MS };
|
||||
const requestMs = Math.min((seconds + HOST_RETURN_GRACE_SECONDS) * 1000, MAX_HOST_REQUEST_TIMEOUT_MS);
|
||||
return { serverMs: seconds * 1000, callMs: requestMs, requestMs };
|
||||
}
|
||||
|
||||
export function nativeQueryTimeout(_tools: Tool[]) {
|
||||
return DEFAULT_QUERY_REQUEST_TIMEOUT_MS;
|
||||
return MAX_NATIVE_QUERY_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
// Bound responses even when an adapter ignores cancellation.
|
||||
export async function boundedQuery<T>(run: (signal: AbortSignal) => Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
signal.throwIfAborted();
|
||||
let abort: (() => void) | undefined;
|
||||
try {
|
||||
return await Promise.race([run(signal), new Promise<never>((_, reject) => {
|
||||
abort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
if (signal.aborted) abort();
|
||||
})]);
|
||||
} finally {
|
||||
if (abort) signal.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,27 @@ 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);
|
||||
|
||||
const transientCodes = new Set(["RATE_LIMIT", "RATE_LIMITED", "QUERY_TIMEOUT", "SOURCE_UNAVAILABLE"]);
|
||||
const networkCodes = new Set(["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN", "ENOTFOUND", "EPIPE", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT"]);
|
||||
function transientStatusError(value: unknown): boolean {
|
||||
if (!record(value)) return false;
|
||||
const status = value.status ?? value.statusCode;
|
||||
if (typeof status === "number") return status === 429 || (status >= 500 && status <= 599);
|
||||
if (typeof value.code === "string" && transientCodes.has(value.code.toUpperCase())) return true;
|
||||
if (value.name === "TimeoutError" || value.code === -32001) return true;
|
||||
if (typeof value.code === "string" && networkCodes.has(value.code)) return true;
|
||||
// Fetch reports transport failures as TypeError; do not retry arbitrary programming errors.
|
||||
if (value.name === "TypeError" && typeof value.message === "string" && /fetch failed|failed to fetch|network|connection/i.test(value.message)) return true;
|
||||
return record(value.cause) && typeof value.cause.code === "string" && networkCodes.has(value.cause.code);
|
||||
}
|
||||
|
||||
// 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 }),
|
||||
random: () => number = Math.random,
|
||||
): TaskCalculationWaiter {
|
||||
const delivered = new Map<string, Set<string>>();
|
||||
return async (sessionId, signal) => {
|
||||
@@ -18,16 +33,33 @@ export function createTaskCalculationWaiter(
|
||||
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 (;;) {
|
||||
const terminal = new Map<string, string>();
|
||||
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);
|
||||
let result;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
signal.throwIfAborted();
|
||||
try {
|
||||
result = await client.query(sessionId, owner, "list_calculation_jobs", { limit: 100, offset }, randomUUID(), signal);
|
||||
if (result.isError) {
|
||||
const body = result.structuredContent;
|
||||
const detail = record(body) && record(body.error) ? body.error : {};
|
||||
throw Object.assign(new Error("Calculation status unavailable"), { code: detail.code });
|
||||
}
|
||||
break;
|
||||
} catch (cause) {
|
||||
signal.throwIfAborted();
|
||||
if (!transientStatusError(cause)) throw cause;
|
||||
if (attempt >= 3) throw new Error("Calculation status unavailable after transient retries; existing jobs were not resubmitted or cancelled", { cause });
|
||||
await pause(Math.round(Math.min(8000, 1000 * 2 ** attempt) * (0.75 + random() * 0.5)), signal);
|
||||
}
|
||||
}
|
||||
const body = result.structuredContent;
|
||||
if (result.isError || !record(body) || !Array.isArray(body.data)) throw new Error("Calculation status unavailable");
|
||||
if (!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)) {
|
||||
@@ -42,11 +74,11 @@ export function createTaskCalculationWaiter(
|
||||
offset += 100;
|
||||
if (offset > 10000) throw new Error("Calculation status paging limit exceeded");
|
||||
}
|
||||
if (!pending) {
|
||||
if (!terminal.size) return null;
|
||||
if (terminal.size) {
|
||||
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 小页读取成功结果后形成判断;失败、取消、超时或中断应按执行异常处理,不得当作无异常或数据缺失。不要重复提交这些作业。`;
|
||||
}
|
||||
if (!pending) return null;
|
||||
await pause(pollMs, signal);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user