From aab1f299dac0d2d96cceef807385c28007c919b4 Mon Sep 17 00:00:00 2001 From: RedPiggy Date: Sun, 10 May 2026 11:41:04 +0800 Subject: [PATCH 1/4] patches: re-apply PATCH-002/003/004/005 after upstream update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATCH-002 (WhatsApp debounce): fix event.type → event.message_type typo that silently broke all WhatsApp DM receiving since 2026-05-09. PATCH-003 (Weixin debounce): re-apply text batching. PATCH-004 (pre_gateway_text_send hook): re-apply plugin outbound hook. PATCH-005 (MoA Requesty router): re-apply with updated models. --- gateway/platforms/base.py | 25 +++++++++++ gateway/platforms/weixin.py | 69 ++++++++++++++++++++++++++++- gateway/platforms/whatsapp.py | 78 ++++++++++++++++++++++++++++++++- gateway/run.py | 2 + gateway/stream_consumer.py | 11 +++++ hermes_cli/plugins.py | 1 + sessions.db | 0 tools/mixture_of_agents_tool.py | 55 ++++++++++++++++++----- 8 files changed, 227 insertions(+), 14 deletions(-) create mode 100644 sessions.db diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 413cebfbe878..85bf68f11716 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -23,6 +23,26 @@ logger = logging.getLogger(__name__) + +def _apply_text_hooks(text: str, event: "MessageEvent", session_id: Optional[str] = None) -> tuple[str, str]: + """Apply pre_gateway_text_send hooks. Returns (action, text). + action is 'allow', 'block', or 'rewrite'. + """ + from hermes_cli.plugins import invoke_hook + result = invoke_hook("pre_gateway_text_send", text=text, event=event, session_id=session_id) + if not result: + return "allow", text + # Hooks may return list of results; take first non-None + for r in result: + if r is not None: + if isinstance(r, dict): + action = r.get("action", "allow") + new_text = r.get("text", text) + return action, new_text + elif isinstance(r, str): + return "allow", r + return "allow", text + # Audio file extensions Hermes recognizes for native audio delivery. # Kept in sync with tools/send_message_tool.py and cron/scheduler.py via # should_send_media_as_audio() below. @@ -2946,6 +2966,11 @@ async def _stop_typing_task() -> None: except OSError: pass + # Apply pre_gateway_text_send hooks + _hook_action, text_content = _apply_text_hooks(text_content, event) + if _hook_action == "block": + text_content = "" + # Send the text portion if text_content: logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id) diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 1c20b3f29020..b8c1797484c0 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -1226,6 +1226,12 @@ def __init__(self, config: PlatformConfig): default=False, ) + # Text debounce batching + self._text_batch_delay_seconds = float(os.getenv("HERMES_WEIXIN_TEXT_BATCH_DELAY_SECONDS", "3.0")) + self._text_batch_split_delay_seconds = float(os.getenv("HERMES_WEIXIN_TEXT_BATCH_SPLIT_DELAY_SECONDS", "5.0")) + self._pending_text_batches: Dict[str, MessageEvent] = {} + self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {} + if self._account_id and not self._token: persisted = load_weixin_account(hermes_home, self._account_id) if persisted: @@ -1293,6 +1299,11 @@ async def connect(self) -> bool: async def disconnect(self) -> None: _LIVE_ADAPTERS.pop(self._token, None) self._running = False + for task in self._pending_text_batch_tasks.values(): + if not task.done(): + task.cancel() + self._pending_text_batches.clear() + self._pending_text_batch_tasks.clear() if self._poll_task and not self._poll_task.done(): self._poll_task.cancel() try: @@ -1441,7 +1452,10 @@ async def _process_message(self, message: Dict[str, Any]) -> None: timestamp=datetime.now(), ) logger.info("[%s] inbound from=%s type=%s media=%d", self.name, _safe_id(sender_id), source.chat_type, len(media_paths)) - await self.handle_message(event) + if event.message_type == MessageType.TEXT: + self._enqueue_text_event(event) + else: + await self.handle_message(event) def _is_dm_allowed(self, sender_id: str) -> bool: if self._dm_policy == "disabled": @@ -1450,6 +1464,59 @@ def _is_dm_allowed(self, sender_id: str) -> bool: return sender_id in self._allow_from return True + # ------------------------------------------------------------------ + # Text debounce batching + # ------------------------------------------------------------------ + + _SPLIT_THRESHOLD = 1800 + + def _text_batch_key(self, event: MessageEvent) -> str: + from gateway.session import build_session_key + return build_session_key( + event.source, + group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), + thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + ) + + def _enqueue_text_event(self, event: MessageEvent) -> None: + key = self._text_batch_key(event) + existing = self._pending_text_batches.get(key) + chunk_len = len(event.text or "") + if existing is None: + event._last_chunk_len = chunk_len + self._pending_text_batches[key] = event + else: + if event.text: + existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text + existing._last_chunk_len = chunk_len + if event.media_urls: + existing.media_urls.extend(event.media_urls) + existing.media_types.extend(event.media_types) + prior_task = self._pending_text_batch_tasks.get(key) + if prior_task and not prior_task.done(): + prior_task.cancel() + self._pending_text_batch_tasks[key] = asyncio.create_task( + self._flush_text_batch(key) + ) + + async def _flush_text_batch(self, key: str) -> None: + current_task = asyncio.current_task() + try: + pending = self._pending_text_batches.get(key) + last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0 + if last_len >= self._SPLIT_THRESHOLD: + delay = self._text_batch_split_delay_seconds + else: + delay = self._text_batch_delay_seconds + await asyncio.sleep(delay) + event = self._pending_text_batches.pop(key, None) + if not event: + return + await self.handle_message(event) + finally: + if self._pending_text_batch_tasks.get(key) is current_task: + self._pending_text_batch_tasks.pop(key, None) + async def _collect_media(self, item: Dict[str, Any], media_paths: List[str], media_types: List[str]) -> None: item_type = item.get("type") if item_type == ITEM_IMAGE: diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index 8e21736441c2..a128e4fc0cd0 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -278,6 +278,12 @@ def __init__(self, config: PlatformConfig): # notification before the normal "✓ whatsapp disconnected" fires. self._shutting_down: bool = False + # Text debounce batching (mirrors Telegram adapter pattern) + self._text_batch_delay_seconds = float(os.getenv("HERMES_WHATSAPP_TEXT_BATCH_DELAY_SECONDS", "5.0")) + self._text_batch_split_delay_seconds = float(os.getenv("HERMES_WHATSAPP_TEXT_BATCH_SPLIT_DELAY_SECONDS", "10.0")) + self._pending_text_batches: Dict[str, MessageEvent] = {} + self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {} + def _effective_reply_prefix(self) -> str: """Return the prefix the Node bridge will add in self-chat mode.""" whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat") @@ -734,6 +740,13 @@ async def disconnect(self) -> None: pass self._poll_task = None + # Cancel pending text batches + for task in self._pending_text_batch_tasks.values(): + if not task.done(): + task.cancel() + self._pending_text_batches.clear() + self._pending_text_batch_tasks.clear() + # Close the persistent HTTP session if self._http_session and not self._http_session.closed: await self._http_session.close() @@ -1077,7 +1090,10 @@ async def _poll_messages(self) -> None: for msg_data in messages: event = await self._build_message_event(msg_data) if event: - await self.handle_message(event) + if event.message_type == MessageType.TEXT: + self._enqueue_text_event(event) + else: + await self.handle_message(event) except asyncio.CancelledError: break except Exception as e: @@ -1090,6 +1106,66 @@ async def _poll_messages(self) -> None: await asyncio.sleep(1) # Poll interval + # ── Text debounce batching ────────────────────────────────────── + + _SPLIT_THRESHOLD = 6000 # WhatsApp supports ~65K; use generous threshold + + def _text_batch_key(self, event: MessageEvent) -> str: + """Session-scoped key for text message batching.""" + from gateway.session import build_session_key + return build_session_key( + event.source, + group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), + thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + ) + + def _enqueue_text_event(self, event: MessageEvent) -> None: + """Buffer a text event and reset the flush timer. + + When WhatsApp delivers rapid-fire messages (e.g. forwarded + batches), this concatenates them and waits for a short quiet + period before dispatching the combined message. + """ + key = self._text_batch_key(event) + existing = self._pending_text_batches.get(key) + chunk_len = len(event.text or "") + if existing is None: + event._last_chunk_len = chunk_len # type: ignore[attr-defined] + self._pending_text_batches[key] = event + else: + if event.text: + existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text + existing._last_chunk_len = chunk_len # type: ignore[attr-defined] + if event.media_urls: + existing.media_urls.extend(event.media_urls) + existing.media_types.extend(event.media_types) + + prior_task = self._pending_text_batch_tasks.get(key) + if prior_task and not prior_task.done(): + prior_task.cancel() + self._pending_text_batch_tasks[key] = asyncio.create_task( + self._flush_text_batch(key) + ) + + async def _flush_text_batch(self, key: str) -> None: + """Wait for quiet period then dispatch aggregated text.""" + current_task = asyncio.current_task() + try: + pending = self._pending_text_batches.get(key) + last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0 + if last_len >= self._SPLIT_THRESHOLD: + delay = self._text_batch_split_delay_seconds + else: + delay = self._text_batch_delay_seconds + await asyncio.sleep(delay) + event = self._pending_text_batches.pop(key, None) + if not event: + return + await self.handle_message(event) + finally: + if self._pending_text_batch_tasks.get(key) is current_task: + self._pending_text_batch_tasks.pop(key, None) + async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]: """Build a MessageEvent from bridge message data, downloading images to cache.""" try: diff --git a/gateway/run.py b/gateway/run.py index 98d7e90cad63..2bf990cead31 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -52,6 +52,7 @@ from agent.account_usage import fetch_account_usage, render_account_usage_lines from agent.i18n import t from hermes_cli.config import cfg_get +from hermes_cli.plugins import get_plugin_manager # --- Agent cache tuning --------------------------------------------------- # Bounds the per-session AIAgent cache to prevent unbounded growth in @@ -3353,6 +3354,7 @@ async def start(self) -> bool: # Discover and load event hooks self.hooks.discover_and_load() + get_plugin_manager().discover_and_load() # Recover background processes from checkpoint (crash recovery) diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index cfd5e9f8d8a3..d926456e886b 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -25,6 +25,12 @@ logger = logging.getLogger("gateway.stream_consumer") + +def _apply_streaming_text_hooks(text: str, event: "MessageEvent", session_id: Optional[str] = None) -> tuple[str, str]: + """Same as _apply_text_hooks but for streaming output.""" + from gateway.platforms.base import _apply_text_hooks + return _apply_text_hooks(text, event, session_id) + # Sentinel to signal the stream is complete _DONE = object() @@ -421,6 +427,11 @@ async def run(self) -> None: # here instead of letting the base gateway path send the # full response again. if self._accumulated: + # Apply pre_gateway_text_send hooks to streamed text + _stream_action, self._accumulated = _apply_streaming_text_hooks(self._accumulated, None) + if _stream_action == "block": + self._accumulated = "" + return if self._fallback_final_send: await self._send_fallback_final(self._accumulated) elif ( diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 15ef7920a15a..14939fb616ae 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -165,6 +165,7 @@ def _install_plugin_debug_handler(force: bool = False) -> None: # choice: "once" | "session" | "always" | "deny" | "timeout" "pre_approval_request", "post_approval_response", + "pre_gateway_text_send", # rewrite/block outbound text before platform send } ENTRY_POINTS_GROUP = "hermes_agent.plugins" diff --git a/sessions.db b/sessions.db new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tools/mixture_of_agents_tool.py b/tools/mixture_of_agents_tool.py index a34e99aa8f70..3798c5fc1af9 100644 --- a/tools/mixture_of_agents_tool.py +++ b/tools/mixture_of_agents_tool.py @@ -24,9 +24,9 @@ 2. Aggregator model synthesizes responses into a high-quality output 3. Multiple layers can be used for iterative refinement (future enhancement) -Models Used (via OpenRouter): -- Reference Models: claude-opus-4.6, gemini-3-pro-preview, gpt-5.4-pro, deepseek-v3.2 -- Aggregator Model: claude-opus-4.6 (highest capability for synthesis) +Models Used (via Requesty or OpenRouter): +- Reference Models: claude-opus-4-7, gemini-3.1-pro, gpt-5.5, deepseek-v4-pro, GLM-5.1 +- Aggregator Model: claude-opus-4-7 (highest capability for synthesis) Configuration: To customize the MoA setup, modify the configuration constants at the top of this file: @@ -57,19 +57,42 @@ logger = logging.getLogger(__name__) +# Requesty router support +_REQUESTY_BASE_URL = "https://router.requesty.ai/v1" + +# OpenRouter base URL (fallback) +_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" + +def _get_requesty_key() -> str | None: + """Lazy-read Requesty API key from environment or .env file.""" + import os + key = os.environ.get("REQUESTY_API_KEY") + if key: + return key + # Try .env files + for env_path in [os.path.expanduser("~/.hermes/.env"), os.path.expanduser("~/.hermes/profiles/dev/.env")]: + if os.path.isfile(env_path): + with open(env_path) as f: + for line in f: + line = line.strip() + if line.startswith("REQUESTY_API_KEY="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return None + # Configuration for MoA processing # Reference models - these generate diverse initial responses in parallel. # Keep this list aligned with current top-tier OpenRouter frontier options. REFERENCE_MODELS = [ - "anthropic/claude-opus-4.6", - "google/gemini-2.5-pro", - "openai/gpt-5.4-pro", - "deepseek/deepseek-v3.2", + "anthropic/claude-opus-4-7", + "google/gemini-3.1-pro", + "openai/gpt-5.5", + "deepseek/deepseek-v4-pro", + "zhipu/GLM-5.1", ] # Aggregator model - synthesizes reference responses into final output. # Prefer the strongest synthesis model in the current OpenRouter lineup. -AGGREGATOR_MODEL = "anthropic/claude-opus-4.6" +AGGREGATOR_MODEL = "anthropic/claude-opus-4-7" # Temperature settings optimized for MoA performance REFERENCE_TEMPERATURE = 0.6 # Balanced creativity for diverse perspectives @@ -298,9 +321,17 @@ async def mixture_of_agents_tool( logger.info("Starting Mixture-of-Agents processing...") logger.info("Query: %s", user_prompt[:100]) - # Validate API key availability - if not os.getenv("OPENROUTER_API_KEY"): - raise ValueError("OPENROUTER_API_KEY environment variable not set") + # Validate API key availability — prefer Requesty, fallback to OpenRouter + openrouter_key = os.getenv("OPENROUTER_API_KEY") + requesty_key = _get_requesty_key() + if requesty_key: + base_url = _REQUESTY_BASE_URL + api_key = requesty_key + elif openrouter_key: + base_url = _OPENROUTER_BASE_URL + api_key = openrouter_key + else: + return "Error: No API key found. Set REQUESTY_API_KEY or OPENROUTER_API_KEY." # Use provided models or defaults ref_models = reference_models or REFERENCE_MODELS @@ -535,7 +566,7 @@ def get_moa_configuration() -> Dict[str, Any]: schema=MOA_SCHEMA, handler=lambda args, **kw: mixture_of_agents_tool(user_prompt=args.get("user_prompt", "")), check_fn=check_moa_requirements, - requires_env=["OPENROUTER_API_KEY"], + requires_env=[], is_async=True, emoji="🧠", ) From 2854c5126beb754c458e3a7b5db61a934c2f419a Mon Sep 17 00:00:00 2001 From: RedPiggy Date: Sun, 10 May 2026 11:42:42 +0800 Subject: [PATCH 2/4] chore: clean branch state after patch re-apply From c0c621095d186ba054fd8b5b227a47d64b2f3968 Mon Sep 17 00:00:00 2001 From: RedPiggy Date: Sun, 10 May 2026 11:59:54 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20PATCH-004=20Optional[str]=20?= =?UTF-8?q?=E2=86=92=20str=20|=20None=20(NameError=20at=20import,=20full?= =?UTF-8?q?=20outage)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 85bf68f11716..c072d955af21 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -24,7 +24,7 @@ logger = logging.getLogger(__name__) -def _apply_text_hooks(text: str, event: "MessageEvent", session_id: Optional[str] = None) -> tuple[str, str]: +def _apply_text_hooks(text: str, event: "MessageEvent", session_id: str | None = None) -> tuple[str, str]: """Apply pre_gateway_text_send hooks. Returns (action, text). action is 'allow', 'block', or 'rewrite'. """ From 359d8fc0bafc37e8d186a21ab6e3c022e93a6fc3 Mon Sep 17 00:00:00 2001 From: RedPiggy Date: Mon, 11 May 2026 09:15:58 +0800 Subject: [PATCH 4/4] fix(skill-tools): follow symlinks in skill discovery via rglob_follow Python's Path.rglob() does not descend into symlinked directories (https://bugs.python.org/issue40358). When users symlink their skills directory (e.g. ~/.hermes/skills/redpiggy -> workspace/skills), skill_manage, skill_view, and skill_usage fail to discover any skills inside the symlinked tree. Add rglob_follow() helper to agent/skill_utils.py that uses os.walk(followlinks=True) and replace the critical rglob calls in: - tools/skill_manager_tool.py (_find_skill) - tools/skill_usage.py (list_agent_created_skill_names, _find_skill_dir) Fixes #8293 --- agent/skill_utils.py | 24 +++++++++++ tests/agent/test_skill_utils.py | 75 +++++++++++++++++++++++++++++++++ tools/skill_manager_tool.py | 4 +- tools/skill_usage.py | 6 ++- 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 28424d7ed622..f8523232f260 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -26,6 +26,30 @@ EXCLUDED_SKILL_DIRS = frozenset((".git", ".github", ".hub", ".archive")) + +def rglob_follow(root: Path, pattern: str): + """Like ``Path.rglob(pattern)`` but follows symlinks into subdirectories. + + Python's ``Path.rglob()`` intentionally does **not** descend into + symlinked directories (see https://bugs.python.org/issue40358). + This helper uses ``os.walk(followlinks=True)`` so that symlinked + skill directories (e.g. ``~/.hermes/skills/redpiggy -> workspace/skills``) + are discovered correctly. + """ + import fnmatch + + for dirpath, dirnames, filenames in os.walk(str(root), followlinks=True): + # Prune excluded dirs (same as rglob's natural traversal) + dirnames[:] = [d for d in dirnames if d not in EXCLUDED_SKILL_DIRS] + dirpath_p = Path(dirpath) + for fname in filenames: + if fnmatch.fnmatch(fname, pattern): + yield dirpath_p / fname + # Also match the pattern against directory names if needed + for dname in dirnames: + if fnmatch.fnmatch(dname, pattern): + yield dirpath_p / dname + # ── Lazy YAML loader ───────────────────────────────────────────────────── _yaml_load_fn = None diff --git a/tests/agent/test_skill_utils.py b/tests/agent/test_skill_utils.py index 206cc5f4b11b..1b1053e55edc 100644 --- a/tests/agent/test_skill_utils.py +++ b/tests/agent/test_skill_utils.py @@ -56,3 +56,78 @@ def test_metadata_missing_entirely(): "fallback_for_tools": [], "requires_tools": [], } + + +# ── rglob_follow tests ──────────────────────────────────────────────────── + +from agent.skill_utils import rglob_follow + + +def test_rglob_follow_finds_through_symlink(tmp_path): + """rgollow should descend into symlinked directories.""" + real = tmp_path / "real-skills" / "alpha" + real.mkdir(parents=True) + (real / "SKILL.md").write_text("name: alpha") + + root = tmp_path / "skills" + root.mkdir() + (root / "alpha").symlink_to(real) + + results = list(rglob_follow(root, "SKILL.md")) + names = [r.parent.name for r in results] + assert "alpha" in names + + +def test_rglob_follow_finds_nested_through_symlink(tmp_path): + """rgollow handles category//SKILL.md inside symlinked dirs.""" + real = tmp_path / "workspace" / "skills" + for name in ["skill-a", "skill-b", "skill-c"]: + d = real / name + d.mkdir(parents=True) + (d / "SKILL.md").write_text(f"name: {name}") + + root = tmp_path / "hermes-skills" + root.mkdir() + (root / "my-skills").symlink_to(real) + + results = list(rglob_follow(root, "SKILL.md")) + names = {r.parent.name for r in results} + assert names == {"skill-a", "skill-b", "skill-c"} + + +def test_rglob_follow_skips_excluded_dirs(tmp_path): + """Excluded dirs like .archive and .git should be pruned.""" + root = tmp_path / "skills" + (root / "good").mkdir(parents=True) + (root / "good" / "SKILL.md").write_text("name: good") + (root / ".archive").mkdir() + (root / ".archive" / "old").mkdir() + (root / ".archive" / "old" / "SKILL.md").write_text("name: old") + (root / ".git").mkdir() + + results = list(rglob_follow(root, "SKILL.md")) + names = [r.parent.name for r in results] + assert "good" in names + assert "old" not in names + + +def test_rglob_follow_matches_directory_names(tmp_path): + """rgollow can also match directory names, not just files.""" + root = tmp_path / "skills" + (root / "alpha").mkdir(parents=True) + (root / "alpha" / "SKILL.md").write_text("") + + results = list(rglob_follow(root, "alpha")) + assert any(r.is_dir() for r in results) + + +def test_rglob_follow_finds_regular_files(tmp_path): + """rgollow also works on regular (non-symlinked) directory trees.""" + root = tmp_path / "skills" + for name in ["a", "b", "c"]: + d = root / name + d.mkdir(parents=True) + (d / "SKILL.md").write_text(f"name: {name}") + + results = list(rglob_follow(root, "SKILL.md")) + assert len(results) == 3 diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index d253cd2a7cd6..abe9104b7556 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -283,11 +283,11 @@ def _find_skill(name: str) -> Optional[Dict[str, Any]]: external dirs configured via skills.external_dirs. Returns {"path": Path} or None. """ - from agent.skill_utils import EXCLUDED_SKILL_DIRS, get_all_skills_dirs + from agent.skill_utils import EXCLUDED_SKILL_DIRS, get_all_skills_dirs, rglob_follow for skills_dir in get_all_skills_dirs(): if not skills_dir.exists(): continue - for skill_md in skills_dir.rglob("SKILL.md"): + for skill_md in rglob_follow(skills_dir, "SKILL.md"): if any(part in EXCLUDED_SKILL_DIRS for part in skill_md.parts): continue if skill_md.parent.name == name: diff --git a/tools/skill_usage.py b/tools/skill_usage.py index e25f1365446a..32a37780bc98 100644 --- a/tools/skill_usage.py +++ b/tools/skill_usage.py @@ -232,7 +232,8 @@ def list_agent_created_skill_names() -> List[str]: names: List[str] = [] # Top-level SKILL.md files (flat layout) AND nested category/skill/SKILL.md - for skill_md in base.rglob("SKILL.md"): + from agent.skill_utils import rglob_follow + for skill_md in rglob_follow(base, "SKILL.md"): # Skip anything under .archive or .hub try: rel = skill_md.relative_to(base) @@ -573,7 +574,8 @@ def _find_skill_dir(skill_name: str) -> Optional[Path]: base = _skills_dir() if not base.exists(): return None - for skill_md in base.rglob("SKILL.md"): + from agent.skill_utils import rglob_follow + for skill_md in rglob_follow(base, "SKILL.md"): try: rel = skill_md.relative_to(base) except ValueError: