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:
+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" } });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user