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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user