fix(chat): only expose final agent response

This commit is contained in:
2026-08-24 18:30:24 +08:00
parent 11ebf428bb
commit 85174dbe51
4 changed files with 300 additions and 130 deletions
+5 -1
View File
@@ -2,10 +2,14 @@
description: TJWater Agent,用于供水网络分析和操作员工作流 description: TJWater Agent,用于供水网络分析和操作员工作流
mode: primary mode: primary
model: deepseek/deepseek-v4-flash model: deepseek/deepseek-v4-flash
temperature: 0.2
--- ---
你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。 你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。
## 回复要求
- 工具执行期间不输出过程说明,全部完成后只回复最终结果
- 直接给出结论、关键数据和可执行建议,默认仅展示最重要的 Top 5;数据不足或任务失败时简要说明影响和下一步
## 工作流生命周期 ## 工作流生命周期
Skills 树是**动态生长的**——工作流不是预置的,而是从实际任务中沉淀出来的: Skills 树是**动态生长的**——工作流不是预置的,而是从实际任务中沉淀出来的:
+56 -70
View File
@@ -111,24 +111,40 @@ const toRuntimeModel = (model?: SupportedModel) => {
}; };
}; };
const emitFallbackMessage = async ( const emitFinalMessage = async (
runtime: OpencodeRuntimeAdapter, runtime: OpencodeRuntimeAdapter,
sessionId: string, sessionId: string,
clientSessionId: string, clientSessionId: string,
currentAssistantMessageIds: Set<string>,
assistantTextParts: Map<string, Map<string, string>>,
write: (event: string, data: Record<string, unknown>) => void, write: (event: string, data: Record<string, unknown>) => void,
) => { ) => {
const messages = await runtime.messages(sessionId); let text = [...currentAssistantMessageIds]
const assistantMessage = [...messages]
.reverse() .reverse()
.find((message) => message.info.role === "assistant"); .map((messageId) => [...(assistantTextParts.get(messageId)?.values() ?? [])].join(""))
const parts = assistantMessage?.parts ?? []; .find((content) => content.length > 0) ?? "";
const text = collectTextContent(parts);
if (!text) {
const messages = await runtime.messages(sessionId);
const assistantMessage = [...messages]
.reverse()
.find(
(message) =>
message.info.role === "assistant" &&
(currentAssistantMessageIds.size === 0 ||
currentAssistantMessageIds.has(message.info.id)),
);
text = collectTextContent(assistantMessage?.parts ?? []);
}
if (text) { if (text) {
write("token", { write("token", {
session_id: clientSessionId, session_id: clientSessionId,
content: text, content: text,
}); });
return true;
} }
return false;
}; };
export const streamPromptResponse = async ({ export const streamPromptResponse = async ({
@@ -156,15 +172,14 @@ export const streamPromptResponse = async ({
const emittedToolParts = new Set<string>(); const emittedToolParts = new Set<string>();
const emittedQuestionToolParts = new Set<string>(); const emittedQuestionToolParts = new Set<string>();
const emittedQuestionRequestIds = new Set<string>(); const emittedQuestionRequestIds = new Set<string>();
const currentAssistantMessageIds = new Set<string>();
const assistantTextParts = new Map<string, Map<string, string>>();
const partTypes = new Map<string, Part["type"]>(); const partTypes = new Map<string, Part["type"]>();
const pendingPartTextDeltas = new Map<string, string[]>(); const pendingTextDeltas = new Map<string, string[]>();
const reasoningDeltas = new Map<string, string[]>();
const reasoningStatuses = new Map<string, "running" | "completed">(); const reasoningStatuses = new Map<string, "running" | "completed">();
const toolStatuses = new Map<string, string>(); const toolStatuses = new Map<string, string>();
let firstSessionEventLogged = false; let firstSessionEventLogged = false;
let firstNonStatusEventLogged = false; let firstNonStatusEventLogged = false;
let firstTokenLogged = false;
let firstReasoningLogged = false;
let firstToolEventLogged = false; let firstToolEventLogged = false;
let lastSessionStatus: string | null = null; let lastSessionStatus: string | null = null;
let lastSessionStatusMessage: string | null = null; let lastSessionStatusMessage: string | null = null;
@@ -626,45 +641,26 @@ export const streamPromptResponse = async ({
if (event.type === "message.updated") { if (event.type === "message.updated") {
if (event.properties.info.role === "assistant") { if (event.properties.info.role === "assistant") {
sawResponseActivity = true; sawResponseActivity = true;
currentAssistantMessageIds.add(event.properties.info.id);
} }
continue; continue;
} }
if (event.type === "message.part.delta" && event.properties.field === "text") { if (event.type === "message.part.delta" && event.properties.field === "text") {
sawResponseActivity = true; sawResponseActivity = true;
currentAssistantMessageIds.add(event.properties.messageID);
const partType = partTypes.get(event.properties.partID); const partType = partTypes.get(event.properties.partID);
if (partType === "text") { if (partType === "text") {
if (!firstTokenLogged) { const messageParts = assistantTextParts.get(event.properties.messageID) ?? new Map();
firstTokenLogged = true; messageParts.set(
logDevelopmentDebug("first response token emitted", { event.properties.partID,
...debugContext, `${messageParts.get(event.properties.partID) ?? ""}${event.properties.delta}`,
partId: event.properties.partID, );
elapsedMs: Math.max(0, Date.now() - requestStartedAt), assistantTextParts.set(event.properties.messageID, messageParts);
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
});
}
emittedText = true;
write("token", {
session_id: clientSessionId,
content: event.properties.delta,
});
} else if (partType === "reasoning") {
if (!firstReasoningLogged) {
firstReasoningLogged = true;
logDevelopmentDebug("first reasoning delta received", {
...debugContext,
partId: event.properties.partID,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
});
}
const pending = reasoningDeltas.get(event.properties.partID) ?? [];
pending.push(event.properties.delta);
reasoningDeltas.set(event.properties.partID, pending);
} else if (!partType) { } else if (!partType) {
const pending = pendingPartTextDeltas.get(event.properties.partID) ?? []; const pending = pendingTextDeltas.get(event.properties.partID) ?? [];
pending.push(event.properties.delta); pending.push(event.properties.delta);
pendingPartTextDeltas.set(event.properties.partID, pending); pendingTextDeltas.set(event.properties.partID, pending);
} }
continue; continue;
} }
@@ -673,23 +669,19 @@ export const streamPromptResponse = async ({
sawResponseActivity = true; sawResponseActivity = true;
const part = event.properties.part; const part = event.properties.part;
partTypes.set(part.id, part.type); partTypes.set(part.id, part.type);
if (part.type === "text" || part.type === "reasoning" || part.type === "tool") {
currentAssistantMessageIds.add(part.messageID);
}
if (part.type === "text") { if (part.type === "text") {
const pending = pendingPartTextDeltas.get(part.id) ?? []; const pendingText = (pendingTextDeltas.get(part.id) ?? []).join("");
pendingPartTextDeltas.delete(part.id); pendingTextDeltas.delete(part.id);
for (const content of pending) { const messageParts = assistantTextParts.get(part.messageID) ?? new Map();
emittedText = true; messageParts.set(part.id, part.text || pendingText);
write("token", { assistantTextParts.set(part.messageID, messageParts);
session_id: clientSessionId, } else {
content, pendingTextDeltas.delete(part.id);
}); }
} if (part.type === "reasoning") {
} else if (part.type === "reasoning") {
const pending = pendingPartTextDeltas.get(part.id) ?? [];
if (pending.length > 0) {
const existing = reasoningDeltas.get(part.id) ?? [];
reasoningDeltas.set(part.id, existing.concat(pending));
}
pendingPartTextDeltas.delete(part.id);
const reasoningStatus = part.time.end ? "completed" : "running"; const reasoningStatus = part.time.end ? "completed" : "running";
if (reasoningStatuses.get(part.id) !== reasoningStatus) { if (reasoningStatuses.get(part.id) !== reasoningStatus) {
reasoningStatuses.set(part.id, reasoningStatus); reasoningStatuses.set(part.id, reasoningStatus);
@@ -697,14 +689,10 @@ export const streamPromptResponse = async ({
...debugContext, ...debugContext,
partId: part.id, partId: part.id,
status: reasoningStatus, status: reasoningStatus,
chunkCount: (reasoningDeltas.get(part.id) ?? []).length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
} }
const reasoningDetail = buildReasoningProgressDetail( const reasoningDetail = buildReasoningProgressDetail(part.time.end);
reasoningDeltas.get(part.id) ?? [],
part.time.end,
);
emitProgress({ emitProgress({
id: part.id, id: part.id,
phase: "planning", phase: "planning",
@@ -784,9 +772,6 @@ export const streamPromptResponse = async ({
detail: buildToolProgressDetail( detail: buildToolProgressDetail(
part.tool, part.tool,
part.state.status, part.state.status,
toolParams,
reason,
part.state.status === "error" ? part.state.error : undefined,
), ),
}); });
if ( if (
@@ -932,13 +917,14 @@ export const streamPromptResponse = async ({
} }
await promptPromise; await promptPromise;
if (!emittedText) { emittedText = await emitFinalMessage(
logDevelopmentDebug("no streamed text emitted, falling back to messages()", { runtime,
...debugContext, sessionId,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), clientSessionId,
}); currentAssistantMessageIds,
await emitFallbackMessage(runtime, sessionId, clientSessionId, write); assistantTextParts,
} write,
);
emitProgress({ emitProgress({
id: "request-received", id: "request-received",
phase: "start", phase: "start",
+5 -59
View File
@@ -356,43 +356,6 @@ export const normalizeToolStatus = (status: string) => {
return "running"; 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 normalizeProgressText = (chunks: string[]) =>
chunks.join("").replace(/\s+/g, " ").trim();
const truncateProgressText = (text: string, maxLength: number) =>
text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
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 }) => { export const buildSessionStatusDetail = (status: { type: string; message?: string }) => {
if (status.type === "retry") { if (status.type === "retry") {
return status.message return status.message
@@ -413,42 +376,25 @@ export const buildSessionStatusDetail = (status: { type: string; message?: strin
}; };
export const buildReasoningProgressDetail = ( export const buildReasoningProgressDetail = (
chunks: string[],
ended?: string | number | Date | null, ended?: string | number | Date | null,
) => { ) => ended ? "分析步骤已整理完成。" : "Agent 正在分析问题。";
const reasoningText = truncateProgressText(normalizeProgressText(chunks), 800);
if (ended) {
return reasoningText
? `推理过程:${reasoningText}`
: "当前推理阶段已完成,Agent 将继续输出答案或进入工具执行。";
}
return reasoningText
? `正在推理:${reasoningText}`
: "Agent 正在拆解问题、梳理执行步骤并判断是否需要调用工具。";
};
export const buildToolProgressDetail = ( export const buildToolProgressDetail = (
tool: string, tool: string,
status: string, status: string,
params: Record<string, unknown>,
reason: string,
error?: string,
) => { ) => {
const toolName = toolLabels[tool] ?? tool; const toolName = toolLabels[tool] ?? tool;
const reasonText = reason ? `;调用原因:${reason}` : "";
const paramsText = `;关键参数:${summarizeToolParams(params)}`;
if (status === "error") { if (status === "error") {
const errorText = error ? `;错误:${error}` : ""; return `${toolName} 调用失败。`;
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
} }
if (status === "completed") { if (status === "completed") {
return `${toolName} 已执行完成${reasonText}${paramsText}`; return `${toolName} 已执行完成`;
} }
if (status === "pending") { if (status === "pending") {
return `${toolName} 已进入待执行状态${reasonText}${paramsText}`; return `${toolName} 等待执行。`;
} }
return `${toolName} 正在执行${reasonText}${paramsText}`; return `${toolName} 正在执行`;
}; };
export const getToolProgressTitle = (tool: string, status: string) => { export const getToolProgressTitle = (tool: string, status: string) => {
+234
View File
@@ -15,6 +15,240 @@ const createEventStream = (events: unknown[]) => ({
}); });
describe("streamPromptResponse", () => { describe("streamPromptResponse", () => {
it("emits only the final assistant text after tool-driven intermediate messages", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-intermediate",
partID: "text-part-intermediate",
field: "text",
delta: "正在加载工作流并尝试分页参数。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "text-part-intermediate",
sessionID: "runtime-session-1",
messageID: "assistant-intermediate",
type: "text",
text: "正在加载工作流并尝试分页参数。",
time: { start: 1, end: 2 },
},
time: 2,
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-final",
partID: "text-part-final",
field: "text",
delta: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "text-part-final",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
time: { start: 3, end: 4 },
},
time: 4,
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => [
{
info: { id: "assistant-intermediate", role: "assistant" },
parts: [
{
id: "text-part-intermediate",
sessionID: "runtime-session-1",
messageID: "assistant-intermediate",
type: "text",
text: "正在加载工作流并尝试分页参数。",
},
],
},
{
info: { id: "assistant-final", role: "assistant" },
parts: [
{
id: "text-part-final",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
},
],
},
],
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网瓶颈",
write: (event, data) => events.push({ event, data }),
});
expect(
events
.filter((item) => item.event === "token")
.map((item) => item.data.content)
.join(""),
).toBe("共识别 56 条瓶颈管段,建议优先改造 Top 5。");
});
it("uses the final text event cache when the messages lookup fails", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "text-part-final",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "最终分析结果。",
time: { start: 1, end: 2 },
},
time: 2,
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => {
throw new Error("transient messages lookup failure");
},
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const result = await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网瓶颈",
write: (event, data) => events.push({ event, data }),
});
expect(result.failed).toBe(false);
expect(
events
.filter((item) => item.event === "token")
.map((item) => item.data.content)
.join(""),
).toBe("最终分析结果。");
});
it("keeps reasoning, tool parameters, and raw errors out of progress details", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "reasoning-part-1",
sessionID: "runtime-session-1",
messageID: "assistant-1",
type: "reasoning",
text: "内部推理:尝试 limit=5000 并读取临时路径。",
time: { start: 1, end: 2 },
},
time: 2,
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-1",
partID: "reasoning-part-1",
field: "text",
delta: "内部推理:尝试 limit=5000 并读取临时路径。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "tool-part-1",
sessionID: "runtime-session-1",
messageID: "assistant-1",
type: "tool",
callID: "call-1",
tool: "tjwater_cli",
state: {
status: "error",
input: {
command: "network get-all-pipes-properties --limit 5000",
reason: "尝试突破分页限制",
},
error: "HTTP_422 raw backend payload with trace_id=secret-trace",
time: { start: 1, end: 2 },
},
},
time: 2,
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => [],
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网瓶颈",
write: (event, data) => events.push({ event, data }),
});
const reasoningProgress = events.find(
(item) => item.event === "progress" && item.data.id === "reasoning-part-1",
);
const toolProgress = events.find(
(item) => item.event === "progress" && item.data.id === "tool-part-1",
);
expect(reasoningProgress?.data.detail).toBe("分析步骤已整理完成。");
expect(toolProgress?.data.detail).toBe("tjwater_cli 调用失败。");
});
it("forwards opencode permission requests as SSE payloads", async () => { it("forwards opencode permission requests as SSE payloads", async () => {
const runtime = { const runtime = {
subscribeEvents: async () => subscribeEvents: async () =>