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,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" })),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user