feat(agent): unify MCP tools and adopt Responses delivery

Move project and host queries into native MCP with host-owned OAuth/context,
while retaining memory, skills, actions and publication plugins. Add nearby-model
queries and explicit chat/task/learning model defaults.

Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped
session ownership, pending input edits and investigation communication.

Disable local-provider discovery: Server /health allowed native vLLM to probe
/v1/models on port 8000. A native before/after regression prevents recurrence.

Validation: 170 focused Bun tests; service/plugin typechecks; native discovery,
provider-discovery and input-inbox smoke checks; no paid model inference.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent f5c3cde2c2
commit 6a49d2209e
164 changed files with 12286 additions and 2368 deletions
+171 -18
View File
@@ -1,14 +1,19 @@
import type { OpenCodeClient as OpencodeClient } from "@opencode/client";
import { LOCAL_QUERY_TOOL_IDS, PRODUCT_MCP_TOOL_IDS } from "./product-mcp-tools.js";
import { assertWebSearchReady } from "./web-search-check.js";
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";
import { existsSync, readFileSync } from "node:fs";
import { createServer } from "node:net";
import { resolve } from "node:path";
import { realpath, rm } from "node:fs/promises";
import { createConversationWorkspace, resolveConversationWorkspace } from "./conversationWorkspace.js";
import { bindScopedConversationWorkspace, createConversationWorkspace, ensureSessionWorkspaceDirectory, isLegacyConversationWorkspace, isManagedConversationWorkspace, migrateConversationWorkspace, resolveConversationWorkspace, resolveStoredConversationWorkspace, sessionWorkspaceDirectory, type SessionWorkspaceScope } from "./conversationWorkspace.js";
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 { parseModelReference } from "../chat/modelConfig.js";
import type { ResponseInput, InputDelivery } from "../chat/response-inputs.js";
import { assertModelCheckPassed, modelCheckReportSchema, modelCheckTargets } from "./model-check.js";
import { config } from "../config.js";
@@ -102,11 +107,17 @@ export class OpencodeRuntimeAdapter {
return mcpReadonlyTools(buildOpencodeConfig());
}
async mcpStatus(sessionId: string) {
async mcpStatus(sessionId: string, scope?: SessionWorkspaceScope) {
const client = await this.ensureClient();
const session = await client.session.get({ sessionID: sessionId });
const directory = resolve(session.location.directory) === process.cwd() ? process.cwd()
: await resolveConversationWorkspace(session.location.directory, config.CONVERSATION_WORKSPACE_ROOT);
let directory: string;
try {
directory = resolve(session.location.directory) === process.cwd() ? process.cwd()
: await resolveStoredConversationWorkspace(session.location.directory, config.CONVERSATION_WORKSPACE_ROOT);
} catch (error) {
if (!scope || (error as NodeJS.ErrnoException).code !== "ENOENT" || !isLegacyConversationWorkspace(session.location.directory, config.CONVERSATION_WORKSPACE_ROOT)) throw error;
directory = await resolveConversationWorkspace(resolve(sessionWorkspaceDirectory(config.CONVERSATION_WORKSPACE_ROOT, { ...scope, sessionId }), "workspace"), config.CONVERSATION_WORKSPACE_ROOT);
}
return this.readMcpReport(client, directory);
}
@@ -145,14 +156,30 @@ export class OpencodeRuntimeAdapter {
}
const session = await this.createSession("tjwater-agent-warmup");
try {
const response = await client.rpc.call({
rpcID: "tjwater.tools", method: "list", input: {},
location: { directory: session.location.directory },
});
const tools = response.output as { id: string; direct: boolean; sandboxed: boolean; workspaceRoot?: string }[] | undefined;
const required = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "session_search", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "get_operational_task_context", "query_operational_task_data", "publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todoread", "todowrite"];
const missing = required.filter((name) => !tools?.some((tool) => tool.id === name && tool.direct));
const readTools = async () => {
const response = await client.rpc.call({
rpcID: "tjwater.tools", method: "list", input: {},
location: { directory: session.location.directory },
}, { signal: AbortSignal.timeout(10_000) });
return response.output as { id: string; direct: boolean; sandboxed: boolean; workspaceRoot?: string }[] | undefined;
};
let tools = await readTools();
const required = [...LOCAL_QUERY_TOOL_IDS, ...(config.TJWATER_MCP_ENABLED ? PRODUCT_MCP_TOOL_IDS : []), "shell", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"];
let missing = required.filter((name) => !tools?.some((tool) => tool.id === name && tool.direct));
const catalogDeadline = Date.now() + 10_000;
// Native MCP connection/catalog publication can finish after plugin setup.
// Wait only for mandatory query tools; lost plugin tools still fail fast.
while (missing.length && missing.every((name) => name.startsWith("mcp_")) && Date.now() < catalogDeadline) {
await new Promise((resolve) => setTimeout(resolve, 100));
tools = await readTools();
missing = required.filter((name) => !tools?.some((tool) => tool.id === name && tool.direct));
}
if (missing.length) throw new Error(`OpenCode 2 business tools missing: ${missing.join(", ")}`);
if (tools?.some((tool) => tool.id.startsWith("browser_") || ["web_search", "mcp_tjwater_web_search", "query_project_data", "query_operational_task_data", "get_operational_task_context"].includes(tool.id))) {
throw new Error("OpenCode retired tools are still registered; reload trusted configuration and plugins");
}
if (!tools?.some((tool) => tool.id === "websearch")) throw new Error("OpenCode websearch tool is missing");
await assertWebSearchReady(client, session.location.directory);
if (!tools?.some((tool) => tool.id === "shell" && tool.sandboxed)) throw new Error("OpenCode shell sandbox replacement is missing");
const actualRoot = tools.find((tool) => tool.id === "shell")?.workspaceRoot;
if (!actualRoot || await realpath(actualRoot) !== await realpath(config.CONVERSATION_WORKSPACE_ROOT)) throw new Error("OpenCode and Agent conversation workspace roots do not match");
@@ -171,27 +198,46 @@ export class OpencodeRuntimeAdapter {
}
}
async createSession(title?: string) {
async createSession(title?: string, scope?: SessionWorkspaceScope) {
const client = await this.ensureClient();
const { providerID, modelID: id } = parseModelReference(config.OPENCODE_MODEL);
const directory = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT);
let createdSessionId: string | undefined;
try {
return await client.session.create({ title, agent: "instruction", model: { providerID, id }, location: { directory } });
const session = await client.session.create({ title, agent: "instruction", model: { providerID, id }, location: { directory } });
createdSessionId = session.id;
if (scope) await this.ensureSessionWorkspace(session.id, scope);
return session;
} catch (error) {
await rm(directory, { recursive: true });
if (createdSessionId) await client.session.remove({ sessionID: createdSessionId }).catch(() => {});
await rm(directory, { recursive: true, force: true });
if (scope && createdSessionId) {
await ensureSessionWorkspaceDirectory(config.CONVERSATION_WORKSPACE_ROOT, { ...scope, sessionId: createdSessionId })
.then((parent) => rm(resolve(parent, "workspace"), { recursive: true, force: true })).catch(() => {});
}
throw error;
}
}
async ensureSessionWorkspace(sessionId: string): Promise<string> {
async ensureSessionWorkspace(sessionId: string, scope?: SessionWorkspaceScope): Promise<string> {
this.workspaceBindings ??= new Map();
const pending = this.workspaceBindings.get(sessionId);
if (pending) return pending;
const operation = (async () => {
const client = await this.ensureClient();
const session = await client.session.get({ sessionID: sessionId });
const owner = scope ?? getRuntimeSessionContext(sessionId);
if (owner) {
let source = session.location.directory;
if (resolve(source) === process.cwd()) source = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT);
const directory = await bindScopedConversationWorkspace(source, config.CONVERSATION_WORKSPACE_ROOT, { ...owner, sessionId });
if (directory !== resolve(session.location.directory)) await client.session.move({ sessionID: sessionId, directory });
return directory;
}
if (resolve(session.location.directory) !== process.cwd()) {
return resolveConversationWorkspace(session.location.directory, config.CONVERSATION_WORKSPACE_ROOT);
const directory = await migrateConversationWorkspace(session.location.directory, config.CONVERSATION_WORKSPACE_ROOT);
if (directory !== resolve(session.location.directory)) await client.session.move({ sessionID: sessionId, directory });
return directory;
}
// Old sessions used the service checkout. Moving an idle session binds a
// fresh directory without copying application data, credentials or logs.
@@ -205,6 +251,25 @@ export class OpencodeRuntimeAdapter {
finally { this.workspaceBindings.delete(sessionId); }
}
async removeSession(sessionId: string, scope?: SessionWorkspaceScope) {
const client = await this.ensureClient();
let session;
try { session = await client.session.get({ sessionID: sessionId }); }
catch (error) { if (isSessionNotFoundError(error)) return; throw error; }
const root = await realpath(config.CONVERSATION_WORKSPACE_ROOT);
const directory = scope ? await this.ensureSessionWorkspace(sessionId, scope)
: await resolveStoredConversationWorkspace(session.location.directory, root);
if (!scope && !isLegacyConversationWorkspace(directory, root) && !isManagedConversationWorkspace(directory, root, sessionId)) {
throw new Error("cannot remove another session's workspace");
}
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");
try { await client.session.remove({ sessionID: sessionId }); }
catch (error) { if (!isSessionNotFoundError(error)) throw error; }
await rm(directory, { recursive: true, force: true });
}
async sendPrompt(sessionId: string, text: string) {
await this.prompt(sessionId, text);
// 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts,
@@ -212,6 +277,30 @@ export class OpencodeRuntimeAdapter {
return this.messages(sessionId);
}
async generateText(prompt: string, model?: RuntimeModelOverride, signal?: AbortSignal) {
const client = await this.ensureClient();
const selected = model ?? parseModelReference(config.OPENCODE_MODEL);
const directory = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT);
let sessionId: string | undefined;
try {
const session = await client.session.create({
title: "prompt-suggestions", agent: "instruction",
model: { providerID: selected.providerID, id: selected.modelID },
location: { directory },
}, { signal });
sessionId = session.id;
// Await the Location's configured model catalog before one-shot generation.
await client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory } }, { signal });
return (await client.session.generate({ sessionID: sessionId, prompt }, { signal })).text;
} finally {
try {
if (sessionId) await client.session.remove({ sessionID: sessionId });
} finally {
await rm(directory, { recursive: true });
}
}
}
async approveSandboxShell(input: {
sessionId: string; command: string; description?: string;
callId: string; messageId: string;
@@ -249,6 +338,65 @@ export class OpencodeRuntimeAdapter {
}
}
async agentMessageState(sessionId: string, messageId: string): Promise<"missing" | "enqueued" | "delivered"> {
const client = await this.ensureClient();
const pending = await client.session.inbox.list({ sessionID: sessionId });
if (pending.some((item) => item.id === messageId)) return "enqueued";
try {
await client.session.message.get({ sessionID: sessionId, messageID: messageId });
return "delivered";
} catch (error) {
// Only a not-found response is safe to treat as absent. A transport
// failure must not cause a second injection.
if (error && typeof error === "object" && "status" in error && error.status === 404) return "missing";
if (isMessageNotFoundError(error)) return "missing";
throw error;
}
}
async enqueueAgentMessage(sessionId: string, messageId: string, text: string, metadata: Record<string, string>) {
const client = await this.ensureClient();
// resume:false is essential: a race with idle must never start an
// unowned background turn in a user's main chat.
await client.session.synthetic({ sessionID: sessionId, id: messageId, text,
description: "调查协作消息(非用户直接指令)", metadata, delivery: "steer", resume: false });
}
async enqueueUserInput(sessionId: string, input: ResponseInput) {
const client = await this.ensureClient();
await client.session.prompt({ sessionID: sessionId, id: input.id, text: input.text,
delivery: input.delivery, resume: true, metadata: { source: "tjwater_user_input" } });
}
async listPendingUserInputs(sessionId: string): Promise<ResponseInput[]> {
const client = await this.ensureClient();
return (await client.session.inbox.list({ sessionID: sessionId })).flatMap((item) =>
item.type === "user" && item.payload.metadata?.source === "tjwater_user_input"
? [{ id: item.id, text: item.payload.text, delivery: item.delivery, status: "pending" as const }] : []);
}
async updatePendingUserInput(sessionId: string, id: string, delivery: InputDelivery) {
const item = (await this.listPendingUserInputs(sessionId)).find((input) => input.id === id);
if (!item) throw Object.assign(new Error("Message has already been consumed or is unavailable"), { status: 409 });
const client = await this.ensureClient();
try { await client.session.inbox.update({ sessionID: sessionId, inboxID: id, delivery }); }
catch (error) {
if (!(await this.listPendingUserInputs(sessionId)).some((input) => input.id === id)) {
throw Object.assign(new Error("Message has already been consumed or is unavailable"), { status: 409 });
}
throw error;
}
return { ...item, delivery };
}
async cancelPendingUserInputs(sessionId: string) {
const client = await this.ensureClient();
for (const input of await this.listPendingUserInputs(sessionId)) {
try { await client.session.inbox.cancel({ sessionID: sessionId, inboxID: input.id }); }
catch (error) { if (!(error && typeof error === "object" && "status" in error && error.status === 404)) throw error; }
}
}
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
const client = await this.ensureClient();
const startedAt = Date.now();
@@ -260,6 +408,11 @@ export class OpencodeRuntimeAdapter {
textChars: text.length,
},
);
const context = getRuntimeSessionContext(sessionId);
// The server owns purpose; a browser prompt or requested model cannot select
// the background coordinator profile. Re-evaluate for resumed sessions too.
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 });
logDevelopmentDebug(
@@ -479,7 +632,7 @@ export class OpencodeRuntimeAdapter {
export const opencodeRuntime = new OpencodeRuntimeAdapter();
function buildOpencodeConfig(): Record<string, unknown> {
return serviceOpencodeConfig(deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()), config.MODEL_CONFIG);
return serviceOpencodeConfig(deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()), config.MODEL_CONFIG, config.CONVERSATION_WORKSPACE_ROOT);
}
function readProjectOpencodeConfig(): Record<string, unknown> {