1515"""Patch functions for Claude Agent SDK instrumentation."""
1616
1717import logging
18+ import os
1819import time
1920from typing import Any , Dict , List , Optional
2021
@@ -133,6 +134,136 @@ def _clear_client_managed_runs(
133134 client_managed_runs .clear ()
134135
135136
137+ # The name of the Claude Agent SDK built-in tool that loads a Skill.
138+ _SKILL_TOOL_NAME = "Skill"
139+
140+ # skill id prefix for project-scoped Claude Agent SDK skills.
141+ _SKILL_ID_PREFIX = "claude:project:"
142+
143+
144+ def _read_skill_metadata (skill_md_path : str ) -> Dict [str , str ]:
145+ """Best-effort read of a Skill's SKILL.md frontmatter.
146+
147+ Returns a dict with any of ``name``/``description``/``version`` keys that
148+ were present in the YAML frontmatter. On any error (missing file, parse
149+ failure, ...) returns an empty dict so telemetry never breaks the SDK call.
150+ """
151+ try :
152+ with open (skill_md_path , "r" , encoding = "utf-8" ) as f :
153+ content = f .read ()
154+ except Exception :
155+ # Missing or unreadable SKILL.md is expected for non-project skills.
156+ return {}
157+
158+ return _parse_skill_frontmatter (content )
159+
160+
161+ def _parse_skill_frontmatter (content : str ) -> Dict [str , str ]:
162+ """Parse selected scalar fields from SKILL.md frontmatter.
163+
164+ This intentionally avoids a runtime PyYAML dependency. Claude skill
165+ frontmatter only needs simple top-level scalar fields for telemetry.
166+ """
167+ try :
168+ stripped = content .lstrip ()
169+ if not stripped .startswith ("---" ):
170+ return {}
171+ # Split off the leading ``---``; the next ``---`` closes the block.
172+ after_open = stripped [3 :]
173+ end_index = after_open .find ("\n ---" )
174+ if end_index == - 1 :
175+ # Frontmatter never closed; treat the remainder as the block.
176+ frontmatter_text = after_open
177+ else :
178+ frontmatter_text = after_open [:end_index ]
179+ except Exception :
180+ return {}
181+
182+ metadata : Dict [str , str ] = {}
183+ wanted_keys = {"name" , "description" , "version" }
184+ for raw_line in frontmatter_text .splitlines ():
185+ line = raw_line .strip ()
186+ if not line or line .startswith ("#" ) or ":" not in line :
187+ continue
188+
189+ key , value = line .split (":" , 1 )
190+ key = key .strip ()
191+ if key not in wanted_keys :
192+ continue
193+
194+ value = value .strip ()
195+ if (
196+ len (value ) >= 2
197+ and value [0 ] == value [- 1 ]
198+ and value [0 ]
199+ in {
200+ '"' ,
201+ "'" ,
202+ }
203+ ):
204+ value = value [1 :- 1 ]
205+ if value :
206+ metadata [key ] = value
207+ return metadata
208+
209+
210+ def _apply_skill_metadata (
211+ tool_invocation : ExecuteToolInvocation ,
212+ skill_name : str ,
213+ cwd : Optional [str ],
214+ ) -> None :
215+ """Attach ``gen_ai.skill.*`` attributes to a Skill load tool span.
216+
217+ Reads the project-level ``SKILL.md`` frontmatter best-effort and fills in
218+ ``skill_name``/``skill_id``/``skill_description``/``skill_version`` on the
219+ invocation. Any failure is swallowed so the SDK call is never affected.
220+ """
221+ if not skill_name :
222+ return
223+
224+ metadata : Dict [str , str ] = {}
225+ if cwd :
226+ skill_md_path = os .path .join (
227+ cwd , ".claude" , "skills" , skill_name , "SKILL.md"
228+ )
229+ metadata = _read_skill_metadata (skill_md_path )
230+
231+ # gen_ai.skill.name: prefer the requested tool input; frontmatter is
232+ # supplemental metadata for description/version.
233+ name = skill_name or metadata .get ("name" )
234+ if not name :
235+ return
236+ tool_invocation .skill_name = name
237+ tool_invocation .skill_id = f"{ _SKILL_ID_PREFIX } { name } "
238+
239+ description = metadata .get ("description" )
240+ if description :
241+ tool_invocation .skill_description = description
242+ version = metadata .get ("version" )
243+ if version :
244+ tool_invocation .skill_version = version
245+
246+
247+ def _apply_skill_fallback (
248+ tool_invocation : ExecuteToolInvocation ,
249+ tool_use_result : Any ,
250+ ) -> None :
251+ """Best-effort fallback to recover skill_name before closing a Skill span.
252+
253+ If ``skill_name`` was not captured at span start (e.g. cwd was unavailable
254+ so SKILL.md could not be read), try ``UserMessage.tool_use_result.commandName``
255+ per the SDK's Skill tool result format.
256+ """
257+ if tool_invocation .skill_name :
258+ return
259+ if not isinstance (tool_use_result , dict ):
260+ return
261+ command_name = tool_use_result .get ("commandName" )
262+ if command_name :
263+ tool_invocation .skill_name = str (command_name )
264+ tool_invocation .skill_id = f"{ _SKILL_ID_PREFIX } { command_name } "
265+
266+
136267def _extract_message_parts (msg : Any ) -> List [Any ]:
137268 """Extract parts (text + tool calls) from an AssistantMessage."""
138269 parts = []
@@ -161,12 +292,17 @@ def _create_tool_spans_from_message(
161292 active_task_stack : List [Any ],
162293 client_managed_runs : Dict [str , ExecuteToolInvocation ],
163294 exclude_tool_names : Optional [List [str ]] = None ,
295+ cwd : Optional [str ] = None ,
164296) -> None :
165297 """Create tool execution spans from ToolUseBlocks in an AssistantMessage.
166298
167299 Tool spans are children of the active SubAgent span (if any), otherwise agent span.
168300 When a Task tool is created, it's pushed onto active_task_stack along with a SubAgent span.
169301
302+ For the built-in ``Skill`` tool, ``gen_ai.skill.*`` attributes are read
303+ best-effort from the project-level ``SKILL.md`` frontmatter (located via
304+ ``cwd``) and attached to the tool span.
305+
170306 The stack structure is: [{"task": ExecuteToolInvocation, "subagent": InvokeAgentInvocation}, ...]
171307 """
172308 if not hasattr (msg , "content" ):
@@ -214,6 +350,22 @@ def _create_tool_spans_from_message(
214350 _apply_session_identity (
215351 tool_invocation , agent_invocation .conversation_id
216352 )
353+
354+ # Skill load: attach gen_ai.skill.* attributes best-effort
355+ # from the project SKILL.md frontmatter. Failures here must
356+ # never propagate to break the SDK call.
357+ if tool_name == _SKILL_TOOL_NAME :
358+ try :
359+ skill_name = ""
360+ if isinstance (tool_input , dict ):
361+ skill_name = str (tool_input .get ("skill" ) or "" )
362+ _apply_skill_metadata (tool_invocation , skill_name , cwd )
363+ except Exception as e :
364+ logger .warning (
365+ f"Failed to read Skill metadata for "
366+ f"'{ tool_input } ': { e } "
367+ )
368+
217369 handler .start_execute_tool (tool_invocation )
218370 client_managed_runs [tool_use_id ] = tool_invocation
219371
@@ -327,6 +479,7 @@ def _process_assistant_message(
327479 collected_messages : List [Dict [str , Any ]],
328480 active_task_stack : List [Any ],
329481 client_managed_runs : Dict [str , ExecuteToolInvocation ],
482+ cwd : Optional [str ] = None ,
330483) -> None :
331484 """Process AssistantMessage: create LLM turn, extract parts, create tool spans."""
332485 parts = _extract_message_parts (msg )
@@ -414,6 +567,7 @@ def _process_assistant_message(
414567 agent_invocation ,
415568 active_task_stack ,
416569 client_managed_runs ,
570+ cwd = cwd ,
417571 )
418572
419573
@@ -535,6 +689,18 @@ def _process_user_message(
535689 Error (message = error_msg , type = RuntimeError ),
536690 )
537691 else :
692+ # Skill load: best-effort fallback to fill skill_name
693+ # from the tool result if it wasn't captured at start.
694+ if tool_invocation .tool_name == _SKILL_TOOL_NAME :
695+ try :
696+ _apply_skill_fallback (
697+ tool_invocation , tool_use_result
698+ )
699+ except Exception as e :
700+ logger .warning (
701+ f"Failed to apply Skill metadata "
702+ f"fallback: { e } "
703+ )
538704 handler .stop_execute_tool (tool_invocation )
539705
540706 if tool_use_id :
@@ -583,18 +749,25 @@ def _process_user_message(
583749def _process_system_message (
584750 msg : Any ,
585751 agent_invocation : InvokeAgentInvocation ,
586- ) -> None :
587- """Process SystemMessage: extract session_id early in the stream.
752+ ) -> Optional [ str ] :
753+ """Process SystemMessage: extract session_id and cwd early in the stream.
588754
589755 SystemMessage appears at the beginning of the message stream and contains
590- the session_id in its data field. We extract it here so that it's available
591- for all subsequent LLM spans.
756+ the session_id and cwd in its data field. We extract them here so they are
757+ available for all subsequent spans (cwd is needed to locate project-level
758+ SKILL.md files for Skill tool telemetry).
759+
760+ Returns the cwd if present, otherwise ``None``.
592761 """
593762 if hasattr (msg , "subtype" ) and msg .subtype == "init" :
594763 if hasattr (msg , "data" ) and isinstance (msg .data , dict ):
595764 session_id = msg .data .get ("session_id" )
596765 if session_id :
597766 _set_session_id (agent_invocation , session_id )
767+ cwd = msg .data .get ("cwd" )
768+ if cwd :
769+ return str (cwd )
770+ return None
598771
599772
600773def _process_stream_event_message (
@@ -670,12 +843,19 @@ async def _process_agent_invocation_stream(
670843 active_task_stack : List [Any ] = []
671844 client_managed_runs : Dict [str , ExecuteToolInvocation ] = {}
672845
846+ # cwd captured from SystemMessage.data.cwd, used to locate project-level
847+ # SKILL.md files for Skill tool telemetry.
848+ session_cwd : Optional [str ] = None
849+ agent_closed = False
850+
673851 try :
674852 async for msg in wrapped_stream :
675853 msg_type = type (msg ).__name__
676854
677855 if msg_type == "SystemMessage" :
678- _process_system_message (msg , agent_invocation )
856+ cwd = _process_system_message (msg , agent_invocation )
857+ if cwd :
858+ session_cwd = cwd
679859 elif msg_type == "StreamEvent" :
680860 _process_stream_event_message (msg , agent_invocation )
681861 elif msg_type == "AssistantMessage" :
@@ -689,6 +869,7 @@ async def _process_agent_invocation_stream(
689869 collected_messages ,
690870 active_task_stack ,
691871 client_managed_runs ,
872+ cwd = session_cwd ,
692873 )
693874 elif msg_type == "UserMessage" :
694875 _process_user_message (
@@ -705,15 +886,21 @@ async def _process_agent_invocation_stream(
705886 yield msg
706887
707888 handler .stop_invoke_agent (agent_invocation )
889+ agent_closed = True
708890
709- except Exception as e :
891+ except BaseException as e :
710892 error_msg = str (e )
711- if agent_invocation .span :
712- agent_invocation .span .set_attribute ("error.type" , type (e ).__name__ )
713- agent_invocation .span .set_attribute ("error.message" , error_msg )
714- handler .fail_invoke_agent (
715- agent_invocation , error = Error (message = error_msg , type = type (e ))
716- )
893+ if not agent_closed :
894+ if agent_invocation .span :
895+ agent_invocation .span .set_attribute (
896+ "error.type" , type (e ).__name__
897+ )
898+ agent_invocation .span .set_attribute ("error.message" , error_msg )
899+ handler .fail_invoke_agent (
900+ agent_invocation ,
901+ error = Error (message = error_msg , type = type (e )),
902+ )
903+ agent_closed = True
717904
718905 raise
719906 finally :
0 commit comments