From 6c0c6d442080a6f424a943e6ae78129853cc4c2a Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 13 Aug 2026 18:48:24 -0700 Subject: [PATCH] refactor(trace): record output messages through record() weave 0.16.6 adds outputMessages to both Turn.record() and SubAgent.record(), so all five gen_ai.output.messages writers stop hand-serializing the attribute. One recordOutput() helper replaces the per-site guard and shape code, so each call site is a single line and the already-ended guard lives in one place. assistantOutputMessages() has no callers left and is deleted. Wire format is unchanged, pinned by the assertions in the parent PR plus the existing subagent assertions. Co-Authored-By: Claude Opus 5 (1M context) --- src/callLifecycle.ts | 4 ++-- src/genaiSpans.ts | 20 +++++++++++++++----- src/hookHandler.ts | 10 +++------- src/session.ts | 6 ++---- src/teamCoordinator.ts | 6 ++---- 5 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/callLifecycle.ts b/src/callLifecycle.ts index e2e703a..5985421 100644 --- a/src/callLifecycle.ts +++ b/src/callLifecycle.ts @@ -7,8 +7,8 @@ import { ATTR, addPermissionRequestEvent, addPermissionResolvedEvent, - assistantOutputMessages, jsonStr, + recordOutput, } from './genaiSpans.js'; import type { SpanParent } from './genaiSpans.js'; import { VERSION } from './setup.js'; @@ -378,7 +378,7 @@ function finishAgentSpan( const output = outcome.ok ? outcome.output : outcome.error; if (output !== undefined && output !== null && output !== '') { const text = typeof output === 'string' ? output : jsonStr(output); - call.span.setAttributes({ [ATTR.OUTPUT_MESSAGES]: assistantOutputMessages([text]) }); + recordOutput(call.span, [text]); } if (outcome.ok) { call.span.end(endTime ? { endTime } : undefined); diff --git a/src/genaiSpans.ts b/src/genaiSpans.ts index 3c2bb52..fe130c9 100644 --- a/src/genaiSpans.ts +++ b/src/genaiSpans.ts @@ -5,7 +5,7 @@ // Attribute-key constants and formatting helpers typed against the `weave` SDK. import type { Attributes } from '@opentelemetry/api'; -import type { MessagePart, SubAgent, Tool, Turn, Usage } from 'weave'; +import type { Message, MessagePart, SubAgent, Tool, Turn, Usage } from 'weave'; import { isTextBlock, isThinkingBlock, isRedactedThinkingBlock, isToolUseBlock } from './parser.js'; import type { UsageSummary } from './parser.js'; @@ -107,10 +107,20 @@ export function jsonStr(v: unknown): string { } } -/** `gen_ai.output.messages` JSON for plain assistant text(s), the shape used on - * turn and subagent `invoke_agent` spans (chat spans carry parts instead). */ -export function assistantOutputMessages(texts: string[]): string { - return jsonStr(texts.map((content) => ({ role: 'assistant', content }))); +function assistantMessages(texts: string[]): Message[] { + return texts.map((content) => ({ role: 'assistant', content })); +} + +/** Record assistant text(s) and model onto a turn or subagent `invoke_agent` + * span; the SDK serializes them at `end()` (chat spans carry parts instead). + * Skipped when there is nothing to record, because `record()` warns once the + * span has already ended. */ +export function recordOutput(span: SpanParent, texts: string[], model?: string): void { + if (!texts.length && !model) return; + span.record({ + outputMessages: texts.length ? assistantMessages(texts) : undefined, + model, + }); } /** Parse an ISO timestamp; returns undefined for missing or unparseable input. */ diff --git a/src/hookHandler.ts b/src/hookHandler.ts index bd84113..49f3271 100644 --- a/src/hookHandler.ts +++ b/src/hookHandler.ts @@ -50,7 +50,7 @@ import type { TracedCall, } from './callLifecycle.js'; import type { CompactionAttrs } from './genaiSpans.js'; -import { ATTR, assistantOutputMessages, snippet } from './genaiSpans.js'; +import { ATTR, recordOutput, snippet } from './genaiSpans.js'; import type { SpanParent } from './genaiSpans.js'; import { parseSessionFd } from './parser.js'; import { Session } from './session.js'; @@ -852,9 +852,7 @@ export class HookHandler { match.call.span.setAttributes({ [ATTR.RESPONSE_MODEL]: transcript.model }); } if (!match.call.toolUseId && text) { - match.call.span.setAttributes({ - [ATTR.OUTPUT_MESSAGES]: assistantOutputMessages([text]), - }); + recordOutput(match.call.span, [text]); } recordAgentStop(session.calls, match); } else if (recovered) { @@ -862,9 +860,7 @@ export class HookHandler { recovered.span.setAttributes({ [ATTR.RESPONSE_MODEL]: transcript.model }); } if (text) { - recovered.span.setAttributes({ - [ATTR.OUTPUT_MESSAGES]: assistantOutputMessages([text]), - }); + recordOutput(recovered.span, [text]); } } diff --git a/src/session.ts b/src/session.ts index b500efb..71a56e0 100644 --- a/src/session.ts +++ b/src/session.ts @@ -9,9 +9,9 @@ import * as weave from 'weave'; import { emitChatSpans } from './chatSpans.js'; import { ATTR, - assistantOutputMessages, buildIntegrationAttrs, parseTimestamp, + recordOutput, setCompactionAttrs, } from './genaiSpans.js'; import type { CompactionAttrs } from './genaiSpans.js'; @@ -449,7 +449,6 @@ export class Session { const text = responses.flatMap(response => extractAssistantTextBlocks(response.content)); if (!text.length && options.lastMessage) text.push(options.lastMessage); const attributes: Attributes = {}; - if (text.length) attributes[ATTR.OUTPUT_MESSAGES] = assistantOutputMessages(text); const finishReasons = responses .map(response => response.finishReason) .filter((reason): reason is string => Boolean(reason)); @@ -457,8 +456,7 @@ export class Session { if (options.orphanReason) attributes[ATTR.WEAVE_ORPHAN_REASON] = options.orphanReason; if (Object.keys(attributes).length) turn.span.setAttributes(attributes); - const model = responses.filter(response => response.model).at(-1)?.model; - if (model) turn.span.record({ model }); + recordOutput(turn.span, text, responses.filter(response => response.model).at(-1)?.model); } private recordFinalTurnOutput( diff --git a/src/teamCoordinator.ts b/src/teamCoordinator.ts index be926ad..fc4be4a 100644 --- a/src/teamCoordinator.ts +++ b/src/teamCoordinator.ts @@ -7,7 +7,7 @@ import type * as weave from 'weave'; import { deferAgentOutcome, denyCall, finishAgentCall } from './callLifecycle.js'; import type { ToolResult, TracedAgent } from './callLifecycle.js'; import { emitChatSpans } from './chatSpans.js'; -import { ATTR, assistantOutputMessages, parseTimestamp } from './genaiSpans.js'; +import { ATTR, parseTimestamp, recordOutput } from './genaiSpans.js'; import type { ParsedTurn } from './parser.js'; import type { Session } from './session.js'; import { VERSION } from './setup.js'; @@ -111,9 +111,7 @@ function emitTeammate( }); try { emitChatSpans(span, responses, { agentName: memberName }); - const output = turns.flatMap(turn => turn.text); - if (output.length) span.setAttributes({ [ATTR.OUTPUT_MESSAGES]: assistantOutputMessages(output) }); - if (model) span.record({ model }); + recordOutput(span, turns.flatMap(turn => turn.text), model); return { model, text: turns.at(-1)?.text.join('\n') || undefined }; } finally { span.end({ endTime: parseTimestamp(responses.at(-1)?.endTime) ?? new Date() });