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