1515"""Patch functions for Claude Agent SDK instrumentation."""
1616
1717import logging
18+ import os
1819import time
1920from typing import Any , Dict , List , Optional
2021
22+ import yaml
23+
2124from opentelemetry import context as otel_context
2225from opentelemetry .instrumentation .claude_agent_sdk .utils import (
2326 extract_usage_from_result_message ,
@@ -86,6 +89,115 @@ def _clear_client_managed_runs() -> None:
8689 _client_managed_runs .clear ()
8790
8891
92+ # The name of the Claude Agent SDK built-in tool that loads a Skill.
93+ _SKILL_TOOL_NAME = "Skill"
94+
95+ # skill id prefix for project-scoped Claude Agent SDK skills.
96+ _SKILL_ID_PREFIX = "claude:project:"
97+
98+
99+ def _read_skill_metadata (skill_md_path : str ) -> Dict [str , str ]:
100+ """Best-effort read of a Skill's SKILL.md frontmatter.
101+
102+ Returns a dict with any of ``name``/``description``/``version`` keys that
103+ were present in the YAML frontmatter. On any error (missing file, parse
104+ failure, ...) returns an empty dict so telemetry never breaks the SDK call.
105+ """
106+ try :
107+ with open (skill_md_path , "r" , encoding = "utf-8" ) as f :
108+ content = f .read ()
109+ except Exception :
110+ # Missing or unreadable SKILL.md is expected for non-project skills.
111+ return {}
112+
113+ return _parse_skill_frontmatter (content )
114+
115+
116+ def _parse_skill_frontmatter (content : str ) -> Dict [str , str ]:
117+ """Parse the YAML frontmatter (``---`` delimited) of a SKILL.md body."""
118+ try :
119+ stripped = content .lstrip ()
120+ if not stripped .startswith ("---" ):
121+ return {}
122+ # Split off the leading ``---``; the next ``---`` closes the block.
123+ after_open = stripped [3 :]
124+ end_index = after_open .find ("\n ---" )
125+ if end_index == - 1 :
126+ # Frontmatter never closed; treat the remainder as the block.
127+ frontmatter_text = after_open
128+ else :
129+ frontmatter_text = after_open [:end_index ]
130+
131+ parsed = yaml .safe_load (frontmatter_text )
132+ if not isinstance (parsed , dict ):
133+ return {}
134+ except Exception :
135+ return {}
136+
137+ metadata : Dict [str , str ] = {}
138+ for key in ("name" , "description" , "version" ):
139+ value = parsed .get (key )
140+ if value is not None :
141+ metadata [key ] = str (value )
142+ return metadata
143+
144+
145+ def _apply_skill_metadata (
146+ tool_invocation : ExecuteToolInvocation ,
147+ skill_name : str ,
148+ cwd : Optional [str ],
149+ ) -> None :
150+ """Attach ``gen_ai.skill.*`` attributes to a Skill load tool span.
151+
152+ Reads the project-level ``SKILL.md`` frontmatter best-effort and fills in
153+ ``skill_name``/``skill_id``/``skill_description``/``skill_version`` on the
154+ invocation. Any failure is swallowed so the SDK call is never affected.
155+ """
156+ if not skill_name :
157+ return
158+
159+ metadata : Dict [str , str ] = {}
160+ if cwd :
161+ skill_md_path = os .path .join (
162+ cwd , ".claude" , "skills" , skill_name , "SKILL.md"
163+ )
164+ metadata = _read_skill_metadata (skill_md_path )
165+
166+ # gen_ai.skill.name: prefer frontmatter, fall back to the requested name.
167+ name = metadata .get ("name" ) or skill_name
168+ tool_invocation .skill_name = name
169+ tool_invocation .skill_id = f"{ _SKILL_ID_PREFIX } { name } "
170+
171+ description = metadata .get ("description" )
172+ if description :
173+ tool_invocation .skill_description = description
174+ version = metadata .get ("version" )
175+ if version :
176+ tool_invocation .skill_version = version
177+
178+
179+ def _apply_skill_fallback (
180+ tool_invocation : ExecuteToolInvocation ,
181+ tool_use_result : Any ,
182+ ) -> None :
183+ """Best-effort fallback to recover skill_name before closing a Skill span.
184+
185+ If ``skill_name`` was not captured at span start (e.g. cwd was unavailable
186+ so SKILL.md could not be read), try ``UserMessage.tool_use_result.commandName``
187+ per the SDK's Skill tool result format.
188+ """
189+ if tool_invocation .skill_name :
190+ return
191+ if not isinstance (tool_use_result , dict ):
192+ return
193+ command_name = tool_use_result .get ("commandName" )
194+ if command_name :
195+ tool_invocation .skill_name = str (command_name )
196+ tool_invocation .skill_id = (
197+ f"{ _SKILL_ID_PREFIX } { command_name } "
198+ )
199+
200+
89201def _extract_message_parts (msg : Any ) -> List [Any ]:
90202 """Extract parts (text + tool calls) from an AssistantMessage."""
91203 parts = []
@@ -113,12 +225,17 @@ def _create_tool_spans_from_message(
113225 agent_invocation : InvokeAgentInvocation ,
114226 active_task_stack : List [Any ],
115227 exclude_tool_names : Optional [List [str ]] = None ,
228+ cwd : Optional [str ] = None ,
116229) -> None :
117230 """Create tool execution spans from ToolUseBlocks in an AssistantMessage.
118231
119232 Tool spans are children of the active SubAgent span (if any), otherwise agent span.
120233 When a Task tool is created, it's pushed onto active_task_stack along with a SubAgent span.
121234
235+ For the built-in ``Skill`` tool, ``gen_ai.skill.*`` attributes are read
236+ best-effort from the project-level ``SKILL.md`` frontmatter (located via
237+ ``cwd``) and attached to the tool span.
238+
122239 The stack structure is: [{"task": ExecuteToolInvocation, "subagent": InvokeAgentInvocation}, ...]
123240 """
124241 if not hasattr (msg , "content" ):
@@ -163,6 +280,26 @@ def _create_tool_spans_from_message(
163280 tool_call_arguments = tool_input ,
164281 tool_description = tool_name ,
165282 )
283+
284+ # Skill load: attach gen_ai.skill.* attributes best-effort
285+ # from the project SKILL.md frontmatter. Failures here must
286+ # never propagate to break the SDK call.
287+ if tool_name == _SKILL_TOOL_NAME :
288+ try :
289+ skill_name = ""
290+ if isinstance (tool_input , dict ):
291+ skill_name = str (
292+ tool_input .get ("skill" ) or ""
293+ )
294+ _apply_skill_metadata (
295+ tool_invocation , skill_name , cwd
296+ )
297+ except Exception as e :
298+ logger .warning (
299+ f"Failed to read Skill metadata for "
300+ f"'{ tool_input } ': { e } "
301+ )
302+
166303 handler .start_execute_tool (tool_invocation )
167304 _client_managed_runs [tool_use_id ] = tool_invocation
168305
@@ -271,6 +408,7 @@ def _process_assistant_message(
271408 handler : ExtendedTelemetryHandler ,
272409 collected_messages : List [Dict [str , Any ]],
273410 active_task_stack : List [Any ],
411+ cwd : Optional [str ] = None ,
274412) -> None :
275413 """Process AssistantMessage: create LLM turn, extract parts, create tool spans."""
276414 parts = _extract_message_parts (msg )
@@ -353,7 +491,7 @@ def _process_assistant_message(
353491 turn_tracker .close_llm_turn ()
354492
355493 _create_tool_spans_from_message (
356- msg , handler , agent_invocation , active_task_stack
494+ msg , handler , agent_invocation , active_task_stack , cwd = cwd
357495 )
358496
359497
@@ -474,6 +612,18 @@ def _process_user_message(
474612 Error (message = error_msg , type = RuntimeError ),
475613 )
476614 else :
615+ # Skill load: best-effort fallback to fill skill_name
616+ # from the tool result if it wasn't captured at start.
617+ if tool_invocation .tool_name == _SKILL_TOOL_NAME :
618+ try :
619+ _apply_skill_fallback (
620+ tool_invocation , tool_use_result
621+ )
622+ except Exception as e :
623+ logger .warning (
624+ f"Failed to apply Skill metadata "
625+ f"fallback: { e } "
626+ )
477627 handler .stop_execute_tool (tool_invocation )
478628
479629 if tool_use_id :
@@ -522,18 +672,23 @@ def _process_user_message(
522672def _process_system_message (
523673 msg : Any ,
524674 agent_invocation : InvokeAgentInvocation ,
525- ) -> None :
526- """Process SystemMessage: extract session_id early in the stream.
675+ ) -> Optional [ str ] :
676+ """Process SystemMessage: extract session_id and cwd early in the stream.
527677
528678 SystemMessage appears at the beginning of the message stream and contains
529- the session_id in its data field. We extract it here so that it's available
530- for all subsequent LLM spans.
679+ the session_id and cwd in its data field. We extract them here so they are
680+ available for all subsequent spans (cwd is needed to locate project-level
681+ SKILL.md files for Skill tool telemetry).
682+
683+ Returns the cwd if present, otherwise ``None``.
531684 """
532685 if hasattr (msg , "subtype" ) and msg .subtype == "init" :
533686 if hasattr (msg , "data" ) and isinstance (msg .data , dict ):
534687 session_id = msg .data .get ("session_id" )
535688 if session_id :
536689 agent_invocation .conversation_id = session_id
690+ return msg .data .get ("cwd" )
691+ return None
537692
538693
539694def _process_result_message (
@@ -590,12 +745,16 @@ async def _process_agent_invocation_stream(
590745 # When its ToolResultBlock is received, it's popped
591746 active_task_stack : List [Any ] = []
592747
748+ # cwd captured from SystemMessage.data.cwd, used to locate project-level
749+ # SKILL.md files for Skill tool telemetry.
750+ session_cwd : Optional [str ] = None
751+
593752 try :
594753 async for msg in wrapped_stream :
595754 msg_type = type (msg ).__name__
596755
597756 if msg_type == "SystemMessage" :
598- _process_system_message (msg , agent_invocation )
757+ session_cwd = _process_system_message (msg , agent_invocation )
599758 elif msg_type == "AssistantMessage" :
600759 _process_assistant_message (
601760 msg ,
@@ -606,6 +765,7 @@ async def _process_agent_invocation_stream(
606765 handler ,
607766 collected_messages ,
608767 active_task_stack ,
768+ cwd = session_cwd ,
609769 )
610770 elif msg_type == "UserMessage" :
611771 _process_user_message (
0 commit comments