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
+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,