feat(agent): improve investigation contracts and runtime readiness
This commit is contained in:
+125
-7
@@ -1,5 +1,7 @@
|
||||
import { LOCAL_QUERY_TOOL_IDS, PRODUCT_MCP_TOOL_IDS } from "./product-mcp-tools.js";
|
||||
import { LOCAL_QUERY_TOOL_IDS, PRODUCT_MCP_TOOL_IDS, PRODUCT_MCP_WRITE_TOOL_IDS, QUERY_MCP_SERVER } from "./product-mcp-tools.js";
|
||||
import { assertWebSearchReady } from "./web-search-check.js";
|
||||
import { allowedQueryTools } from "./query-mcp-policy.js";
|
||||
import { setTimeout as delayAbortable } from "node:timers/promises";
|
||||
import { isMessageNotFoundError, isSessionNotFoundError, type OpenCodeClient as OpencodeClient } from "@opencode/client";
|
||||
import { createRuntimeClient, startOpencode2 } from "./opencode2-server.js";
|
||||
import { formAnswers, normalizeRuntimeMessage, Opencode2EventNormalizer } from "./opencode2-events.js";
|
||||
@@ -11,7 +13,7 @@ import { bindScopedConversationWorkspace, createConversationWorkspace, ensureSes
|
||||
import { getRuntimeSessionContext } from "./sessionContext.js";
|
||||
import { TASK_AGENT } from "./task-agent-policy.js";
|
||||
import { serviceOpencodeConfig } from "./opencode2-config.js";
|
||||
import { mcpReadonlyTools, mcpRuntimeReportSchema } from "./mcp-config.js";
|
||||
import { mcpReadonlyTools, mcpRuntimeReportSchema, mcpTimeoutDefaults } from "./mcp-config.js";
|
||||
import { parseModelReference } from "../chat/modelConfig.js";
|
||||
import type { ResponseInput, InputDelivery } from "../chat/response-inputs.js";
|
||||
import { assertModelCheckPassed, modelCheckReportSchema, modelCheckTargets } from "./model-check.js";
|
||||
@@ -82,10 +84,24 @@ export const resolveEmbeddedOpencodePort = async (
|
||||
: 0;
|
||||
};
|
||||
|
||||
export class McpPreparationError extends Error {
|
||||
readonly code = "MCP_NOT_READY";
|
||||
readonly status = 503;
|
||||
constructor(readonly reason: string) { super(`MCP 准备失败:${reason};未启动模型,请重试或检查服务配置。`); }
|
||||
}
|
||||
|
||||
function recoverableMcpConnectionError(error: unknown): boolean {
|
||||
const detail = typeof error === "string" ? error : error instanceof Error ? error.message
|
||||
: error && typeof error === "object" && "message" in error ? String(error.message) : "";
|
||||
if (/401|403|auth|forbidden|configuration|schema|protocol|not.?found/i.test(detail)) return false;
|
||||
return /ECONNREFUSED|ECONNRESET|EPIPE|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|fetch failed|connection (?:closed|reset)|socket hang up|network (?:error|unavailable)|transport.*closed/i.test(detail);
|
||||
}
|
||||
|
||||
export class OpencodeRuntimeAdapter {
|
||||
private clientPromise: Promise<OpencodeClient> | null = null;
|
||||
private closeServer: (() => void) | null = null;
|
||||
private workspaceBindings = new Map<string, Promise<string>>();
|
||||
private promptPreparations?: Map<string, Set<AbortController>>;
|
||||
|
||||
async ensureClient(): Promise<OpencodeClient> {
|
||||
if (!this.clientPromise) {
|
||||
@@ -121,13 +137,107 @@ export class OpencodeRuntimeAdapter {
|
||||
return this.readMcpReport(client, directory);
|
||||
}
|
||||
|
||||
private async readMcpReport(client: OpencodeClient, directory: string) {
|
||||
private async readMcpReport(client: OpencodeClient, directory: string, signal?: AbortSignal) {
|
||||
const response = await client.rpc.call({
|
||||
rpcID: "tjwater.tools", method: "mcp", input: {}, location: { directory },
|
||||
}, { signal: AbortSignal.timeout(15_000) });
|
||||
}, { signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000) });
|
||||
return mcpRuntimeReportSchema.parse(response.output);
|
||||
}
|
||||
|
||||
private async ensureSessionToolsReady(sessionId: string, signal?: AbortSignal) {
|
||||
signal?.throwIfAborted();
|
||||
const controller = new AbortController();
|
||||
const preparations = this.promptPreparations ??= new Map();
|
||||
const pending = preparations.get(sessionId) ?? new Set<AbortController>();
|
||||
pending.add(controller);
|
||||
preparations.set(sessionId, pending);
|
||||
const deadline = AbortSignal.timeout(mcpTimeoutDefaults.startup + mcpTimeoutDefaults.catalog);
|
||||
const readySignal = AbortSignal.any([controller.signal, deadline, ...(signal ? [signal] : [])]);
|
||||
try {
|
||||
const client = await this.ensureClient();
|
||||
const directory = await this.ensureSessionWorkspace(sessionId);
|
||||
readySignal.throwIfAborted();
|
||||
const context = getRuntimeSessionContext(sessionId);
|
||||
const grants = new Set(this.mcpReadonlyTools());
|
||||
// Verify only queries this host-owned scope can expose. Readiness never
|
||||
// grants a tool, changes its executor, or enables an optional MCP server.
|
||||
const queries = context ? allowedQueryTools(context).filter((name) =>
|
||||
(config.TJWATER_MCP_ENABLED || !PRODUCT_MCP_TOOL_IDS.includes(name)) &&
|
||||
(grants.has(name) || PRODUCT_MCP_WRITE_TOOL_IDS.includes(name))) : [];
|
||||
if (context?.purpose === "task_execution") {
|
||||
if (!context.projectId || !context.taskId) throw new McpPreparationError("后台调查缺少可信任务范围");
|
||||
if (!config.TJWATER_MCP_ENABLED) throw new McpPreparationError("后台调查所需业务 MCP 已禁用");
|
||||
if (!queries.includes(`${QUERY_MCP_SERVER}_get_task_context`))
|
||||
throw new McpPreparationError("后台调查所需上下文工具未获授权");
|
||||
}
|
||||
const required = context?.purpose === "task_execution"
|
||||
? ["ask_task_user", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", ...queries]
|
||||
: ["ask_user", "final_answer", ...queries];
|
||||
let reconnected = false, transientReadRetries = 0;
|
||||
for (;;) {
|
||||
readySignal.throwIfAborted();
|
||||
let tools: Array<{ id: string; direct: boolean }>;
|
||||
let report;
|
||||
try {
|
||||
const [catalog, current] = await Promise.all([
|
||||
client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory } }, { signal: readySignal }),
|
||||
this.readMcpReport(client, directory, readySignal),
|
||||
]);
|
||||
if (!Array.isArray(catalog.output) || catalog.output.some((tool) =>
|
||||
!tool || typeof tool.id !== "string" || typeof tool.direct !== "boolean"))
|
||||
throw new McpPreparationError("工具目录格式无效");
|
||||
tools = catalog.output;
|
||||
report = current;
|
||||
} catch (error) {
|
||||
readySignal.throwIfAborted();
|
||||
if (error instanceof McpPreparationError) throw error;
|
||||
if (!recoverableMcpConnectionError(error) || transientReadRetries++ >= 1)
|
||||
throw new McpPreparationError("无法读取工具目录或连接状态");
|
||||
await delayAbortable(100, undefined, { signal: readySignal });
|
||||
continue;
|
||||
}
|
||||
readySignal.throwIfAborted();
|
||||
if (report.configuration_errors.length) throw new McpPreparationError("MCP 配置无效");
|
||||
const connection = report.servers.find((server) => server.name === QUERY_MCP_SERVER);
|
||||
if (queries.length && connection?.status === "needs_auth") throw new McpPreparationError("业务工具连接需要鉴权");
|
||||
if (queries.length && connection?.status === "disabled") throw new McpPreparationError("业务工具连接已禁用");
|
||||
if (queries.length && connection?.status === "failed") {
|
||||
const native = await client.mcp.list({ location: { directory } }, { signal: readySignal });
|
||||
readySignal.throwIfAborted();
|
||||
const state = native.data.find((server) => server.name === QUERY_MCP_SERVER)?.status;
|
||||
if (state?.status === "connected" || state?.status === "pending") {
|
||||
await delayAbortable(100, undefined, { signal: readySignal });
|
||||
continue;
|
||||
}
|
||||
if (reconnected || state?.status !== "failed" || !recoverableMcpConnectionError(state.error))
|
||||
throw new McpPreparationError("业务工具连接失败");
|
||||
reconnected = true;
|
||||
try { await client.mcp.connect({ server: QUERY_MCP_SERVER, location: { directory } }, { signal: readySignal }); }
|
||||
catch { readySignal.throwIfAborted(); throw new McpPreparationError("业务工具重连失败"); }
|
||||
continue;
|
||||
}
|
||||
const missing = required.filter((name) => !tools.some((tool) => tool.id === name && tool.direct));
|
||||
if (missing.some((name) => !name.startsWith("mcp_"))) throw new McpPreparationError("必要的宿主工具未注册");
|
||||
if (!missing.length && (!queries.length || connection?.status === "connected" && queries.every((name) => connection.tools.includes(name)))) return;
|
||||
await delayAbortable(100, undefined, { signal: readySignal });
|
||||
}
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
controller.signal.throwIfAborted();
|
||||
if (deadline.aborted) throw new McpPreparationError("等待业务工具目录就绪超时");
|
||||
if (error instanceof McpPreparationError) throw error;
|
||||
throw new McpPreparationError("业务工具准备不可用");
|
||||
} finally {
|
||||
pending.delete(controller);
|
||||
if (!pending.size) preparations.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private cancelSessionPreparations(sessionId: string) {
|
||||
for (const controller of this.promptPreparations?.get(sessionId) ?? [])
|
||||
controller.abort(new DOMException("Session preparation cancelled", "AbortError"));
|
||||
}
|
||||
|
||||
async resolveAvailableModel(preferredModelIds: string[]): Promise<string> {
|
||||
const client = await this.ensureClient();
|
||||
// V2 2.0.24 model.list alone does not await a cold Location's plugin setup.
|
||||
@@ -262,6 +372,7 @@ export class OpencodeRuntimeAdapter {
|
||||
if (!scope && !isLegacyConversationWorkspace(directory, root) && !isManagedConversationWorkspace(directory, root, sessionId)) {
|
||||
throw new Error("cannot remove another session's workspace");
|
||||
}
|
||||
this.cancelSessionPreparations(sessionId);
|
||||
await client.session.interrupt({ sessionID: sessionId });
|
||||
await this.waitForSessionIdle(sessionId);
|
||||
if ((await client.session.active())[sessionId]) throw new Error("session is still active; cleanup can be retried");
|
||||
@@ -365,6 +476,7 @@ export class OpencodeRuntimeAdapter {
|
||||
|
||||
async enqueueUserInput(sessionId: string, input: ResponseInput, options: { resume?: boolean } = {}) {
|
||||
const client = await this.ensureClient();
|
||||
if (options.resume !== false) await this.ensureSessionToolsReady(sessionId);
|
||||
await client.session.prompt({ sessionID: sessionId, id: input.id, text: input.text,
|
||||
delivery: input.delivery, resume: options.resume ?? true, metadata: { source: "tjwater_user_input" } });
|
||||
}
|
||||
@@ -417,7 +529,7 @@ export class OpencodeRuntimeAdapter {
|
||||
return { ...item, status: "cancelled" };
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride, messageId?: string) {
|
||||
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride, messageId?: string, signal?: AbortSignal) {
|
||||
const client = await this.ensureClient();
|
||||
const startedAt = Date.now();
|
||||
logDevelopmentDebug(
|
||||
@@ -434,7 +546,9 @@ export class OpencodeRuntimeAdapter {
|
||||
const agent = context?.purpose === "task_execution" && context.taskId && context.projectId ? TASK_AGENT : "instruction";
|
||||
await client.session.switchAgent({ sessionID: sessionId, agent });
|
||||
if (model) await client.session.switchModel({ sessionID: sessionId, model: { providerID: model.providerID, id: model.modelID } });
|
||||
await client.session.prompt({ sessionID: sessionId, text, ...(messageId ? { id: messageId, delivery: "steer" as const, metadata: { source: "tjwater_host_answer" } } : {}) });
|
||||
await this.ensureSessionToolsReady(sessionId, signal);
|
||||
signal?.throwIfAborted();
|
||||
await client.session.prompt({ sessionID: sessionId, text, ...(messageId ? { id: messageId, delivery: "steer" as const, metadata: { source: "tjwater_host_answer" } } : {}) }, { signal });
|
||||
logDevelopmentDebug(
|
||||
"opencode session.prompt returned",
|
||||
{
|
||||
@@ -472,12 +586,14 @@ export class OpencodeRuntimeAdapter {
|
||||
signal?.throwIfAborted();
|
||||
if (state === "missing") {
|
||||
if (active) throw new Error("Previous native turn is still active");
|
||||
await this.prompt(sessionId, text, model, messageId);
|
||||
await this.prompt(sessionId, text, model, messageId, signal);
|
||||
return "started";
|
||||
}
|
||||
if (active) return "observing";
|
||||
if (state === "enqueued") {
|
||||
// Resume an admitted steering input without constructing another message.
|
||||
await this.ensureSessionToolsReady(sessionId, signal);
|
||||
signal?.throwIfAborted();
|
||||
await client.session.interrupt({ sessionID: sessionId, resume: true });
|
||||
return "observing";
|
||||
}
|
||||
@@ -552,6 +668,7 @@ export class OpencodeRuntimeAdapter {
|
||||
}
|
||||
|
||||
async abortSession(sessionId: string) {
|
||||
this.cancelSessionPreparations(sessionId);
|
||||
const client = await this.ensureClient();
|
||||
return client.session.interrupt({ sessionID: sessionId });
|
||||
}
|
||||
@@ -640,6 +757,7 @@ export class OpencodeRuntimeAdapter {
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
for (const sessionId of this.promptPreparations?.keys() ?? []) this.cancelSessionPreparations(sessionId);
|
||||
this.closeServer?.();
|
||||
this.closeServer = null;
|
||||
this.clientPromise = null;
|
||||
|
||||
Reference in New Issue
Block a user