Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b84fc90
fix(agent-sessions): keep plain-text values of JSON-typed GenAI attri…
JeremyFunk Sep 28, 2026
9e572d2
fix(agent-sessions): read OpenAI-format tool_calls and tool messages
JeremyFunk Sep 28, 2026
60cff82
fix(agent-sessions): unwrap the OpenRouter Broadcast message envelopes
JeremyFunk Sep 28, 2026
f110484
fix(agent-sessions): decode chat.completion output and llm.finish_reason
JeremyFunk Sep 28, 2026
454e3cf
fix(agent-sessions): read finish reasons off the output messages
JeremyFunk Sep 28, 2026
eb4a1cc
fix(agent-sessions): read a streamed Google ADK reply as its aggregate
JeremyFunk Sep 28, 2026
061cc4d
fix(agent-sessions): unwrap the LangChain ToolMessage tool result
JeremyFunk Sep 28, 2026
29acd37
fix(agent-sessions): decode OpenInference keys for framework vendor ids
JeremyFunk Sep 28, 2026
be780c5
fix(agent-sessions): prefer real tool arguments over a dual-written s…
JeremyFunk Sep 28, 2026
66ceedf
fix(agent-sessions): read a bare OpenAI message output as the reply (…
JeremyFunk Sep 28, 2026
a8791fd
fix(agent-sessions): read OpenInference input/output values by span k…
JeremyFunk Sep 28, 2026
44354c2
fix(agent-sessions): read OpenInference's flattened llm.*_messages ke…
JeremyFunk Sep 28, 2026
7f5520c
test(agent-sessions): refresh the integration SQL baseline for the sp…
JeremyFunk Sep 28, 2026
7bc1a37
fix(agent-sessions): drop streamed chunks only beside a finished aggr…
JeremyFunk Sep 28, 2026
dcad4b6
fix(agent-sessions): keep a completion whose choices carry no message…
JeremyFunk Sep 28, 2026
f02f604
fix(agent-sessions): show a failed tool span's error as its result (#30)
JeremyFunk Sep 28, 2026
11b138b
fix(agent-sessions): drop empty flattened content parts (#30)
JeremyFunk Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -954,7 +954,9 @@ function ToolBlock({
missingResultNote={
row.fromMessageOnly
? "not captured — this call is known only from the message that made it. Whether it ran is unknown."
: "not captured — the span carries no result attribute and no later message echoes this call id. Whether it succeeded is unknown."
: row.failed
? "not captured — the span failed without recording a result or an error message."
: "not captured — the span carries no result attribute and no later message echoes this call id. Whether it succeeded is unknown."
}
keyPrefix={row.key}
openRows={openRows}
Expand Down
50 changes: 50 additions & 0 deletions packages/agent-sessions/src/session-transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1293,6 +1293,56 @@ describe("buildTranscript — what counts as a captured reply", () => {
expect(tools[0]!.fromMessageOnly).toBe(false)
})

// OpenInference records the call id in the model's output but not on the tool
// span it opens, so the id matches nothing and the name is again all there is.
it("drops a message-only tool call whose id no tool span recorded", () => {
const spans = outputOnly(
[{ type: "tool_call", id: "call_S2ZCSLmFSJRp7QxaBw8DoCgT", name: "get_weather", arguments: {} }],
[
toolSpan({
spanId: "t1",
parentSpanId: "agent",
startMs: 2 * SECOND,
durationMs: SECOND,
toolName: "get_weather",
}),
],
)
const tools = findRows(transcript(spans), "tool")
expect(tools).toHaveLength(1)
expect(tools[0]!.fromMessageOnly).toBe(false)
})

// The crewai `fetch_transport_data.run` span: failed, no result attribute, no
// call id, the reason in its status message only.
it("shows a failed tool span's status message as its result", () => {
const spans = outputOnly(
[
{
type: "tool_call",
id: "call_iLx2i9SBqgg8iUJyjJOMHGhn",
name: "fetch_transport_data",
arguments: {},
},
],
[
toolSpan({
spanId: "t1",
parentSpanId: "agent",
startMs: 2 * SECOND,
durationMs: SECOND,
toolName: "fetch_transport_data",
statusCode: "Error",
statusMessage: "transport data service unavailable (503)",
}),
],
)
const tools = findRows(transcript(spans), "tool")
expect(tools).toHaveLength(1)
expect(tools[0]!.failed).toBe(true)
expect(tools[0]!.result?.text).toBe("transport data service unavailable (503)")
})

it("keeps the message-only row when no span could be the same call", () => {
const spans = outputOnly([{ type: "tool_call", name: "web_search", arguments: { q: "x" } }])
const tools = findRows(transcript(spans), "tool")
Expand Down
13 changes: 9 additions & 4 deletions packages/agent-sessions/src/session-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
type SpanMessage,
type SpanMessagePart,
} from "./span-detail"
import { rawFailureText } from "./failure-text"
import { countTurnUsage, type SessionTurnUsage } from "./session-summary"

/* -------------------------------------------------------------------------- */
Expand Down Expand Up @@ -504,7 +505,8 @@ interface TurnContext {
/**
* Tool spans in this turn that carry no `gen_ai.tool.call.id`, counted by name.
*
* An id-less `tool_call` part in an output message cannot be matched to its
* A `tool_call` part no tool span's call id claims — an id-less part, or one
* whose tool span recorded no id (OpenInference) — cannot be matched to its
* span by id, so without this the call renders twice: once first-hand from the
* span, once again from the message that made it. The tool NAME is the only
* evidence left, and it is spent conservatively — only against spans that no id
Expand Down Expand Up @@ -1216,7 +1218,7 @@ function outputRows(
function coveredBySpan(part: Extract<SpanMessagePart, { kind: "tool_call" }>, context: TurnContext): boolean {
// A tool span for the same call carries the duration, the service and the
// error; the message-only row exists only where there is no such span.
if (part.id !== undefined) return context.coveredCallIds.has(part.id)
if (part.id !== undefined && context.coveredCallIds.has(part.id)) return true
if (part.name === undefined) return false
const unclaimed = context.unclaimedToolNames.get(part.name)
if (unclaimed === undefined || unclaimed === 0) return false
Expand Down Expand Up @@ -1296,12 +1298,15 @@ function toolArgsText(span: AiSessionSpan): string | undefined {
}

/** A tool span's captured result. The session-wide index only fills an absence:
* a later call's echoed response never overrides the span's own report. */
* a later call's echoed response never overrides the span's own report. A
* failed span that recorded no result still says why it failed (its status
* message), which is the result the call produced. */
function toolResultText(span: AiSessionSpan, context: TurnContext): string | undefined {
const own = span.genAi.toolCallResult ?? undefined
if (own !== undefined) return jsonText(own)
const id = span.genAi.toolCallId
return id === undefined ? undefined : toolResultFor(context.input.toolResults, span.traceId, id)
const echoed = id === undefined ? undefined : toolResultFor(context.input.toolResults, span.traceId, id)
return echoed ?? (spanFailed(span) ? rawFailureText(span) : undefined)
}

/* -------------------------------------------------------------------------- */
Expand Down
105 changes: 105 additions & 0 deletions packages/agent-sessions/src/span-detail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,111 @@ describe("spanMessages", () => {
expect(spanMessages(span)[0]!.parts).toEqual([{ kind: "text", text: '{"type":"image","media":"…"}' }])
})

it("reads OpenAI-format tool calls and tool messages", () => {
// Trimmed from a LiteLLM `chat` span: the assistant message that only
// calls a tool has `content: null` (output) or no content (history).
const call = {
index: 0,
function: { arguments: '{"city":"Berlin"}', name: "get_weather" },
id: "call_PnF57J0QOIKUlzQKpqwxV8tl",
type: "function",
}
const span = llmSpan({
spanId: "l1",
startMs: 0,
durationMs: 1000,
genAi: {
inputMessages: [
{ role: "user", content: "What's the weather in Berlin?" },
{ role: "assistant", tool_calls: [call], provider_specific_fields: { reasoning: null } },
{
role: "tool",
tool_call_id: "call_PnF57J0QOIKUlzQKpqwxV8tl",
content: '{"city": "Berlin", "temperature_c": 21, "condition": "partly cloudy"}',
},
],
outputMessages: [
{ content: null, role: "assistant", tool_calls: [call], function_call: null },
],
},
})

const toolCall = {
kind: "tool_call",
id: "call_PnF57J0QOIKUlzQKpqwxV8tl",
name: "get_weather",
argumentsText: '{"city":"Berlin"}',
}
expect(spanMessages(span).map((message) => [message.role, message.parts])).toEqual([
["user", [{ kind: "text", text: "What's the weather in Berlin?" }]],
["assistant", [toolCall]],
[
"tool",
[
{
kind: "tool_result",
id: "call_PnF57J0QOIKUlzQKpqwxV8tl",
resultText: '{"city": "Berlin", "temperature_c": 21, "condition": "partly cloudy"}',
},
],
],
["assistant", [toolCall]],
])
expect(spanToolCalls(span).map((c) => c.name)).toEqual(["get_weather"])
})

it("reads a streamed Google ADK reply as its aggregate, not its chunks", () => {
// A `generate_content` span of a streamed turn: one message per chunk with
// an empty finish reason, then the aggregate that finished.
const chunk = (content: string) => ({
role: "assistant",
parts: [{ content, type: "text" }],
finish_reason: "",
})
const span = llmSpan({
spanId: "l1",
startMs: 0,
durationMs: 1000,
genAi: {
outputMessages: [
...["The", " capital", " of", " France", " is", " Paris", "."].map(chunk),
{
role: "assistant",
parts: [{ content: "The capital of France is Paris.", type: "text" }],
finish_reason: "stop",
},
],
},
})

expect(spanMessages(span).map((message) => message.parts)).toEqual([
[{ kind: "text", text: "The capital of France is Paris." }],
])

// Chunks with no aggregate behind them are all the reply there is.
const unfinished = llmSpan({
spanId: "l2",
startMs: 0,
durationMs: 1000,
genAi: { outputMessages: [chunk("The"), chunk(" capital")] },
})
expect(spanMessages(unfinished)).toHaveLength(2)

// A message that names no finish reason is not an aggregate either.
const unmarked = llmSpan({
spanId: "l3",
startMs: 0,
durationMs: 1000,
genAi: {
outputMessages: [
chunk("The"),
{ role: "assistant", parts: [{ type: "text", content: "x" }] },
],
},
})
expect(spanMessages(unmarked)).toHaveLength(2)
})

it("captures nothing when nothing was captured — the ordinary case", () => {
const span = llmSpan({ spanId: "l1", startMs: 0, durationMs: 1000 })
expect(spanMessages(span)).toEqual([])
Expand Down
38 changes: 37 additions & 1 deletion packages/agent-sessions/src/span-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ function parseMessages(value: unknown, origin: SpanMessage["origin"]): readonly
}

const messages: SpanMessage[] = []
for (const entry of value) {
for (const entry of origin === "output" ? withoutStreamedChunks(value) : value) {
if (!isRecord(entry)) {
messages.push({
role: origin === "output" ? "assistant" : "user",
Expand All @@ -327,15 +327,51 @@ function parseMessages(value: unknown, origin: SpanMessage["origin"]): readonly
return messages
}

/**
* Google ADK records a streamed reply as every chunk, each with an empty
* `finish_reason`, followed by the aggregate that finished. The chunks repeat
* the aggregate piecewise, so they are dropped whenever a finished message is
* there to stand for them.
*/
function withoutStreamedChunks(entries: readonly unknown[]): readonly unknown[] {
const aggregated = entries.some(
(entry) => isRecord(entry) && typeof entry.finish_reason === "string" && entry.finish_reason !== "",
)
return aggregated ? entries.filter((entry) => !(isRecord(entry) && entry.finish_reason === "")) : entries
}

function messageParts(message: Record<string, unknown>): readonly SpanMessagePart[] {
const source = message.parts ?? message.content
// OpenAI chat format: a tool message answers one call by `tool_call_id`.
if (typeof message.tool_call_id === "string") {
return [{ kind: "tool_result", id: message.tool_call_id, resultText: jsonText(source ?? "") }]
}
return [...contentParts(source), ...openAiToolCalls(message.tool_calls)]
}

function contentParts(source: unknown): readonly SpanMessagePart[] {
if (typeof source === "string") return source === "" ? [] : [{ kind: "text", text: source }]
if (!Array.isArray(source)) {
return source === undefined || source === null ? [] : [rawPart(source)]
}
return source.map(parsePart)
}

/** OpenAI chat format puts the calls beside `content`, which is `null` when
* the message only calls tools: `[{ id, function: { name, arguments } }]`. */
function openAiToolCalls(value: unknown): readonly SpanMessagePart[] {
if (!Array.isArray(value)) return []
return value.filter(isRecord).map((call) => {
const fn = isRecord(call.function) ? call.function : {}
return {
kind: "tool_call",
id: stringOrUndefined(call.id),
name: stringOrUndefined(fn.name),
argumentsText: fn.arguments === undefined ? undefined : jsonText(fn.arguments),
}
})
}

function parsePart(part: unknown): SpanMessagePart {
if (typeof part === "string") return { kind: "text", text: part }
if (!isRecord(part)) return rawPart(part)
Expand Down
Loading
Loading