+
+ {(multipleAgents || item.toolResult === undefined) && (
+
+
+ {multipleAgents && (
+
+ {item.agentName}
+
+ )}
+ {fmtMs(item.span.start_offset_ms)}
+
+ {item.toolResult === undefined && (
+
+ )}
+
+ )}
{item.showError && }
{item.messages.map((message, index) => (
))}
- {item.toolResult !== undefined && }
-
+ {item.toolResult !== undefined && }
))}
{queries.map(
@@ -116,30 +128,49 @@ export function TraceConversation({
);
}
-function ConversationTool({ item }: { item: ConversationItem }) {
- const [open, setOpen] = useState(false);
+function ConversationTool({ item, onOpenStep }: { item: ConversationItem; onOpenStep: (id: string) => void }) {
+ const [open, setOpen] = useState(item.span.status === "error");
const failed = item.span.status === "error";
+ const summary = toolSummary(item.toolCall?.args);
return (
-
-
+
+
+
+
+
{open && (
-
- {item.toolCall &&
}
+
+ {item.toolCall &&
}
{item.span.error && item.span.error !== item.toolResult && (
{item.span.error}
)}
@@ -152,9 +183,7 @@ function ConversationTool({ item }: { item: ConversationItem }) {
iconOnly
/>
-
- {item.toolResult || "No output recorded"}
-
+
)}
@@ -171,13 +200,7 @@ function ConversationMessage({ message }: { message: TraceMessage }) {
);
- if (message.role === "tool")
- return (
-
- {message.name || "Tool result"}
- {message.content}
-
- );
+ if (message.role === "tool") return ;
return (
{message.content && (
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.test.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.test.ts
index 2a42089a739..6063ae3661c 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.test.ts
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.test.ts
@@ -20,6 +20,40 @@ const detail = (span_id: string, input: unknown, output: unknown): SpanDetail =>
});
describe("trace conversation", () => {
+ it.each(["reviewer", "__proto__", "constructor"])("keeps repeated agent name %s stable as steps load", (name) => {
+ const first = { ...root, span_id: "first", name, start_offset_ms: 1 };
+ const second = { ...first, span_id: "second", start_offset_ms: 2 };
+ const spans = [second, first];
+ const firstDetails = new Map([["first", detail("first", "Review code", "")]]);
+ const partial = buildConversation(spans, firstDetails, false);
+ const complete = buildConversation(
+ spans,
+ new Map([...firstDetails, ["second", detail("second", "Review tests", "")]]),
+ true,
+ );
+ expect(partial.map(({ agentId, agentName }) => ({ agentId, agentName }))).toEqual([
+ { agentId: "first", agentName: `${name} (1)` },
+ ]);
+ expect(complete.map(({ agentId, agentName }) => ({ agentId, agentName }))).toEqual([
+ { agentId: "first", agentName: `${name} (1)` },
+ { agentId: "second", agentName: `${name} (2)` },
+ ]);
+ });
+
+ it("breaks equal-time agent label ties by span ID without changing event order", () => {
+ const first = { ...root, span_id: "first", name: "reviewer", start_offset_ms: 1 };
+ const second = { ...first, span_id: "second" };
+ const details = new Map([
+ ["first", detail("first", "Review code", "")],
+ ["second", detail("second", "Review tests", "")],
+ ]);
+ const items = buildConversation([second, first], details, true);
+ expect(items.map(({ agentId, agentName }) => ({ agentId, agentName }))).toEqual([
+ { agentId: "second", agentName: "reviewer (2)" },
+ { agentId: "first", agentName: "reviewer (1)" },
+ ]);
+ });
+
it("removes repeated prefixes and trimmed context, but preserves a genuinely repeated question", () => {
expect(newConversationMessages([user, call, result], [user, call, result, answer])).toEqual([answer]);
expect(newConversationMessages([user, call, result], [call, result, answer])).toEqual([answer]);
@@ -252,3 +286,56 @@ describe("trace conversation", () => {
expect(items.flatMap((item) => item.messages)).toEqual([answer, answer]);
});
});
+
+describe("recorded tool summaries", () => {
+ it("pairs repeated named calls within their own branch and preserves unmatched calls and custom text", () => {
+ const model = { ...root, span_id: "model", parent_span_id: "root", type: "llm", start_offset_ms: 1 } as Span;
+ const first = { ...model, span_id: "first", name: "terminal", type: "tool", start_offset_ms: 2 } as Span;
+ const second = { ...first, span_id: "second", start_offset_ms: 3 };
+ const child = { ...root, span_id: "child", parent_span_id: "root", start_offset_ms: 4 };
+ const childTool = { ...first, span_id: "child-tool", parent_span_id: "child", start_offset_ms: 5 };
+ const saved = "SAVED TASK RESUMED: Continue the unfinished task exactly as recorded";
+ const summary = JSON.stringify([{ content: "Checking", tool_names: ["terminal", "terminal", "read_file"] }]);
+ const details = new Map([
+ ["root", detail("root", [{ role: "user", content: saved }], [])],
+ [
+ "model",
+ { ...detail("model", [], []), output: summary, output_ui: { kind: "text", text: summary } } as SpanDetail,
+ ],
+ ["first", detail("first", { command: "pwd" }, { output: "/workspace", exit_code: 0 })],
+ ["second", detail("second", { command: "pwd" }, { output: "/workspace", exit_code: 0 })],
+ ["child", detail("child", [{ role: "user", content: saved }], [])],
+ ["child-tool", detail("child-tool", { path: "README.md" }, "content")],
+ ]);
+ const items = buildConversation([root, model, first, second, child, childTool], details, true);
+ expect(items.flatMap((item) => item.messages).filter((message) => message.content === saved)).toHaveLength(2);
+ expect(items.flatMap((item) => item.messages.flatMap((message) => message.tool_calls ?? []))).toEqual([
+ { name: "read_file", args: undefined },
+ ]);
+ expect(items.filter((item) => item.toolCall).map((item) => item.id)).toEqual(["first", "second", "child-tool"]);
+ expect(JSON.parse(items.find((item) => item.id === "first")!.toolResult!)).toEqual({
+ output: "/workspace",
+ exit_code: 0,
+ });
+ });
+
+ it("deduplicates native OpenAI function calls without requiring message text", () => {
+ const model = { ...root, span_id: "model", parent_span_id: "root", type: "llm", start_offset_ms: 1 } as Span;
+ const tool = { ...model, span_id: "tool", name: "read_file", type: "tool", start_offset_ms: 2 } as Span;
+ const output = [
+ {
+ role: "assistant",
+ content: null,
+ tool_calls: [{ type: "function", function: { name: "read_file", arguments: '{"path":"README.md"}' } }],
+ },
+ ];
+ const details = new Map([
+ ["root", detail("root", [], [])],
+ ["model", detail("model", [], output)],
+ ["tool", detail("tool", { path: "README.md" }, "file content")],
+ ]);
+ expect(
+ buildConversation([root, model, tool], details, true).filter((item) => item.toolCall || item.messages.length),
+ ).toHaveLength(1);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.ts
index 1adbee35570..81cc4e17d0e 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.ts
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.ts
@@ -1,6 +1,6 @@
import type { Span, SpanDetail, TraceMessage, TraceToolCall, UIContent } from "../../types";
-import { isFrameworkSpan, parseJson, parseMessages, prettyPayload } from "../../utils";
-import { toTraceMessage } from "../content/payload";
+import { isFrameworkSpan, parseAssistantSummary, parseMessages, prettyPayload } from "../../utils";
+import { toTraceMessage, toolInput } from "../content/payload";
export const CONVERSATION_PAGE_SIZE = 20;
@@ -24,7 +24,8 @@ function contentText(value: string, content?: UIContent): string {
function messages(value: string, content: UIContent | undefined, role: string): TraceMessage[] {
if (content?.kind === "messages") return content.messages.map(toTraceMessage);
- const parsed = !content ? parseMessages(value) : null;
+ const source = content?.kind === "text" ? content.text : value;
+ const parsed = parseMessages(source) ?? (role === "assistant" ? parseAssistantSummary(source) : null);
if (parsed) return parsed;
const text = contentText(value, content);
return text ? [{ role, content: text }] : [];
@@ -69,6 +70,8 @@ export interface ConversationItem {
messages: TraceMessage[];
toolCall?: TraceToolCall;
toolResult?: string;
+ agentId?: string;
+ agentName?: string;
showError?: boolean;
}
@@ -78,16 +81,13 @@ function toolItem(
pending: TraceToolCall[],
items: ConversationItem[],
): ConversationItem {
- const args =
- parseJson(detail.input) ??
- (detail.input_ui?.kind === "fields"
- ? Object.fromEntries(detail.input_ui.fields.map((field) => [field.key, field.value]))
- : detail.input);
+ const args = toolInput(detail.input, detail.input_ui);
const call = { name: span.name, args };
const match = pending.findIndex(
(candidate) =>
candidate.name === call.name &&
- JSON.stringify(stableValue(candidate.args)) === JSON.stringify(stableValue(call.args)),
+ (candidate.args === undefined ||
+ JSON.stringify(stableValue(candidate.args)) === JSON.stringify(stableValue(call.args))),
);
if (match >= 0) {
const [matched] = pending.splice(match, 1);
@@ -97,7 +97,10 @@ function toolItem(
message.tool_calls = message.tool_calls.filter((call) => call !== matched);
}
}
- const result = contentText(detail.output, detail.output_ui);
+ const result =
+ detail.output_ui?.kind === "text"
+ ? detail.output_ui.text
+ : prettyPayload(detail.output) || contentText(detail.output, detail.output_ui);
return { id: span.span_id, span, messages: [], toolCall: call, toolResult: result };
}
@@ -168,6 +171,17 @@ function withoutForwardedAnswers(
);
}
+function agentLabels(agents: readonly Span[]): ReadonlyMap {
+ const named = agents.map((agent) => ({ agent, name: agent.name || agent.agent || "Agent" }));
+ return new Map(
+ Object.values(groupBy(named, ({ name }) => JSON.stringify(name))).flatMap((group) =>
+ orderBy(group, [({ agent }) => agent.start_offset_ms, ({ agent }) => agent.span_id], ["asc", "asc"]).map(
+ ({ agent, name }, index) => [agent.span_id, group.length > 1 ? `${name} (${index + 1})` : name] as const,
+ ),
+ ),
+ );
+}
+
export function buildConversation(
spans: readonly Span[],
details: ReadonlyMap,
@@ -241,10 +255,18 @@ export function buildConversation(
};
if (combined.length || item.showError) items.push(item);
}
+ const agents = [...new Set(conversationSteps(spans).map(branch))].flatMap((id) => {
+ const span = byId.get(id);
+ return span ? [span] : [];
+ });
+ const labels = agentLabels(agents);
return items
.map((item) => ({
...item,
+ agentId: branch(item.span),
+ agentName: labels.get(branch(item.span)) || item.span.agent,
messages: item.messages.filter((message) => Boolean(message.content) || Boolean(message.tool_calls?.length)),
}))
.filter((item) => item.messages.length || item.toolResult !== undefined || item.showError);
}
+import { groupBy, orderBy } from "es-toolkit";
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailPane.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailPane.integration.test.tsx
index a950d68aa78..fb9285d0777 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailPane.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailPane.integration.test.tsx
@@ -341,12 +341,14 @@ describe("DetailPane", () => {
expect(output).not.toHaveTextContent("raw output left unparsed");
});
- it("keeps the failed-tool styling when a tool's output arrives as a single message", async () => {
+ it("identifies and styles a failed tool result when its output arrives as a single message", async () => {
vi.mocked(agentTraceSpanCall).mockResolvedValue(failedToolMessageDetail);
renderPane(spanRow(failedTool));
const output = await screen.findByRole("region", { name: "Output" });
- const result = within(output).getByText("permission denied: /etc/shadow");
+ const result = within(output).getByRole("group", { name: "Failed tool result" });
+ expect(result).toBeVisible();
expect(result).toHaveClass("text-destructive");
+ expect(result).toHaveTextContent("permission denied: /etc/shadow");
expect(output).not.toHaveTextContent("Assistant");
});
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/tree/TreeRows.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/tree/TreeRows.tsx
index 931b53a4259..ae4d249575c 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/tree/TreeRows.tsx
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/tree/TreeRows.tsx
@@ -10,6 +10,7 @@ import type { GroupRowData, LoadMoreRowData, SpanRowData } from "../../tree";
import type { SpanType } from "../../types";
import { fmtMs, previewText, type TreeGuide } from "../../utils";
import { groupFacts, SpanHoverCard, spanFacts } from "./SpanHoverCard";
+import { toolSummary } from "../content/payload";
import { BAR_TRACK, barGeometry } from "./timeline";
export type TreeLayout = "tree" | "waterfall";
@@ -146,6 +147,7 @@ const readablePreview = (preview: string): string => {
const subtitle = (row: SpanRowData, filtering: boolean): string => {
const { span } = row;
+ if (span.type === "tool") return toolSummary(span.input_preview);
if (filtering) return readablePreview(span.input_preview) || span.agent;
return span.type === "agent" && span.parent_span_id ? readablePreview(span.input_preview) : "";
};
diff --git a/ui/litellm-dashboard/src/components/lens/traces/utils.test.ts b/ui/litellm-dashboard/src/components/lens/traces/utils.test.ts
index eb7cca4bcac..d074a5c56fe 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/utils.test.ts
+++ b/ui/litellm-dashboard/src/components/lens/traces/utils.test.ts
@@ -300,10 +300,42 @@ describe("payload helpers", () => {
expect(
parseMessages(JSON.stringify({ role: "assistant", content: [{ type: "text", text: "An execution record" }] })),
).toEqual([{ role: "assistant", content: "An execution record" }]);
+ expect(parseMessages(JSON.stringify({ role: "assistant", content: "hello", tool_calls: {} }))).toEqual([
+ { role: "assistant", content: "hello", tool_calls: [{ name: "Tool call", args: {} }] },
+ ]);
+ expect(
+ parseMessages(
+ JSON.stringify({ role: "assistant", content: "hello", tool_calls: [{ name: "lookup", args: "42" }] }),
+ ),
+ ).toEqual([{ role: "assistant", content: "hello", tool_calls: [{ name: "lookup", args: "42" }] }]);
expect(parseMessages('[{"role":"assistant","tool_calls":[]}]')).toBeNull();
expect(parseMessages('[{"role":"user","content":42}]')).toBeNull();
});
+ it("preserves conversation text and incomplete calls beside valid function calls", () => {
+ const incomplete = { id: "pending", function: { arguments: '{"path":"README.md"}' } };
+ const tool_calls = [incomplete, { function: { name: "read_file", arguments: '{"path":"AGENTS.md"}' } }, null];
+ expect(
+ parseMessages(
+ JSON.stringify([
+ { role: "user", content: "Read the project instructions" },
+ { role: "assistant", content: "Checking the files", tool_calls },
+ ]),
+ ),
+ ).toEqual([
+ { role: "user", content: "Read the project instructions" },
+ {
+ role: "assistant",
+ content: "Checking the files",
+ tool_calls: [
+ { name: "Tool call", args: incomplete },
+ { name: "read_file", args: { path: "AGENTS.md" } },
+ { name: "Tool call", args: null },
+ ],
+ },
+ ]);
+ });
+
it("reads LangChain's serialized messages with their roles, names and tool calls", () => {
const dumped = [
{ type: "human", data: { content: "What is an agent trace?", name: null } },
diff --git a/ui/litellm-dashboard/src/components/lens/traces/utils.ts b/ui/litellm-dashboard/src/components/lens/traces/utils.ts
index 99a2a1194ec..a71fdb329eb 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/utils.ts
+++ b/ui/litellm-dashboard/src/components/lens/traces/utils.ts
@@ -323,6 +323,9 @@ const LANGCHAIN_ROLE: Readonly> = {
tool: "tool",
};
+const isMessageContent = (value: unknown): value is string | unknown[] =>
+ typeof value === "string" || Array.isArray(value);
+
const langchainToolCalls = (data: object): TraceToolCall[] | undefined => {
const calls: unknown = Reflect.get(data, "tool_calls");
if (!Array.isArray(calls) || calls.length === 0) return undefined;
@@ -337,7 +340,7 @@ const parseLangchainMessage = (value: object): TraceMessage | null => {
const data: unknown = Reflect.get(value, "data");
if (!role || typeof data !== "object" || data === null) return null;
const content: unknown = Reflect.get(data, "content");
- if (typeof content !== "string" && !Array.isArray(content)) return null;
+ if (!isMessageContent(content)) return null;
const name: unknown = Reflect.get(data, "name");
const toolCalls = langchainToolCalls(data);
return {
@@ -348,15 +351,59 @@ const parseLangchainMessage = (value: object): TraceMessage | null => {
};
};
+const parseToolCall = (call: unknown): TraceToolCall => {
+ const unknownCall = { name: "Tool call", args: call };
+ if (!call || typeof call !== "object") return unknownCall;
+ const fn: unknown = Reflect.get(call, "function");
+ const source = fn && typeof fn === "object" ? fn : call;
+ const name: unknown = Reflect.get(source, "name");
+ if (typeof name !== "string" || !name) return unknownCall;
+ if ("args" in source) return { name, args: source.args };
+ const args: unknown = Reflect.get(source, "arguments");
+ return { name, args: typeof args === "string" ? parseJson(args) ?? args : args };
+};
+
const parseMessage = (value: unknown): TraceMessage | null => {
if (typeof value !== "object" || value === null) return null;
if (!("role" in value) && "data" in value) return parseLangchainMessage(value);
const role: unknown = Reflect.get(value, "role");
const content: unknown = Reflect.get(value, "content") ?? Reflect.get(value, "parts");
- if (typeof role !== "string" || (typeof content !== "string" && !Array.isArray(content))) return null;
- return { ...value, role, content: messageText(typeof content === "string" ? content : JSON.stringify(content)) };
+ const rawCalls: unknown = Reflect.get(value, "tool_calls");
+ const callEntries = Array.isArray(rawCalls) ? rawCalls : [rawCalls];
+ const hasCalls = rawCalls != null && callEntries.length > 0;
+ const emptyToolMessage = content == null && hasCalls;
+ const hasContent = isMessageContent(content) || emptyToolMessage;
+ if (typeof role !== "string" || !hasContent) return null;
+ const calls = callEntries.map(parseToolCall);
+ const text = typeof content === "string" ? content : JSON.stringify(content ?? "");
+ return {
+ ...value,
+ role,
+ content: content == null ? "" : messageText(text),
+ ...(rawCalls != null ? { tool_calls: calls } : {}),
+ };
};
+export function parseAssistantSummary(value: string): TraceMessage[] | null {
+ const parsed = parseJson(value);
+ if (!Array.isArray(parsed) || !parsed.length) return null;
+ const isSummary = (item: unknown): item is { content: string | null; tool_names: string[] } => {
+ if (!item || typeof item !== "object") return false;
+ const content: unknown = Reflect.get(item, "content");
+ const tools: unknown = Reflect.get(item, "tool_names");
+ const knownFields = Object.keys(item).every((key) => key === "content" || key === "tool_names");
+ const validContent = content === null || typeof content === "string";
+ const validTools = Array.isArray(tools) && tools.every((name: unknown) => typeof name === "string");
+ return knownFields && validContent && validTools;
+ };
+ if (!parsed.every(isSummary)) return null;
+ return parsed.map((item) => ({
+ role: "assistant",
+ content: item.content ?? "",
+ tool_calls: item.tool_names.map((name: string) => ({ name, args: undefined })),
+ }));
+}
+
/** An llm span's input (array of messages) or output (one message); null when it isn't one. */
export function parseMessages(value: string): TraceMessage[] | null {
const parsed = parseJson(value);