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
+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" } });
}
});