Skip to content

Commit 8f6b1bc

Browse files
authored
Merge pull request #1247 from srtab/worktree-skills-refactor
feat(skills): adopt upstream skills_locations + skills_load_warnings; drop is_builtin/is_global tagging
2 parents 88c0d35 + dbb86fa commit 8f6b1bc

3 files changed

Lines changed: 463 additions & 105 deletions

File tree

daiv/automation/agent/graph.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ async def create_daiv_agent(
232232
TodoListMiddleware(system_prompt=dynamic_write_todos_system_prompt(bash_tool_enabled=_sandbox_enabled)),
233233
SkillsMiddleware(
234234
backend=backend,
235-
sources=[GLOBAL_SKILLS_PATH, *[f"/{agent_path.name}/{source}" for source in SKILLS_SOURCES]],
235+
sources=[(GLOBAL_SKILLS_PATH, "Global"), *[f"/{agent_path.name}/{source}" for source in SKILLS_SOURCES]],
236236
subagents=subagents,
237237
),
238238
*([SandboxMiddleware(backend=backend, agent_root=agent_root)] if _sandbox_enabled else []),

daiv/automation/agent/middlewares/skills.py

Lines changed: 61 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)