Skip to content

fix(skills): don't report persisted skill bodies as loaded - #98736

Closed
mira-solari wants to merge 1 commit into
NousResearch:mainfrom
mira-solari:fix/skill-view-incomplete-linked-file
Closed

mira-solari wants to merge 1 commit into
NousResearch:mainfrom
mira-solari:fix/skill-view-incomplete-linked-file

Conversation

@mira-solari

@mira-solari mira-solari commented Aug 30, 2026 •

Copy link
Copy Markdown

What does this PR do?

skill_view can currently report a mandatory skill as loaded even when Hermes removed most of its body to protect the context window.

The failure has two parts:

  1. Per-result or aggregate persistence can replace the JSON payload with a generic <persisted-output> preview that still begins with {"success": true, ...} and a partial instruction body.
  2. skill_view records the load before that later replacement, so a retry can say the earlier body is “still current and complete” even though it never reached the model.

There is a second correctness issue for linked files: the recovery guidance omitted file_path. Following the suggested call therefore resolves headings and #n selectors against SKILL.md, not the linked document that produced the index.

This PR makes incomplete delivery explicit and gives the model a complete route back without increasing thresholds or weakening persistence.

Related Issue

No dedicated issue currently tracks this exact persistence-ordering and linked-file continuation bug. I searched open and closed issues/PRs before submitting.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • Added a small per-tool formatter registry that is consulted only after the existing storage layer has independently decided to persist a result. The registry is empty by default; skill_view is its only consumer.
  • Replaced an omitted skill body with a bounded [SKILL_INCOMPLETE: ...] index instead of a partial instruction preview.
  • Added hierarchical section retrieval with stable selectors, duplicate-heading disambiguation, complete-part retrieval for oversized sections, and grouped navigation when an index itself would overflow.
  • Revoked premature skill_view dedup records and restored use accounting when the body did not reach the model.
  • Preserved the real tool name for formatter lookup during aggregate enforcement while leaving threshold=0, candidate order, persistence decisions, previews, and writes unchanged.
  • Kept file_path in every recovery surface so linked-file selectors remain scoped to the document that produced them.
  • Updated the system-prompt guidance and the skill_view schema for incomplete loads and linked-file continuation.
  • Added fail-first coverage for both persistence layers, dedup truthfulness, direct consumers, section navigation, non-skill invariants, and linked-file heading collisions.

How to Test

  1. Create a skill whose SKILL.md and references/big.md both contain ## Overview and ## Omega, with different content, and make the linked file exceed the result threshold.
  2. Call skill_view(name="...", file_path="references/big.md"). It should return an incomplete marker and a heading index, with no partial body.
  3. Call the advertised selector while keeping the same file_path. It should return the linked file’s complete section, never the same-named section from SKILL.md.
  4. Run the affected suite:
HERMES_TEST_FILE_RETRIES=0 scripts/run_tests.sh -j 4 \
  tests/tools/test_skill_incomplete_load.py \
  tests/tools/test_oversized_result_formatters.py \
  tests/tools/test_skill_incomplete_direct_consumers.py \
  tests/agent/test_skill_incomplete_guidance.py \
  tests/tools/test_tool_result_storage.py \
  tests/tools/test_budget_config.py \
  tests/tools/test_skills_tool.py \
  tests/tools/test_skill_usage.py \
  tests/tools/test_skill_view_dedup.py \
  tests/tools/test_skill_view_path_check.py \
  tests/tools/test_skill_view_traversal.py \
  tests/tools/test_skill_improvements.py \
  tests/tools/test_skill_ledger.py \
  tests/tools/test_skills_tool_discovery_cache.py \
  tests/tools/test_skills_tool_profile_scope.py \
  tests/tools/test_skill_size_limits.py \
  tests/tools/test_skill_manager_tool.py \
  tests/agent/test_prompt_builder.py \
  tests/agent/test_system_prompt.py \
  tests/agent/test_ghost_skill_pruning.py \
  tests/agent/test_skills_guidance_content_filter.py \
  tests/agent/test_skill_commands.py \
  tests/agent/test_skill_invocation_description.py \
  tests/agent/test_skill_utils.py -q

Local result: 538 passed, 2 skipped, 0 failed across 24 files. The focused new modules are 78/78 green. ruff check passes on all changed files, and scripts/check-windows-footguns.py --diff HEAD^ reports no findings.

The same real two-call sequence fails on current main: the first result is a partial <persisted-output> preview and the second claims that omitted body is complete. It passes on this branch with all 11 acceptance checks true.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 26.6.2, arm64, Python 3.11

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — N/A; user-facing behavior is described in the tool schema and system guidance changed here
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A; no config change
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A; no contributor workflow change
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — no OS-specific code; Windows footgun check passes
  • I've updated tool descriptions/schemas if I changed tool behavior — skill_view schema updated

Compatibility / non-goals

  • Generic tool-result persistence remains byte-identical when no formatter is registered.
  • Preloaded/slash-command skills, both cron consumers, and the direct MCP surface still receive the full body because they do not pass through result persistence.
  • Plugin-sourced skills still ignore section=; that pre-existing limitation is not widened into this fix. An incomplete plugin skill now fails visibly instead of being falsely reported as complete.
  • I ran the affected CI-parity suite locally rather than the repository-wide suite; full cross-platform coverage is left to CI.

`skill_view` returns one JSON payload whose `content` IS the skill. Two
independent context-protection decisions in tools/tool_result_storage.py can
delete that body on its way to the model and leave a generic
`<persisted-output>` preview that still opens `{"success": true, …}` followed
by a fragment of the body. The model reads a truncated instruction set as a
loaded skill, and the repeat-view dedup cache then tells a retry the earlier
load "is still current and complete" about a body nobody ever saw.

