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:
@@ -1,16 +1,55 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
chown,
|
||||
lstat,
|
||||
mkdir,
|
||||
rename,
|
||||
realpath,
|
||||
stat,
|
||||
} from "node:fs/promises";
|
||||
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
|
||||
export const DEFAULT_SESSION_WORKSPACE_ROOT = "./data/session-workspaces";
|
||||
export const SANDBOX_UID = 10_001;
|
||||
export const SANDBOX_GID = 10_001;
|
||||
|
||||
export type SessionWorkspaceScope = { actorKey: string; projectKey: string };
|
||||
export type SessionWorkspaceContext = SessionWorkspaceScope & { sessionId: string };
|
||||
|
||||
export const sessionWorkspaceDirectory = (root: string, context: SessionWorkspaceContext) => {
|
||||
for (const key of [context.actorKey, context.projectKey]) {
|
||||
if (!/^[a-z0-9][a-z0-9_-]{0,127}$/i.test(key)) throw new Error("invalid workspace scope");
|
||||
}
|
||||
if (!context.sessionId.trim()) throw new Error("sessionId is required");
|
||||
const key = createHash("sha256").update(context.sessionId).digest("hex");
|
||||
const scope = context.projectKey.startsWith("personal-") ? "personal" : context.projectKey;
|
||||
if (scope === "personal" && context.projectKey !== `personal-${context.actorKey}`) throw new Error("personal workspace owner mismatch");
|
||||
return resolve(root, context.actorKey, scope, `session-${key}`);
|
||||
};
|
||||
|
||||
export const isManagedConversationWorkspace = (directory: string, root: string, sessionId?: string) => {
|
||||
const parts = relative(root, directory).split(sep);
|
||||
return (parts.length === 1 && parts[0]!.startsWith("conversation-")) ||
|
||||
(parts.length === 4 && /^actor-[a-z0-9_-]+$/i.test(parts[0]!) &&
|
||||
(parts[1] === "personal" || /^project-[a-z0-9_-]+$/i.test(parts[1]!)) && /^session-[a-f0-9]{64}$/.test(parts[2]!) && parts[3] === "workspace" &&
|
||||
(!sessionId || parts[2] === `session-${createHash("sha256").update(sessionId).digest("hex")}`));
|
||||
};
|
||||
|
||||
// The service owns chat records; only the workspace child is a native Location.
|
||||
export const ensureSessionWorkspaceDirectory = async (root: string, context: SessionWorkspaceContext) => {
|
||||
await mkdir(root, { recursive: true, mode: 0o711 });
|
||||
if ((await lstat(root)).isSymbolicLink()) throw new Error("workspace root must not be a symbolic link");
|
||||
const canonicalRoot = await realpath(root);
|
||||
const directory = sessionWorkspaceDirectory(canonicalRoot, context);
|
||||
let current = canonicalRoot;
|
||||
for (const part of relative(canonicalRoot, directory).split(sep)) {
|
||||
current = resolve(current, part);
|
||||
await mkdir(current, { recursive: true, mode: 0o711 });
|
||||
if ((await lstat(current)).isSymbolicLink()) throw new Error("workspace path must not be a symbolic link");
|
||||
}
|
||||
return directory;
|
||||
};
|
||||
|
||||
const isOutsideRoot = (root: string, candidate: string) => {
|
||||
const relativePath = relative(root, candidate);
|
||||
return (
|
||||
@@ -42,17 +81,9 @@ export const resolveConversationWorkspace = async (
|
||||
if (isOutsideRoot(resolvedImportRoot, resolvedWorkspaceDirectory)) {
|
||||
throw new Error("conversation workspace must be inside CONVERSATION_WORKSPACE_ROOT");
|
||||
}
|
||||
const relativeWorkspace = relative(
|
||||
resolvedImportRoot,
|
||||
resolvedWorkspaceDirectory,
|
||||
);
|
||||
if (
|
||||
!relativeWorkspace ||
|
||||
relativeWorkspace.includes("/") ||
|
||||
relativeWorkspace.includes("\\")
|
||||
) {
|
||||
if (!isManagedConversationWorkspace(resolvedWorkspaceDirectory, resolvedImportRoot)) {
|
||||
throw new Error(
|
||||
"conversation workspace must be a direct child of CONVERSATION_WORKSPACE_ROOT",
|
||||
"conversation workspace must be a direct child or a managed scoped workspace",
|
||||
);
|
||||
}
|
||||
const workspaceStat = await stat(resolvedWorkspaceDirectory);
|
||||
@@ -62,6 +93,24 @@ export const resolveConversationWorkspace = async (
|
||||
return resolvedWorkspaceDirectory;
|
||||
};
|
||||
|
||||
export const bindScopedConversationWorkspace = async (
|
||||
directory: string, root: string, context: SessionWorkspaceContext,
|
||||
) => {
|
||||
const parent = await ensureSessionWorkspaceDirectory(root, context);
|
||||
const destination = resolve(parent, "workspace");
|
||||
if (resolve(directory) === destination) return resolveConversationWorkspace(destination, root);
|
||||
if (await pathExists(directory)) {
|
||||
const source = await migrateConversationWorkspace(directory, root);
|
||||
if (basename(source) === "workspace") throw new Error("workspace belongs to another user, project or session");
|
||||
if (await pathExists(destination)) throw new Error("workspace migration destination already exists");
|
||||
await rename(source, destination);
|
||||
} else {
|
||||
// A failed native move may leave the old flat binding after an atomic rename.
|
||||
if (!basename(directory).startsWith("conversation-")) throw new Error("unmanaged workspace migration source");
|
||||
}
|
||||
return resolveConversationWorkspace(destination, root);
|
||||
};
|
||||
|
||||
export const createConversationWorkspace = async (root: string) => {
|
||||
await mkdir(root, { recursive: true, mode: 0o711 });
|
||||
if ((await lstat(root)).isSymbolicLink()) throw new Error("workspace root must not be a symbolic link");
|
||||
@@ -76,3 +125,48 @@ export const createConversationWorkspace = async (root: string) => {
|
||||
}
|
||||
return directory;
|
||||
};
|
||||
|
||||
const legacyRootFor = (directory: string, root: string) => {
|
||||
const parent = dirname(resolve(directory));
|
||||
const roots = [resolve(dirname(dirname(resolve(root))), ".agent-workspaces"), resolve(dirname(resolve(root)), "conversation-workspaces")];
|
||||
return basename(directory).startsWith("conversation-") ? roots.find((candidate) => candidate === parent && candidate !== resolve(root)) : undefined;
|
||||
};
|
||||
|
||||
export const isLegacyConversationWorkspace = (directory: string, root: string) =>
|
||||
Boolean(legacyRootFor(directory, root)) ||
|
||||
(dirname(resolve(directory)) === resolve(root) && basename(directory).startsWith("conversation-"));
|
||||
|
||||
const pathExists = async (path: string) => {
|
||||
try { await lstat(path); return true; }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Read-only status paths can still resolve a recorded legacy location after
|
||||
// its files have moved, without modifying the native session binding.
|
||||
export const resolveStoredConversationWorkspace = async (directory: string, root: string) => {
|
||||
const legacyRoot = legacyRootFor(directory, root);
|
||||
if (!legacyRoot) return resolveConversationWorkspace(directory, root);
|
||||
return await pathExists(directory)
|
||||
? resolveConversationWorkspace(directory, legacyRoot)
|
||||
: resolveConversationWorkspace(resolve(root, basename(directory)), root);
|
||||
};
|
||||
|
||||
// Keep the directory name and contents. A failed native binding update can
|
||||
// retry using the moved destination; collisions never overwrite either side.
|
||||
export const migrateConversationWorkspace = async (directory: string, root: string) => {
|
||||
const legacyRoot = legacyRootFor(directory, root);
|
||||
if (!legacyRoot) return resolveConversationWorkspace(directory, root);
|
||||
await mkdir(root, { recursive: true, mode: 0o711 });
|
||||
if ((await lstat(root)).isSymbolicLink()) throw new Error("workspace root must not be a symbolic link");
|
||||
const destination = resolve(await realpath(root), basename(directory));
|
||||
if (await pathExists(directory)) {
|
||||
if ((await lstat(legacyRoot)).isSymbolicLink()) throw new Error("legacy workspace root must not be a symbolic link");
|
||||
const source = await resolveConversationWorkspace(directory, legacyRoot);
|
||||
if (await pathExists(destination)) throw new Error("workspace migration destination already exists");
|
||||
await rename(source, destination);
|
||||
}
|
||||
return resolveConversationWorkspace(destination, root);
|
||||
};
|
||||
|
||||
@@ -78,6 +78,7 @@ export function mcpLocalEnvironment(parent: NodeJS.ProcessEnv, explicit: Record<
|
||||
export const mcpSessionPolicySchema = z.object({
|
||||
mode: z.enum(["interactive", "readonly", "disabled"]),
|
||||
readonly_tools: z.array(z.string()),
|
||||
query_tools: z.array(z.string()).default([]),
|
||||
}).strict();
|
||||
export type McpSessionPolicy = z.infer<typeof mcpSessionPolicySchema>;
|
||||
export const mcpServerStatusSchema = z.object({
|
||||
|
||||
+171
-18
@@ -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> {
|
||||
|
||||
@@ -1,11 +1,52 @@
|
||||
import { PRODUCT_MCP_TOOL_IDS, QUERY_MCP_TOOL_IDS, queryMcpToolId } from "./product-mcp-tools.js";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { normalizeMcpConfig } from "./mcp-config.js";
|
||||
import { compileOpencodeModels, loadAgentModelConfig, type AgentModelConfig } from "../chat/modelConfig.js";
|
||||
import { DEFAULT_SESSION_WORKSPACE_ROOT } from "./conversationWorkspace.js";
|
||||
import { TASK_AGENT, TASK_READER_AGENT, TASK_READER_TOOLS } from "./task-agent-policy.js";
|
||||
|
||||
const agentMessageTools = [queryMcpToolId("list_investigation_tasks"), queryMcpToolId("get_investigation_task"), "send_task_message", queryMcpToolId("read_agent_messages"), "reply_agent_message"];
|
||||
|
||||
type PermissionRule = { action: string; resource: string; effect: "allow" | "ask" | "deny" };
|
||||
const deniedProductQuery = (name: string, rules: PermissionRule[]) => rules.some((rule) => {
|
||||
if (rule.effect !== "deny") return false;
|
||||
const backslash = String.fromCharCode(92);
|
||||
const special = `${backslash}.^$+()[]{}|`;
|
||||
const pattern = Array.from(rule.action).map((char) => char === "*" ? ".*" : char === "?" ? "." : special.includes(char) ? backslash + char : char).join("");
|
||||
return new RegExp(`^${pattern}$`).test(name);
|
||||
});
|
||||
const dataPatterns = new Set(["data/**", "**/data/**"]);
|
||||
|
||||
// Exempt only managed session locations from ancestor data/ denials. Earlier
|
||||
// explicit read/edit denials are reapplied; later administrator rules still win.
|
||||
export const sessionWorkspacePermissions = (rules: PermissionRule[], workspaceRoot: string) => {
|
||||
const root = resolve(workspaceRoot).replaceAll("\\", "/");
|
||||
const result: PermissionRule[] = [];
|
||||
for (const [index, rule] of rules.entries()) {
|
||||
result.push(rule);
|
||||
if (!["read", "edit"].includes(rule.action) || rule.effect !== "deny" || !dataPatterns.has(rule.resource)) continue;
|
||||
if (rules.slice(index + 1).some((next) => next.action === rule.action && next.effect === "deny" && dataPatterns.has(next.resource))) continue;
|
||||
if (rule.action === "read") {
|
||||
result.push({ action: "read", resource: `${root}/conversation-*`, effect: "allow" }, { action: "read", resource: `${root}/conversation-*/**`, effect: "allow" });
|
||||
for (const scope of ["project-*", "personal"]) {
|
||||
const workspace = `${root}/actor-*/${scope}/session-*/workspace`;
|
||||
result.push({ action: "read", resource: workspace, effect: "allow" }, { action: "read", resource: `${workspace}/**`, effect: "allow" });
|
||||
}
|
||||
} else {
|
||||
for (const child of ["files", "tmp", ".cache"]) result.push({ action: "edit", resource: `${root}/conversation-*/${child}/**`, effect: "ask" });
|
||||
for (const scope of ["project-*", "personal"]) for (const child of ["files", "tmp", ".cache"]) {
|
||||
result.push({ action: "edit", resource: `${root}/actor-*/${scope}/session-*/workspace/${child}/**`, effect: "ask" });
|
||||
}
|
||||
}
|
||||
result.push(...rules.slice(0, index).filter((previous) => previous.action === rule.action && previous.effect === "deny" && !dataPatterns.has(previous.resource)));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// A conversation Location can be outside the checkout. Load only the trusted
|
||||
// service assets explicitly, rather than relying on its current directory.
|
||||
export function serviceOpencodeConfig(input: Record<string, unknown>, models: AgentModelConfig = loadAgentModelConfig()) {
|
||||
export function serviceOpencodeConfig(input: Record<string, unknown>, models: AgentModelConfig = loadAgentModelConfig(), workspaceRoot = process.env.TJWATER_AGENT_WORKSPACE_ROOT ?? process.env.CONVERSATION_WORKSPACE_ROOT ?? DEFAULT_SESSION_WORKSPACE_ROOT) {
|
||||
const agents = (input.agents ?? {}) as Record<string, unknown>;
|
||||
const instruction = (agents.instruction ?? {}) as Record<string, unknown>;
|
||||
const system = readFileSync(resolve(".opencode/agents/instruction.md"), "utf8")
|
||||
@@ -13,12 +54,43 @@ export function serviceOpencodeConfig(input: Record<string, unknown>, models: Ag
|
||||
return {
|
||||
...input,
|
||||
...compileOpencodeModels(models),
|
||||
permissions: sessionWorkspacePermissions([...(input.permissions ?? []) as PermissionRule[], ...QUERY_MCP_TOOL_IDS.filter((name) => !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action): PermissionRule => ({ action, resource: "*", effect: "allow" }))], workspaceRoot),
|
||||
snapshots: false,
|
||||
mcp: normalizeMcpConfig(input.mcp),
|
||||
plugins: ["*", ...((input.plugins ?? []) as unknown[]), resolve(".opencode/tjwater-plugin")],
|
||||
plugins: ["*", ...((input.plugins ?? []) as unknown[]), resolve(".opencode/tjwater-plugin"),
|
||||
"-opencode.browser", "-opencode.browser.*",
|
||||
// The wildcard also loads local-model discovery. vLLM probes port 8000,
|
||||
// mistaking TJWater Server's /health for a model server. Models, including
|
||||
// local providers, must come from agent-models.json instead of discovery.
|
||||
"-opencode.provider.vllm", "-opencode.provider.lmstudio", "-opencode.provider.ollama"],
|
||||
skills: [resolve(process.env.OPENCODE_SKILLS_ROOT_DIR ?? ".opencode/skills")],
|
||||
agents: { ...agents, instruction: {
|
||||
description: "TJWater structured data and UI agent", mode: "primary", ...instruction, model: models.default_model, system,
|
||||
} },
|
||||
agents: { ...agents,
|
||||
instruction: {
|
||||
description: "TJWater structured data and UI agent", mode: "primary", ...instruction, model: models.defaults.chat, system,
|
||||
permissions: [
|
||||
...agentMessageTools.map((action): PermissionRule => ({ action, resource: "*", effect: action.startsWith("mcp_") && deniedProductQuery(action, (input.permissions ?? []) as PermissionRule[]) ? "deny" : "allow" })),
|
||||
...((instruction.permissions ?? []) as PermissionRule[]),
|
||||
{ action: "subagent", resource: "*", effect: "deny" },
|
||||
],
|
||||
},
|
||||
[TASK_AGENT]: {
|
||||
description: "后台工况调查,协调只读子任务并统一发布进展与报告", mode: "primary",
|
||||
model: models.defaults.task,
|
||||
system: `${system}\n\n${readFileSync(resolve(".opencode/agents/investigation.md"), "utf8")}`,
|
||||
permissions: [
|
||||
...agentMessageTools.map((action): PermissionRule => ({ action, resource: "*", effect: [queryMcpToolId("read_agent_messages"), "reply_agent_message"].includes(action) && !(action.startsWith("mcp_") && deniedProductQuery(action, (input.permissions ?? []) as PermissionRule[])) ? "allow" : "deny" })),
|
||||
{ action: "subagent", resource: "*", effect: "deny" },
|
||||
{ action: "subagent", resource: TASK_READER_AGENT, effect: "allow" },
|
||||
],
|
||||
},
|
||||
[TASK_READER_AGENT]: {
|
||||
description: "独立核查一项调查问题,仅查询当前任务授权资料并返回证据编号、发现与局限", mode: "subagent",
|
||||
system: readFileSync(resolve(".opencode/agents/investigation-reader.md"), "utf8"),
|
||||
permissions: [
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
...TASK_READER_TOOLS.filter((name) => !PRODUCT_MCP_TOOL_IDS.includes(name) || !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action) => ({ action, resource: "*", effect: "allow" })),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export class Opencode2EventNormalizer {
|
||||
normalize(event: V2Event): Event[] {
|
||||
if (!("data" in event)) return [];
|
||||
const data = event.data;
|
||||
if (event.type.startsWith("session.inbox.")) return [legacyEvent(event.type, data)];
|
||||
if (event.type === "permission.asked") {
|
||||
return [legacyEvent("permission.v2.asked", {
|
||||
...event.data,
|
||||
|
||||
@@ -23,7 +23,8 @@ export async function startOpencode2(options: {
|
||||
"serve", `--hostname=${options.hostname}`, `--port=${options.port}`,
|
||||
], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, ...options.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config) },
|
||||
env: Object.fromEntries(Object.entries({ ...process.env, ...options.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config) })
|
||||
.filter(([name]) => !name.startsWith("TJWATER_MCP_"))),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export const PRODUCT_MCP_READ_TOOLS = [
|
||||
"get_analysis_run",
|
||||
"get_element_gis_properties",
|
||||
"get_element_properties",
|
||||
"get_model_baseline_at_time",
|
||||
"get_network_options",
|
||||
"get_network_schema",
|
||||
"get_scada_device",
|
||||
"get_sensor_placement_run",
|
||||
"get_source_gis_properties",
|
||||
"get_task_context",
|
||||
"get_task_evidence",
|
||||
"get_workbench_runtime",
|
||||
"list_analysis_results",
|
||||
"list_analysis_runs",
|
||||
"list_elements",
|
||||
"list_scada_devices",
|
||||
"list_sensor_placement_runs",
|
||||
"query_analysis_series",
|
||||
"query_analysis_values",
|
||||
"query_element_history",
|
||||
"query_model_baseline_field",
|
||||
"query_model_baseline_series",
|
||||
"query_nearby_elements",
|
||||
"query_peripheral_data",
|
||||
"query_scada_readings",
|
||||
] as const;
|
||||
export const PRODUCT_MCP_ANALYSIS_TOOLS = ["run_simulation", "run_burst_detection", "run_burst_location", "analyze_valve_isolation", "run_leakage_identification", "run_contaminant_simulation", "analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation", "optimize_sensor_placement"] as const;
|
||||
export const PRODUCT_MCP_TOOLS = [...PRODUCT_MCP_READ_TOOLS, ...PRODUCT_MCP_ANALYSIS_TOOLS] as const;
|
||||
export const QUERY_MCP_SERVER = "mcp_tjwater";
|
||||
export const queryMcpToolId = (name: string) => `${QUERY_MCP_SERVER}_${name}`;
|
||||
export const PRODUCT_MCP_TOOL_IDS = PRODUCT_MCP_TOOLS.map(queryMcpToolId);
|
||||
export const PRODUCT_MCP_READ_TOOL_IDS = PRODUCT_MCP_READ_TOOLS.map(queryMcpToolId);
|
||||
export const LOCAL_QUERY_TOOLS = ["geocode", "session_search", "list_investigation_tasks", "get_investigation_task", "read_agent_messages", "todoread"] as const;
|
||||
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];
|
||||
@@ -0,0 +1,14 @@
|
||||
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
// Only a public loopback endpoint, outside model-writable workspaces. The
|
||||
// trusted OpenCode process already owns its separate internal-channel token.
|
||||
export function writeQueryMcpConnection(metadataRoot: string, url: string, executionTimeoutMs = 35_000) {
|
||||
const path = resolve(join(dirname(metadataRoot), "mcp-query", "connection.json"));
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${path}.${process.pid}.tmp`;
|
||||
writeFileSync(temporary, JSON.stringify({ url, executionTimeoutMs }), { mode: 0o600 });
|
||||
renameSync(temporary, path);
|
||||
process.env.TJWATER_AGENT_MCP_CONNECTION_PATH = path;
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { RuntimeSessionContext } from "./sessionContext.js";
|
||||
import { LOCAL_QUERY_TOOLS, PRODUCT_MCP_TOOLS, PRODUCT_MCP_READ_TOOLS, queryMcpToolId } from "./product-mcp-tools.js";
|
||||
|
||||
const projectLocal = new Set(["geocode", "list_investigation_tasks", "get_investigation_task", "read_agent_messages"]);
|
||||
export function allowedQueryTools(context: RuntimeSessionContext, reader = false): string[] {
|
||||
if (context.learningMode === "review") return [];
|
||||
const project = !!context.projectId && !context.projectKey.startsWith("personal-") && ["ordinary_chat", "task_execution", "task_collaboration"].includes(context.purpose ?? "");
|
||||
if (reader) return project ? PRODUCT_MCP_READ_TOOLS.map(queryMcpToolId) : [];
|
||||
return [...(project ? PRODUCT_MCP_TOOLS : []), ...LOCAL_QUERY_TOOLS.filter((name) =>
|
||||
(!projectLocal.has(name) || project) && !(context.purpose === "task_execution" && ["list_investigation_tasks", "get_investigation_task"].includes(name)))].map(queryMcpToolId);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { PRODUCT_MCP_READ_TOOL_IDS } from "./product-mcp-tools.js";
|
||||
import { getRuntimeSessionContext, type RuntimeSessionContext } from "./sessionContext.js";
|
||||
|
||||
export const TASK_AGENT = "investigation";
|
||||
export const TASK_READER_AGENT = "investigation-reader";
|
||||
export const TASK_READER_TOOLS = [...PRODUCT_MCP_READ_TOOL_IDS] as const;
|
||||
export const TASK_SUBAGENT_LIMIT = 2;
|
||||
|
||||
export type NativeTaskSession = { id: string; parentID?: string; agent?: string; location: { directory: string } };
|
||||
export type TaskAgentScope = { role: "coordinator" | "reader"; owner: RuntimeSessionContext };
|
||||
|
||||
// Never accept a parent, project or credential chosen by the model. Resolve the
|
||||
// direct parent from OpenCode and delegate only reads while its run is active.
|
||||
export async function resolveTaskAgentScope(
|
||||
sessionId: string,
|
||||
getSession: (id: string) => Promise<NativeTaskSession>,
|
||||
hasActiveRun: (id: string) => boolean,
|
||||
): Promise<TaskAgentScope | null> {
|
||||
const session = await getSession(sessionId);
|
||||
if (session.id !== sessionId) return null;
|
||||
const ownerId = session.parentID ?? sessionId;
|
||||
const owner = getRuntimeSessionContext(ownerId);
|
||||
if (!owner?.taskId || !owner.projectId || owner.purpose !== "task_execution" || !hasActiveRun(ownerId)) return null;
|
||||
if (!session.parentID) return session.agent === TASK_AGENT ? { role: "coordinator", owner } : null;
|
||||
if (session.agent !== TASK_READER_AGENT) return null;
|
||||
const parent = await getSession(ownerId);
|
||||
if (parent.id !== ownerId || parent.parentID || parent.agent !== TASK_AGENT
|
||||
|| parent.location.directory !== session.location.directory) return null;
|
||||
return { role: "reader", owner };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { OpenCodeClient } from "@opencode/client";
|
||||
|
||||
// Check native registration and effective selection without sending a search
|
||||
// query or returning credentials and configuration contents.
|
||||
export async function assertWebSearchReady(client: Pick<OpenCodeClient, "websearch" | "config">, directory: string) {
|
||||
const [providers, entries] = await Promise.all([
|
||||
client.websearch.providers({ location: { directory } }, { signal: AbortSignal.timeout(5000) }),
|
||||
client.config.get({ location: { directory } }, { signal: AbortSignal.timeout(5000) }),
|
||||
]).catch(() => { throw new Error("OpenCode web search configuration unavailable"); });
|
||||
let selection: false | { provider: string } | undefined;
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "document" && entry.info.websearch !== undefined) selection = entry.info.websearch;
|
||||
}
|
||||
if (selection === false) throw new Error("OpenCode web search is disabled");
|
||||
if (!providers.data.length) throw new Error("OpenCode has no registered search provider");
|
||||
if (selection && selection.provider !== "random" && !providers.data.some((provider) => provider.id === selection.provider)) {
|
||||
throw new Error("OpenCode configured search provider is unavailable");
|
||||
}
|
||||
return { provider: selection?.provider ?? "unselected", providerCount: providers.data.length };
|
||||
}
|
||||
Reference in New Issue
Block a user