fix(agent): stabilize sandboxed streaming workflows
This commit is contained in:
+174
-17
@@ -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",
|
||||
|
||||
@@ -57,6 +57,11 @@ export type TodoUpdatePayload = {
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export type AssistantMessagePhase =
|
||||
| "commentary"
|
||||
| "final_answer"
|
||||
| "unknown";
|
||||
|
||||
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
||||
|
||||
const toolLabels: Record<string, string> = {
|
||||
@@ -110,6 +115,19 @@ export const getUnknownErrorMessage = (error: unknown) => {
|
||||
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
export const getAssistantMessagePhase = (
|
||||
metadata: unknown,
|
||||
): AssistantMessagePhase => {
|
||||
if (!isObjectRecord(metadata)) {
|
||||
return "unknown";
|
||||
}
|
||||
const openai = isObjectRecord(metadata.openai) ? metadata.openai : undefined;
|
||||
const phase = openai?.phase ?? metadata.phase;
|
||||
return phase === "commentary" || phase === "final_answer"
|
||||
? phase
|
||||
: "unknown";
|
||||
};
|
||||
|
||||
export const normalizeToolParams = (value: unknown): Record<string, unknown> => {
|
||||
if (isObjectRecord(value)) {
|
||||
return value;
|
||||
|
||||
+51
-9
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
createOpencode,
|
||||
type OpencodeClient,
|
||||
type OutputFormat,
|
||||
} from "@opencode-ai/sdk/v2";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
@@ -38,6 +39,10 @@ type RuntimeModelOverride = {
|
||||
modelID: string;
|
||||
};
|
||||
|
||||
type RuntimePromptOptions = {
|
||||
format?: OutputFormat;
|
||||
};
|
||||
|
||||
export type PermissionReply = "once" | "always" | "reject";
|
||||
export type QuestionAnswers = string[][];
|
||||
|
||||
@@ -180,7 +185,12 @@ export class OpencodeRuntimeAdapter {
|
||||
return this.messages(sessionId);
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
|
||||
async prompt(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
model?: RuntimeModelOverride,
|
||||
options?: RuntimePromptOptions,
|
||||
) {
|
||||
const client = await this.ensureClient();
|
||||
const startedAt = Date.now();
|
||||
logDevelopmentDebug(
|
||||
@@ -194,6 +204,7 @@ export class OpencodeRuntimeAdapter {
|
||||
await client.session.prompt({
|
||||
sessionID: sessionId,
|
||||
model,
|
||||
...(options?.format ? { format: options.format } : {}),
|
||||
parts: [{ type: "text", text }],
|
||||
});
|
||||
logDevelopmentDebug(
|
||||
@@ -299,35 +310,41 @@ export class OpencodeRuntimeAdapter {
|
||||
);
|
||||
}
|
||||
|
||||
async subscribeEvents() {
|
||||
async subscribeEvents(directory?: string) {
|
||||
const client = await this.ensureClient();
|
||||
const response = await client.event.subscribe();
|
||||
const response = await client.event.subscribe(
|
||||
directory ? { directory } : undefined,
|
||||
);
|
||||
return response.stream;
|
||||
}
|
||||
|
||||
async replyPermission(options: {
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
directory?: string;
|
||||
reply: PermissionReply;
|
||||
message?: string;
|
||||
}) {
|
||||
const client = await this.ensureClient();
|
||||
const directory = await this.resolveInteractionDirectory(options);
|
||||
if ("permission" in client && client.permission?.reply) {
|
||||
const response = await client.permission.reply({
|
||||
requestID: options.requestId,
|
||||
directory,
|
||||
reply: options.reply,
|
||||
message: options.message,
|
||||
});
|
||||
return response.data;
|
||||
return requireData(response.data, "permission.reply");
|
||||
}
|
||||
|
||||
if ("permission" in client && client.permission?.respond && options.sessionId) {
|
||||
const response = await client.permission.respond({
|
||||
sessionID: options.sessionId,
|
||||
permissionID: options.requestId,
|
||||
directory,
|
||||
response: options.reply,
|
||||
});
|
||||
return response.data;
|
||||
return requireData(response.data, "permission.respond");
|
||||
}
|
||||
|
||||
throw new Error("opencode permission reply API is unavailable");
|
||||
@@ -336,16 +353,19 @@ export class OpencodeRuntimeAdapter {
|
||||
async replyQuestion(options: {
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
directory?: string;
|
||||
answers: QuestionAnswers;
|
||||
}) {
|
||||
const client = await this.ensureClient();
|
||||
const directory = await this.resolveInteractionDirectory(options);
|
||||
if ("question" in client && client.question?.reply) {
|
||||
try {
|
||||
const response = await client.question.reply({
|
||||
requestID: options.requestId,
|
||||
directory,
|
||||
answers: options.answers,
|
||||
});
|
||||
return response.data;
|
||||
return requireData(response.data, "question.reply");
|
||||
} catch (error) {
|
||||
if (!options.sessionId) {
|
||||
throw error;
|
||||
@@ -360,6 +380,7 @@ export class OpencodeRuntimeAdapter {
|
||||
reply?: (parameters: {
|
||||
sessionID: string;
|
||||
requestID: string;
|
||||
directory?: string;
|
||||
questionV2Reply: { answers: QuestionAnswers };
|
||||
}) => Promise<{ data: unknown }>;
|
||||
};
|
||||
@@ -371,11 +392,12 @@ export class OpencodeRuntimeAdapter {
|
||||
const response = await v2Question.reply({
|
||||
sessionID: options.sessionId,
|
||||
requestID: options.requestId,
|
||||
directory,
|
||||
questionV2Reply: {
|
||||
answers: options.answers,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
return requireData(response.data, "question.v2.reply");
|
||||
}
|
||||
|
||||
throw new Error("opencode question reply API is unavailable");
|
||||
@@ -384,14 +406,17 @@ export class OpencodeRuntimeAdapter {
|
||||
async rejectQuestion(options: {
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
directory?: string;
|
||||
}) {
|
||||
const client = await this.ensureClient();
|
||||
const directory = await this.resolveInteractionDirectory(options);
|
||||
if ("question" in client && client.question?.reject) {
|
||||
try {
|
||||
const response = await client.question.reject({
|
||||
requestID: options.requestId,
|
||||
directory,
|
||||
});
|
||||
return response.data;
|
||||
return requireData(response.data, "question.reject");
|
||||
} catch (error) {
|
||||
if (!options.sessionId) {
|
||||
throw error;
|
||||
@@ -406,6 +431,7 @@ export class OpencodeRuntimeAdapter {
|
||||
reject?: (parameters: {
|
||||
sessionID: string;
|
||||
requestID: string;
|
||||
directory?: string;
|
||||
}) => Promise<{ data: unknown }>;
|
||||
};
|
||||
};
|
||||
@@ -416,13 +442,29 @@ export class OpencodeRuntimeAdapter {
|
||||
const response = await v2Question.reject({
|
||||
sessionID: options.sessionId,
|
||||
requestID: options.requestId,
|
||||
directory,
|
||||
});
|
||||
return response.data;
|
||||
return requireData(response.data, "question.v2.reject");
|
||||
}
|
||||
|
||||
throw new Error("opencode question reject API is unavailable");
|
||||
}
|
||||
|
||||
private async resolveInteractionDirectory(options: {
|
||||
sessionId?: string;
|
||||
directory?: string;
|
||||
}): Promise<string | undefined> {
|
||||
const directory = options.directory?.trim();
|
||||
if (directory) {
|
||||
return directory;
|
||||
}
|
||||
if (!options.sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
const session = await this.getSession(options.sessionId);
|
||||
return session.directory;
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.toolOutputCleanupTimer) {
|
||||
clearInterval(this.toolOutputCleanupTimer);
|
||||
|
||||
Reference in New Issue
Block a user