Reproduced on main at 4f22543 through the registered handler plus layer 2:
a 353,176-char skill_view result comes back as a `<persisted-output>` block
whose preview is `{"success": true, "name": …, "content": "# Linked acceptance
reference\n\n## Overview…` — the skill, cut off mid-sentence, labelled success.

No size escapes this. The per-result threshold is 100,000 chars, but the
aggregate turn budget decides AFTER skill_view has returned, inside
enforce_turn_budget, which already had the tool name on the message and threw
it away; with threshold=0 and enough siblings it spills a 1,550-char
skill_view result, measured on the same commit. So the repair goes where the
truncation is decided.

tools/oversized_result_formatters.py is a per-tool replacement-formatter
registry consulted ONLY after the storage layer has already decided to persist.
skill_view registers the only formatter. When the body is gone it returns an
index-only receipt — one typed `[SKILL_INCOMPLETE:` marker saying the skill is
NOT loaded, `load_status: "incomplete"`, `content_returned: false`, a bounded
heading index, the availability metadata, and no fragment of the body — revokes
the premature dedup record, and puts back the `use` bump, because an
undelivered skill was viewed, not used. `skill_view(name, section=…)` is the
route back, and content comes back only when the whole of what was asked for
fits: sections are hierarchical, every heading occurrence gets a stable `#n`,
an oversized section becomes `#n.partK` selectors that concatenate back to the
original span byte-for-byte, and an overflowing index groups into `#a-b` rather
than truncating, so no entry is ever left without a route to it.

Index-only rather than a bounded head is deliberate: a labelled partial
instruction body is still persuasive enough to act on, and removing it entirely
makes the invariant structural.

A linked file needs one thing more. `skill_view(name=X, file_path="refs/big.md")`
produced a receipt honest about WHICH document was missing but whose only
actionable instruction named no file:

    skill_view(name="X", section="<heading>")

Following that literally drops file_path, so the call takes the main-SKILL.md
branch and the selectors resolve against a different document with its own
heading index. On a skill whose SKILL.md and linked file both have an
`## Overview`, `section="Overview"` answered out of SKILL.md and `section="NousResearch#3"`
returned SKILL.md's third section advertised as the linked file's — both with
`section_found: true`, no marker, and nothing saying the file had changed. The
resolver was already correctly scoped to the linked file's own body; the
instruction was wrong. So every surface that tells the model how to continue
now keeps the path: the marker, the two notices that carry no marker, the
`section` schema description and the Skill Safety Rule all emit
`skill_view(name="X", file_path="Y", section="<heading>")`, and the marker
regex gained the matching optional segment so emit and detect cannot drift.

Thresholds, previews, persist decisions, aggregate candidate order and the
storage writes are untouched. resolve_threshold("skill_view") is still 100,000
with no override or pin; the real tool name travels in a separate
`formatter_name` kwarg used for the lookup and nothing else, so layer 3 keeps
forcing threshold=0 under its synthetic tool name. With an empty registry —
the shipping default for every tool but one — each receipt is byte-identical to
what it was, and that is asserted rather than argued: every generic path is
compared with and without the registry.

The four skill_view callers with no tool-result budget still receive the full
body: preloaded/slash commands, both cron injection sites, and the MCP surface,
which dispatches through model_tools.handle_function_call and never persists.
Putting the completeness decision inside skill_view() — the obvious design —
would strip all four while only one had a problem;
tests/tools/test_skill_incomplete_direct_consumers.py is the guard.

Tested on macOS 26.6.2 (arm64), Python 3.11. Four focused modules: 50 failed +
1 collection error before, 78 passed after. Twenty affected skill/persistence/
prompt modules: 460 passed, 2 skipped on both the base and this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mira-solari
mira-solari force-pushed the fix/skill-view-incomplete-linked-file branch from 52b8fe5 to 2fce577 Compare August 30, 2026 17:41
@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint tool/skills Skills system (list, view, manage) P3 Low — cosmetic, nice to have labels Aug 30, 2026
@mira-solari

Copy link
Copy Markdown
Author

Closing because I’m no longer pursuing upstream contributions.

@mira-solari mira-solari closed this Sep 2, 2026
ppazosp added a commit to useomnia/hermes-agent that referenced this pull request Sep 18, 2026
* fix(skills): make omitted instructions explicit and recoverable

Adapt NousResearch#98736 (2fce577) to the fork without its upstream-only repeat-view cache. Preserve linked-file selectors and recover complete sections through both per-result and aggregate budgets.

Co-authored-by: Mira Solari <268252643+mira-solari@users.noreply.github.com>

* fix(delegation): preserve worker context and deliver complete artifacts

Adapt the current-prompt budget correction from upstream NousResearch#103486, cache-path mapping from NousResearch#103667, and tasks-only schema from NousResearch#96424. Retain the legacy call interface, preserve shared batch context, and transfer only active-profile delegation artifacts into the paired Toolbox using existing file APIs.

* fix(execute-code): deliver large RPC results without replaying tools

Use the existing file transport or bounded shell chunks, publish atomically, and retain dispatched results through delivery retries. Fail explicitly after exhausted delivery instead of executing the same request again.

* docs(delegation): explain remote transcript refresh behavior

* test(execute-code): assert transferred bytes instead of shell command order

---------

Co-authored-by: Mira Solari <268252643+mira-solari@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P3 Low — cosmetic, nice to have tool/skills Skills system (list, view, manage) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants