hermes - ✅(Solved) Fix [Bug]: _build_skill_message fallback rglob can inject thousands of files into context from nested dependency dirs [4 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
NousResearch/hermes-agent#18675Fetched 2026-05-03 04:54:59
View on GitHub
Comments
1
Participants
2
Timeline
10
Reactions
0
Timeline (top)
cross-referenced ×4labeled ×4commented ×1referenced ×1

Root Cause

Root cause: scan rule mismatch

Fix Action

Fix / Workaround

  1. rglob should skip well-known large directories: .git, node_modules, .venv, venv, __pycache__, .tox, .pytest_cache, .mypy_cache, .ruff_cache
  2. There should be a hard cap on supporting file count (e.g. 200)
  3. Empty files should be skipped
  4. The scanning rules between skill_view and the fallback should be consistent — if skill_view intentionally uses non-recursive glob, the fallback shouldn't silently upgrade to recursive rglob

PR fix notes

PR #18677: fix(skills): bound fallback supporting file scan

Description (problem / solution / changelog)

Fixes #18675.

Summary

  • replace the unbounded fallback rglob("*") supporting-file scan with a deterministic scanner that prunes dependency/cache directories before descent
  • cap supporting-file hints at 200 entries and emit a truncation note outside the file-path list
  • skip empty files in the fallback scan
  • add regression coverage for nested node_modules, empty files, and the 200-file cap

Verification

  • scripts/run_tests.sh tests/agent/test_skill_commands.py::TestSkillDirectoryHeader::test_fallback_supporting_file_scan_skips_dependency_dirs_and_caps
  • scripts/run_tests.sh tests/agent/test_skill_commands.py
  • git diff --check origin/main...HEAD

Changed files

  • agent/skill_commands.py (modified, +54/-7)
  • tests/agent/test_skill_commands.py (modified, +26/-0)

PR #18717: fix(agent): preserve _skill_commands cache on scan failure; cap rglob fallback (#18659, #18675)

Description (problem / solution / changelog)

Summary

Fixes two related bugs in agent/skill_commands.py:

Fix 1 — #18659: Cache cleared on scan failure (P2)

Root cause: scan_skill_commands() set _skill_commands = {} unconditionally before the try block that populated it. Any exception (broken import, unreadable skills dir, etc.) left the global permanently empty with no user-visible error.

Fix: Build results into a local fresh dict inside the try block. On success, atomically replace the global. On failure, log a WARNING and leave the previous cache intact so all previously-registered slash commands keep working.

# Before
_skill_commands = {}    # ← clears BEFORE try — data loss on exception
try:
    ...populate...
except Exception:
    pass                # ← silently swallowed, global stays empty

# After
fresh = {}
try:
    ...populate fresh...
except Exception as exc:
    logger.warning("scan_skill_commands: scan failed, preserving %d cached command(s). Error: %s", ...)
    return _skill_commands   # ← previous cache preserved
_skill_commands = fresh      # ← atomic replace only on success

Fix 2 — #18675: rglob fallback injects node_modules (P2)

Root cause: The _build_skill_message fallback path used subdir.rglob("*") with no directory exclusions or file count cap. Skills with scripts/node_modules/ could inject thousands of file paths into the model context.

Fix: Mirror skill_view()'s intent by:

  1. Skipping any path whose components include common package-manager dirs: node_modules, .venv, venv, __pycache__, .git, .tox, dist, build, .mypy_cache
  2. Capping at _MAX_SUPPORTING_FILES = 50

Tests Added

  • test_preserves_cache_on_total_scan_failure — RuntimeError inside scan leaves cache intact
  • test_preserves_cache_on_import_failure — ImportError inside scan leaves cache intact
  • TestBuildSkillMessageRglobFallback.test_node_modules_excluded_from_fallback_scan — node_modules paths don't appear in skill message
  • TestBuildSkillMessageRglobFallback.test_fallback_caps_at_max_files — 200 reference files → ≤50 listed

Checklist

  • Fixes root cause, not symptom
  • Backward compatible — successful scans behave identically
  • Tests cover both failure modes
  • Logging added for scan failure (warning, not silent)

Changed files

  • agent/skill_commands.py (modified, +48/-9)
  • tests/agent/test_skill_commands.py (modified, +112/-0)

PR #18724: fix: skip dependency dirs in skill rglob fallback to prevent context injection

Description (problem / solution / changelog)

Summary

_build_skill_message() and skill_view() use rglob('*') to discover supporting files when linked_files is empty. This traverses into node_modules/, .venv/, __pycache__/ and similar directories, injecting thousands of file paths into the agent context — silently inflating token usage and potentially causing context overflow.

Changes

agent/skill_commands.py_build_skill_message() fallback scan:

  • Added _SKIP_DIRS frozenset: .git, .hg, .svn, node_modules, .venv, venv, .tox, __pycache__, .pytest_cache, .mypy_cache, .ruff_cache
  • Added _MAX_SUPPORTING_FILES = 200 hard cap with truncation message
  • Skip empty files (f.stat().st_size > 0)
  • Skip any path whose parts contain a skip-dir name

tools/skills_tool.pyskill_view() file scan:

  • Same _SKIP_DIRS filter on skill_dir.rglob("*") (line 1111)
  • Same filter + empty-file check on assets_dir.rglob("*") (line 1210)

Root Cause

skill_view uses non-recursive glob(ext) for scripts/ and references/, but the fallback in _build_skill_message uses recursive rglob("*") with no exclusions. When skill_view returns linked_files: null (e.g. scripts/ only contains node_modules/ with no top-level .py/.sh/.js), the unfiltered fallback kicks in and scans everything.

Fixes #18675

Changed files

  • agent/skill_commands.py (modified, +20/-1)
  • tools/skills_tool.py (modified, +8/-2)

PR #18735: fix: resolve 7 identified issues [automated]

Description (problem / solution / changelog)

Summary

This automated PR resolves 7 identified upstream issues focusing on reliability, cross-platform behavior, and security hardening.

Resolved issues

  1. #18722 — cron jobs with next_run_at: null now recover for recurring schedules; scheduler now tolerates non-dict origin values.

    • Files: cron/jobs.py, cron/scheduler.py, tests/cron/test_jobs.py, tests/cron/test_scheduler.py
  2. #18705 — dotenv loading no longer overrides runtime-injected environment variables.

    • Files: hermes_cli/env_loader.py, tests/hermes_cli/test_env_loader.py
  3. #18659scan_skill_commands no longer clears cached commands before a successful rescan.

    • Files: agent/skill_commands.py
  4. #18675 — skill fallback file scan now skips heavy dependency directories and enforces a file cap.

    • Files: agent/skill_commands.py
  5. #18617 — context compressor now synchronizes threshold_percent correctly across model switch, fallback activation, and primary restoration.

    • Files: run_agent.py
  6. #18681 — custom provider /model path now correctly carries provider credentials during model verification path in this branch baseline (already included in upstream branch state; preserved in final branch history).

    • Files: gateway/run.py (resolved in branch baseline)
  7. #18707 — request debug dumps are now redacted before writing to disk/stdout to avoid plaintext secret leakage.

    • Files: run_agent.py

Validation

  • python3 -m py_compile run_agent.py cron/jobs.py cron/scheduler.py hermes_cli/env_loader.py agent/skill_commands.py gateway/run.py
  • pytest -n 0 tests/hermes_cli/test_env_loader.py tests/gateway/test_model_command_custom_providers.py tests/cron/test_jobs.py::TestGetDueJobs::test_broken_cron_without_next_run_is_recovered tests/cron/test_scheduler.py::TestResolveOrigin::test_non_dict_origin_tolerated tests/agent/test_skill_commands.py tests/agent/test_skill_commands_reload.py

Changed files

  • Dockerfile (modified, +3/-2)
  • acp_adapter/session.py (modified, +12/-0)
  • agent/auxiliary_client.py (modified, +280/-28)
  • agent/context_compressor.py (modified, +496/-52)
  • agent/skill_commands.py (modified, +18/-4)
  • agent/title_generator.py (modified, +2/-2)
  • agent/transports/chat_completions.py (modified, +14/-0)
  • agent/usage_pricing.py (modified, +4/-0)
  • cli-config.yaml.example (modified, +5/-0)
  • cli.py (modified, +27/-3)
  • cron/jobs.py (modified, +13/-2)
  • cron/scheduler.py (modified, +14/-4)
  • docker/entrypoint.sh (modified, +9/-1)
  • gateway/channel_directory.py (modified, +14/-4)
  • gateway/platforms/discord.py (modified, +33/-7)
  • gateway/platforms/email.py (modified, +12/-2)
  • gateway/platforms/feishu.py (modified, +34/-1)
  • gateway/platforms/qqbot/adapter.py (modified, +8/-2)
  • gateway/platforms/telegram_network.py (modified, +7/-2)
  • gateway/platforms/weixin.py (modified, +10/-1)
  • gateway/run.py (modified, +129/-32)
  • gateway/status.py (modified, +8/-1)
  • hermes_cli/auth.py (modified, +2/-2)
  • hermes_cli/commands.py (modified, +1/-1)
  • hermes_cli/config.py (modified, +271/-40)
  • hermes_cli/copilot_auth.py (modified, +1/-1)
  • hermes_cli/doctor.py (modified, +6/-1)
  • hermes_cli/env_loader.py (modified, +5/-4)
  • hermes_cli/gateway.py (modified, +16/-13)
  • hermes_cli/main.py (modified, +69/-3)
  • hermes_cli/memory_setup.py (modified, +1/-1)
  • hermes_cli/model_switch.py (modified, +6/-1)
  • hermes_cli/models.py (modified, +60/-2)
  • hermes_cli/profiles.py (modified, +16/-3)
  • hermes_cli/runtime_provider.py (modified, +16/-13)
  • hermes_cli/setup.py (modified, +8/-2)
  • hermes_cli/slack_cli.py (modified, +1/-2)
  • hermes_cli/status.py (modified, +17/-2)
  • hermes_cli/web_server.py (modified, +1/-1)
  • hermes_constants.py (modified, +16/-3)
  • model_tools.py (modified, +44/-13)
  • run_agent.py (modified, +408/-84)
  • setup-hermes.sh (modified, +23/-12)
  • skills/red-teaming/godmode/scripts/load_godmode.py (modified, +9/-8)
  • tests/agent/test_context_compressor.py (modified, +389/-0)
  • tests/agent/transports/test_chat_completions.py (modified, +11/-0)
  • tests/cron/test_jobs.py (modified, +26/-0)
  • tests/cron/test_scheduler.py (modified, +4/-0)
  • tests/gateway/test_compress_command.py (modified, +49/-0)
  • tests/hermes_cli/test_api_key_providers.py (modified, +5/-5)
  • tests/hermes_cli/test_config.py (modified, +17/-0)
  • tests/hermes_cli/test_env_loader.py (modified, +6/-6)
  • tests/run_agent/test_413_compression.py (modified, +81/-1)
  • tests/run_agent/test_compression_boundary_hook.py (modified, +42/-0)
  • tests/run_agent/test_run_agent.py (modified, +100/-13)
  • tests/tools/test_skill_manager_tool.py (modified, +270/-0)
  • tools/approval.py (modified, +1/-1)
  • tools/delegate_tool.py (modified, +4/-1)
  • tools/environments/docker.py (modified, +36/-5)
  • tools/environments/local.py (modified, +8/-1)
  • tools/file_operations.py (modified, +70/-67)
  • tools/file_tools.py (modified, +13/-2)
  • tools/send_message_tool.py (modified, +72/-2)
  • tools/session_search_tool.py (modified, +2/-2)
  • tools/skill_manager_tool.py (modified, +82/-21)
  • tools/skills_tool.py (modified, +13/-1)
  • tools/terminal_tool.py (modified, +6/-0)
  • tools/tool_backend_helpers.py (modified, +15/-5)
  • tools/tts_tool.py (modified, +27/-16)
  • tools/voice_mode.py (modified, +23/-10)
  • toolsets.py (modified, +14/-1)
  • tui_gateway/server.py (modified, +5/-3)
  • ui-tui/src/app/turnController.ts (modified, +1/-1)
  • ui-tui/src/app/useInputHandlers.ts (modified, +8/-3)
  • ui-tui/src/app/useSessionLifecycle.ts (modified, +1/-1)
  • ui-tui/src/gatewayTypes.ts (modified, +1/-0)
  • utils.py (modified, +9/-0)
  • uv.lock (modified, +161/-2)
  • website/docs/reference/environment-variables.md (modified, +1/-1)

Code Example

# skills_tool.py:1216NON-recursive, extension-filtered
for ext in ["*.py", "*.sh", "*.bash", "*.js", "*.ts", "*.rb"]:
    script_files.extend([str(f.relative_to(skill_dir)) for f in scripts_dir.glob(ext)])

---

# skill_commands.py:183RECURSIVE, unfiltered
for f in sorted(subdir_path.rglob("*")):
    if f.is_file() and not f.is_symlink():
        rel = str(f.relative_to(skill_dir))
        supporting.append(rel)

---

# 1. Create a skill with a nested dependency directory
mkdir -p ~/.hermes/skills/test-rglob/scripts/node_modules/fake-dep/lib
cat > ~/.hermes/skills/test-rglob/SKILL.md << 'EOF'
---
name: test-rglob
description: Test skill
---
# Test
EOF

# 2. Populate node_modules with many files (no top-level .py/.sh/.js in scripts/)
for i in $(seq 1 5000); do
  echo "// file $i" > ~/.hermes/skills/test-rglob/scripts/node_modules/fake-dep/lib/module_$i.js
done

# 3. Invoke the skill — context gets 5000+ file paths injected
# The supporting files block in the skill message will list every file

---

--- a/agent/skill_commands.py
+++ b/agent/skill_commands.py
@@ -176,11 +176,30 @@ def _build_skill_message(
         if isinstance(entries, list):
             supporting.extend(entries)

+    _SKIP_DIRS = frozenset({
+        ".git", ".hg", ".svn",
+        "node_modules", ".venv", "venv", ".tox",
+        "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache",
+    })
+    _MAX_SUPPORTING_FILES = 200
+
     if not supporting and skill_dir:
         for subdir in ("references", "templates", "scripts", "assets"):
             subdir_path = skill_dir / subdir
             if subdir_path.exists():
                 for f in sorted(subdir_path.rglob("*")):
-                    if f.is_file() and not f.is_symlink():
+                    if any(part in _SKIP_DIRS for part in f.parts):
+                        continue
+                    if f.is_file() and not f.is_symlink() and f.stat().st_size > 0:
                         rel = str(f.relative_to(skill_dir))
                         supporting.append(rel)
+                        if len(supporting) >= _MAX_SUPPORTING_FILES:
+                            supporting.append(
+                                f"... (truncated, {_MAX_SUPPORTING_FILES} file limit)"
+                            )
+                            break
+                if len(supporting) >= _MAX_SUPPORTING_FILES:
+                    break
RAW_BUFFERClick to expand / collapse

Bug Description

agent/skill_commands.py:179-186 contains an unfiltered rglob("*") fallback that recursively scans supporting file directories. When skill_view() returns linked_files: null (which happens when none of the four subdirectories — references/, templates/, scripts/, assets/ — contain files matching skill_view's extension-based patterns), the fallback kicks in and scans all four subdirectories with rglob("*") — no directory exclusions, no depth limit, no file count cap.

If a skill has e.g. scripts/node_modules/ or scripts/.venv/ with thousands of files, they all get listed in the supporting files block and injected into the context.

Root cause: scan rule mismatch

skill_view uses non-recursive glob(ext) for scripts/ and references/:

# skills_tool.py:1216 — NON-recursive, extension-filtered
for ext in ["*.py", "*.sh", "*.bash", "*.js", "*.ts", "*.rb"]:
    script_files.extend([str(f.relative_to(skill_dir)) for f in scripts_dir.glob(ext)])

The fallback in _build_skill_message uses recursive rglob("*") with no filter:

# skill_commands.py:183 — RECURSIVE, unfiltered
for f in sorted(subdir_path.rglob("*")):
    if f.is_file() and not f.is_symlink():
        rel = str(f.relative_to(skill_dir))
        supporting.append(rel)

When script_files is empty (e.g. scripts/ only contains node_modules/ with zero top-level .py/.sh/.js files), skill_view returns linked_files: null, and the unfiltered rglob fallback takes over.

Steps to Reproduce

# 1. Create a skill with a nested dependency directory
mkdir -p ~/.hermes/skills/test-rglob/scripts/node_modules/fake-dep/lib
cat > ~/.hermes/skills/test-rglob/SKILL.md << 'EOF'
---
name: test-rglob
description: Test skill
---
# Test
EOF

# 2. Populate node_modules with many files (no top-level .py/.sh/.js in scripts/)
for i in $(seq 1 5000); do
  echo "// file $i" > ~/.hermes/skills/test-rglob/scripts/node_modules/fake-dep/lib/module_$i.js
done

# 3. Invoke the skill — context gets 5000+ file paths injected
# The supporting files block in the skill message will list every file

Expected Behavior

  1. rglob should skip well-known large directories: .git, node_modules, .venv, venv, __pycache__, .tox, .pytest_cache, .mypy_cache, .ruff_cache
  2. There should be a hard cap on supporting file count (e.g. 200)
  3. Empty files should be skipped
  4. The scanning rules between skill_view and the fallback should be consistent — if skill_view intentionally uses non-recursive glob, the fallback shouldn't silently upgrade to recursive rglob

Actual Behavior

The fallback unconditionally traverses every subdirectory with rglob("*"), potentially dumping tens of thousands of file paths into the skill message. This inflates context usage (token cost + cache invalidation), and in extreme cases can cause the agent call to fail due to context overflow.

Affected Component

  • Primary: agent/skill_commands.py:179-186_build_skill_message() fallback supporting-files scan
  • Related: tools/skills_tool.py:1210skill_view() also uses rglob("*") for assets/, same vulnerability but less likely to trigger since assets/ rarely contains dependency dirs
  • Downstream: build_skill_invocation_message() and build_preloaded_skills_prompt() — both call _build_skill_message

Debug Report

N/A — no crash or stack trace. The bug manifests as silently inflated context with thousands of unnecessary file paths. The agent call may succeed but incurs significant token overhead and cache invalidation.

Proposed Fix

--- a/agent/skill_commands.py
+++ b/agent/skill_commands.py
@@ -176,11 +176,30 @@ def _build_skill_message(
         if isinstance(entries, list):
             supporting.extend(entries)

+    _SKIP_DIRS = frozenset({
+        ".git", ".hg", ".svn",
+        "node_modules", ".venv", "venv", ".tox",
+        "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache",
+    })
+    _MAX_SUPPORTING_FILES = 200
+
     if not supporting and skill_dir:
         for subdir in ("references", "templates", "scripts", "assets"):
             subdir_path = skill_dir / subdir
             if subdir_path.exists():
                 for f in sorted(subdir_path.rglob("*")):
-                    if f.is_file() and not f.is_symlink():
+                    if any(part in _SKIP_DIRS for part in f.parts):
+                        continue
+                    if f.is_file() and not f.is_symlink() and f.stat().st_size > 0:
                         rel = str(f.relative_to(skill_dir))
                         supporting.append(rel)
+                        if len(supporting) >= _MAX_SUPPORTING_FILES:
+                            supporting.append(
+                                f"... (truncated, {_MAX_SUPPORTING_FILES} file limit)"
+                            )
+                            break
+                if len(supporting) >= _MAX_SUPPORTING_FILES:
+                    break

Operating System

macOS (platform-independent bug — affects all OS)

extent analysis

TL;DR

The proposed fix involves modifying the _build_skill_message function in agent/skill_commands.py to skip well-known large directories, implement a hard cap on supporting file count, and skip empty files.

Guidance

  • Apply the proposed fix to agent/skill_commands.py to address the inconsistent scanning rules and prevent the fallback from dumping thousands of file paths into the skill message.
  • Verify that the fix works by testing the skill with a nested dependency directory and checking that the supporting files block in the skill message does not exceed the file count cap and skips large directories.
  • Consider reviewing other parts of the code that use rglob to ensure consistent scanning rules and prevent similar issues.
  • Test the fix on different operating systems to ensure platform independence.

Example

The proposed fix includes a code snippet that demonstrates how to modify the _build_skill_message function to skip large directories and implement a file count cap:

_SKIP_DIRS = frozenset({
    ".git", ".hg", ".svn",
    "node_modules", ".venv", "venv", ".tox",
    "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache",
})
_MAX_SUPPORTING_FILES = 200

if not supporting and skill_dir:
    for subdir in ("references", "templates", "scripts", "assets"):
        subdir_path = skill_dir / subdir
        if subdir_path.exists():
            for f in sorted(subdir_path.rglob("*")):
                if any(part in _SKIP_DIRS for part in f.parts):
                    continue
                if f.is_file() and not f.is_symlink() and f.stat().st_size > 0:
                    rel = str(f.relative_to(skill_dir))
                    supporting.append(rel)
                    if len(supporting) >= _MAX_SUPPORTING_FILES:
                        supporting.append(
                            f"... (truncated, {_MAX_SUPPORTING_FILES} file limit)"
                        )

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

hermes - ✅(Solved) Fix [Bug]: _build_skill_message fallback rglob can inject thousands of files into context from nested dependency dirs [4 pull requests, 1 comments, 2 participants]