feat(agent): 完善分析编排与结果传输
This commit is contained in:
+153
-343
@@ -2,24 +2,16 @@ 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,
|
||||
type OpencodeRuntimeAdapter,
|
||||
} from "../runtime/opencode.js";
|
||||
import {
|
||||
buildPermissionDetail,
|
||||
buildPermissionV2Detail,
|
||||
buildReasoningProgressDetail,
|
||||
buildSessionStatusDetail,
|
||||
buildToolProgressDetail,
|
||||
collectTextContent,
|
||||
extractRequestReason,
|
||||
extractSkillAuditInfo,
|
||||
getErrorMessage,
|
||||
getAssistantMessagePhase,
|
||||
getToolProgressTitle,
|
||||
getUnknownErrorMessage,
|
||||
hasToolParams,
|
||||
isPermissionAskedEvent,
|
||||
@@ -35,6 +27,7 @@ import {
|
||||
isQuestionV2RepliedEvent,
|
||||
isSessionEvent,
|
||||
isSkillEvent,
|
||||
initialActivity,
|
||||
logDevelopmentDebug,
|
||||
normalizeQuestionAnswers,
|
||||
normalizeQuestionPayload,
|
||||
@@ -42,13 +35,15 @@ import {
|
||||
normalizeTodoPriority,
|
||||
normalizeTodoStatus,
|
||||
normalizeToolParams,
|
||||
normalizeToolStatus,
|
||||
type ActivityPayload,
|
||||
type ActivityUpdatePayload,
|
||||
type PermissionRequestPayload,
|
||||
type QuestionRequestPayload,
|
||||
type AssistantMessagePhase,
|
||||
type TodoItemPayload,
|
||||
type TodoUpdatePayload,
|
||||
} from "./chatStreamEvents.js";
|
||||
import { createActivityTracker } from "./chatActivityTracker.js";
|
||||
import {
|
||||
resolvePermissionApproval,
|
||||
type ApprovalMode,
|
||||
@@ -56,10 +51,13 @@ import {
|
||||
|
||||
export {
|
||||
collectTextContent,
|
||||
initialActivity,
|
||||
type PermissionRequestPayload,
|
||||
type QuestionRequestPayload,
|
||||
type TodoItemPayload,
|
||||
type TodoUpdatePayload,
|
||||
type ActivityPayload,
|
||||
type ActivityUpdatePayload,
|
||||
} from "./chatStreamEvents.js";
|
||||
|
||||
export type { ApprovalMode } from "./chatPermissionPolicy.js";
|
||||
@@ -78,16 +76,6 @@ type StreamPromptOptions = {
|
||||
write: (event: string, data: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
type ProgressStatus = "running" | "completed" | "error";
|
||||
|
||||
type ProgressPayload = {
|
||||
id: string;
|
||||
phase: string;
|
||||
status: ProgressStatus;
|
||||
title: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
const getPermissionTarget = (metadata: unknown) => {
|
||||
if (!isObjectRecord(metadata)) {
|
||||
return undefined;
|
||||
@@ -115,38 +103,20 @@ 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 FINAL_ANSWER_TOOL_NAME = "final_answer";
|
||||
const ACTIVITY_UPDATE_TOOL_NAME = "activity_update";
|
||||
|
||||
const requiresStructuredFinalAnswer = (model?: SupportedModel) =>
|
||||
(model ?? config.OPENCODE_MODEL).split("/", 1)[0] === "deepseek";
|
||||
|
||||
const extractStructuredAnswer = (value: unknown) =>
|
||||
const extractFinalAnswer = (value: unknown) =>
|
||||
isObjectRecord(value) && typeof value.answer === "string"
|
||||
? value.answer.trim()
|
||||
: "";
|
||||
|
||||
const emitFinalMessage = async (
|
||||
const resolveFinalMessage = 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()
|
||||
@@ -180,30 +150,53 @@ const emitFinalMessage = async (
|
||||
(currentAssistantMessageIds.size === 0 ||
|
||||
currentAssistantMessageIds.has(message.info.id)),
|
||||
);
|
||||
const structured =
|
||||
assistantMessage && "structured" in assistantMessage.info
|
||||
? assistantMessage.info.structured
|
||||
: undefined;
|
||||
const structuredAnswer = extractStructuredAnswer(structured);
|
||||
text =
|
||||
structuredAnswer ||
|
||||
collectTextContent(
|
||||
(assistantMessage?.parts ?? []).filter(
|
||||
const assistantParts = assistantMessage?.parts ?? [];
|
||||
text = collectTextContent(
|
||||
assistantParts.filter(
|
||||
(part) =>
|
||||
part.type !== "text" ||
|
||||
getAssistantMessagePhase(part.metadata) !== "commentary",
|
||||
),
|
||||
);
|
||||
if (!text) {
|
||||
const finalAnswerPart = [...assistantParts]
|
||||
.reverse()
|
||||
.find(
|
||||
(part) =>
|
||||
part.type !== "text" ||
|
||||
getAssistantMessagePhase(part.metadata) !== "commentary",
|
||||
),
|
||||
);
|
||||
part.type === "tool" && part.tool === FINAL_ANSWER_TOOL_NAME,
|
||||
);
|
||||
if (finalAnswerPart?.type === "tool") {
|
||||
text = extractFinalAnswer(finalAnswerPart.state.input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (text) {
|
||||
write("token", {
|
||||
session_id: clientSessionId,
|
||||
content: text,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return text.trim();
|
||||
};
|
||||
|
||||
const normalizeActivityTodos = (value: unknown): TodoItemPayload[] | undefined => {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const now = Date.now();
|
||||
return value
|
||||
.filter(isObjectRecord)
|
||||
.map((todo, index) => {
|
||||
const content = typeof todo.content === "string" ? todo.content.trim() : "";
|
||||
return {
|
||||
id:
|
||||
typeof todo.id === "string" && todo.id.trim()
|
||||
? todo.id.trim()
|
||||
: `todo-${index}-${content.slice(0, 24)}`,
|
||||
content,
|
||||
status: normalizeTodoStatus(
|
||||
typeof todo.status === "string" ? todo.status : "pending",
|
||||
),
|
||||
priority: normalizeTodoPriority(
|
||||
typeof todo.priority === "string" ? todo.priority : "",
|
||||
),
|
||||
updated_at: now,
|
||||
};
|
||||
})
|
||||
.filter((todo) => todo.content.length > 0);
|
||||
};
|
||||
|
||||
export const streamPromptResponse = async ({
|
||||
@@ -227,15 +220,13 @@ export const streamPromptResponse = async ({
|
||||
const iterator = eventStream[Symbol.asyncIterator]();
|
||||
const requestStartedAt = Date.now();
|
||||
const promptStartedAt = Date.now();
|
||||
const progressStartedAtMap = new Map<string, number>();
|
||||
const finalizedProgressIds = new Set<string>();
|
||||
const emittedToolParts = new Set<string>();
|
||||
const emittedActivityParts = new Set<string>();
|
||||
const emittedQuestionToolParts = new Set<string>();
|
||||
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">();
|
||||
@@ -246,13 +237,12 @@ export const streamPromptResponse = async ({
|
||||
let lastSessionStatus: string | null = null;
|
||||
let lastSessionStatusMessage: string | null = null;
|
||||
let sawResponseActivity = false;
|
||||
let emittedText = false;
|
||||
let finalAnswerText = "";
|
||||
let toolCallCount = 0;
|
||||
let done = false;
|
||||
let promptSettled = false;
|
||||
let aborted = signal?.aborted ?? false;
|
||||
let failed = false;
|
||||
let structuredFinalAnswerEmitted = false;
|
||||
const debugContext = {
|
||||
sessionId,
|
||||
clientSessionId,
|
||||
@@ -278,94 +268,24 @@ export const streamPromptResponse = async ({
|
||||
})
|
||||
: null;
|
||||
|
||||
const emitProgress = ({ id, phase, status, title, detail }: ProgressPayload) => {
|
||||
if (status === "running" && finalizedProgressIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
const activityTracker = createActivityTracker({ clientSessionId, write });
|
||||
|
||||
const now = Date.now();
|
||||
const startedAt = progressStartedAtMap.get(id) ?? now;
|
||||
if (!progressStartedAtMap.has(id)) {
|
||||
progressStartedAtMap.set(id, startedAt);
|
||||
}
|
||||
|
||||
if (status === "running") {
|
||||
write("progress", {
|
||||
session_id: clientSessionId,
|
||||
id,
|
||||
phase,
|
||||
status,
|
||||
title,
|
||||
detail,
|
||||
started_at: startedAt,
|
||||
elapsed_ms: Math.max(0, now - startedAt),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const durationMs = Math.max(0, now - startedAt);
|
||||
finalizedProgressIds.add(id);
|
||||
progressStartedAtMap.delete(id);
|
||||
write("progress", {
|
||||
session_id: clientSessionId,
|
||||
id,
|
||||
phase,
|
||||
status,
|
||||
title,
|
||||
detail,
|
||||
started_at: startedAt,
|
||||
ended_at: now,
|
||||
duration_ms: durationMs,
|
||||
});
|
||||
const captureFinalText = (text: string) => {
|
||||
const answer = text.trim();
|
||||
if (answer) finalAnswerText = answer;
|
||||
};
|
||||
|
||||
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",
|
||||
status: "running",
|
||||
title: "已收到请求,正在启动 Agent 分析",
|
||||
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
|
||||
});
|
||||
activityTracker.start(
|
||||
initialActivity.id,
|
||||
initialActivity.title,
|
||||
initialActivity.reason,
|
||||
);
|
||||
|
||||
const promptPromise = runtime
|
||||
.prompt(
|
||||
sessionId,
|
||||
message,
|
||||
toRuntimeModel(model),
|
||||
requiresStructuredFinalAnswer(model)
|
||||
? { format: STRUCTURED_FINAL_ANSWER_FORMAT }
|
||||
: undefined,
|
||||
)
|
||||
.then(() => {
|
||||
promptSettled = true;
|
||||
@@ -388,6 +308,8 @@ export const streamPromptResponse = async ({
|
||||
...debugContext,
|
||||
});
|
||||
|
||||
let pendingIteratorNext: ReturnType<typeof iterator.next> | undefined;
|
||||
|
||||
try {
|
||||
while (!done) {
|
||||
if (signal?.aborted) {
|
||||
@@ -399,8 +321,8 @@ export const streamPromptResponse = async ({
|
||||
break;
|
||||
}
|
||||
|
||||
const nextEvent = iterator
|
||||
.next()
|
||||
pendingIteratorNext ??= iterator.next();
|
||||
const nextEvent = pendingIteratorNext
|
||||
.then((result) => ({ type: "event" as const, result }));
|
||||
const nextPrompt = promptSettled
|
||||
? null
|
||||
@@ -426,6 +348,7 @@ export const streamPromptResponse = async ({
|
||||
if (next.type === "prompt") {
|
||||
continue;
|
||||
}
|
||||
pendingIteratorNext = undefined;
|
||||
if (next.result.done) {
|
||||
break;
|
||||
}
|
||||
@@ -465,18 +388,6 @@ export const streamPromptResponse = async ({
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
}
|
||||
emitProgress({
|
||||
id: "session-status",
|
||||
phase: "session",
|
||||
status: event.properties.status.type === "idle" ? "completed" : "running",
|
||||
title:
|
||||
event.properties.status.type === "retry"
|
||||
? `模型请求重试中:${event.properties.status.message}`
|
||||
: event.properties.status.type === "busy"
|
||||
? "Agent 正在处理请求"
|
||||
: "Agent 已空闲",
|
||||
detail: buildSessionStatusDetail(event.properties.status),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -508,17 +419,6 @@ export const streamPromptResponse = async ({
|
||||
patterns: event.properties.patterns,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
emitProgress({
|
||||
id: `permission-${event.properties.id}`,
|
||||
phase: "permission",
|
||||
status: permissionApproval.autoReject
|
||||
? "error"
|
||||
: permissionApproval.autoApprove
|
||||
? "completed"
|
||||
: "running",
|
||||
title: permissionApproval.title,
|
||||
detail: permissionApproval.detail ?? buildPermissionDetail(event),
|
||||
});
|
||||
if (permissionApproval.autoApprove || permissionApproval.autoReject) {
|
||||
const reply = permissionApproval.autoReject ? "reject" : "once";
|
||||
await runtime.replyPermission({
|
||||
@@ -534,12 +434,15 @@ export const streamPromptResponse = async ({
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const activity = activityTracker.getCurrentContext();
|
||||
write("permission_request", {
|
||||
session_id: clientSessionId,
|
||||
request_id: event.properties.id,
|
||||
permission: event.properties.permission,
|
||||
patterns: event.properties.patterns,
|
||||
target: getPermissionTarget(event.properties.metadata),
|
||||
activity_id: activity?.id,
|
||||
reason: activity?.reason,
|
||||
always: event.properties.always,
|
||||
tool: event.properties.tool,
|
||||
created_at: Date.now(),
|
||||
@@ -565,17 +468,6 @@ export const streamPromptResponse = async ({
|
||||
resources: event.properties.resources,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
emitProgress({
|
||||
id: `permission-${event.properties.id}`,
|
||||
phase: "permission",
|
||||
status: permissionApproval.autoReject
|
||||
? "error"
|
||||
: permissionApproval.autoApprove
|
||||
? "completed"
|
||||
: "running",
|
||||
title: permissionApproval.title,
|
||||
detail: permissionApproval.detail ?? buildPermissionV2Detail(event),
|
||||
});
|
||||
if (permissionApproval.autoApprove || permissionApproval.autoReject) {
|
||||
const reply = permissionApproval.autoReject ? "reject" : "once";
|
||||
await runtime.replyPermission({
|
||||
@@ -591,12 +483,15 @@ export const streamPromptResponse = async ({
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const activity = activityTracker.getCurrentContext();
|
||||
write("permission_request", {
|
||||
session_id: clientSessionId,
|
||||
request_id: event.properties.id,
|
||||
permission: event.properties.action,
|
||||
patterns: event.properties.resources,
|
||||
target: getPermissionTarget(event.properties.metadata),
|
||||
activity_id: activity?.id,
|
||||
reason: activity?.reason,
|
||||
always: event.properties.save ?? [],
|
||||
tool: undefined,
|
||||
created_at: Date.now(),
|
||||
@@ -612,21 +507,6 @@ export const streamPromptResponse = async ({
|
||||
reply: event.properties.reply,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
emitProgress({
|
||||
id: `permission-${event.properties.requestID}`,
|
||||
phase: "permission",
|
||||
status: event.properties.reply === "reject" ? "error" : "completed",
|
||||
title:
|
||||
event.properties.reply === "reject"
|
||||
? "权限请求已拒绝"
|
||||
: "权限请求已允许",
|
||||
detail:
|
||||
event.properties.reply === "always"
|
||||
? "已允许本次请求,并记住同类权限。"
|
||||
: event.properties.reply === "once"
|
||||
? "已允许本次请求。"
|
||||
: "已拒绝本次请求。",
|
||||
});
|
||||
write("permission_response", {
|
||||
session_id: clientSessionId,
|
||||
request_id: event.properties.requestID,
|
||||
@@ -643,21 +523,6 @@ export const streamPromptResponse = async ({
|
||||
reply: event.properties.reply,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
emitProgress({
|
||||
id: `permission-${event.properties.requestID}`,
|
||||
phase: "permission",
|
||||
status: event.properties.reply === "reject" ? "error" : "completed",
|
||||
title:
|
||||
event.properties.reply === "reject"
|
||||
? "权限请求已拒绝"
|
||||
: "权限请求已允许",
|
||||
detail:
|
||||
event.properties.reply === "always"
|
||||
? "已允许本次请求,并记住同类权限。"
|
||||
: event.properties.reply === "once"
|
||||
? "已允许本次请求。"
|
||||
: "已拒绝本次请求。",
|
||||
});
|
||||
write("permission_response", {
|
||||
session_id: clientSessionId,
|
||||
request_id: event.properties.requestID,
|
||||
@@ -674,15 +539,6 @@ export const streamPromptResponse = async ({
|
||||
questionCount: event.properties.questions.length,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
emitProgress({
|
||||
id: `question-${event.properties.id}`,
|
||||
phase: "question",
|
||||
status: "running",
|
||||
title: "等待用户补充信息",
|
||||
detail: event.properties.questions
|
||||
.map((question) => question.question)
|
||||
.join("\n"),
|
||||
});
|
||||
const payload = normalizeQuestionPayload(event, clientSessionId);
|
||||
emittedQuestionRequestIds.add(payload.request_id);
|
||||
write("question_request", payload);
|
||||
@@ -696,16 +552,6 @@ export const streamPromptResponse = async ({
|
||||
requestId: event.properties.requestID,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
emitProgress({
|
||||
id: `question-${event.properties.requestID}`,
|
||||
phase: "question",
|
||||
status: "completed",
|
||||
title: "已收到补充信息",
|
||||
detail: normalizeQuestionAnswers(event.properties.answers)
|
||||
.map((answer) => answer.join("、"))
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
});
|
||||
write("question_response", {
|
||||
session_id: clientSessionId,
|
||||
request_id: event.properties.requestID,
|
||||
@@ -721,13 +567,6 @@ export const streamPromptResponse = async ({
|
||||
requestId: event.properties.requestID,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
emitProgress({
|
||||
id: `question-${event.properties.requestID}`,
|
||||
phase: "question",
|
||||
status: "completed",
|
||||
title: "已跳过补充信息",
|
||||
detail: "用户选择跳过本次补充信息。",
|
||||
});
|
||||
write("question_response", {
|
||||
session_id: clientSessionId,
|
||||
request_id: event.properties.requestID,
|
||||
@@ -738,11 +577,11 @@ export const streamPromptResponse = async ({
|
||||
|
||||
if (isSkillEvent(event)) {
|
||||
sawResponseActivity = true;
|
||||
const { name, reason, payload } = extractSkillAuditInfo(event);
|
||||
const { name, payload } = extractSkillAuditInfo(event);
|
||||
const activity = activityTracker.getCurrentContext();
|
||||
logDevelopmentDebug("skill event received", {
|
||||
...debugContext,
|
||||
skill: name,
|
||||
reason: reason || null,
|
||||
payloadKeys: Object.keys(payload).slice(0, 8),
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
@@ -753,8 +592,9 @@ export const streamPromptResponse = async ({
|
||||
traceId,
|
||||
projectId,
|
||||
target: name,
|
||||
reason,
|
||||
reasonProvided: Boolean(reason),
|
||||
activityId: activity?.id,
|
||||
activityTitle: activity?.title,
|
||||
activityReason: activity?.reason,
|
||||
payload,
|
||||
}).catch((error) => {
|
||||
logger.warn({ err: error }, "failed to write skill audit log");
|
||||
@@ -780,9 +620,7 @@ export const streamPromptResponse = async ({
|
||||
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);
|
||||
captureFinalText(text);
|
||||
}
|
||||
} else if (!partType) {
|
||||
const pending = pendingTextDeltas.get(event.properties.partID) ?? [];
|
||||
@@ -809,9 +647,7 @@ export const streamPromptResponse = async ({
|
||||
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));
|
||||
captureFinalText(text);
|
||||
}
|
||||
} else {
|
||||
pendingTextDeltas.delete(part.id);
|
||||
@@ -827,14 +663,6 @@ export const streamPromptResponse = async ({
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
}
|
||||
const reasoningDetail = buildReasoningProgressDetail(part.time.end);
|
||||
emitProgress({
|
||||
id: part.id,
|
||||
phase: "planning",
|
||||
status: part.time.end ? "completed" : "running",
|
||||
title: part.time.end ? "分析规划完成" : "正在规划分析步骤",
|
||||
detail: reasoningDetail,
|
||||
});
|
||||
}
|
||||
if (part.type === "tool") {
|
||||
if (!firstToolEventLogged) {
|
||||
@@ -849,7 +677,6 @@ export const streamPromptResponse = async ({
|
||||
});
|
||||
}
|
||||
const toolParams = normalizeToolParams(part.state.input);
|
||||
const reason = extractRequestReason(toolParams);
|
||||
const isToolFinalState =
|
||||
part.state.status === "completed" || part.state.status === "error";
|
||||
const nextToolStatus = String(part.state.status);
|
||||
@@ -861,7 +688,6 @@ export const streamPromptResponse = async ({
|
||||
partId: part.id,
|
||||
tool: part.tool,
|
||||
status: nextToolStatus,
|
||||
reason: reason || null,
|
||||
inputKeys: Object.keys(toolParams).slice(0, 8),
|
||||
error:
|
||||
part.state.status === "error" ? (part.state.error ?? "unknown") : null,
|
||||
@@ -869,7 +695,7 @@ export const streamPromptResponse = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (part.tool === STRUCTURED_OUTPUT_TOOL_NAME) {
|
||||
if (part.tool === FINAL_ANSWER_TOOL_NAME) {
|
||||
if (part.state.status === "error") {
|
||||
logger.warn(
|
||||
{
|
||||
@@ -878,38 +704,71 @@ export const streamPromptResponse = async ({
|
||||
partId: part.id,
|
||||
error: part.state.error,
|
||||
},
|
||||
"structured final answer tool failed",
|
||||
"final answer tool failed",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
(part.state.status !== "running" &&
|
||||
part.state.status !== "completed") ||
|
||||
structuredFinalAnswerEmitted
|
||||
part.state.status !== "completed")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const answer = extractStructuredAnswer(toolParams);
|
||||
const answer = extractFinalAnswer(toolParams);
|
||||
if (!answer) {
|
||||
logger.warn(
|
||||
{ sessionId, clientSessionId, partId: part.id },
|
||||
"structured final answer tool received without an answer",
|
||||
"final answer tool received without an answer",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
structuredFinalAnswerEmitted = true;
|
||||
emitFinalText(part.id, answer);
|
||||
logDevelopmentDebug("final answer submitted through StructuredOutput", {
|
||||
captureFinalText(answer);
|
||||
logDevelopmentDebug("final answer submitted through tool", {
|
||||
...debugContext,
|
||||
partId: part.id,
|
||||
tool: part.tool,
|
||||
answerChars: answer.length,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.tool === ACTIVITY_UPDATE_TOOL_NAME) {
|
||||
const title = typeof toolParams.title === "string"
|
||||
? toolParams.title.trim()
|
||||
: "";
|
||||
const reason = typeof toolParams.reason === "string"
|
||||
? toolParams.reason.trim()
|
||||
: "";
|
||||
const todos = normalizeActivityTodos(toolParams.todos);
|
||||
if (
|
||||
title &&
|
||||
reason &&
|
||||
!emittedActivityParts.has(part.id) &&
|
||||
(hasToolParams(toolParams) || isToolFinalState)
|
||||
) {
|
||||
emittedActivityParts.add(part.id);
|
||||
const activity = activityTracker.start(part.id, title, reason, todos);
|
||||
void writeLlmRequestAuditLog({
|
||||
kind: "activity",
|
||||
sessionId,
|
||||
clientSessionId,
|
||||
traceId,
|
||||
projectId,
|
||||
target: ACTIVITY_UPDATE_TOOL_NAME,
|
||||
activityId: activity.id,
|
||||
activityTitle: activity.title,
|
||||
activityReason: activity.reason,
|
||||
payload: toolParams,
|
||||
}).catch((error) => {
|
||||
logger.warn({ err: error }, "failed to write activity audit log");
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const questionToolPayload = normalizeQuestionToolPayload(
|
||||
part,
|
||||
toolParams,
|
||||
@@ -926,49 +785,23 @@ export const streamPromptResponse = async ({
|
||||
questionCount: questionToolPayload.questions.length,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
emitProgress({
|
||||
id: `question-${questionToolPayload.request_id}`,
|
||||
phase: "question",
|
||||
status: "running",
|
||||
title: "等待用户补充信息",
|
||||
detail: questionToolPayload.questions
|
||||
.map((question) => question.question)
|
||||
.join("\n"),
|
||||
});
|
||||
write("question_request", questionToolPayload);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
emitProgress({
|
||||
id: part.id,
|
||||
phase: "tool",
|
||||
status: normalizeToolStatus(part.state.status),
|
||||
title: getToolProgressTitle(part.tool, part.state.status),
|
||||
detail: buildToolProgressDetail(
|
||||
part.tool,
|
||||
part.state.status,
|
||||
toolParams,
|
||||
reason,
|
||||
part.state.status === "error" ? part.state.error : undefined,
|
||||
),
|
||||
});
|
||||
if (part.tool === "todowrite" || part.tool === "todo") {
|
||||
continue;
|
||||
}
|
||||
|
||||
activityTracker.upsertAction(part, toolParams);
|
||||
if (
|
||||
!emittedToolParts.has(part.id) &&
|
||||
(hasToolParams(toolParams) || isToolFinalState)
|
||||
) {
|
||||
emittedToolParts.add(part.id);
|
||||
toolCallCount += 1;
|
||||
if (!reason) {
|
||||
logger.warn(
|
||||
{
|
||||
tool: part.tool,
|
||||
sessionId: sessionId,
|
||||
clientSessionId,
|
||||
},
|
||||
"llm tool request missing reason",
|
||||
);
|
||||
}
|
||||
const activity = activityTracker.getActionContext(part.id);
|
||||
void writeLlmRequestAuditLog({
|
||||
kind: "tool",
|
||||
sessionId: sessionId,
|
||||
@@ -976,8 +809,9 @@ export const streamPromptResponse = async ({
|
||||
traceId,
|
||||
projectId,
|
||||
target: part.tool,
|
||||
reason,
|
||||
reasonProvided: Boolean(reason),
|
||||
activityId: activity?.id,
|
||||
activityTitle: activity?.title,
|
||||
activityReason: activity?.reason,
|
||||
payload: toolParams,
|
||||
}).catch((error) => {
|
||||
logger.warn({ err: error }, "failed to write tool audit log");
|
||||
@@ -986,7 +820,6 @@ export const streamPromptResponse = async ({
|
||||
session_id: clientSessionId,
|
||||
tool: part.tool,
|
||||
params: toolParams,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1007,18 +840,6 @@ export const streamPromptResponse = async ({
|
||||
priority: normalizeTodoPriority(todo.priority),
|
||||
updated_at: Date.now(),
|
||||
}));
|
||||
const completed = todos.filter(
|
||||
(todo) => todo.status === "completed",
|
||||
).length;
|
||||
emitProgress({
|
||||
id: "todo-progress",
|
||||
phase: "planning",
|
||||
status: completed === todos.length ? "completed" : "running",
|
||||
title: `计划进度 ${completed}/${todos.length}`,
|
||||
detail: todos
|
||||
.map((todo) => `${todo.status}: ${todo.content}`)
|
||||
.join("\n"),
|
||||
});
|
||||
write("todo_update", {
|
||||
session_id: clientSessionId,
|
||||
todos: normalizedTodos,
|
||||
@@ -1036,6 +857,7 @@ export const streamPromptResponse = async ({
|
||||
? getErrorMessage(event.properties.error)
|
||||
: "opencode session error",
|
||||
});
|
||||
activityTracker.finalize("error");
|
||||
write("error", {
|
||||
session_id: clientSessionId,
|
||||
message: event.properties.error
|
||||
@@ -1059,17 +881,10 @@ export const streamPromptResponse = async ({
|
||||
}
|
||||
logDevelopmentDebug("session idle received", {
|
||||
...debugContext,
|
||||
emittedText,
|
||||
hasFinalAnswer: Boolean(finalAnswerText),
|
||||
toolCallCount,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
emitProgress({
|
||||
id: "session-status",
|
||||
phase: "session",
|
||||
status: "completed",
|
||||
title: "Agent 已完成处理",
|
||||
detail: "当前会话已无待执行任务,正在收尾并准备返回最终结果。",
|
||||
});
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
@@ -1079,6 +894,7 @@ export const streamPromptResponse = async ({
|
||||
...debugContext,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
activityTracker.finalize("cancelled");
|
||||
await runtime.abortSession(sessionId).catch((error) => {
|
||||
logger.warn({ sessionId: sessionId, err: error }, "failed to abort opencode session");
|
||||
});
|
||||
@@ -1096,40 +912,34 @@ export const streamPromptResponse = async ({
|
||||
}
|
||||
|
||||
await promptPromise;
|
||||
if (!emittedText) {
|
||||
emittedText = await emitFinalMessage(
|
||||
if (!finalAnswerText) {
|
||||
finalAnswerText = await resolveFinalMessage(
|
||||
runtime,
|
||||
sessionId,
|
||||
clientSessionId,
|
||||
currentAssistantMessageIds,
|
||||
assistantTextParts,
|
||||
assistantTextPartPhases,
|
||||
write,
|
||||
);
|
||||
}
|
||||
emitProgress({
|
||||
id: "request-received",
|
||||
phase: "start",
|
||||
status: "completed",
|
||||
title: "请求处理完成",
|
||||
detail: "本次请求的分析、工具执行和结果整理流程已经完成。",
|
||||
});
|
||||
emitProgress({
|
||||
id: "request-completed",
|
||||
phase: "complete",
|
||||
status: "completed",
|
||||
title: "分析完成",
|
||||
detail: emittedText
|
||||
? "最终回答已生成并推送到前端。"
|
||||
: "已完成分析,并通过兜底消息补发最终回答内容。",
|
||||
});
|
||||
activityTracker.finalize("completed");
|
||||
if (finalAnswerText) {
|
||||
// Keep one compatibility cycle for deployed frontends that only consume token.
|
||||
write("token", {
|
||||
session_id: clientSessionId,
|
||||
content: finalAnswerText,
|
||||
});
|
||||
write("final_answer", {
|
||||
session_id: clientSessionId,
|
||||
content: finalAnswerText,
|
||||
});
|
||||
}
|
||||
write("done", {
|
||||
session_id: clientSessionId,
|
||||
total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
logDevelopmentDebug("chat stream completed", {
|
||||
...debugContext,
|
||||
emittedText,
|
||||
hasFinalAnswer: Boolean(finalAnswerText),
|
||||
toolCallCount,
|
||||
totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user