feat(agent): 完善分析编排与结果传输
Generic Container CI/CD / test-build-publish (push) Failing after 1m20s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Failing after 1m20s

This commit is contained in:
2026-08-26 18:04:51 +08:00
parent a438433068
commit 72ebf4d6c1
33 changed files with 1027 additions and 705 deletions
+4 -3
View File
@@ -4,14 +4,15 @@ import { dirname } from "node:path";
import { config } from "../config.js";
export type LlmRequestAuditEntry = {
kind: "tool" | "skill";
kind: "activity" | "tool" | "skill";
sessionId: string;
clientSessionId: string;
traceId?: string;
projectId?: string;
target: string;
reason: string;
reasonProvided: boolean;
activityId?: string;
activityTitle?: string;
activityReason?: string;
payload?: Record<string, unknown>;
};
+30
View File
@@ -35,6 +35,7 @@ import { registerChatAuxiliaryRoutes } from "./chatAuxiliaryRoutes.js";
import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js";
import {
collectTextContent,
type ActivityUpdatePayload,
type PermissionRequestPayload,
type QuestionRequestPayload,
streamPromptResponse,
@@ -46,7 +47,9 @@ import {
type StreamSubscriber,
appendBackendToolArtifact,
cancelBackendTodos,
completeBackendActivities,
completeBackendProgress,
completeBackendTodos,
createInitialStreamingMessages,
isObjectRecord,
toFrontendPermission,
@@ -54,6 +57,7 @@ import {
updateLastAssistantMessage,
updateLastAssistantPermission,
updateLastAssistantQuestion,
upsertBackendActivity,
upsertBackendProgress,
upsertBackendQuestion,
upsertBackendTodoUpdate,
@@ -687,6 +691,27 @@ export const buildChatRouter = (
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
isError: false,
}));
} else if (event === "final_answer") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content: typeof data.content === "string" ? data.content : "",
isError: false,
}));
} else if (event === "activity_update") {
const payload = data as ActivityUpdatePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
activities: upsertBackendActivity(message.activities, payload.activity),
...(payload.todos
? {
todos: upsertBackendTodoUpdate(message.todos, {
session_id: payload.session_id,
todos: payload.todos,
created_at: payload.todos_created_at ?? Date.now(),
}),
}
: {}),
}));
} else if (event === "progress") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
@@ -702,6 +727,8 @@ export const buildChatRouter = (
? message.content
: "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completeBackendProgress(message.progress),
activities: completeBackendActivities(message.activities),
todos: completeBackendTodos(message.todos),
}));
} else if (event === "error") {
activeRun.status = activeRun.status === "aborted" ? "aborted" : "error";
@@ -714,6 +741,7 @@ export const buildChatRouter = (
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
isError: true,
progress: completeBackendProgress(message.progress),
activities: completeBackendActivities(message.activities, "error"),
todos: cancelBackendTodos(message.todos),
}));
} else if (event === "auth_required") {
@@ -727,6 +755,7 @@ export const buildChatRouter = (
: "⚠️ **登录态已过期,请刷新登录后重试**",
isError: true,
progress: completeBackendProgress(message.progress),
activities: completeBackendActivities(message.activities, "error"),
todos: cancelBackendTodos(message.todos),
}));
} else if (event === "permission_request") {
@@ -968,6 +997,7 @@ export const buildChatRouter = (
: "⚠️ **请求已中断**",
isError: true,
progress: completeBackendProgress(message.progress),
activities: completeBackendActivities(message.activities, "cancelled"),
todos: cancelBackendTodos(message.todos),
}));
void queueSessionUiStatePersist().catch((error) => {
+180
View File
@@ -0,0 +1,180 @@
import type { Part } from "@opencode-ai/sdk/v2";
import {
getToolLabel,
normalizeToolStatus,
type ActivityActionPayload,
type ActivityPayload,
type ActivityStatus,
type ActivityUpdatePayload,
type TodoItemPayload,
} from "./chatStreamEvents.js";
type ToolPart = Extract<Part, { type: "tool" }>;
type ActivityContext = Pick<ActivityPayload, "id" | "title" | "reason">;
const getActionTarget = (params: Record<string, unknown>) => {
for (const key of ["command", "file_path", "filePath", "path", "query", "keyword"]) {
const value = params[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return undefined;
};
export const createActivityTracker = ({
clientSessionId,
write,
}: {
clientSessionId: string;
write: (event: string, data: Record<string, unknown>) => void;
}) => {
const activities = new Map<string, ActivityPayload>();
const actionActivityIds = new Map<string, string>();
let currentActivityId: string | null = null;
const emit = (activity: ActivityPayload, todos?: TodoItemPayload[]) => {
const now = Date.now();
const snapshot = activity.status === "running"
? {
...activity,
elapsed_ms: Math.max(0, now - activity.started_at),
actions: activity.actions.map((action) =>
action.status === "running"
? { ...action, elapsed_ms: Math.max(0, now - action.started_at) }
: action,
),
}
: activity;
write("activity_update", {
session_id: clientSessionId,
activity: snapshot,
...(todos
? {
todos,
todos_created_at: now,
}
: {}),
} satisfies ActivityUpdatePayload);
};
const current = (): ActivityPayload | undefined =>
currentActivityId ? activities.get(currentActivityId) : undefined;
const finalize = (status: Exclude<ActivityStatus, "running">) => {
const activity = current();
if (!activity || activity.status !== "running") return;
const endedAt = Date.now();
const nextActivity: ActivityPayload = {
...activity,
status,
ended_at: endedAt,
elapsed_ms: undefined,
duration_ms: Math.max(0, endedAt - activity.started_at),
actions: activity.actions.map((action) => {
if (action.status !== "running") return action;
return {
...action,
status: status === "error" ? "error" : "completed",
ended_at: endedAt,
elapsed_ms: undefined,
duration_ms: Math.max(0, endedAt - action.started_at),
...(status === "error" ? { error: action.error ?? "活动执行失败" } : {}),
};
}),
};
activities.set(activity.id, nextActivity);
emit(nextActivity);
};
const start = (
id: string,
title: string,
reason: string,
todos?: TodoItemPayload[],
) => {
finalize("completed");
const activity: ActivityPayload = {
id,
title,
reason,
status: "running",
actions: [],
started_at: Date.now(),
elapsed_ms: 0,
};
activities.set(id, activity);
currentActivityId = id;
emit(activity, todos);
return activity;
};
const ensure = (tool: string) => {
const activity = current();
if (activity?.status === "running") return activity;
return start(
`activity-fallback-${Date.now().toString(36)}`,
"执行分析操作",
`为完成当前请求,需要使用${getToolLabel(tool)}处理相关信息。`,
);
};
const upsertAction = (part: ToolPart, params: Record<string, unknown>) => {
const associatedActivityId = actionActivityIds.get(part.id);
const activity = associatedActivityId
? activities.get(associatedActivityId)
: ensure(part.tool);
if (!activity) return;
if (!associatedActivityId) actionActivityIds.set(part.id, activity.id);
const now = Date.now();
const actionIndex = activity.actions.findIndex((action) => action.id === part.id);
const previous = actionIndex >= 0 ? activity.actions[actionIndex] : undefined;
const status = normalizeToolStatus(part.state.status);
const startedAt = previous?.started_at ?? now;
const endedAt = status === "running" ? undefined : now;
const action: ActivityActionPayload = {
id: part.id,
tool: part.tool,
title: getToolLabel(part.tool),
status,
target: getActionTarget(params),
error: part.state.status === "error" ? part.state.error : undefined,
started_at: startedAt,
ended_at: endedAt,
elapsed_ms: status === "running" ? Math.max(0, now - startedAt) : undefined,
duration_ms: endedAt ? Math.max(0, endedAt - startedAt) : undefined,
};
const actions = [...activity.actions];
if (actionIndex >= 0) actions[actionIndex] = action;
else actions.push(action);
const nextActivity = { ...activity, actions };
activities.set(activity.id, nextActivity);
emit(nextActivity);
};
const getActionContext = (actionId: string): ActivityContext | undefined => {
const activityId = actionActivityIds.get(actionId);
const activity = activityId ? activities.get(activityId) : undefined;
return activity
? { id: activity.id, title: activity.title, reason: activity.reason }
: undefined;
};
const getCurrentContext = (): ActivityContext | undefined => {
const activity = current();
return activity
? { id: activity.id, title: activity.title, reason: activity.reason }
: undefined;
};
return {
finalize,
getActionContext,
getCurrentContext,
start,
upsertAction,
};
};
+153 -343
View File
@@ -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),
});
+47 -119
View File
@@ -9,6 +9,8 @@ export type PermissionRequestPayload = {
permission: string;
patterns: string[];
target?: string;
activity_id?: string;
reason?: string;
always: string[];
tool?: {
messageID: string;
@@ -17,6 +19,46 @@ export type PermissionRequestPayload = {
created_at: number;
};
export type ActivityStatus = "running" | "completed" | "error" | "cancelled";
export type ActivityActionPayload = {
id: string;
tool: string;
title: string;
status: "running" | "completed" | "error";
target?: string;
error?: string;
started_at: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
};
export type ActivityPayload = {
id: string;
title: string;
reason: string;
status: ActivityStatus;
actions: ActivityActionPayload[];
started_at: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
};
export type ActivityUpdatePayload = {
session_id: string;
activity: ActivityPayload;
todos?: TodoItemPayload[];
todos_created_at?: number;
};
export const initialActivity = {
id: "activity-startup",
title: "正在准备分析",
reason: "正在理解请求并确定本次分析需要完成的业务步骤。",
} as const;
type QuestionOptionPayload = {
label: string;
description: string;
@@ -65,6 +107,9 @@ export type AssistantMessagePhase =
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
const toolLabels: Record<string, string> = {
tjwater_cli: "查询后端数据",
bash: "运行本地分析",
store_render_ref: "保存渲染结果",
memory_manager: "记忆写入",
geocode: "地理编码",
session_search: "历史会话检索",
@@ -76,6 +121,7 @@ const toolLabels: Record<string, string> = {
view_scada: "SCADA 面板",
show_chart: "图表渲染",
render_junctions: "节点渲染",
apply_layer_style: "图层样式调整",
};
export const logDevelopmentDebug = (
@@ -143,20 +189,6 @@ export const normalizeToolParams = (value: unknown): Record<string, unknown> =>
return {};
};
export const extractRequestReason = (params: Record<string, unknown>) => {
const candidates = ["reason", "request_reason", "why", "purpose", "rationale"];
for (const key of candidates) {
const value = params[key];
if (typeof value === "string") {
const normalized = value.trim();
if (normalized) {
return normalized;
}
}
}
return "";
};
export const isSkillEvent = (event: OpencodeEvent) =>
event.type.toLowerCase().includes("skill");
@@ -172,10 +204,8 @@ export const extractSkillAuditInfo = (event: OpencodeEvent) => {
: typeof payload.name === "string"
? payload.name
: event.type;
const reason = extractRequestReason(payload);
return {
name: candidateName,
reason,
payload,
};
};
@@ -240,24 +270,6 @@ export const isQuestionV2RejectedEvent = (
): event is Extract<OpencodeEvent, { type: "question.v2.rejected" }> =>
event.type === "question.v2.rejected";
export const buildPermissionDetail = (
event: Extract<OpencodeEvent, { type: "permission.asked" }>,
) => {
const patterns = event.properties.patterns.length
? event.properties.patterns.join(", ")
: event.properties.permission;
return `需要用户确认权限:${event.properties.permission};匹配规则:${patterns}`;
};
export const buildPermissionV2Detail = (
event: Extract<OpencodeEvent, { type: "permission.v2.asked" }>,
) => {
const resources = event.properties.resources.length
? event.properties.resources.join(", ")
: event.properties.action;
return `需要用户确认权限:${event.properties.action};资源:${resources}`;
};
export const normalizeQuestionPayload = (
event: Extract<OpencodeEvent, { type: "question.asked" | "question.v2.asked" }>,
clientSessionId: string,
@@ -374,88 +386,4 @@ export const normalizeToolStatus = (status: string) => {
return "running";
};
const formatProgressValue = (value: unknown): string => {
if (typeof value === "string") {
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
value === null ||
value === undefined
) {
return String(value);
}
try {
const serialized = JSON.stringify(value);
return serialized.length > 120 ? `${serialized.slice(0, 117)}...` : serialized;
} catch {
return "[unserializable]";
}
};
const summarizeToolParams = (params: Record<string, unknown>) => {
const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]);
const summary = Object.entries(params)
.filter(([key]) => !ignoredKeys.has(key))
.slice(0, 4)
.map(([key, value]) => `${key}=${formatProgressValue(value)}`)
.join(", ");
return summary || "无附加参数";
};
export const buildSessionStatusDetail = (status: { type: string; message?: string }) => {
if (status.type === "retry") {
return status.message
? `模型请求需要重试,原因:${status.message}`
: "模型请求正在重试,等待下一次响应。";
}
if (status.type === "busy") {
return status.message
? `Agent 正在处理中:${status.message}`
: "Agent 正在执行推理、工具调用或结果整理。";
}
if (status.type === "idle") {
return status.message
? `Agent 已空闲:${status.message}`
: "当前会话暂时没有待处理任务。";
}
return status.message ? `会话状态更新:${status.message}` : `会话状态更新:${status.type}`;
};
export const buildReasoningProgressDetail = (
ended?: string | number | Date | null,
) => ended ? "分析步骤已整理完成。" : "Agent 正在分析问题。";
export const buildToolProgressDetail = (
tool: string,
status: string,
params: Record<string, unknown>,
reason: string,
error?: string,
) => {
const toolName = toolLabels[tool] ?? tool;
const reasonText = reason ? `;调用原因:${reason}` : "";
const paramsText = `;关键参数:${summarizeToolParams(params)}`;
if (status === "error") {
const errorText = error ? `;错误:${error}` : "";
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
}
if (status === "completed") {
return `${toolName} 已执行完成${reasonText}${paramsText}`;
}
if (status === "pending") {
return `${toolName} 已进入待执行状态${reasonText}${paramsText}`;
}
return `${toolName} 正在执行${reasonText}${paramsText}`;
};
export const getToolProgressTitle = (tool: string, status: string) => {
const toolName = toolLabels[tool] ?? tool;
if (status === "completed") return `${toolName} 已完成`;
if (status === "error") return `${toolName} 执行失败`;
if (status === "pending") return `准备调用 ${toolName}`;
return `正在调用 ${toolName}`;
};
export const getToolLabel = (tool: string) => toolLabels[tool] ?? tool;
+119 -9
View File
@@ -1,5 +1,7 @@
import { type PermissionReply } from "../runtime/opencode.js";
import {
type ActivityPayload,
initialActivity,
type PermissionRequestPayload,
type QuestionRequestPayload,
type TodoUpdatePayload,
@@ -28,6 +30,7 @@ type ToolCallPayload = {
session_id?: string;
tool?: string;
params?: unknown;
/** Legacy payload field. New tool calls inherit purpose from their Activity. */
reason?: string;
};
@@ -56,13 +59,13 @@ export const createInitialStreamingMessages = (
id: createFrontendMessageId(),
role: "assistant",
content: "",
progress: [
activities: [
{
id: "request-received",
phase: "start",
id: initialActivity.id,
status: "running",
title: "已收到请求,正在启动 Agent 分析",
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
title: initialActivity.title,
reason: initialActivity.reason,
actions: [],
startedAt: Date.now(),
elapsedMs: 0,
elapsedSnapshotAt: Date.now(),
@@ -72,6 +75,90 @@ export const createInitialStreamingMessages = (
];
};
const toFrontendActivityAction = (
action: ActivityPayload["actions"][number],
) => ({
id: action.id,
tool: action.tool,
title: action.title,
status: action.status,
target: action.target,
error: action.error,
startedAt: action.started_at,
endedAt: action.ended_at,
elapsedMs: action.elapsed_ms,
elapsedSnapshotAt: action.elapsed_ms === undefined ? undefined : Date.now(),
durationMs: action.duration_ms,
});
const toFrontendActivity = (activity: ActivityPayload) => ({
id: activity.id,
title: activity.title,
reason: activity.reason,
status: activity.status,
actions: activity.actions.map(toFrontendActivityAction),
startedAt: activity.started_at,
endedAt: activity.ended_at,
elapsedMs: activity.elapsed_ms,
elapsedSnapshotAt: activity.elapsed_ms === undefined ? undefined : Date.now(),
durationMs: activity.duration_ms,
});
export const upsertBackendActivity = (
activities: unknown,
activity: ActivityPayload,
) => {
const next = Array.isArray(activities) ? [...activities] : [];
const index = next.findIndex(
(item) => isObjectRecord(item) && item.id === activity.id,
);
const nextItem = toFrontendActivity(activity);
if (index >= 0) next[index] = nextItem;
else next.push(nextItem);
return next;
};
export const completeBackendActivities = (
activities: unknown,
status: "completed" | "error" | "cancelled" = "completed",
) => Array.isArray(activities)
? activities.map((activity) => {
if (!isObjectRecord(activity) || activity.status !== "running") return activity;
const endedAt = Date.now();
const startedAt = typeof activity.startedAt === "number"
? activity.startedAt
: endedAt;
const actions = Array.isArray(activity.actions)
? activity.actions.map((action) => {
if (!isObjectRecord(action) || action.status !== "running") return action;
const actionStartedAt = typeof action.startedAt === "number"
? action.startedAt
: endedAt;
return {
...action,
status: status === "error" ? "error" : "completed",
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs: Math.max(0, endedAt - actionStartedAt),
...(status === "error"
? { error: action.error ?? "活动执行失败" }
: {}),
};
})
: activity.actions;
return {
...activity,
status,
actions,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs: Math.max(0, endedAt - startedAt),
};
})
: activities;
export const upsertBackendProgress = (
progress: unknown,
payload: Record<string, unknown>,
@@ -152,6 +239,31 @@ export const cancelBackendTodos = (todos: unknown) =>
})
: todos;
export const completeBackendTodos = (todos: unknown) =>
Array.isArray(todos)
? todos.map((todoUpdate) => {
if (!isObjectRecord(todoUpdate) || !Array.isArray(todoUpdate.todos)) {
return todoUpdate;
}
return {
...todoUpdate,
todos: todoUpdate.todos.map((todo) => {
if (!isObjectRecord(todo)) {
return todo;
}
if (todo.status !== "pending" && todo.status !== "in_progress") {
return todo;
}
return {
...todo,
status: "completed",
updatedAt: Date.now(),
};
}),
};
})
: todos;
export const updateLastAssistantMessage = (
messages: unknown[],
updater: (message: Record<string, unknown>) => Record<string, unknown>,
@@ -249,10 +361,6 @@ export const appendBackendToolArtifact = (
tool,
kind: getToolArtifactKind(tool),
title: getToolArtifactTitle(tool, params),
description:
typeof payload.reason === "string" && payload.reason.trim()
? payload.reason.trim()
: undefined,
params,
});
return next;
@@ -267,6 +375,8 @@ export const toFrontendPermission = (
permission: payload.permission,
patterns: payload.patterns,
target: payload.target,
activityId: payload.activity_id,
reason: payload.reason,
always: payload.always,
tool: payload.tool,
createdAt: payload.created_at,
-7
View File
@@ -1,7 +1,6 @@
import {
createOpencode,
type OpencodeClient,
type OutputFormat,
} from "@opencode-ai/sdk/v2";
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
@@ -39,10 +38,6 @@ type RuntimeModelOverride = {
modelID: string;
};
type RuntimePromptOptions = {
format?: OutputFormat;
};
export type PermissionReply = "once" | "always" | "reject";
export type QuestionAnswers = string[][];
@@ -189,7 +184,6 @@ export class OpencodeRuntimeAdapter {
sessionId: string,
text: string,
model?: RuntimeModelOverride,
options?: RuntimePromptOptions,
) {
const client = await this.ensureClient();
const startedAt = Date.now();
@@ -204,7 +198,6 @@ export class OpencodeRuntimeAdapter {
await client.session.prompt({
sessionID: sessionId,
model,
...(options?.format ? { format: options.format } : {}),
parts: [{ type: "text", text }],
});
logDevelopmentDebug(