Skip to content

Commit 4546104

Browse files
committed
fix(tracing): record gen_ai.output.messages on the chat span
Per spec (docs/specs/2026-05-18-cubepi-tracing-design.md §10.3/§10.5), the `chat` span's gen_ai.output.messages should reflect what the provider actually returned - independent of the turn/agent-level rollup. The code in _on_provider_response already had a comment saying it would "record ... the normalized output messages where derivable", but only ever set cubepi.llm.raw_response; gen_ai.output.messages was never written on this span at all (only on invoke_agent and cubepi.turn). Add _derive_output_message_from_body(), mirroring the existing three-way provider-shape dispatch in _record_chat_response_attrs (Anthropic-shaped, OpenAI chat.completion-shaped, OpenAI Responses-shaped) to reconstruct the semconv output-message parts (text/reasoning/tool_call) directly from the assembled response body - this span only ever sees that raw body via subscribe_response, not the parsed AssistantMessage the agent loop builds later. Defensive throughout: an unrecognized shape or empty content just sets nothing, identical to today's behavior (no regression).
1 parent 05dd7d7 commit 4546104

2 files changed

Lines changed: 239 additions & 0 deletions

File tree

cubepi/tracing/recorder.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,6 +1178,11 @@ def _on_provider_response(
11781178
# response (JSON dict) for backends that prefer it,
11791179
# and the normalized output messages where derivable.
11801180
self._set_content_attr(span, CUBEPI_LLM_RAW_RESPONSE, body)
1181+
output_message = _derive_output_message_from_body(body)
1182+
if output_message:
1183+
self._set_content_attr(
1184+
span, GEN_AI_OUTPUT_MESSAGES, output_message
1185+
)
11811186
# Cooperative abort: providers may finish the response
11821187
# listener with ``exc is None`` and a body whose finish
11831188
# reason is ``"aborted"`` (faux + the agent's signal path).
@@ -1402,6 +1407,121 @@ def _maybe_record_system_prompt_hash(self, payload: dict, run: _RunState) -> Non
14021407
run.agent_span.set_attribute(CUBEPI_AGENT_SYSTEM_PROMPT_SHA256, digest)
14031408

14041409

1410+
def _derive_output_message_from_body(body: dict) -> list[dict[str, Any]] | None:
1411+
"""Reconstruct the semconv output-message shape (§10.5) directly from an
1412+
assembled provider response body, for the ``chat`` span's own
1413+
``gen_ai.output.messages`` (§10.3).
1414+
1415+
The `chat` span only ever sees the raw provider-shaped ``body`` (via
1416+
``subscribe_response``) — never the parsed cubepi ``AssistantMessage``
1417+
that ``messages_to_semconv`` normally converts, since that object is
1418+
built later in the agent loop. Mirrors the same three-way provider-shape
1419+
dispatch already used by ``_record_chat_response_attrs`` right above.
1420+
1421+
Returns ``None`` (never raises) when the shape isn't recognized or
1422+
carries no content — the caller treats that as "nothing to record",
1423+
identical to today's behavior.
1424+
"""
1425+
# Anthropic-shaped: {"content": [...], "stop_reason": ..., "usage": {...}}
1426+
if (
1427+
"stop_reason" in body
1428+
and "usage" in body
1429+
and isinstance(body.get("content"), list)
1430+
):
1431+
parts: list[dict[str, Any]] = []
1432+
for block in body["content"]:
1433+
if not isinstance(block, dict):
1434+
continue
1435+
btype = block.get("type")
1436+
if btype == "text" and block.get("text"):
1437+
parts.append({"type": "text", "content": block["text"]})
1438+
elif btype == "thinking" and block.get("thinking"):
1439+
parts.append({"type": "reasoning", "content": block["thinking"]})
1440+
elif btype == "tool_use":
1441+
parts.append(
1442+
{
1443+
"type": "tool_call",
1444+
"id": block.get("id"),
1445+
"name": block.get("name"),
1446+
"arguments": block.get("input") or {},
1447+
}
1448+
)
1449+
return [{"role": "assistant", "parts": parts}] if parts else None
1450+
1451+
# OpenAI chat.completion-shaped: {"choices": [{"message": {...}}]}
1452+
if "choices" in body and isinstance(body.get("choices"), list) and body["choices"]:
1453+
first = body["choices"][0]
1454+
message = first.get("message") if isinstance(first, dict) else None
1455+
if not isinstance(message, dict):
1456+
return None
1457+
parts = []
1458+
if message.get("content"):
1459+
parts.append({"type": "text", "content": message["content"]})
1460+
for tc in message.get("tool_calls") or []:
1461+
if not isinstance(tc, dict):
1462+
continue
1463+
fn = tc.get("function") or {}
1464+
args = _try_parse_json(fn.get("arguments"))
1465+
parts.append(
1466+
{
1467+
"type": "tool_call",
1468+
"id": tc.get("id"),
1469+
"name": fn.get("name"),
1470+
"arguments": args,
1471+
}
1472+
)
1473+
return [{"role": "assistant", "parts": parts}] if parts else None
1474+
1475+
# OpenAI Responses-shaped: {"object": "response", "output": [...]}
1476+
if body.get("object") == "response" or isinstance(body.get("output"), list):
1477+
output = body.get("output")
1478+
if not isinstance(output, list):
1479+
return None
1480+
parts = []
1481+
for item in output:
1482+
if not isinstance(item, dict):
1483+
continue
1484+
itype = item.get("type")
1485+
if itype == "message":
1486+
for c in item.get("content") or []:
1487+
if (
1488+
isinstance(c, dict)
1489+
and c.get("type") == "output_text"
1490+
and c.get("text")
1491+
):
1492+
parts.append({"type": "text", "content": c["text"]})
1493+
elif itype == "reasoning":
1494+
for s in item.get("summary") or []:
1495+
if isinstance(s, dict) and s.get("text"):
1496+
parts.append({"type": "reasoning", "content": s["text"]})
1497+
elif itype == "function_call":
1498+
args = _try_parse_json(item.get("arguments"))
1499+
parts.append(
1500+
{
1501+
"type": "tool_call",
1502+
"id": item.get("call_id") or item.get("id"),
1503+
"name": item.get("name"),
1504+
"arguments": args,
1505+
}
1506+
)
1507+
return [{"role": "assistant", "parts": parts}] if parts else None
1508+
1509+
return None
1510+
1511+
1512+
def _try_parse_json(value: Any) -> Any:
1513+
"""Tool call arguments arrive as a JSON-encoded string on the wire;
1514+
decode to match the object shape ``ToolCall.arguments`` uses elsewhere
1515+
(§10.5). Falls back to the raw value if it isn't valid JSON rather than
1516+
dropping it."""
1517+
if isinstance(value, str):
1518+
try:
1519+
return json.loads(value)
1520+
except (TypeError, ValueError):
1521+
return value
1522+
return value or {}
1523+
1524+
14051525
def _extract_system_prompt(payload: dict) -> str | None:
14061526
"""Find the system prompt text in a provider's wire payload.
14071527

tests/tracing/test_recorder.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import annotations
88

99
import asyncio
10+
import json
1011
from typing import Any
1112

1213
from opentelemetry.sdk.trace import ReadableSpan
@@ -828,6 +829,124 @@ async def test_max_output_tokens_lands_in_request_max_tokens(self):
828829
assert attrs.get("gen_ai.request.max_tokens") == 4096
829830

830831

832+
class TestChatSpanOutputMessages:
833+
"""Per spec §10.3/§10.5 (docs/specs/2026-05-18-cubepi-tracing-design.md),
834+
the `chat` span's own `gen_ai.output.messages` should reflect what the
835+
provider actually returned - independent of the turn/agent-level rollup,
836+
which is built later from the agent's own message accumulation and isn't
837+
available yet inside the `subscribe_response` callback. Previously only
838+
`cubepi.llm.raw_response` was recorded here despite the code comment
839+
already saying otherwise.
840+
"""
841+
842+
async def _drive(self, body: dict) -> dict[str, Any]:
843+
provider = FauxProvider(provider_id="faux")
844+
agent = Agent(model=provider.model(MODEL.id), system_prompt="s")
845+
exporter = InMemoryExporter()
846+
tracer = Tracer(
847+
service_name="t", agent_name="a", exporters=[exporter], record_content=True
848+
)
849+
tracer.attach(agent)
850+
851+
recorder = _find_attached_recorder(provider)
852+
assert recorder is not None
853+
from cubepi.agent.types import AgentStartEvent, TurnStartEvent
854+
855+
await recorder._on_agent_event(AgentStartEvent())
856+
await recorder._on_agent_event(TurnStartEvent())
857+
recorder._on_provider_request({"messages": []}, MODEL)
858+
recorder._on_provider_response(body, MODEL, None)
859+
await tracer.shutdown()
860+
861+
chats = [s for s in exporter.spans if s.name.startswith("chat ")]
862+
assert chats, "no chat span captured"
863+
return _attrs(chats[-1])
864+
865+
async def test_openai_chat_completions_text(self):
866+
attrs = await self._drive(
867+
{
868+
"id": "resp1",
869+
"model": "faux-1",
870+
"choices": [{"message": {"role": "assistant", "content": "hi there"}}],
871+
}
872+
)
873+
raw = attrs.get("gen_ai.output.messages")
874+
assert raw, "gen_ai.output.messages missing on chat span"
875+
messages = json.loads(raw)
876+
assert messages == [
877+
{"role": "assistant", "parts": [{"type": "text", "content": "hi there"}]}
878+
]
879+
880+
async def test_openai_chat_completions_tool_call(self):
881+
attrs = await self._drive(
882+
{
883+
"id": "resp1",
884+
"model": "faux-1",
885+
"choices": [
886+
{
887+
"message": {
888+
"role": "assistant",
889+
"content": None,
890+
"tool_calls": [
891+
{
892+
"id": "call_1",
893+
"function": {
894+
"name": "search",
895+
"arguments": '{"query": "weather"}',
896+
},
897+
}
898+
],
899+
}
900+
}
901+
],
902+
}
903+
)
904+
messages = json.loads(attrs["gen_ai.output.messages"])
905+
assert messages == [
906+
{
907+
"role": "assistant",
908+
"parts": [
909+
{
910+
"type": "tool_call",
911+
"id": "call_1",
912+
"name": "search",
913+
"arguments": {"query": "weather"},
914+
}
915+
],
916+
}
917+
]
918+
919+
async def test_anthropic_shaped_text_and_thinking(self):
920+
attrs = await self._drive(
921+
{
922+
"id": "msg1",
923+
"type": "message",
924+
"role": "assistant",
925+
"model": "faux-1",
926+
"content": [
927+
{"type": "thinking", "thinking": "let me think"},
928+
{"type": "text", "text": "the answer"},
929+
],
930+
"stop_reason": "end_turn",
931+
"usage": {"input_tokens": 1, "output_tokens": 1},
932+
}
933+
)
934+
messages = json.loads(attrs["gen_ai.output.messages"])
935+
assert messages == [
936+
{
937+
"role": "assistant",
938+
"parts": [
939+
{"type": "reasoning", "content": "let me think"},
940+
{"type": "text", "content": "the answer"},
941+
],
942+
}
943+
]
944+
945+
async def test_unrecognized_shape_sets_nothing(self):
946+
attrs = await self._drive({"model": "faux-1", "weird": True})
947+
assert "gen_ai.output.messages" not in attrs
948+
949+
831950
class TestDetachFlushGuarantee:
832951
"""``Tracer.attach()``'s ``detach()`` must let callers await the
833952
flush so buffered spans land before they proceed — previously the

0 commit comments

Comments
 (0)