fix(agent): stabilize sandboxed streaming workflows

This commit is contained in:
2026-08-26 12:26:29 +08:00
parent 80cfc1f2ab
commit 4ec010455b
12 changed files with 916 additions and 34 deletions
+174 -17
View File
@@ -2,6 +2,7 @@ import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
import { type SupportedModel } from "../chat/models.js";
import { config } from "../config.js";
import { logger } from "../logger.js";
import {
type PermissionReply,
@@ -17,6 +18,7 @@ import {
extractRequestReason,
extractSkillAuditInfo,
getErrorMessage,
getAssistantMessagePhase,
getToolProgressTitle,
getUnknownErrorMessage,
hasToolParams,
@@ -43,6 +45,7 @@ import {
normalizeToolStatus,
type PermissionRequestPayload,
type QuestionRequestPayload,
type AssistantMessagePhase,
type TodoItemPayload,
type TodoUpdatePayload,
} from "./chatStreamEvents.js";
@@ -112,17 +115,59 @@ const toRuntimeModel = (model?: SupportedModel) => {
};
};
const STRUCTURED_OUTPUT_TOOL_NAME = "StructuredOutput";
const STRUCTURED_FINAL_ANSWER_FORMAT = {
type: "json_schema" as const,
schema: {
type: "object",
properties: {
answer: {
type: "string",
description: "直接展示给用户的完整最终回答,使用简体中文和 Markdown。",
},
},
required: ["answer"],
additionalProperties: false,
},
};
const requiresStructuredFinalAnswer = (model?: SupportedModel) =>
(model ?? config.OPENCODE_MODEL).split("/", 1)[0] === "deepseek";
const extractStructuredAnswer = (value: unknown) =>
isObjectRecord(value) && typeof value.answer === "string"
? value.answer.trim()
: "";
const emitFinalMessage = async (
runtime: OpencodeRuntimeAdapter,
sessionId: string,
clientSessionId: string,
currentAssistantMessageIds: Set<string>,
assistantTextParts: Map<string, Map<string, string>>,
assistantTextPartPhases: Map<string, AssistantMessagePhase>,
write: (event: string, data: Record<string, unknown>) => void,
) => {
let text = [...currentAssistantMessageIds]
.reverse()
.map((messageId) => [...(assistantTextParts.get(messageId)?.values() ?? [])].join(""))
.map((messageId) => {
const parts = assistantTextParts.get(messageId);
if (!parts) {
return "";
}
const candidates = [...parts.entries()];
const finalText = candidates
.filter(([partId]) => assistantTextPartPhases.get(partId) === "final_answer")
.map(([, content]) => content)
.join("");
if (finalText) {
return finalText;
}
return candidates
.filter(([partId]) => assistantTextPartPhases.get(partId) !== "commentary")
.map(([, content]) => content)
.join("");
})
.find((content) => content.length > 0) ?? "";
if (!text) {
@@ -135,7 +180,20 @@ const emitFinalMessage = async (
(currentAssistantMessageIds.size === 0 ||
currentAssistantMessageIds.has(message.info.id)),
);
text = collectTextContent(assistantMessage?.parts ?? []);
const structured =
assistantMessage && "structured" in assistantMessage.info
? assistantMessage.info.structured
: undefined;
const structuredAnswer = extractStructuredAnswer(structured);
text =
structuredAnswer ||
collectTextContent(
(assistantMessage?.parts ?? []).filter(
(part) =>
part.type !== "text" ||
getAssistantMessagePhase(part.metadata) !== "commentary",
),
);
}
if (text) {
@@ -165,7 +223,7 @@ export const streamPromptResponse = async ({
failed: boolean;
toolCallCount: number;
}> => {
const eventStream = await runtime.subscribeEvents();
const eventStream = await runtime.subscribeEvents(workspaceRoot);
const iterator = eventStream[Symbol.asyncIterator]();
const requestStartedAt = Date.now();
const promptStartedAt = Date.now();
@@ -176,6 +234,8 @@ export const streamPromptResponse = async ({
const emittedQuestionRequestIds = new Set<string>();
const currentAssistantMessageIds = new Set<string>();
const assistantTextParts = new Map<string, Map<string, string>>();
const assistantTextPartPhases = new Map<string, AssistantMessagePhase>();
const emittedFinalTextLengths = new Map<string, number>();
const partTypes = new Map<string, Part["type"]>();
const pendingTextDeltas = new Map<string, string[]>();
const reasoningStatuses = new Map<string, "running" | "completed">();
@@ -192,6 +252,7 @@ export const streamPromptResponse = async ({
let promptSettled = false;
let aborted = signal?.aborted ?? false;
let failed = false;
let structuredFinalAnswerEmitted = false;
const debugContext = {
sessionId,
clientSessionId,
@@ -258,6 +319,37 @@ export const streamPromptResponse = async ({
});
};
const emitFinalText = (partId: string, text: string) => {
const emittedLength = emittedFinalTextLengths.get(partId) ?? 0;
if (text.length <= emittedLength) {
return;
}
const content = text.slice(emittedLength);
emittedFinalTextLengths.set(partId, text.length);
emittedText = true;
write("token", {
session_id: clientSessionId,
content,
});
};
const emitCommentaryProgress = (
partId: string,
text: string,
completed: boolean,
) => {
if (!text.trim()) {
return;
}
emitProgress({
id: `commentary-${partId}`,
phase: "commentary",
status: completed ? "completed" : "running",
title: completed ? "Agent 过程已更新" : "Agent 正在处理",
detail: text,
});
};
emitProgress({
id: "request-received",
phase: "start",
@@ -267,7 +359,14 @@ export const streamPromptResponse = async ({
});
const promptPromise = runtime
.prompt(sessionId, message, toRuntimeModel(model))
.prompt(
sessionId,
message,
toRuntimeModel(model),
requiresStructuredFinalAnswer(model)
? { format: STRUCTURED_FINAL_ANSWER_FORMAT }
: undefined,
)
.then(() => {
promptSettled = true;
logDevelopmentDebug("runtime.prompt resolved", {
@@ -425,6 +524,7 @@ export const streamPromptResponse = async ({
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
directory: workspaceRoot,
reply,
});
write("permission_response", {
@@ -481,6 +581,7 @@ export const streamPromptResponse = async ({
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
directory: workspaceRoot,
reply,
});
write("permission_response", {
@@ -674,11 +775,15 @@ export const streamPromptResponse = async ({
const partType = partTypes.get(event.properties.partID);
if (partType === "text") {
const messageParts = assistantTextParts.get(event.properties.messageID) ?? new Map();
messageParts.set(
event.properties.partID,
`${messageParts.get(event.properties.partID) ?? ""}${event.properties.delta}`,
);
const text = `${messageParts.get(event.properties.partID) ?? ""}${event.properties.delta}`;
messageParts.set(event.properties.partID, text);
assistantTextParts.set(event.properties.messageID, messageParts);
const phase = assistantTextPartPhases.get(event.properties.partID) ?? "unknown";
if (phase === "final_answer") {
emitFinalText(event.properties.partID, text);
} else if (phase === "commentary") {
emitCommentaryProgress(event.properties.partID, text, false);
}
} else if (!partType) {
const pending = pendingTextDeltas.get(event.properties.partID) ?? [];
pending.push(event.properties.delta);
@@ -695,11 +800,19 @@ export const streamPromptResponse = async ({
currentAssistantMessageIds.add(part.messageID);
}
if (part.type === "text") {
const phase = getAssistantMessagePhase(part.metadata);
assistantTextPartPhases.set(part.id, phase);
const pendingText = (pendingTextDeltas.get(part.id) ?? []).join("");
pendingTextDeltas.delete(part.id);
const messageParts = assistantTextParts.get(part.messageID) ?? new Map();
messageParts.set(part.id, part.text || pendingText);
const text = part.text || pendingText;
messageParts.set(part.id, text);
assistantTextParts.set(part.messageID, messageParts);
if (phase === "final_answer") {
emitFinalText(part.id, text);
} else if (phase === "commentary") {
emitCommentaryProgress(part.id, text, Boolean(part.time?.end));
}
} else {
pendingTextDeltas.delete(part.id);
}
@@ -756,6 +869,47 @@ export const streamPromptResponse = async ({
});
}
if (part.tool === STRUCTURED_OUTPUT_TOOL_NAME) {
if (part.state.status === "error") {
logger.warn(
{
sessionId,
clientSessionId,
partId: part.id,
error: part.state.error,
},
"structured final answer tool failed",
);
continue;
}
if (
(part.state.status !== "running" &&
part.state.status !== "completed") ||
structuredFinalAnswerEmitted
) {
continue;
}
const answer = extractStructuredAnswer(toolParams);
if (!answer) {
logger.warn(
{ sessionId, clientSessionId, partId: part.id },
"structured final answer tool received without an answer",
);
continue;
}
structuredFinalAnswerEmitted = true;
emitFinalText(part.id, answer);
logDevelopmentDebug("final answer submitted through StructuredOutput", {
...debugContext,
partId: part.id,
answerChars: answer.length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
continue;
}
const questionToolPayload = normalizeQuestionToolPayload(
part,
toolParams,
@@ -942,14 +1096,17 @@ export const streamPromptResponse = async ({
}
await promptPromise;
emittedText = await emitFinalMessage(
runtime,
sessionId,
clientSessionId,
currentAssistantMessageIds,
assistantTextParts,
write,
);
if (!emittedText) {
emittedText = await emitFinalMessage(
runtime,
sessionId,
clientSessionId,
currentAssistantMessageIds,
assistantTextParts,
assistantTextPartPhases,
write,
);
}
emitProgress({
id: "request-received",
phase: "start",