Merge pull request #1299 from pikasTech/fix-1286-error-observability

fix: improve code agent failure diagnostics
This commit is contained in:
Lyon
2026-06-16 02:31:47 +08:00
committed by GitHub
4 changed files with 241 additions and 44 deletions
@@ -7,39 +7,58 @@ import { firstNonEmptyString } from "@/utils";
const props = defineProps<{ message: ChatMessage }>();
const trace = computed(() => props.message.runnerTrace ?? null);
const agentRun = computed(() => asAgentRun(trace.value?.agentRun));
const error = computed(() => props.message.error ?? null);
const traceRecord = computed(() => recordValue(trace.value));
const agentRun = computed(() => asAgentRun(traceRecord.value?.agentRun ?? (props.message as Record<string, unknown>).agentRun));
const error = computed(() => normalizeSummaryError(props.message.error ?? traceRecord.value?.error));
const eventCount = computed(() => trace.value?.eventCount ?? trace.value?.events?.length ?? 0);
const errorTitle = computed(() => firstNonEmptyString(error.value?.code, error.value?.category, "失败原因"));
const errorMessage = computed(() => firstNonEmptyString(error.value?.message, error.value?.userMessage, error.value?.reason));
const rows = computed(() => [
{ key: "messageStatus", label: "消息状态", value: props.message.status },
{ key: "traceStatus", label: "Trace 状态", value: firstNonEmptyString(trace.value?.status, trace.value?.traceStatus) },
{ key: "traceId", label: "Trace", value: props.message.traceId ?? trace.value?.traceId },
{ key: "conversationId", label: "Conversation", value: props.message.conversationId },
{ key: "sessionId", label: "Session", value: props.message.sessionId ?? trace.value?.sessionId },
{ key: "threadId", label: "Thread", value: props.message.threadId ?? trace.value?.threadId },
{ key: "runnerKind", label: "Runner", value: trace.value?.runnerKind ?? agentRun.value?.adapter },
{ key: "sessionMode", label: "Session Mode", value: trace.value?.sessionMode ?? agentRun.value?.backendProfile },
{ key: "waitingFor", label: "Waiting For", value: trace.value?.waitingFor },
{ key: "lastEvent", label: "Last Event", value: trace.value?.lastEventLabel },
{ key: "eventCount", label: "Events", value: String(eventCount.value) },
{ key: "eventsCompacted", label: "Compacted", value: trace.value?.eventsCompacted === true ? "true" : "false" },
{ key: "fullTraceLoaded", label: "Full Trace", value: trace.value?.fullTraceLoaded === true ? "loaded" : "unverified" },
{ key: "runId", label: "AgentRun", value: agentRun.value?.runId },
{ key: "commandId", label: "Command", value: agentRun.value?.commandId },
{ key: "attemptId", label: "Attempt", value: agentRun.value?.attemptId },
{ key: "runnerId", label: "Runner ID", value: agentRun.value?.runnerId },
{ key: "jobName", label: "Job", value: agentRun.value?.jobName },
{ key: "namespace", label: "Namespace", value: agentRun.value?.namespace },
{ key: "terminalStatus", label: "Terminal", value: agentRun.value?.terminalStatus },
{ key: "errorCode", label: "Error Code", value: error.value?.code },
{ key: "errorCategory", label: "Error Category", value: error.value?.category },
{ key: "providerStatus", label: "Provider Status", value: error.value?.providerStatus == null ? null : String(error.value.providerStatus) }
].map((row) => ({ ...row, value: row.value == null || row.value === "" ? "未观测" : String(row.value) })));
summaryRow("messageStatus", "消息状态", props.message.status),
summaryRow("traceStatus", "Trace 状态", firstNonEmptyString(trace.value?.status, trace.value?.traceStatus)),
summaryRow("traceId", "Trace", props.message.traceId ?? trace.value?.traceId),
summaryRow("conversationId", "Conversation", props.message.conversationId),
summaryRow("sessionId", "Session", props.message.sessionId ?? trace.value?.sessionId),
summaryRow("threadId", "Thread", props.message.threadId ?? trace.value?.threadId),
summaryRow("runnerKind", "Runner", trace.value?.runnerKind ?? agentRun.value?.adapter),
summaryRow("sessionMode", "Session Mode", trace.value?.sessionMode ?? agentRun.value?.backendProfile),
summaryRow("waitingFor", "Waiting For", trace.value?.waitingFor),
summaryRow("lastEvent", "Last Event", trace.value?.lastEventLabel),
summaryRow("eventCount", "Events", eventCount.value > 0 ? eventCount.value : null),
summaryRow("eventsCompacted", "Compacted", trace.value?.eventsCompacted === true ? "true" : null),
summaryRow("fullTraceLoaded", "Full Trace", trace.value?.fullTraceLoaded === true ? "loaded" : null),
summaryRow("runId", "AgentRun", agentRun.value?.runId),
summaryRow("commandId", "Command", agentRun.value?.commandId),
summaryRow("attemptId", "Attempt", agentRun.value?.attemptId),
summaryRow("runnerId", "Runner ID", agentRun.value?.runnerId),
summaryRow("jobName", "Job", agentRun.value?.jobName),
summaryRow("namespace", "Namespace", agentRun.value?.namespace),
summaryRow("terminalStatus", "Terminal", agentRun.value?.terminalStatus),
summaryRow("errorCode", "Error Code", error.value?.code),
summaryRow("errorCategory", "Error Category", error.value?.category),
summaryRow("providerStatus", "Provider Status", error.value?.providerStatus)
].filter((row): row is { key: string; label: string; value: string } => Boolean(row)));
function asAgentRun(value: unknown): AgentRunProvenance | null {
return value && typeof value === "object" ? value as AgentRunProvenance : null;
}
function recordValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? value as Record<string, unknown> : null;
}
function summaryRow(key: string, label: string, value: unknown): { key: string; label: string; value: string } | null {
const text = value == null ? "" : String(value).trim();
return text ? { key, label, value: text } : null;
}
function normalizeSummaryError(value: unknown): Record<string, unknown> | null {
if (!value) return null;
if (typeof value === "string") return value.trim() ? { message: value.trim() } : null;
return value && typeof value === "object" ? value as Record<string, unknown> : null;
}
</script>
<template>
@@ -48,7 +67,11 @@ function asAgentRun(value: unknown): AgentRunProvenance | null {
<span>Code Agent 状态摘要</span>
<StatusBadge :status="message.status" />
</summary>
<dl>
<div v-if="error" class="code-agent-error-summary">
<strong>{{ errorTitle }}</strong>
<p v-if="errorMessage">{{ errorMessage }}</p>
</div>
<dl v-if="rows.length > 0">
<div v-for="row in rows" :key="row.key" class="code-agent-status-row" :data-status-row="row.key">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
@@ -16,6 +16,7 @@ export interface TraceSnapshot {
terminalEvidence?: unknown;
finalResponse?: unknown;
traceSummary?: unknown;
error?: AgentChatResultResponse["error"];
lastEventLabel?: string;
waitingFor?: string;
eventSource?: string;
@@ -62,6 +63,7 @@ export function snapshotToRunnerTrace(snapshot: TraceSnapshot): NonNullable<Chat
finalResponse: snapshot.finalResponse,
traceSummary: snapshot.traceSummary,
agentRun: snapshot.agentRun,
error: snapshot.error,
eventSource: snapshot.eventSource ?? "trace-api",
runnerKind: snapshot.agentRun?.adapter,
sessionMode: snapshot.agentRun?.backendProfile,
@@ -108,30 +110,41 @@ function traceEventIdentity(event: TraceEvent): string | null {
export function mergeTraceResults(terminal: AgentChatResultResponse, trace: TraceSnapshot | null): AgentChatResultResponse {
if (!trace) return terminal;
const mergedTrace: TraceSnapshot = {
...trace,
sessionId: trace.sessionId ?? terminal.sessionId,
threadId: trace.threadId ?? terminal.threadId,
agentRun: trace.agentRun ?? terminal.agentRun,
terminalEvidence: trace.terminalEvidence ?? terminal.terminalEvidence,
finalResponse: trace.finalResponse ?? terminal.finalResponse,
traceSummary: trace.traceSummary ?? terminal.traceSummary,
error: trace.error ?? terminal.error
};
const events = Array.isArray(trace.events) ? trace.events : [];
const assistantText = bestAssistantTextCandidate([
finalResponseText(trace.finalResponse),
finalResponseText(mergedTrace.finalResponse),
terminalAssistantTextFromTraceEvents(events),
firstNonEmptyResultText(terminal),
assistantTextFromTraceEvents(events)
]);
return {
...terminal,
traceId: trace.traceId ?? terminal.traceId,
sessionId: trace.sessionId ?? terminal.sessionId,
threadId: trace.threadId ?? terminal.threadId,
agentRun: trace.agentRun ?? terminal.agentRun,
traceId: mergedTrace.traceId ?? terminal.traceId,
sessionId: mergedTrace.sessionId ?? terminal.sessionId,
threadId: mergedTrace.threadId ?? terminal.threadId,
agentRun: mergedTrace.agentRun ?? terminal.agentRun,
error: mergedTrace.error ?? terminal.error,
assistantText: assistantText ?? terminal.assistantText,
runnerTrace: snapshotToRunnerTrace(trace),
runnerTrace: snapshotToRunnerTrace(mergedTrace),
traceEvents: terminal.traceEvents ?? events,
events,
eventCount: trace.eventCount ?? events.length,
traceStatus: trace.traceStatus ?? terminal.traceStatus,
retention: trace.retention ?? terminal.retention,
terminalEvidence: trace.terminalEvidence ?? terminal.terminalEvidence,
finalResponse: trace.finalResponse ?? terminal.finalResponse,
traceSummary: trace.traceSummary ?? terminal.traceSummary,
lastEventLabel: trace.lastEventLabel ?? terminal.lastEventLabel ?? events.at(-1)?.label
eventCount: mergedTrace.eventCount ?? events.length,
traceStatus: mergedTrace.traceStatus ?? terminal.traceStatus,
retention: mergedTrace.retention ?? terminal.retention,
terminalEvidence: mergedTrace.terminalEvidence ?? terminal.terminalEvidence,
finalResponse: mergedTrace.finalResponse ?? terminal.finalResponse,
traceSummary: mergedTrace.traceSummary ?? terminal.traceSummary,
lastEventLabel: mergedTrace.lastEventLabel ?? terminal.lastEventLabel ?? events.at(-1)?.label
};
}
@@ -227,6 +240,7 @@ function resultToTraceSnapshot(traceId: string, result: AgentChatResultResponse)
terminalEvidence: result.terminalEvidence,
finalResponse: result.finalResponse,
traceSummary: result.traceSummary,
error: result.error,
lastEventLabel: result.lastEventLabel ?? lastEvent?.label ?? lastEvent?.type,
eventSource: "trace-api",
updatedAt: new Date().toISOString()
+142 -4
View File
@@ -2,7 +2,7 @@ import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { api } from "@/api";
import { mergeRunnerTrace, snapshotToRunnerTrace, subscribeToTrace, type TraceSnapshot } from "@/composables/useTraceSubscription";
import type { AgentChatResponse, AgentChatResultResponse, ApiResult, ChatMessage, ConversationRecord, LiveSurface, ProviderProfile, WorkspaceRecord } from "@/types";
import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, ConversationRecord, LiveSurface, ProviderProfile, TraceEvent, WorkspaceRecord } from "@/types";
import { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils";
import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, defaultProviderProfileOptions, normalizeRecentDrafts, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session";
@@ -55,6 +55,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
rememberWorkbenchProjectId(workspaceProjectId(workspace.value, projectId.value));
conversations.value = conversationsResult.ok ? conversationsResult.data?.conversations ?? [] : [];
messages.value = messagesFromWorkspace(workspace.value);
void hydrateTerminalMessageDiagnostics();
await refreshProviderOptions();
const activeTraceId = activeTraceIdFromWorkspace(workspace.value);
if (activeTraceId) void validateAndReattachTrace(activeTraceId);
@@ -103,6 +104,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (response.ok) {
workspace.value = response.data?.workspace ?? current;
messages.value = messagesFromWorkspace(workspace.value);
void hydrateTerminalMessageDiagnostics();
currentRequest.value = null;
} else {
workspace.value = previousWorkspace;
@@ -288,14 +290,37 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const text = firstNonEmptyString(result.assistantText, finalResponseText(result.finalResponse), typeof result.reply === "string" ? result.reply : result.reply?.content, result.text, result.summary) ?? "Code Agent 已完成,但没有返回可展示的 final response。";
messages.value = messages.value.map((message) => {
if (message.traceId !== traceId || message.role !== "agent") return message;
const runnerTrace = result.runnerTrace ? mergeRunnerTrace(message.runnerTrace, result.runnerTrace) : message.runnerTrace ?? null;
return { ...message, status: result.status === "completed" ? "completed" : statusFromResult(result.status), text, runnerTrace, updatedAt: new Date().toISOString() };
const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result);
const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error);
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
return { ...message, status: result.status === "completed" ? "completed" : statusFromResult(result.status), text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
});
chatPending.value = false;
currentRequest.value = null;
void clearActiveTrace(traceId, "trace-terminal");
}
async function hydrateTerminalMessageDiagnostics(): Promise<void> {
const targets = messages.value.filter(messageNeedsTerminalDiagnostics).slice(-6);
await Promise.all(targets.map(async (message) => {
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
if (!traceId) return;
const result = await api.agent.getAgentChatResult(`/v1/agent/chat/result/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(activeProjectId.value)}`, 8000, () => activityRef.value);
if (!result.ok || !result.data) return;
applyTerminalResultDiagnostics(traceId, result.data);
}));
}
function applyTerminalResultDiagnostics(traceId: string, result: AgentChatResultResponse): void {
messages.value = messages.value.map((message) => {
if (message.traceId !== traceId || message.role !== "agent") return message;
const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result);
const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error);
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
return { ...message, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
});
}
function failTrace(traceId: string, message: string): void {
markMessage(traceId, { status: "failed", text: message });
chatPending.value = false;
@@ -364,7 +389,120 @@ function messagesFromWorkspace(workspace: WorkspaceRecord | null): ChatMessage[]
function normalizeChatMessage(message: ChatMessage): ChatMessage {
const text = firstNonEmptyString(message.text, messageText((message as Record<string, unknown>).content), messageText((message as Record<string, unknown>).message), finalResponseText((message as Record<string, unknown>).finalResponse)) ?? "";
return { ...message, text, id: message.id ?? nextProtocolId("msg"), title: message.title ?? (message.role === "user" ? "用户" : "Code Agent"), createdAt: message.createdAt ?? new Date().toISOString(), status: message.status ?? "source" };
const runnerTrace = normalizeMessageRunnerTrace(message);
const error = normalizeAgentError(message.error ?? runnerTrace?.error);
const agentRun = agentRunFromMessage(message) ?? asAgentRun(runnerTrace?.agentRun);
return { ...message, text, id: message.id ?? nextProtocolId("msg"), title: message.title ?? (message.role === "user" ? "用户" : "Code Agent"), createdAt: message.createdAt ?? new Date().toISOString(), status: message.status ?? "source", runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined };
}
function mergeTerminalResultTrace(previous: ChatMessage["runnerTrace"], result: AgentChatResultResponse): NonNullable<ChatMessage["runnerTrace"]> {
const resultTrace = recordValue(result.runnerTrace);
const events = firstArray(result.events, result.traceEvents, resultTrace?.events, previous?.events);
const agentRun = asAgentRun(result.agentRun ?? resultTrace?.agentRun ?? previous?.agentRun);
return mergeRunnerTrace(previous, {
...resultTrace,
traceId: firstNonEmptyString(result.traceId, resultTrace?.traceId, previous?.traceId) ?? undefined,
status: firstNonEmptyString(result.traceStatus, resultTrace?.status, result.status, previous?.status) ?? undefined,
sessionId: firstNonEmptyString(result.sessionId, resultTrace?.sessionId, previous?.sessionId) ?? undefined,
threadId: firstNonEmptyString(result.threadId, resultTrace?.threadId, previous?.threadId) ?? undefined,
events,
eventCount: firstFiniteNumber(result.eventCount, resultTrace?.eventCount, previous?.eventCount, events.length),
eventsCompacted: firstBoolean(resultTrace?.eventsCompacted, previous?.eventsCompacted),
traceStatus: firstNonEmptyString(result.traceStatus, resultTrace?.traceStatus, previous?.traceStatus) ?? undefined,
retention: result.retention ?? resultTrace?.retention ?? previous?.retention,
terminalEvidence: result.terminalEvidence ?? resultTrace?.terminalEvidence ?? previous?.terminalEvidence,
finalResponse: result.finalResponse ?? resultTrace?.finalResponse ?? previous?.finalResponse,
traceSummary: result.traceSummary ?? resultTrace?.traceSummary ?? previous?.traceSummary,
agentRun: agentRun ?? undefined,
error: normalizeAgentError(result.error ?? resultTrace?.error ?? previous?.error) ?? undefined,
runnerKind: firstNonEmptyString(agentRun?.adapter, resultTrace?.runnerKind, previous?.runnerKind) ?? undefined,
sessionMode: firstNonEmptyString(agentRun?.backendProfile, resultTrace?.sessionMode, previous?.sessionMode) ?? undefined,
lastEventLabel: firstNonEmptyString(result.lastEventLabel, resultTrace?.lastEventLabel, previous?.lastEventLabel, events.at(-1)?.label, events.at(-1)?.type) ?? undefined,
updatedAt: new Date().toISOString()
} as NonNullable<ChatMessage["runnerTrace"]>);
}
function normalizeMessageRunnerTrace(message: ChatMessage): ChatMessage["runnerTrace"] {
const trace = recordValue(message.runnerTrace);
const agentRun = agentRunFromMessage(message);
const error = normalizeAgentError(message.error ?? trace?.error);
if (!trace && !agentRun && !error) return message.runnerTrace ?? null;
return {
...(trace ?? {}),
traceId: message.traceId ?? trace?.traceId,
sessionId: message.sessionId ?? trace?.sessionId,
threadId: message.threadId ?? trace?.threadId,
agentRun: agentRun ?? trace?.agentRun,
error: error ?? trace?.error,
runnerKind: agentRun?.adapter ?? trace?.runnerKind,
sessionMode: agentRun?.backendProfile ?? trace?.sessionMode
} as ChatMessage["runnerTrace"];
}
function messageNeedsTerminalDiagnostics(message: ChatMessage): boolean {
if (message.role !== "agent") return false;
if (!isTerminalMessageStatus(message.status)) return false;
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
if (!traceId) return false;
const agentRun = agentRunFromMessage(message);
const error = normalizeAgentError(message.error ?? message.runnerTrace?.error);
return !agentRun || (message.status !== "completed" && !error);
}
function agentRunFromResult(result: AgentChatResultResponse, runnerTrace: ChatMessage["runnerTrace"]): AgentRunProvenance | null {
return asAgentRun(result.agentRun ?? runnerTrace?.agentRun);
}
function agentRunFromMessage(message: ChatMessage): AgentRunProvenance | null {
return asAgentRun((message as Record<string, unknown>).agentRun ?? message.runnerTrace?.agentRun);
}
function asAgentRun(value: unknown): AgentRunProvenance | null {
return value && typeof value === "object" ? value as AgentRunProvenance : null;
}
function normalizeAgentError(value: unknown): ChatMessage["error"] | null {
if (!value) return null;
if (typeof value === "string") return value.trim() ? { message: value.trim() } : null;
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
const message = firstNonEmptyString(record.message, record.userMessage, record.reason, record.detail, record.error);
const code = firstNonEmptyString(record.code, record.failureKind, record.name);
const category = firstNonEmptyString(record.category, record.layer, record.type);
const providerStatus = typeof record.providerStatus === "number" ? record.providerStatus : Number.isFinite(Number(record.providerStatus)) ? Number(record.providerStatus) : undefined;
return { ...record, code: code ?? undefined, message: message ?? undefined, category: category ?? undefined, providerStatus };
}
return { message: String(value) };
}
function isTerminalMessageStatus(value: unknown): boolean {
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(value ?? "").trim().toLowerCase().replace(/_/gu, "-"));
}
function recordValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? value as Record<string, unknown> : null;
}
function firstArray(...values: unknown[]): TraceEvent[] {
for (const value of values) {
if (Array.isArray(value)) return value as TraceEvent[];
}
return [];
}
function firstFiniteNumber(...values: unknown[]): number | undefined {
for (const value of values) {
const number = typeof value === "number" ? value : Number(value);
if (Number.isFinite(number)) return number;
}
return undefined;
}
function firstBoolean(...values: unknown[]): boolean | undefined {
for (const value of values) {
if (typeof value === "boolean") return value;
}
return undefined;
}
function finalResponseText(value: unknown): string | null {
@@ -1230,6 +1230,28 @@
border-top: 1px solid #d8e1eb;
}
.code-agent-error-summary {
display: grid;
gap: 4px;
border-top: 1px solid #d8e1eb;
background: #fff7ed;
padding: 9px 10px;
color: #7c2d12;
}
.code-agent-error-summary strong {
font-size: 12px;
font-weight: 800;
}
.code-agent-error-summary p {
margin: 0;
overflow-wrap: anywhere;
color: #7f1d1d;
font-size: 12px;
line-height: 1.45;
}
.code-agent-status-row {
display: grid;
grid-template-columns: minmax(110px, 0.8fr) minmax(0, 1.2fr);