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
+137
View File
@@ -0,0 +1,137 @@
import type { Express, Request, Response, Router } from "express";
import { z } from "zod";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
import { getLocalAgentContext, getTrustedProductCredential, isTrustedTaskDispatchRequest } from "../context/localContext.js";
import type { SessionMetadataStore } from "../sessions/metadataStore.js";
import { readPurpose } from "../sessions/metadataStore.js";
import type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js";
import { logger } from "../logger.js";
const uuid = z.string().uuid();
const paging = z.object({ cursor: z.string().max(200).optional(), limit: z.number().int().min(1).max(50).optional() }).strict();
const discovery = z.object({ limit: z.number().int().min(1).max(50).optional(), offset: z.number().int().min(0).optional() }).strict();
const text = z.string().trim().min(1).max(8000);
const errorResponse = (res: Response, error: unknown) => {
const status = error instanceof z.ZodError ? 400
: error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 503;
res.status(status).json({ message: status === 503 ? "Agent 消息服务暂不可用" : error instanceof Error ? error.message : "Agent message rejected" });
};
const deny = (message: string) => Object.assign(new Error(message), { status: 403 });
// Called by the local MCP service, with a host-resolved native session only.
export function createAgentMessageQuery({ sessions, messages }: { sessions: SessionMetadataStore; messages: AgentMessageCoordinator }) {
return async (sessionId: string, operation: "list" | "get" | "read", value: unknown) => {
const context = getRuntimeSessionContext(sessionId);
if (!context?.projectId || context.learningMode === "review") throw deny("project conversation required");
const record = await sessions.get(context, sessionId);
if (!record || !["ordinary_chat", "task_collaboration", "task_execution"].includes(readPurpose(record))) throw deny("conversation unavailable");
const execution = readPurpose(record) === "task_execution";
if (execution && (operation !== "read" || !messages.isActive(record.sessionId))) throw deny("active investigation may only read its own messages");
if (operation === "list") {
const input = discovery.parse(value);
const query = new URLSearchParams({ task_type: "event_investigation", limit: String(input.limit ?? 20), offset: String(input.offset ?? 0) });
return messages.client.request(record.sessionId, `/api/v1/operational-tasks?${query}`);
}
if (operation === "get") {
const input = z.object({ task_id: uuid, run_id: uuid.optional() }).strict().parse(value);
const task = await messages.client.request(record.sessionId, `/api/v1/operational-tasks/${input.task_id}/agent-context`);
const runs = await messages.client.request(record.sessionId, `/api/v1/operational-tasks/${input.task_id}/runs${input.run_id ? `/${input.run_id}` : ""}`);
return { context: task, ...(input.run_id ? { run: runs } : { runs }) };
}
const input = paging.parse(value);
return messages.client.list(record.sessionId, record.sessionId, input.cursor, input.limit ?? 20);
};
}
export function registerAgentMessageBridge(app: Express, options: {
internalToken: string; sessions: SessionMetadataStore; messages: AgentMessageCoordinator;
}) {
const { sessions, messages } = options;
app.post("/internal/agent-messages/:operation", async (req, res) => {
if (!isTrustedInternalBridgeRequest(req, options.internalToken)) { res.status(403).json({ message: "forbidden" }); return; }
try {
const envelope = z.object({ session_id: z.string().min(1).max(128), input: z.unknown() }).strict().parse(req.body);
const context = getRuntimeSessionContext(envelope.session_id);
if (!context?.projectId || context.learningMode === "review") throw deny("project conversation required");
const record = await sessions.get(context, envelope.session_id);
if (!record || !["ordinary_chat", "task_collaboration", "task_execution"].includes(readPurpose(record))) throw deny("conversation unavailable");
const sessionId = record.sessionId;
const execution = readPurpose(record) === "task_execution";
const operation = req.params.operation;
if (execution && !["read", "reply"].includes(operation)) throw deny("investigation may only read and reply to its own messages");
if (execution && !messages.isActive(sessionId)) throw deny("active investigation required");
if (operation === "send") {
const input = z.object({ task_id: uuid, run_id: uuid, message: text, request_id: uuid }).strict().parse(envelope.input);
const item = await messages.client.send(sessionId, input);
messages.notify(item);
// Active target has its own current service credential. Never lend the
// caller's credential to an investigation session.
if (item.target_session_id && messages.isActive(item.target_session_id)) {
void messages.deliver(item.target_session_id, item).catch(() => logger.warn("Agent message retained for worker delivery"));
}
res.json(item);
} else if (operation === "reply") {
const input = z.object({ message_id: uuid, message: text, request_id: uuid }).strict().parse(envelope.input);
const original = await messages.client.get(sessionId, input.message_id);
if (execution && (original.task_id !== record.taskId || original.run_id !== record.runId)) throw deny("message does not belong to this investigation run");
if (!execution && (original.target_session_id !== sessionId || original.direction !== "to_chat")) throw deny("reply must belong to the calling conversation");
const item = await messages.client.reply(sessionId, input);
messages.notify(item);
// Persisted already; receipt is not a promise that the receiver read it.
const deliveryCredential = item.direction === "to_task" && item.target_session_id && messages.isActive(item.target_session_id)
? item.target_session_id : sessionId;
void messages.deliver(deliveryCredential, item).catch(() => logger.warn("Agent reply retained for worker delivery"));
res.json(item);
} else res.status(404).json({ message: "unknown message operation" });
} catch (error) { errorResponse(res, error); }
});
}
export function registerAgentMessageRoutes(router: Router, options: {
sessions: SessionMetadataStore; messages: AgentMessageCoordinator;
}) {
const { sessions, messages } = options;
const authorized = async (req: Request) => {
const context = getLocalAgentContext(req);
const credential = getTrustedProductCredential(req);
if (!credential) throw deny("project authorization required");
const id = z.string().min(1).max(128).parse(req.params.session_id);
const record = await sessions.get(context, id);
if (!record || !["ordinary_chat", "task_collaboration"].includes(readPurpose(record))) throw deny("conversation unavailable");
return { record, credential };
};
router.get("/session/:session_id/agent-messages", async (req, res) => {
try {
const { record, credential } = await authorized(req);
const query = z.object({ cursor: z.string().max(200).optional(), limit: z.coerce.number().int().min(1).max(50).default(50) }).parse(req.query);
res.setHeader("Cache-Control", "no-store");
res.json(await messages.client.list(credential, record.sessionId, query.cursor, query.limit));
} catch (error) { errorResponse(res, error); }
});
router.get("/session/:session_id/agent-messages/stream", async (req, res) => {
try {
const { record, credential } = await authorized(req);
// Also verify current Server permissions before creating the subscription.
await messages.client.list(credential, record.sessionId, undefined, 1);
res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive" });
res.flushHeaders();
const notify = () => { if (!res.destroyed) res.write(`event: agent_message\ndata: ${JSON.stringify({ session_id: record.sessionId })}\n\n`); };
const unsubscribe = messages.subscribe(record.sessionId, notify);
notify();
const heartbeat = setInterval(() => { if (!res.destroyed) res.write(": heartbeat\n\n"); }, 15_000);
const renewal = setTimeout(() => { res.write("event: reconnect\ndata: {}\n\n"); res.end(); }, 60_000);
res.on("close", () => { unsubscribe(); clearInterval(heartbeat); clearTimeout(renewal); });
} catch (error) { if (!res.headersSent) errorResponse(res, error); else res.end(); }
});
router.post("/task-messages/deliver", async (req, res) => {
try {
const credential = getTrustedProductCredential(req);
if (!credential || !isTrustedTaskDispatchRequest(req)) throw deny("service task execution authorization required");
const input = z.object({ message_id: uuid }).strict().parse(req.body);
const item = await messages.client.get(credential, input.message_id);
if (item.project_id !== credential.projectId) throw deny("message project mismatch");
res.json(await messages.deliver(credential, item));
} catch (error) { errorResponse(res, error); }
});
}
+1 -1
View File
@@ -33,7 +33,7 @@ export const buildAgentTriggerPrompt = (trigger: AgentTriggerContext) => [
event_id: trigger.trigger_id,
})}</external_work_order>`,
"这是合成演练数据,不得表述为生产事实。",
"根据这张工单的事实动态规划本次调查,不套用预设路线。先读取 get_operational_task_context,自主查询授权数据并保存证据和调查报告;todo、Mermaid 和可选的旧计划仅向人解释,不约束执行或决定任务完成。数据权限由系统配置,人工审核仅针对具体工单或设备命令。",
"根据这张工单的事实动态规划本次调查,不套用预设路线。先读取 mcp_tjwater_get_task_context,自主查询授权数据并保存证据和调查报告;todo、Mermaid 和可选的旧计划仅向人解释,不约束执行或决定任务完成。数据权限由系统配置,人工审核仅针对具体工单或设备命令。",
"每项判断必须列明实际依据。预设证据标注为合成场景输入,不得声称执行过未调用的数据查询工具。关键数据不足时发布 final/insufficient_data 报告,记录 stop_reason 和有查询依据的阻塞 data_gaps,清空 recommendations 与 pending_items,正常结束本轮;权限拒绝和接口错误不能证明数据缺失。现场采样、清洗、复测及回访是否完成以外部反馈为准,未来人工工作不阻止本轮调查结束。",
"外围样板随事件提供的合成场景输入(不是 Agent 实时查询结果):上游三个模拟水质监测点的数据质量均为 valid,演示指标均在场景阈值内,且相邻片区没有同类投诉;投诉集中在演示小区,二次供水水箱最近一次清洗记录待核验。",
"依据实际取得的证据判断可能原因,区分合成场景输入、已查询数据和待现场核验事项。已形成判断时可给出处置建议;提议具体动作前先保存 interim/concluded 报告,提议成功后再保存 final 报告。不得将疑似判断写成已确认事故。",
+20 -5
View File
@@ -1,4 +1,4 @@
import { Router } from "express";
import { Router, type NextFunction, type Request, type Response } from "express";
import { agentModelOptions, resolveDefaultModel } from "../chat/models.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
@@ -7,9 +7,9 @@ import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js
import { frontendActionRegistryResponse } from "../frontendAction/registry.js";
import { type LearningOrchestrator } from "../learning/orchestrator.js";
import { type MemoryStore } from "../memory/store.js";
import { type ResultReferenceResolver } from "../results/resolver.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { SessionContextMismatchError } from "../sessions/metadataStore.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { uiRegistryResponse } from "../uiEnvelope/registry.js";
@@ -23,13 +23,17 @@ import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js";
import { createChatRunState } from "./chatRunState.js";
import { registerChatSessionRoutes } from "./chatSessionRoutes.js";
import { registerChatStreamRoute } from "./chatStreamRoute.js";
import { registerResponseInputRoutes } from "./response-input-routes.js";
import { registerMcpStatusRoutes } from "./mcpRoutes.js";
import { registerChatSuggestionRoutes } from "./chatSuggestionRoutes.js";
import { registerAgentMessageRoutes } from "./agent-message-routes.js";
import type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js";
export type BuildChatRouterOptions = {
agentMessages?: AgentMessageCoordinator;
frontendActionCoordinator: FrontendActionCoordinator;
learningOrchestrator: LearningOrchestrator;
memoryStore: MemoryStore;
resultReferenceResolver: ResultReferenceResolver;
runtime: OpencodeRuntimeAdapter;
sessionBridge: ChatSessionBridge;
sessionMetadataStore: SessionMetadataStore;
@@ -44,12 +48,12 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
frontendActionCoordinator,
learningOrchestrator,
memoryStore,
resultReferenceResolver,
runtime,
sessionBridge,
sessionMetadataStore,
sessionTranscriptStore,
sessionUiStateStore,
agentMessages,
} = options;
const chatRouter = Router();
const { activeRuns, lastRunStatuses } = runState;
@@ -125,6 +129,7 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
});
registerChatSessionRoutes(chatRouter, {
learningOrchestrator,
frontendActionCoordinator,
runState,
runtime,
@@ -134,11 +139,12 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
sessionUiStateStore,
});
registerMcpStatusRoutes(chatRouter, { sessionMetadataStore, runtime });
registerChatSuggestionRoutes(chatRouter, runtime);
if (agentMessages) registerAgentMessageRoutes(chatRouter, { sessions: sessionMetadataStore, messages: agentMessages });
registerChatAuxiliaryRoutes(chatRouter, {
activeRuns,
lastRunStatuses,
resultReferenceResolver,
sessionBridge,
sessionMetadataStore,
sessionUiStateStore,
@@ -152,7 +158,9 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
sessionUiStateStore,
});
registerResponseInputRoutes(chatRouter, { runtime, sessionMetadataStore, runState });
registerChatStreamRoute(chatRouter, {
agentMessages,
frontendActionCoordinator,
learningOrchestrator,
memoryStore,
@@ -164,5 +172,12 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
sessionUiStateStore,
});
chatRouter.use((error: unknown, _req: Request, res: Response, next: NextFunction) => {
if (error instanceof SessionContextMismatchError && !res.headersSent) {
res.status(404).json({ message: "session not found" });
return;
}
next(error);
});
return chatRouter;
};
+9 -36
View File
@@ -4,12 +4,9 @@ import { z } from "zod";
import { getLocalAgentContext } from "../context/localContext.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { logger } from "../logger.js";
import { type ResultReferenceResolver } from "../results/resolver.js";
import { RESULT_REFERENCE_KIND } from "../results/store.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
type ActiveRun,
type RunStatus,
@@ -25,15 +22,16 @@ const abortPayloadSchema = z.object({
type RegisterAuxiliaryRoutesOptions = {
activeRuns: Map<string, ActiveRun>;
lastRunStatuses: Map<string, RunStatus>;
resultReferenceResolver: ResultReferenceResolver;
sessionBridge: ChatSessionBridge;
sessionMetadataStore: SessionMetadataStore;
sessionUiStateStore: SessionUiStateStore;
frontendActionCoordinator: FrontendActionCoordinator;
};
const toSessionUiStateContext = (sessionId: string) => ({
sessionId,
const toSessionUiStateContext = (record: { sessionId: string; actorKey: string; projectKey: string }) => ({
sessionId: record.sessionId,
actorKey: record.actorKey,
projectKey: record.projectKey,
});
export const registerChatAuxiliaryRoutes = (
@@ -41,38 +39,12 @@ export const registerChatAuxiliaryRoutes = (
{
activeRuns,
lastRunStatuses,
resultReferenceResolver,
sessionBridge,
sessionMetadataStore,
sessionUiStateStore,
frontendActionCoordinator,
}: RegisterAuxiliaryRoutesOptions,
) => {
chatRouter.get("/result-ref/:result_ref", async (req, res) => {
const resultRef = req.params.result_ref?.trim();
const context = getLocalAgentContext(req);
const clientSessionId = typeof req.query.session_id === "string" ? req.query.session_id.trim() : undefined;
const offset = Math.max(0, Number.parseInt(String(req.query.offset ?? "0"), 10) || 0);
const limit = Math.min(500, Math.max(1, Number.parseInt(String(req.query.limit ?? "100"), 10) || 100));
if (!resultRef || !clientSessionId) {
res.status(400).json({ message: "result_ref and session_id are required" });
return;
}
const result = await resultReferenceResolver.getFullAuthorized(resultRef, {
actorKey: context.actorKey, clientSessionId, projectId: context.projectId,
}, { expectedKind: RESULT_REFERENCE_KIND.serverApiPayload });
if (!result) {
res.status(404).json({ message: "result_ref not found" });
return;
}
const rows = Array.isArray(result.data)
? result.data
: result.data && typeof result.data === "object" && Array.isArray((result.data as { data?: unknown }).data)
? (result.data as { data: unknown[] }).data
: [result.data];
res.json({ ...result, data: rows.slice(offset, offset + limit), page: { offset, limit, total: rows.length, has_more: offset + limit < rows.length } });
});
chatRouter.post("/abort", async (req, res) => {
const parsed = abortPayloadSchema.safeParse(req.body);
if (!parsed.success) {
@@ -98,6 +70,7 @@ export const registerChatAuxiliaryRoutes = (
return;
}
frontendActionCoordinator.cancelSession(sessionRecord.sessionId);
activeRuns.get(sessionRecord.sessionId)?.inputs?.close();
const binding = await sessionBridge.abort({
clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId,
@@ -118,9 +91,9 @@ export const registerChatAuxiliaryRoutes = (
todos: cancelBackendTodos(message.todos),
}));
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
toSessionUiStateContext(sessionRecord),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
@@ -130,9 +103,9 @@ export const registerChatAuxiliaryRoutes = (
session_id: parsed.data.session_id,
message: "请求已中断",
});
subscriber.close();
}
run.subscribers.clear();
// The generation route emits its terminal Response after cleanup and
// closes subscribers. Closing here would truncate the Responses stream.
}
logger.info(
+6 -5
View File
@@ -1,17 +1,18 @@
import type { Part } from "@opencode-ai/sdk/v2";
import { PRODUCT_MCP_TOOL_IDS, queryMcpToolId } from "../runtime/product-mcp-tools.js";
const labels = { reference: "查找所需资料", data: "获取业务数据", analysis: "分析与核查", visualization: "更新地图与图表", other: "执行操作" };
const classify = (tool: string): keyof typeof labels => {
if (["skill", "read", "glob", "grep", "session_search", "memory_manager", "skill_manager"].includes(tool)) return "reference";
if (["query_project_data", "query_operational_task_data", "get_operational_task_context"].includes(tool)) return "data";
if (["render_scada_analysis", "clear_scada_analysis", "locate_features", "zoom_to_map", "show_chart", "view_history"].includes(tool)) return "visualization";
if (["publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence"].includes(tool)) return "analysis";
if (["skill", "read", "glob", "grep", queryMcpToolId("session_search"), "session_search", "memory_manager", "skill_manager"].includes(tool)) return "reference";
if ([...PRODUCT_MCP_TOOL_IDS, "websearch", queryMcpToolId("geocode"), queryMcpToolId("list_investigation_tasks"), queryMcpToolId("get_investigation_task"), queryMcpToolId("read_agent_messages"), "query_project_data", "query_operational_task_data", "get_operational_task_context"].includes(tool)) return "data";
if (["render_scada_analysis", "clear_scada_analysis", "locate_features", "zoom_to_map", "show_chart", "view_history", "publish_task_presentation"].includes(tool)) return "visualization";
if (["publish_execution_plan", "publish_task_report", "publish_task_progress", "propose_task_action", "add_task_human_evidence"].includes(tool)) return "analysis";
return "other";
};
export const createBusinessStageTracker = (write: (event: string, data: Record<string, unknown>) => void) => {
const stages = new Map<string, { startedAt: number; actions: Map<string, string> }>();
return (part: Extract<Part, { type: "tool" }>) => {
if (["final_answer", "todowrite", "todoread", "question"].includes(part.tool)) return;
if (["final_answer", "todowrite", "todoread", queryMcpToolId("todoread"), "question"].includes(part.tool)) return;
const kind = classify(part.tool);
const stage = stages.get(kind) ?? { startedAt: Date.now(), actions: new Map() };
stage.actions.set(part.id, part.state.status);
+4 -6
View File
@@ -1,7 +1,5 @@
import { isMcpTool } from "../runtime/mcp-config.js";
// Shell and file mutation remain interactive even when ordinary read-only
// business tools are automatically approved. Static denials stay in OpenCode.
export function canAutomaticallyApprove(mode: string, action: string) {
return mode === "always" && !isMcpTool(action) && !["shell", "bash", "edit", "write", "patch", "read", "external_directory", "execute", "subagent"].includes(action);
// Called only for native "asked" events. Explicit denials and host isolation
// still run before this point; the selected round mode answers pending requests.
export function canAutomaticallyApprove(mode: string, _action: string) {
return mode === "always";
}
+1 -1
View File
@@ -160,7 +160,7 @@ export const generateSessionTitle = async (
return fallback;
} finally {
if (titleSessionId) {
await runtime.abortSession(titleSessionId).catch((error) => {
await runtime.removeSession(titleSessionId).catch((error) => {
logger.debug({ sessionId: titleSessionId, err: error }, "failed to cleanup title session");
});
}
+67 -33
View File
@@ -16,10 +16,12 @@ import {
type SessionMetadataStore,
type SessionRecord,
type SessionPurpose,
SessionContextMismatchError,
readPurpose,
} from "../sessions/metadataStore.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { type LearningOrchestrator } from "../learning/orchestrator.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import { type ChatRunState } from "./chatRunState.js";
import { buildForkedSessionUiState } from "./chatSession.js";
@@ -56,6 +58,7 @@ const forkPayloadSchema = z.object({
});
type RegisterChatSessionRoutesOptions = {
learningOrchestrator: LearningOrchestrator;
frontendActionCoordinator: FrontendActionCoordinator;
runState: ChatRunState;
runtime: OpencodeRuntimeAdapter;
@@ -67,6 +70,8 @@ type RegisterChatSessionRoutesOptions = {
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId,
actorKey: sessionRecord.actorKey,
projectKey: sessionRecord.projectKey,
});
const toSse = (event: string, data: Record<string, unknown>) =>
@@ -78,6 +83,7 @@ export const registerChatSessionRoutes = (
) => {
const {
frontendActionCoordinator,
learningOrchestrator,
runState,
runtime,
sessionBridge,
@@ -96,7 +102,7 @@ export const registerChatSessionRoutes = (
) => {
queueMicrotask(() => {
const baseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? `http://127.0.0.1:${config.PORT}`;
void fetch(new URL("/api/v1/agent/chat/stream", baseUrl), {
void fetch(new URL("/api/v1/responses", baseUrl), {
method: "POST",
headers: {
Authorization: credential.authorization,
@@ -104,9 +110,10 @@ export const registerChatSessionRoutes = (
"X-Project-Id": credential.projectId,
},
body: JSON.stringify({
session_id: record.sessionId,
conversation: record.sessionId,
model,
message: `执行工况任务${workflow ? `:${workflow}` : ""}。先读取后端任务上下文,自主查询并保存证据和调查报告;可在报告内用 Mermaid 调查图向人解释方向、分支和依据,图及 todo 均不约束执行。数据权限由系统配置,关键数据不足时发布最终报告并正常结束本轮,人工审核仅针对具体工单等业务动作。`,
stream: true,
input: `执行工况任务${workflow ? `:${workflow}` : ""}。先读取后端任务上下文,自主查询并保存证据和调查报告;每次开始真实步骤或改变路线时发布完整 task progress,可用 Mermaid 调查图向人解释方向、分支和依据,图及 todo 均不约束执行。实际查询资料可通过 task presentation 发布,recorded 仅表示已记录,不等待浏览器展示。数据权限由系统配置,关键数据不足时发布最终报告并正常结束本轮,人工审核仅针对具体工单等业务动作。`,
}),
}).then(async (response) => {
const reader = response.body?.getReader();
@@ -185,6 +192,15 @@ export const registerChatSessionRoutes = (
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const trigger = parsed.data.trigger_context;
if (trigger && !projectId) {
res.status(403).json({ code: "PROJECT_CONTEXT_REQUIRED", message: "任务会话必须关联项目" });
return;
}
const parentSessionId = parsed.data.parent_session_id?.trim();
if (parentSessionId && !await sessionMetadataStore.get(authContext, parentSessionId)) {
res.status(404).json({ message: "session not found" });
return;
}
const purpose: SessionPurpose = trigger ? "task_execution" : "ordinary_chat";
const taskId = trigger?.subject_id;
if (trigger) {
@@ -208,10 +224,14 @@ export const registerChatSessionRoutes = (
}
}
const requestedSessionId = parsed.data.session_id?.trim();
if (requestedSessionId && !await sessionMetadataStore.get(authContext, requestedSessionId)) {
res.status(404).json({ message: "session not found" });
return;
}
let sessionId = requestedSessionId;
if (!sessionId) {
try {
sessionId = (await runtime.createSession()).id;
sessionId = (await runtime.createSession(undefined, { actorKey, projectKey })).id;
} catch (error) {
sessionId = sessionBridge.createClientSessionId();
logger.warn(
@@ -300,7 +320,7 @@ export const registerChatSessionRoutes = (
});
return;
}
const runtimeSession = await runtime.createSession();
const runtimeSession = await runtime.createSession(undefined, metadataContext);
const { record } = await sessionMetadataStore.ensure({
...metadataContext,
sessionId: runtimeSession.id,
@@ -396,7 +416,7 @@ export const registerChatSessionRoutes = (
});
return;
}
const runtimeSession = await runtime.createSession();
const runtimeSession = await runtime.createSession(undefined, metadataContext);
const { record } = await sessionMetadataStore.ensure({
...metadataContext,
sessionId: runtimeSession.id,
@@ -638,34 +658,44 @@ export const registerChatSessionRoutes = (
});
chatRouter.delete("/session/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
);
if (!sessionRecord) {
try {
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
{ includeDeleted: true },
);
if (!sessionRecord) {
res.status(204).end();
return;
}
await sessionMetadataStore.remove(sessionRecord);
frontendActionCoordinator.cancelSession(sessionRecord.sessionId);
await learningOrchestrator.cancelSession(sessionRecord.sessionId);
await sessionBridge.deleteSession({
clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId,
actorKey: sessionRecord.actorKey,
projectKey: sessionRecord.projectKey,
});
await sessionMetadataStore.purge(sessionRecord);
activeRuns.delete(sessionRecord.sessionId);
lastRunStatuses.delete(sessionRecord.sessionId);
res.status(204).end();
return;
} catch (error) {
if (error instanceof SessionContextMismatchError) { res.status(404).json({ message: "session not found" }); return; }
logger.warn({ err: error, sessionId: req.params.session_id }, "session cleanup incomplete");
res.status(503).json({ code: "SESSION_CLEANUP_PENDING", message: "Session cleanup could not finish. Retry deleting this session." });
}
await sessionUiStateStore.remove(toSessionUiStateContext(sessionRecord));
await sessionBridge.deleteSession({
clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId,
});
frontendActionCoordinator.cancelSession(sessionRecord.sessionId);
activeRuns.delete(sessionRecord.sessionId);
lastRunStatuses.delete(sessionRecord.sessionId);
await sessionMetadataStore.remove(sessionRecord);
res.status(204).end();
});
chatRouter.post("/fork", async (req, res) => {
@@ -698,11 +728,15 @@ export const registerChatSessionRoutes = (
sourceSessionId,
)
: null;
if (sourceSessionId && !sourceSessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
if (sourceSessionRecord && readPurpose(sourceSessionRecord) !== "ordinary_chat") {
res.status(409).json({ message: "task sessions cannot be forked into ordinary chat history" });
return;
}
const forkSession = await runtime.createSession();
const forkSession = await runtime.createSession(undefined, { actorKey, projectKey });
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: sourceSessionId,
+47 -9
View File
@@ -70,6 +70,12 @@ export {
export type ApprovalMode = "request" | "always";
type StreamPromptOptions = {
onPromptDispatched?: () => void;
onInboxEvent?: (type: string, data: { inboxID?: string; delivery?: "steer" | "queue" }) => void;
waitForUserInput?: () => Promise<boolean>;
beforePrompt?: () => Promise<void>;
beforeIdle?: () => Promise<boolean>;
beforeRichAnswerClose?: () => Promise<boolean>;
runtime: OpencodeRuntimeAdapter;
sessionId: string;
clientSessionId: string;
@@ -146,6 +152,12 @@ export const streamPromptResponse = async ({
suppressLegacyFrontendActions = false,
suppressAnswerUi = false,
write,
beforePrompt,
beforeIdle,
beforeRichAnswerClose,
onPromptDispatched,
onInboxEvent,
waitForUserInput,
}: StreamPromptOptions): Promise<{
aborted: boolean;
failed: boolean;
@@ -264,8 +276,11 @@ export const streamPromptResponse = async ({
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
});
const promptPromise = runtime
.prompt(sessionId, message, toRuntimeModel(model))
const promptPromise = Promise.resolve().then(async () => {
await beforePrompt?.();
await runtime.prompt(sessionId, message, toRuntimeModel(model));
onPromptDispatched?.();
})
.then(() => {
promptSettled = true;
logDevelopmentDebug("runtime.prompt resolved", {
@@ -337,6 +352,11 @@ export const streamPromptResponse = async ({
if (!isSessionEvent(event, sessionId)) {
continue;
}
const inboxEvent = event as unknown as { type: string; properties: { inboxID?: string; delivery?: "steer" | "queue" } };
if (inboxEvent.type.startsWith("session.inbox.")) {
onInboxEvent?.(inboxEvent.type, inboxEvent.properties);
continue;
}
if (!firstSessionEventLogged) {
firstSessionEventLogged = true;
@@ -410,19 +430,21 @@ export const streamPromptResponse = async ({
title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
detail:
autoApprove
? "当前批准模式为始终允许,已自动允许本次权限请求。"
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
: buildPermissionDetail(event),
});
if (autoApprove) {
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
reply: "always",
// Automatic approval applies only while this round is active.
// Native "always" would also authorize later manual rounds.
reply: "once",
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.id,
reply: "always" satisfies PermissionReply,
reply: "once" satisfies PermissionReply,
});
continue;
}
@@ -456,19 +478,19 @@ export const streamPromptResponse = async ({
title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
detail:
autoApprove
? "当前批准模式为始终允许,已自动允许本次权限请求。"
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
: buildPermissionV2Detail(event),
});
if (autoApprove) {
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
reply: "always",
reply: "once",
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.id,
reply: "always" satisfies PermissionReply,
reply: "once" satisfies PermissionReply,
});
continue;
}
@@ -750,7 +772,8 @@ export const streamPromptResponse = async ({
const refs = (output as { ui_block_ids?: unknown }).ui_block_ids;
finalAnswerUiBlockIds = Array.isArray(refs) ? [...new Set(refs.filter((id): id is string => typeof id === "string" && presentedAnswerIds.has(id)))].slice(0, 48) : [];
const hasPendingTool = [...toolStatuses.values()].some((status) => status !== "completed" && status !== "error");
if (presentedAnswerIds.size > 0 && !suppressAnswerUi && !hasPendingTool) {
if (presentedAnswerIds.size > 0 && !suppressAnswerUi && !hasPendingTool
&& (!beforeRichAnswerClose || await beforeRichAnswerClose())) {
// A final rich-chat answer closes this turn; native interruption stops
// automatic revisions without marking a background task complete.
await runtime.abortSession(sessionId);
@@ -966,6 +989,21 @@ export const streamPromptResponse = async ({
});
continue;
}
if (beforeIdle && !await beforeIdle()) {
// A durable message won the race with completion. Continue within
// the same managed session/lease, never create another task run.
finalAnswer = undefined;
sawResponseActivity = false;
await runtime.prompt(sessionId, "请处理收信箱中新到达的调查协作消息,按需回复,然后完成本轮工作。", toRuntimeModel(model));
continue;
}
if (waitForUserInput && await waitForUserInput()) {
// Native inbox admission already resumes queued work; do not inject a
// synthetic extra prompt or close the stream between executions.
finalAnswer = undefined;
sawResponseActivity = false;
continue;
}
logDevelopmentDebug("session idle received", {
...debugContext,
emittedText,
+140 -52
View File
@@ -1,9 +1,11 @@
import { createResponsesProjection, responseRequestSchema, responsePrompt } from "../chat/responses-protocol.js";
import { ResponseInputTracker } from "../chat/response-inputs.js";
import { createAnswerStreamProjection } from "../chat/answer-stream-projection.js";
import type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js";
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
import { type Router } from "express";
import { randomUUID } from "node:crypto";
import {
UI_MESSAGE_STREAM_HEADERS,
type UIMessage,
type UIMessageChunk,
} from "ai";
@@ -12,6 +14,7 @@ import { createExecutionPlanSnapshot, executionPlanNeedsTaskPublish, readExecuti
import { executionPlanSchema } from "./executionPlan.js";
import { isSupportedModel } from "../chat/models.js";
import { config } from "../config.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import {
getLocalAgentContext,
@@ -100,6 +103,8 @@ const payloadSchema = z.object({
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId,
actorKey: sessionRecord.actorKey,
projectKey: sessionRecord.projectKey,
});
const runtimeHasConversation = async (
@@ -242,20 +247,8 @@ export const buildFailedTaskExecutionPlan = (history: Record<string, unknown>[])
};
};
const pipeUiChunk = (
res: {
write: (chunk: string) => void;
writableEnded?: boolean;
destroyed?: boolean;
},
chunk: UIMessageChunk,
) => {
if (!res.writableEnded && !res.destroyed) {
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
}
};
type RegisterChatStreamRouteOptions = {
agentMessages?: AgentMessageCoordinator;
frontendActionCoordinator: FrontendActionCoordinator;
learningOrchestrator: LearningOrchestrator;
memoryStore: MemoryStore;
@@ -279,12 +272,23 @@ export const registerChatStreamRoute = (
sessionMetadataStore,
sessionTranscriptStore,
sessionUiStateStore,
agentMessages,
}: RegisterChatStreamRouteOptions,
) => {
const { activeRuns, lastRunStatuses } = runState;
const startingSessions = new Set<string>();
chatRouter.post("/stream", async (req, res) => {
const parsed = payloadSchema.safeParse(req.body);
chatRouter.post("/responses", async (req, res) => {
const request = responseRequestSchema.safeParse(req.body);
if (!request.success) {
res.status(400).json({ error: { type: "invalid_request_error", code: "invalid_request", message: "Unsupported or invalid Responses request", param: request.error.issues[0]?.path.join(".") ?? null } });
return;
}
const input = request.data;
const parsed = payloadSchema.safeParse({
message: responsePrompt(input.input), model: input.model,
session_id: typeof input.conversation === "string" ? input.conversation : input.conversation?.id,
...input.tjwater,
});
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
@@ -293,6 +297,7 @@ export const registerChatStreamRoute = (
return;
}
let wire: ReturnType<typeof createResponsesProjection> | undefined;
let boundOperationalSessionId: string | undefined;
let startingSessionId: string | undefined;
try {
@@ -340,6 +345,24 @@ export const registerChatStreamRoute = (
res.status(409).json({ message: "trigger does not match session" });
return;
}
const running = existingSessionRecord ? activeRuns.get(existingSessionRecord.sessionId) : undefined;
if (running?.status === "running") {
if (!input.tjwater.delivery || !input.tjwater.input_id || input.stream || trigger || readPurpose(existingSessionRecord!) === "task_execution") {
res.status(409).json({ message: "Running conversations require a non-streaming steer/queue input with input_id" });
return;
}
if (!running.inputs) {
res.status(409).json({ message: "Session is starting; retry after it is ready" });
return;
}
const receipt = await running.inputs.submit({ id: input.tjwater.input_id, text: promptText, delivery: input.tjwater.delivery, status: "pending" });
res.status(202).json({ object: "tjwater.response_input", session_id: existingSessionRecord!.sessionId, ...receipt });
return;
}
if (input.tjwater.delivery || input.tjwater.input_id) {
res.status(409).json({ message: "The response has finished; send a new response instead" });
return;
}
if (
existingSessionRecord &&
readPurpose(existingSessionRecord) === "task_execution" &&
@@ -350,6 +373,14 @@ export const registerChatStreamRoute = (
return;
}
const hadExistingRuntimeSession = Boolean(existingSessionRecord);
if (existingSessionRecord) {
if (startingSessions.has(existingSessionRecord.sessionId)) {
res.status(409).json({ message: "Session is starting; retry after it is ready" });
return;
}
startingSessions.add(existingSessionRecord.sessionId);
startingSessionId = existingSessionRecord.sessionId;
}
const { binding, requestContext, created } = await sessionBridge.resolve({
sessionId: requestedSessionId,
@@ -377,8 +408,7 @@ export const registerChatStreamRoute = (
const activeSessionRecord = await sessionMetadataStore.touch(ensuredSessionRecord);
const sessionPurpose = readPurpose(activeSessionRecord);
if (
activeRuns.get(activeSessionRecord.sessionId)?.status === "running" ||
startingSessions.has(activeSessionRecord.sessionId)
activeRuns.get(activeSessionRecord.sessionId)?.status === "running"
) {
res.status(409).json({
message: "session is already streaming",
@@ -456,14 +486,21 @@ export const registerChatStreamRoute = (
);
res.status(200);
for (const [header, value] of Object.entries(UI_MESSAGE_STREAM_HEADERS)) {
res.setHeader(header, value);
if (input.stream) {
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
}
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
const clientSessionId = requestContext.clientSessionId;
let streamClosed = false;
wire = createResponsesProjection({
model: parsed.data.model ?? config.OPENCODE_MODEL, conversationId: clientSessionId, metadata: input.metadata,
emit: (event) => {
if (input.stream && !res.writableEnded && !res.destroyed) res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
},
});
wire.begin();
const abortController = new AbortController();
sessionBridge.registerAbortController(clientSessionId, abortController);
const initialMessages = createInitialStreamingMessages(
@@ -486,7 +523,7 @@ export const registerChatStreamRoute = (
startingSessionId = undefined;
lastRunStatuses.set(clientSessionId, "running");
const sessionUiStateContext = toSessionUiStateContext(activeSessionRecord);
const assistantUiMessageId = `assistant-${Date.now().toString(36)}`;
const assistantUiMessageId = `msg_${randomUUID().replaceAll("-", "")}`;
const syncLatestUiMessages = () => {
latestUiMessages = [
...requestMessages,
@@ -498,7 +535,8 @@ export const registerChatStreamRoute = (
];
};
const writeUiChunk = (chunk: UIMessageChunk) => {
if (!streamClosed && !res.writableEnded && !res.destroyed) pipeUiChunk(res, chunk);
// Persist canonical parts after disconnect; the wire emitter checks the socket.
wire!.write(chunk);
};
const projection = createAnswerStreamProjection({
messageId: assistantUiMessageId, sessionId: clientSessionId,
@@ -562,14 +600,6 @@ export const registerChatStreamRoute = (
},
};
activeRun.subscribers.add(primarySubscriber);
const handleClientClose = () => {
streamClosed = true;
// Keep projecting and persisting parts after the browser disconnects.
// writeUiChunk guards the closed response; replay subscribers remain independent.
};
req.on("close", handleClientClose);
res.on("close", handleClientClose);
let taskArtifactPublishQueue: Promise<unknown> = Promise.resolve();
const publish = (event: string, data: Record<string, unknown>) => {
@@ -782,6 +812,7 @@ export const registerChatStreamRoute = (
abortController.signal.addEventListener("abort", cancelCredentialRefresh, { once: true });
let taskHeartbeat: ReturnType<typeof setInterval> | undefined;
let taskHeartbeatActive = true;
let taskCompleting = false;
let taskHeartbeatPromise: Promise<void> | undefined;
let taskLeaseFailure: Error | undefined;
let taskRunFinalized = false;
@@ -792,7 +823,7 @@ export const registerChatStreamRoute = (
};
if (sessionPurpose === "task_execution" && activeSessionRecord.taskId) {
taskHeartbeat = setInterval(() => {
if (!taskHeartbeatActive || taskHeartbeatPromise) return;
if (!taskHeartbeatActive || taskHeartbeatPromise || taskCompleting) return;
const heartbeat = operationalTaskClient.heartbeatTask(
binding.sessionId,
activeSessionRecord.taskId!,
@@ -852,6 +883,18 @@ export const registerChatStreamRoute = (
restoreConversation: shouldRestoreConversation,
},
);
const messaging = getTrustedProductCredential(req) ? agentMessages : undefined;
messaging?.start(activeSessionRecord);
const inputTracker = new ResponseInputTracker(runtime, binding.sessionId, (receipt) => {
if (receipt.status === "delivered" && !requestMessages.some((message) => message.id === receipt.id)) {
requestMessages.push({ id: receipt.id, role: "user", parts: [{ type: "text", text: receipt.text }] } as UIMessage);
const tail = activeRun.messages.at(-1);
activeRun.messages = [...activeRun.messages.slice(0, -1), { id: receipt.id, role: "user", content: receipt.text }, ...(tail ? [tail] : [])];
syncLatestUiMessages();
}
publish("user_input", { session_id: clientSessionId, ...receipt });
});
activeRun.inputs = inputTracker;
const streamResult = await streamPromptResponse({
runtime,
sessionId: binding.sessionId,
@@ -864,6 +907,44 @@ export const registerChatStreamRoute = (
signal: abortController.signal,
suppressLegacyFrontendActions: parsed.data.capabilities.includes("frontend-action@1"),
suppressAnswerUi: sessionPurpose === "task_execution",
beforePrompt: async () => { await inputTracker.restore(); await messaging?.flush(binding.sessionId); },
onPromptDispatched: () => inputTracker.markReady(),
onInboxEvent: (type, data) => inputTracker.observe(type, data),
waitForUserInput: async () => !await inputTracker.canClose(),
beforeRichAnswerClose: async () => {
// A consumed input may still be awaiting its model step. Let the
// native runner drain it instead of interrupting on an older answer.
if (inputTracker.hasAdditionalInput) return false;
if (messaging && (messaging.hasInputThisTurn(binding.sessionId) || !await messaging.closeInteractive(activeSessionRecord))) return false;
return inputTracker.canClose();
},
beforeIdle: messaging ? async () => {
if (sessionPurpose !== "task_execution") return messaging.closeInteractive(activeSessionRecord);
if (await messaging.flush(binding.sessionId)) return false;
await taskArtifactPublishQueue;
if (taskLeaseFailure || !activeSessionRecord.taskId || !activeSessionRecord.runId) return true;
taskCompleting = true;
try {
// Finish any earlier lease renewal before making the run terminal.
await taskHeartbeatPromise;
if (taskLeaseFailure) return true;
const task = await operationalTaskClient.completeTaskRun(binding.sessionId,
activeSessionRecord.taskId, activeSessionRecord.runId, clientSessionId);
taskRunFinalized = true;
messaging.stop(binding.sessionId);
await stopTaskHeartbeat();
if (task.lifecycle_status === "failed") throw new Error("Server rejected incomplete investigation artifacts");
return true;
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "PENDING_AGENT_MESSAGES") {
await messaging.flush(binding.sessionId);
return false;
}
throw error;
} finally {
taskCompleting = false;
}
} : undefined,
write: (event, data) => {
publish(event, data);
},
@@ -886,6 +967,7 @@ export const registerChatStreamRoute = (
&& activeSessionRecord.taskId
&& activeSessionRecord.runId
&& !taskLeaseFailure
&& !taskRunFinalized
) {
if (streamResult.failed || streamResult.aborted) {
await operationalTaskClient.failTaskRun(
@@ -993,6 +1075,7 @@ export const registerChatStreamRoute = (
}
}
} catch (error) {
publish("error", { session_id: clientSessionId, message: abortController.signal.aborted ? "请求已中断" : "Agent response failed" });
await stopTaskHeartbeat();
if (
sessionPurpose === "task_execution" &&
@@ -1019,6 +1102,15 @@ export const registerChatStreamRoute = (
}
throw error;
} finally {
activeRun.inputs?.close();
if (abortController.signal.aborted || activeRun.status === "error") {
await runtime.cancelPendingUserInputs?.(binding.sessionId).catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to cancel pending user inputs");
});
if (activeRun.inputs?.hasAdditionalInput && activeRun.status === "error") await runtime.abortSession(binding.sessionId).catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to stop pending input continuation");
});
}
unsubscribeCredentialRefresh();
abortController.signal.removeEventListener("abort", cancelCredentialRefresh);
await stopTaskHeartbeat();
@@ -1053,6 +1145,7 @@ export const registerChatStreamRoute = (
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
sessionBridge.finalizeRequest(clientSessionId);
agentMessages?.stop(binding.sessionId);
releaseOperationalTaskCredential(binding.sessionId);
activeRun.status = (taskLeaseFailure || credentialFailure)
? "error"
@@ -1062,6 +1155,8 @@ export const registerChatStreamRoute = (
? "completed"
: activeRun.status;
lastRunStatuses.set(clientSessionId, activeRun.status);
wire.finish(activeRun.status === "aborted" ? "cancelled" : activeRun.status === "error" ? "failed" : "completed");
if (!input.stream && !res.writableEnded && !res.destroyed) res.json(wire.response);
for (const subscriber of activeRun.subscribers) {
subscriber.close();
}
@@ -1072,9 +1167,6 @@ export const registerChatStreamRoute = (
) {
activeRuns.delete(clientSessionId);
}
streamClosed = true;
req.off("close", handleClientClose);
res.off("close", handleClientClose);
}
if (!res.writableEnded && !res.destroyed) {
@@ -1087,26 +1179,22 @@ export const registerChatStreamRoute = (
if (boundOperationalSessionId) {
releaseOperationalTaskCredential(boundOperationalSessionId);
}
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat stream failed");
if (res.headersSent) {
if (wire) {
wire.write({ type: "error", errorText: "Agent response failed" });
wire.finish("failed");
if (!res.writableEnded && !res.destroyed) {
pipeUiChunk(res, {
type: "error",
errorText: detail,
});
pipeUiChunk(res, {
type: "finish",
finishReason: "error",
});
res.end();
if (!input.stream) res.json(wire.response);
else res.end();
}
return;
}
res.status(500).json({
message: "chat stream failed",
detail,
});
const inputStatus = error && typeof error === "object" && "status" in error ? error.status : undefined;
if (inputStatus === 409 || inputStatus === 429) {
res.status(inputStatus).json({ message: inputStatus === 429 ? "Too many pending user inputs" : "Response input is no longer available; refresh the conversation" });
return;
}
res.status(500).json({ error: { type: "server_error", code: "agent_response_failed", message: "Agent response failed" } });
}
});
+31
View File
@@ -0,0 +1,31 @@
import type { Router } from "express";
import { config } from "../config.js";
import { isSupportedModel } from "../chat/models.js";
import { createPromptSuggestionService, promptSuggestionRequestSchema } from "../chat/prompt-suggestions.js";
import { getLocalAgentContext } from "../context/localContext.js";
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { logger } from "../logger.js";
export function registerChatSuggestionRoutes(router: Router, runtime: OpencodeRuntimeAdapter) {
const generate = createPromptSuggestionService(runtime);
router.post("/suggestions", async (req, res) => {
const parsed = promptSuggestionRequestSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ message: "invalid suggestion request" });
return;
}
const model = parsed.data.model ?? config.OPENCODE_MODEL;
if (!isSupportedModel(model)) {
res.status(400).json({ message: "unsupported model" });
return;
}
res.setHeader("Cache-Control", "no-store");
try {
const result = await generate(getLocalAgentContext(req), parsed.data, model);
if (!res.destroyed) res.json(result);
} catch {
logger.warn("prompt suggestion generation unavailable");
if (!res.destroyed) res.status(503).json({ message: "prompt suggestions unavailable" });
}
});
}
+2
View File
@@ -1,4 +1,5 @@
import { type PermissionReply } from "../runtime/opencode.js";
import type { ResponseInputTracker } from "../chat/response-inputs.js";
import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
import {
type PermissionRequestPayload,
@@ -14,6 +15,7 @@ export type StreamSubscriber = {
};
export type ActiveRun = {
inputs?: ResponseInputTracker;
clientSessionId: string;
controller: AbortController;
messages: unknown[];
-33
View File
@@ -1,33 +0,0 @@
import { type Express } from "express";
import { z } from "zod";
import { ExternalInformationClient, geocodeInput, webSearchInput } from "../product/external-information-client.js";
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
export function registerExternalInformationBridge(app: Express, options: {
internalToken: string;
client?: ExternalInformationClient;
}) {
const client = options.client ?? new ExternalInformationClient();
for (const name of ["web-search", "geocode"] as const) {
app.post(`/internal/tools/${name}`, async (req, res) => {
if (!isTrustedInternalBridgeRequest(req, options.internalToken)) { res.status(403).json({ message: "forbidden" }); return; }
const parsed = z.object({ session_id: z.string().min(1).max(128), input: name === "web-search" ? webSearchInput : geocodeInput }).strict().safeParse(req.body);
if (!parsed.success) { res.status(400).json({ message: "invalid external information input" }); return; }
const context = getRuntimeSessionContext(parsed.data.session_id);
if (!context || context.learningMode === "review") { res.status(403).json({ message: "active session context required" }); return; }
const controller = new AbortController();
const cancel = () => { if (!res.writableEnded) controller.abort(); };
res.once("close", cancel);
try {
const result = name === "web-search"
? await client.webSearch(context.sessionId, webSearchInput.parse(parsed.data.input), controller.signal)
: await client.geocode(context.sessionId, geocodeInput.parse(parsed.data.input), controller.signal);
if (!controller.signal.aborted) res.json(result);
} catch (error) {
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502;
if (!controller.signal.aborted) res.status(status).json({ message: error instanceof Error ? error.message : "external information unavailable" });
} finally { res.removeListener("close", cancel); }
});
}
}
+13 -6
View File
@@ -5,20 +5,27 @@ import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
import { type McpRuntimeReport, type McpSessionPolicy } from "../runtime/mcp-config.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { allowedQueryTools } from "../runtime/query-mcp-policy.js";
import type { QueryOwner } from "../product/mcp-query-client.js";
export function registerMcpPolicyBridge(app: Express, options: {
internalToken: string;
runtime: { mcpReadonlyTools(): string[] };
resolveReader?: (id: string) => Promise<QueryOwner | null>;
}) {
app.post("/internal/mcp/session-policy", (req, res) => {
app.post("/internal/mcp/session-policy", async (req, res) => {
if (!isTrustedInternalBridgeRequest(req, options.internalToken)) { res.status(403).json({ message: "forbidden" }); return; }
const parsed = z.object({ session_id: z.string().min(1).max(128) }).strict().safeParse(req.body);
if (!parsed.success) { res.status(400).json({ message: "invalid MCP session policy request" }); return; }
const context = getRuntimeSessionContext(parsed.data.session_id);
const direct = getRuntimeSessionContext(parsed.data.session_id);
const context = direct ?? (await options.resolveReader?.(parsed.data.session_id))?.context;
if (!context) { res.status(404).json({ message: "session context not found" }); return; }
const readonlyTools = options.runtime.mcpReadonlyTools();
const grants = new Set(readonlyTools);
const policy: McpSessionPolicy = {
mode: context.learningMode === "review" ? "disabled" : context.purpose === "task_execution" ? "readonly" : "interactive",
readonly_tools: options.runtime.mcpReadonlyTools(),
mode: context.learningMode === "review" ? "disabled" : !direct || context.purpose === "task_execution" ? "readonly" : "interactive",
readonly_tools: readonlyTools,
query_tools: allowedQueryTools(context, !direct).filter((name) => grants.has(name)),
};
res.json(policy);
});
@@ -26,13 +33,13 @@ export function registerMcpPolicyBridge(app: Express, options: {
export function registerMcpStatusRoutes(router: Router, options: {
sessionMetadataStore: SessionMetadataStore;
runtime: { mcpStatus(sessionId: string): Promise<McpRuntimeReport> };
runtime: { mcpStatus(sessionId: string, scope?: { actorKey: string; projectKey: string }): Promise<McpRuntimeReport> };
}) {
router.get("/session/:session_id/mcp", async (req, res) => {
try {
const record = await options.sessionMetadataStore.get(getLocalAgentContext(req), req.params.session_id);
if (!record) { res.status(404).json({ message: "session not found" }); return; }
const report = await options.runtime.mcpStatus(record.sessionId);
const report = await options.runtime.mcpStatus(record.sessionId, record);
if (report.configuration_errors.length) { res.status(503).json({ message: "MCP configuration invalid" }); return; }
res.json({ servers: report.servers });
} catch { res.status(503).json({ message: "MCP runtime unavailable" }); }
+37
View File
@@ -0,0 +1,37 @@
import type { Router } from "express";
import { z } from "zod";
import { getLocalAgentContext } from "../context/localContext.js";
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { readPurpose, type SessionMetadataStore } from "../sessions/metadataStore.js";
import type { ChatRunState } from "./chatRunState.js";
const id = z.string().regex(/^msg_[a-f0-9]{32}$/);
const update = z.object({ delivery: z.enum(["steer", "queue"]) }).strict();
export function registerResponseInputRoutes(router: Router, options: {
runtime: OpencodeRuntimeAdapter; sessionMetadataStore: SessionMetadataStore; runState: ChatRunState;
}) {
for (const method of ["get", "patch"] as const) {
router[method](method === "get" ? "/session/:session_id/inputs" : "/session/:session_id/inputs/:input_id", async (req, res) => {
const sessionId = String(req.params.session_id);
try {
const record = await options.sessionMetadataStore.get(getLocalAgentContext(req), sessionId);
if (!record || readPurpose(record) === "task_execution") { res.status(404).json({ message: "session not found" }); return; }
if (method === "get") {
res.json({ items: await options.runtime.listPendingUserInputs(sessionId) });
return;
}
const inputId = id.parse((req.params as Record<string, string>).input_id);
const { delivery } = update.parse(req.body);
const input = await options.runtime.updatePendingUserInput(sessionId, inputId, delivery);
options.runState.activeRuns.get(sessionId)?.inputs?.observe("session.inbox.delivery.changed", { inboxID: inputId, delivery });
res.json(input);
} catch (error) {
if (error instanceof z.ZodError) { res.status(400).json({ message: "invalid input delivery" }); return; }
const status = error && typeof error === "object" && "status" in error ? error.status : undefined;
res.status(status === 404 || status === 409 ? 409 : 503).json({ message: status === 404 || status === 409
? "Message has already been consumed or is unavailable" : "Input inbox is unavailable" });
}
});
}
}
+35
View File
@@ -0,0 +1,35 @@
import type { Express } from "express";
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
import { hasOperationalTaskRun } from "../product/operational-task-client.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
import { resolveTaskAgentScope, type NativeTaskSession } from "../runtime/task-agent-policy.js";
export function registerTaskAgentBridge(app: Express, options: {
internalToken: string;
getSession: (id: string) => Promise<NativeTaskSession>;
hasActiveRun?: (id: string) => boolean;
}) {
const scope = (id: string) => resolveTaskAgentScope(id, options.getSession, options.hasActiveRun ?? hasOperationalTaskRun);
app.post("/internal/operational-task/agent-policy", async (req, res) => {
if (!isTrustedInternalBridgeRequest(req, options.internalToken)) {
res.status(403).json({ message: "forbidden" }); return;
}
const id = typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
try {
const policy = id ? await scope(id) : null;
res.json({ role: policy?.role ?? "denied" });
} catch { res.status(403).json({ message: "task agent policy unavailable" }); }
});
// Used only by MCP query execution/policy. Do not register a child as its parent
// or copy credentials: publication/action/Shell bridges must still reject it.
return async (id: string) => {
const direct = getRuntimeSessionContext(id);
if (direct?.taskId) return { context: direct, runtimeSessionId: id, taskId: direct.taskId };
try {
const child = await scope(id);
return child?.role === "reader" ? {
context: child.owner, runtimeSessionId: child.owner.sessionId, taskId: child.owner.taskId!,
} : null;
} catch { return null; }
};
}
+65
View File
@@ -0,0 +1,65 @@
import { type Express } from "express";
import { z } from "zod";
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
import { operationalTaskClient, type OperationalTaskClient, taskArtifactBridgeSchema } from "../product/operational-task-client.js";
import { taskPresentationBridgeSchema } from "../product/task-presentation.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
const artifactsInputSchema = taskArtifactBridgeSchema.and(z.object({ session_id: z.string().trim().min(1).max(128) }));
export function registerTaskPublicationBridge(app: Express, options: {
internalToken: string;
client?: Pick<OperationalTaskClient, "publishArtifacts" | "publishPresentation">;
}) {
const client = options.client ?? operationalTaskClient;
for (const kind of ["artifacts", "presentation"] as const) {
app.post(`/internal/operational-task/${kind}`, async (req, res) => {
if (!isTrustedInternalBridgeRequest(req, options.internalToken)) {
res.status(403).json({ message: "forbidden" });
return;
}
const sessionId = typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context?.taskId) {
res.status(403).json({ message: "operational task context unavailable" });
return;
}
if ((kind === "presentation" || req.body?.progress !== undefined) && context.purpose !== "task_execution") {
res.status(403).json({ message: "task execution context required" });
return;
}
try {
if (kind === "presentation") {
const parsed = taskPresentationBridgeSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ message: "invalid task presentation", detail: parsed.error.flatten() });
return;
}
res.json(await client.publishPresentation(sessionId, context.taskId, {
requestId: parsed.data.request_id,
sourceSessionId: context.clientSessionId,
command: parsed.data.command,
}));
} else {
const parsed = artifactsInputSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ message: "invalid task artifacts", detail: parsed.error.flatten() });
return;
}
res.json(await client.publishArtifacts(sessionId, context.taskId, {
requestId: parsed.data.request_id,
sourceSessionId: context.clientSessionId,
evidence: parsed.data.evidence,
executionPlan: parsed.data.execution_plan,
report: parsed.data.report,
progress: parsed.data.progress,
}));
}
} catch (error) {
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502;
res.status(status).json({ message: error instanceof Error ? error.message : `task ${kind} publish failed` });
}
});
}
}