Skip to content

Commit 99a51b9

Browse files
committed
fix: capture Claude SDK stream model metadata
1 parent 61d445f commit 99a51b9

3 files changed

Lines changed: 427 additions & 28 deletions

File tree

instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Fixed
11+
12+
- Capture Claude Agent SDK default model names from stream metadata when the
13+
client options and environment do not provide an explicit model.
14+
- Fail open LLM spans when Claude Agent SDK streams raise exceptions, preserving
15+
parent context cleanup for subsequent calls.
16+
- Preserve tool-only assistant messages as tool spans without fabricating LLM
17+
spans.
18+
1019
## Version 0.7.0 (2026-07-03)
1120

1221
### Added

instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/patch.py

Lines changed: 104 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,46 @@ def _set_llm_session_id(
106106
_apply_session_identity(llm_invocation, session_id)
107107

108108

109+
def _normalize_model(model: Any) -> Optional[str]:
110+
if model is None:
111+
return None
112+
model_text = str(model).strip()
113+
return model_text or None
114+
115+
116+
def _is_unknown_model(model: Any) -> bool:
117+
model_text = _normalize_model(model)
118+
return model_text is None or model_text == "unknown"
119+
120+
121+
def _resolve_assistant_model(msg: Any, fallback_model: str) -> str:
122+
"""Use AssistantMessage.model when options/env did not provide a model."""
123+
normalized_fallback = _normalize_model(fallback_model) or "unknown"
124+
message_model = _normalize_model(getattr(msg, "model", None))
125+
if _is_unknown_model(normalized_fallback) and message_model:
126+
return message_model
127+
return normalized_fallback
128+
129+
130+
def _apply_assistant_model(
131+
agent_invocation: InvokeAgentInvocation, model: str
132+
) -> None:
133+
"""Late-fill the agent request model once the SDK reveals its default."""
134+
if _is_unknown_model(
135+
agent_invocation.request_model
136+
) and not _is_unknown_model(model):
137+
agent_invocation.request_model = model
138+
139+
140+
def _extract_model_from_result_message(msg: Any) -> Optional[str]:
141+
model_usage = getattr(msg, "model_usage", None)
142+
if not isinstance(model_usage, dict) or not model_usage:
143+
return None
144+
if len(model_usage) == 1:
145+
return _normalize_model(next(iter(model_usage)))
146+
return None
147+
148+
109149
def _clear_client_managed_runs(
110150
handler: ExtendedTelemetryHandler,
111151
client_managed_runs: Dict[str, ExecuteToolInvocation],
@@ -482,6 +522,9 @@ def _process_assistant_message(
482522
cwd: Optional[str] = None,
483523
) -> None:
484524
"""Process AssistantMessage: create LLM turn, extract parts, create tool spans."""
525+
message_model = _resolve_assistant_model(msg, model)
526+
_apply_assistant_model(agent_invocation, message_model)
527+
485528
parts = _extract_message_parts(msg)
486529
has_text_content = any(isinstance(p, Text) for p in parts)
487530
has_tool_calls = any(isinstance(p, ToolCall) for p in parts)
@@ -494,10 +537,11 @@ def _process_assistant_message(
494537
turn_tracker.close_llm_turn()
495538

496539
message_arrival_time = time.time()
540+
finish_reason = "tool_calls" if has_tool_calls else "stop"
497541

498542
turn_tracker.start_llm_turn(
499543
msg,
500-
model,
544+
message_model,
501545
prompt,
502546
collected_messages,
503547
provider=infer_provider_from_base_url(),
@@ -506,9 +550,11 @@ def _process_assistant_message(
506550
)
507551

508552
if parts:
509-
turn_tracker.add_assistant_output(parts)
553+
turn_tracker.add_assistant_output(parts, finish_reason)
510554
output_msg = OutputMessage(
511-
role="assistant", parts=list(parts), finish_reason="stop"
555+
role="assistant",
556+
parts=list(parts),
557+
finish_reason=finish_reason,
512558
)
513559
agent_invocation.output_messages.append(output_msg)
514560

@@ -527,14 +573,8 @@ def _process_assistant_message(
527573
last_output_msg.parts.extend(parts)
528574
last_output_msg.finish_reason = "tool_calls"
529575
else:
530-
turn_tracker.add_assistant_output(parts)
531-
output_msg = OutputMessage(
532-
role="assistant",
533-
parts=list(parts),
534-
finish_reason="tool_calls",
535-
)
536-
turn_tracker.current_llm_invocation.output_messages.append(
537-
output_msg
576+
turn_tracker.add_assistant_output(
577+
parts, finish_reason="tool_calls"
538578
)
539579

540580
# Only add to collected_messages if not inside a Task
@@ -557,7 +597,8 @@ def _process_assistant_message(
557597
{"role": "assistant", "parts": list(parts)}
558598
)
559599

560-
# Close LLM turn before creating tool spans to ensure correct timeline
600+
# Close an existing LLM turn before creating tool spans to keep tool spans
601+
# under the agent context and avoid leaking the LLM context into tools.
561602
if has_tool_calls and turn_tracker.current_llm_invocation:
562603
turn_tracker.close_llm_turn()
563604

@@ -749,25 +790,27 @@ def _process_user_message(
749790
def _process_system_message(
750791
msg: Any,
751792
agent_invocation: InvokeAgentInvocation,
752-
) -> Optional[str]:
793+
) -> tuple[Optional[str], Optional[str]]:
753794
"""Process SystemMessage: extract session_id and cwd early in the stream.
754795
755796
SystemMessage appears at the beginning of the message stream and contains
756797
the session_id and cwd in its data field. We extract them here so they are
757798
available for all subsequent spans (cwd is needed to locate project-level
758799
SKILL.md files for Skill tool telemetry).
759800
760-
Returns the cwd if present, otherwise ``None``.
801+
Returns ``(cwd, model)`` when present.
761802
"""
762803
if hasattr(msg, "subtype") and msg.subtype == "init":
763804
if hasattr(msg, "data") and isinstance(msg.data, dict):
764805
session_id = msg.data.get("session_id")
765806
if session_id:
766807
_set_session_id(agent_invocation, session_id)
767808
cwd = msg.data.get("cwd")
768-
if cwd:
769-
return str(cwd)
770-
return None
809+
model = _normalize_model(msg.data.get("model"))
810+
if model:
811+
_apply_assistant_model(agent_invocation, model)
812+
return str(cwd) if cwd else None, model
813+
return None, None
771814

772815

773816
def _process_stream_event_message(
@@ -795,6 +838,11 @@ def _process_result_message(
795838
) -> None:
796839
"""Process ResultMessage: update session_id (fallback), token usage, and close any open LLM turn."""
797840

841+
result_model = _extract_model_from_result_message(msg)
842+
if result_model:
843+
_apply_assistant_model(agent_invocation, result_model)
844+
turn_tracker.apply_model(result_model)
845+
798846
_set_session_id(agent_invocation, getattr(msg, "session_id", None))
799847
turn_tracker.set_session_id(agent_invocation.conversation_id)
800848
_update_token_usage(agent_invocation, turn_tracker, msg)
@@ -846,6 +894,7 @@ async def _process_agent_invocation_stream(
846894
# cwd captured from SystemMessage.data.cwd, used to locate project-level
847895
# SKILL.md files for Skill tool telemetry.
848896
session_cwd: Optional[str] = None
897+
current_model = model
849898
agent_closed = False
850899

851900
def close_agent_successfully() -> None:
@@ -854,20 +903,30 @@ def close_agent_successfully() -> None:
854903
handler.stop_invoke_agent(agent_invocation)
855904
agent_closed = True
856905

906+
def fail_agent(error: Error) -> None:
907+
nonlocal agent_closed
908+
if not agent_closed:
909+
handler.fail_invoke_agent(agent_invocation, error=error)
910+
agent_closed = True
911+
857912
try:
858913
async for msg in wrapped_stream:
859914
msg_type = type(msg).__name__
860915

861916
if msg_type == "SystemMessage":
862-
cwd = _process_system_message(msg, agent_invocation)
917+
cwd, system_model = _process_system_message(
918+
msg, agent_invocation
919+
)
863920
if cwd:
864921
session_cwd = cwd
922+
if system_model and _is_unknown_model(current_model):
923+
current_model = system_model
865924
elif msg_type == "StreamEvent":
866925
_process_stream_event_message(msg, agent_invocation)
867926
elif msg_type == "AssistantMessage":
868927
_process_assistant_message(
869928
msg,
870-
model,
929+
current_model,
871930
prompt,
872931
agent_invocation,
873932
turn_tracker,
@@ -896,17 +955,15 @@ def close_agent_successfully() -> None:
896955

897956
except BaseException as e:
898957
error_msg = str(e)
958+
error = Error(message=error_msg, type=type(e))
899959
if not agent_closed:
960+
turn_tracker.fail_llm_turn(error)
900961
if agent_invocation.span:
901962
agent_invocation.span.set_attribute(
902963
"error.type", type(e).__name__
903964
)
904965
agent_invocation.span.set_attribute("error.message", error_msg)
905-
handler.fail_invoke_agent(
906-
agent_invocation,
907-
error=Error(message=error_msg, type=type(e)),
908-
)
909-
agent_closed = True
966+
fail_agent(error)
910967

911968
raise
912969
finally:
@@ -1018,13 +1075,15 @@ def start_llm_turn(
10181075
self.current_llm_invocation = llm_invocation
10191076
return llm_invocation
10201077

1021-
def add_assistant_output(self, parts: List[Any]) -> None:
1078+
def add_assistant_output(
1079+
self, parts: List[Any], finish_reason: str = "stop"
1080+
) -> None:
10221081
"""Add output message parts to current LLM invocation."""
10231082
if not self.current_llm_invocation or not parts:
10241083
return
10251084

10261085
output_msg = OutputMessage(
1027-
role="assistant", parts=list(parts), finish_reason="stop"
1086+
role="assistant", parts=list(parts), finish_reason=finish_reason
10281087
)
10291088
self.current_llm_invocation.output_messages.append(output_msg)
10301089

@@ -1053,13 +1112,32 @@ def set_session_id(self, session_id: Optional[str]) -> None:
10531112
if target_invocation:
10541113
_set_llm_session_id(target_invocation, session_id)
10551114

1115+
def apply_model(self, model: str) -> None:
1116+
"""Update an open or recently closed LLM invocation model if unknown."""
1117+
target_invocation = (
1118+
self.current_llm_invocation or self.last_closed_llm_invocation
1119+
)
1120+
if (
1121+
target_invocation
1122+
and _is_unknown_model(target_invocation.request_model)
1123+
and not _is_unknown_model(model)
1124+
):
1125+
target_invocation.request_model = model
1126+
10561127
def close_llm_turn(self) -> None:
10571128
"""Close the current LLM invocation span."""
10581129
if self.current_llm_invocation:
10591130
self.handler.stop_llm(self.current_llm_invocation)
10601131
self.last_closed_llm_invocation = self.current_llm_invocation
10611132
self.current_llm_invocation = None
10621133

1134+
def fail_llm_turn(self, error: Error) -> None:
1135+
"""Fail the current LLM invocation span."""
1136+
if self.current_llm_invocation:
1137+
self.handler.fail_llm(self.current_llm_invocation, error)
1138+
self.last_closed_llm_invocation = self.current_llm_invocation
1139+
self.current_llm_invocation = None
1140+
10631141
def close(self) -> None:
10641142
"""Close any open LLM invocation (cleanup fallback)."""
10651143
if self.current_llm_invocation:

0 commit comments

Comments
 (0)