@@ -85,6 +85,8 @@ class DAIVSkillsState(SkillsState):
8585- Only use skills listed in <available_skills> below, but creation is possible
8686- Do not invoke a skill that is already running.
8787
88+ {{skills_locations}}{{skills_load_warnings}}
89+
8890{{skills_list}}""" # noqa: E501
8991
9092
@@ -94,12 +96,6 @@ class DAIVSkillsState(SkillsState):
9496 <skill>
9597 <name>{{name}}</name>
9698 <description>{{description}}</description>
97- {{#metadata.is_builtin}}
98- <builtin>true</builtin>
99- {{/metadata.is_builtin}}
100- {{#metadata.is_global}}
101- <global>true</global>
102- {{/metadata.is_global}}
10399 </skill>
104100 {{/skills_list}}
105101</available_skills>""" ,
@@ -128,35 +124,25 @@ async def abefore_agent(
128124 """
129125 Apply builtin slash commands early in the conversation and copy builtin skills to the project skills directory
130126 to make them available to the agent.
127+
128+ ``skills_load_errors`` are captured once per session and persist through subsequent turns;
129+ a misconfig fixed mid-session will continue surfacing in ``<skill_load_warnings>`` until restart.
131130 """
132- # Clear the active skill mode when the user sends a follow-up message. This allows the agent to transition
133- # from plan mode (read-only) to implementation mode (full tool access) when the user says "proceed" or similar.
134131 clear_skill_mode = state .get ("active_skill_mode" ) is not None and self ._has_user_followup (state ["messages" ])
135132 if clear_skill_mode :
136133 logger .info ("[%s] Clearing active skill mode '%s' on user follow-up" , self .name , state ["active_skill_mode" ])
137134
138- # Materialize before super() so the `skill` tool can resolve files on disk,
139- # not just metadata; otherwise the agent gets a not_found at invocation time.
140- builtin_skills , custom_global_skills = await self ._copy_global_skills ()
135+ # Skip the filesystem walk once skills_metadata is in state — upstream `abefore_agent` also
136+ # short-circuits on the same condition, so re-walking is pure waste on turns 2+.
137+ local_load_errors : list [str ] = []
138+ if "skills_metadata" not in state :
139+ local_load_errors = await self ._copy_global_skills ()
141140
142141 skills_update = await super ().abefore_agent (state , runtime , config )
143142
144- # Mark skills based on their origin. Custom global skills take precedence over built-in skills with the same
145- # name. Per-repository skills (not in either list) get both flags removed.
146- if skills_update is not None :
147- for skill in skills_update ["skills_metadata" ]:
148- if skill ["name" ] in custom_global_skills :
149- skill ["metadata" ]["is_global" ] = True
150- skill ["metadata" ].pop ("is_builtin" , None )
151- elif skill ["name" ] in builtin_skills :
152- skill ["metadata" ]["is_builtin" ] = True
153- skill ["metadata" ].pop ("is_global" , None )
154- else :
155- skill ["metadata" ].pop ("is_builtin" , None )
156- skill ["metadata" ].pop ("is_global" , None )
157-
158- # If the super method returns None, it means that the skills metadata was already captured and registered in
159- # the state.
143+ if local_load_errors and skills_update is not None :
144+ skills_update .setdefault ("skills_load_errors" , []).extend (local_load_errors )
145+
160146 skills_metadata = skills_update ["skills_metadata" ] if skills_update else state ["skills_metadata" ]
161147
162148 builtin_slash_commands = None
@@ -166,6 +152,8 @@ async def abefore_agent(
166152 )
167153
168154 if builtin_slash_commands :
155+ if skills_update is not None and "skills_load_errors" in skills_update :
156+ builtin_slash_commands ["skills_load_errors" ] = skills_update ["skills_load_errors" ]
169157 if clear_skill_mode :
170158 builtin_slash_commands ["active_skill_mode" ] = None
171159 return builtin_slash_commands
@@ -175,52 +163,61 @@ async def abefore_agent(
175163
176164 return skills_update
177165
178- async def _copy_global_skills (self ) -> tuple [ list [str ], list [ str ] ]:
166+ async def _copy_global_skills (self ) -> list [str ]:
179167 """
180168 Materialize builtin and custom global skills into the virtual ``GLOBAL_SKILLS_PATH``,
181- sibling to the agent's working directory.
169+ sibling to the agent's working directory. Custom global skills override builtins with
170+ the same name at first cache population.
182171
183- Custom global skills override builtins with the same name (later writes in
184- ``files_to_upload`` win).
185-
186- Returns a tuple of (builtin skill names, custom global skill names).
172+ Returns source-level load errors so callers can surface them via ``skills_load_errors``.
187173 """
188174 files_to_upload : list [tuple [str , bytes ]] = []
175+ errors : list [str ] = []
189176 skills_path = Path (GLOBAL_SKILLS_PATH )
190177
191- builtin_skills = self ._collect_skill_files (BUILTIN_SKILLS_PATH , skills_path , files_to_upload )
178+ self ._collect_skill_files (BUILTIN_SKILLS_PATH , skills_path , files_to_upload , errors )
192179
193- custom_global_skills : list [str ] = []
194180 custom_skills_path = agent_settings .CUSTOM_SKILLS_PATH
195181 if custom_skills_path is not None and custom_skills_path .is_dir ():
196182 try :
197- custom_global_skills = self ._collect_skill_files (custom_skills_path , skills_path , files_to_upload )
198- except OSError :
199- logger .exception ("Failed to read custom global skills from '%s', skipping" , custom_skills_path )
183+ self ._collect_skill_files (custom_skills_path , skills_path , files_to_upload , errors )
184+ except OSError as exc :
185+ # Host path stays in the operator log; the agent gets a generic warning
186+ # because it cannot act on a real-filesystem location and shouldn't leak it.
187+ # ``exc.strerror`` carries the OS message without the path (which lives in
188+ # ``exc.filename``); ``str(exc)`` would include both.
189+ logger .exception ("Failed to read custom global skills from '%s'" , custom_skills_path )
190+ reason = exc .strerror or type (exc ).__name__
191+ errors .append (f"Some custom global skills failed to load: { reason } " )
200192 elif custom_skills_path is not None :
201193 logger .warning ("Custom global skills path '%s' does not exist or is not a directory" , custom_skills_path )
202194
203- for response in await self ._backend .aupload_files (files_to_upload ):
204- if response .error :
205- raise RuntimeError (f"Failed to upload skill: { response .error } " )
206- return builtin_skills , custom_global_skills
195+ responses = await self ._backend .aupload_files (files_to_upload )
196+ failures = [
197+ (dest , resp .error ) for (dest , _ ), resp in zip (files_to_upload , responses , strict = True ) if resp .error
198+ ]
199+ if failures :
200+ for dest , err in failures :
201+ logger .error ("Skill upload failed: dest=%s error=%s" , dest , err )
202+ first_dest , first_err = failures [0 ]
203+ extra = f"; first failure at '{ first_dest } ': { first_err } "
204+ raise RuntimeError (f"Failed to upload { len (failures )} skill file(s){ extra } " )
205+
206+ return errors
207207
208208 @staticmethod
209209 def _collect_skill_files (
210- source_root : Path , project_skills_path : Path , files_to_upload : list [tuple [str , bytes ]]
211- ) -> list [ str ] :
210+ source_root : Path , project_skills_path : Path , files_to_upload : list [tuple [str , bytes ]], errors : list [ str ]
211+ ) -> None :
212212 """
213- Walk skill directories under ``source_root`` and append files to ``files_to_upload``.
214-
215- Returns the list of skill names found .
213+ Walk skill directories under ``source_root``, appending uploadable files to
214+ ``files_to_upload`` and ``SKILL.md`` read failures to ``errors`` so a broken
215+ manifest is not silently dropped from the agent's view .
216216 """
217- skill_names : list [str ] = []
218217 for skill_dir in source_root .iterdir ():
219218 if not skill_dir .is_dir () or skill_dir .name == "__pycache__" :
220219 continue
221220
222- skill_names .append (skill_dir .name )
223-
224221 for root , dirs , files in skill_dir .walk ():
225222 dirs [:] = [d for d in dirs if d != "__pycache__" ]
226223 for file in files :
@@ -233,13 +230,20 @@ def _collect_skill_files(
233230 # is a virtual path under ``GLOBAL_SKILLS_PATH``, which never exists
234231 # on the host fs — the disk equivalent is ``SKILLS_CACHE_PATH/rel``.
235232 dest_path = project_skills_path / rel
236- if not (SKILLS_CACHE_PATH / rel ).exists ():
237- try :
238- files_to_upload .append ((str (dest_path ), source_path .read_bytes ()))
239- except OSError :
240- logger .warning ("Failed to read skill file '%s', skipping" , source_path )
241-
242- return skill_names
233+ if (SKILLS_CACHE_PATH / rel ).exists ():
234+ continue
235+ try :
236+ files_to_upload .append ((str (dest_path ), source_path .read_bytes ()))
237+ except OSError as exc :
238+ logger .warning (
239+ "Failed to read skill file '%s' (skill='%s'), skipping" , source_path , skill_dir .name
240+ )
241+ # Surface broken SKILL.md by skill name so the agent can warn a user
242+ # who invokes the skill. Host paths stay in the log only — ``exc.strerror``
243+ # is the OS message without the path (which lives in ``exc.filename``).
244+ if source_path .name == "SKILL.md" :
245+ reason = exc .strerror or type (exc ).__name__
246+ errors .append (f"Cannot load skill '{ skill_dir .name } ': { reason } " )
243247
244248 @override
245249 def _format_skills_list (self , skills : list [SkillMetadata ]) -> str :
0 commit comments