feat(agent): improve investigation contracts and runtime readiness
Generic Container CI/CD / test-build-publish (push) Successful in 1m53s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m53s

This commit is contained in:
2026-11-07 19:43:57 +08:00
parent c7b750b099
commit b8f7edb3ee
29 changed files with 1465 additions and 119 deletions
+125 -7
View File
@@ -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;