NousResearch/hermes-agent

Background review fork tools cache fragmentation fix

Cluster 29567. Data as of

Nothing open on GitHub. Fix merged in #29704 on 21 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #29704. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #29704. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and PRs address a problem where background review forks send a wider tools[] array than the parent, breaking Anthropic prefix cache hits and wasting roughly 50% of cache-write tokens on long sessions. The proposed fix is to inherit the parent's toolset configuration in the background review fork to maintain cache parity.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #29704 fix(background_review): inherit parent toolset config for tools[] cache parity (salvage #29568)

    merged to main

    files: agent/background_review.py, scripts/release.py, tests/run_agent/test_background_review_cache_parity.py, tests/run_agent/test_background_review_toolset_restriction.py

    Show the diff
    • agent/background_review.py
      @@ -390,6 +390,9 @@ def _bg_review_auto_deny(command, description, **kwargs):
                   # parent below so memory(action="add") writes from
                   # the review still land on disk; the review just
                   # has zero side effects on external providers.
      +            # Match parent's toolset config so ``tools[]`` is byte-identical
      +            # in the request body — Anthropic's cache key includes it.
      +            # (The runtime whitelist below still restricts dispatch.)
                   review_agent = AIAgent(
                       model=agent.model,
                       max_iterations=16,
      @@ -401,6 +404,8 @@ def _bg_review_auto_deny(command, description, **kwargs):
                       api_key=_parent_runtime.get("api_key") or None,
                       credential_pool=[redacted:secret-assignment] "_credential_pool", None),
                       parent_session_id=agent.session_id,
      +                enabled_toolsets=getattr(agent, "enabled_toolsets", None),
      +                disabled_toolsets=getattr(agent, "disabled_toolsets", None),
                       skip_memory=True,
                   )
                   review_agent._memory_write_origin = "background_review"
    • scripts/release.py
      @@ -718,6 +718,7 @@
           "[email protected]": "cresslank",
           "[redacted:email]": "trevthefoolish",
           "[email protected]": "ziliangpeng",
      +    "[redacted:email]": "ziliangpeng",
           "[email protected]": "centripetal-star",
           "[email protected]": "LeonSGP43",
           "[email protected]": "LeonSGP43",
    • tests/run_agent/test_background_review_cache_parity.py
      @@ -38,6 +38,9 @@ def _make_agent_stub(agent_cls):
           agent._MEMORY_REVIEW_PROMPT = "review memory"
           agent._SKILL_REVIEW_PROMPT = "review skills"
           agent._COMBINED_REVIEW_PROMPT = "review both"
      +    # Non-None so the test catches a missing-kwarg regression.
      +    agent.enabled_toolsets = ["memory", "skills", "terminal"]
      +    agent.disabled_toolsets = ["spotify", "feishu_doc"]
           return agent
       
       
      @@ -183,3 +186,54 @@ def close(self):
               "Review fork did not inherit parent's session_id — "
               "system-prompt rebuild paths would diverge."
           )
      +
      +
      +def test_review_fork_inherits_parent_toolset_config():
      +    """``tools[]`` byte-stability: fork must inherit parent's toolset config."""
      +    import run_agent
      +
      +    agent = _make_agent_stub(run_agent.AIAgent)
      +
      +    captured = {}
      +
      +    class _Recorder:
      +        def __init__(self, *args, **kwargs):
      +            captured["enabled_toolsets"] = kwargs.get("enabled_toolsets")
      +            captured["disabled_toolsets"] = kwargs.get("disabled_toolsets")
      +            self._cached_system_prompt = None
      +            self._memory_write_origin = None
      +            self._memory_write_context = None
      +            self._memory_store = None
      +            self._memory_enabled = None
      +            self._user_profile_enabled = None
      +            self._memory_nudge_interval = None
      +            self._skill_nudge_interval = None
      +            self.suppress_status_output = None
      +            self.session_start = None
      +            self.session_id = None
      +
      +        def run_conversation(self, *args, **kwargs):
      +            raise RuntimeError("stop after recording — don't actually call the API")
      +
      +        def shutdown_memory_provider(self):
      +            pass
      +
      +        def close(self):
      +            pass
      +
      +    with patch.object(run_agent, "AIAgent", _Recorder), \
      +         patch("threading.Thread", _SyncThread):
      +        agent._spawn_background_review(
      +            messages_snapshot=[],
      +            review_memory=True,
      +            review_skills=False,
      +        )
      +
      +    assert captured.get("enabled_toolsets") == agent.enabled_toolsets, (
      +        f"enabled_toolsets mismatch: {captured.get('enabled_toolsets')!r} "
      +        f"vs expected {agent.enabled_toolsets!r}"
      +    )
      +    assert captured.get("disabled_toolsets") == agent.disabled_toolsets, (
      +        f"disabled_toolsets mismatch: {captured.get('disabled_toolsets')!r} "
      +        f"vs expected {agent.disabled_toolsets!r}"
      +    )
    • tests/run_agent/test_background_review_toolset_restriction.py
      @@ -38,6 +38,9 @@ def _make_agent_stub(agent_cls):
           agent._MEMORY_REVIEW_PROMPT = "review memory"
           agent._SKILL_REVIEW_PROMPT = "review skills"
           agent._COMBINED_REVIEW_PROMPT = "review both"
      +    # Non-None so the test catches a missing-kwarg regression.
      +    agent.enabled_toolsets = ["memory", "skills", "terminal"]
      +    agent.disabled_toolsets = ["spotify", "feishu_doc"]
           return agent
       
       
      @@ -52,13 +55,8 @@ def start(self):
                   self._target()
       
       
      -def test_background_review_does_not_narrow_toolset_schema():
      -    """The review fork must NOT pass enabled_toolsets to AIAgent.
      -
      -    Narrowing the schema diverges the ``tools`` cache key from the parent's,
      -    which sits above ``system`` in Anthropic's cache hierarchy and forces a
      -    full prefix-cache miss on every review (see #25322, PR #17276).
      -    """
      +def test_background_review_matches_parent_toolset_config():
      +    """Fork must receive parent's toolset config so ``tools[]`` cache key matches."""
           import run_agent
       
           agent = _make_agent_stub(run_agent.AIAgent)
      @@ -66,6 +64,7 @@ def test_background_review_does_not_narrow_toolset_schema():
       
           def _capture_init(self, *args, **kwargs):
               captured["enabled_toolsets"] = kwargs.get("enabled_toolsets", "UNSET")
      +        captured["disabled_toolsets"] = kwargs.get("disabled_toolsets", "UNSET")
               raise RuntimeError("stop after capturing init args")
       
           with patch.object(run_agent.AIAgent, "__init__", _capture_init), \
      @@ -77,11 +76,13 @@ def _capture_init(self, *args, **kwargs):
               )
       
           assert "enabled_toolsets" in captured, "AIAgent.__init__ was not called"
      -    # The kwarg must be absent — letting AIAgent inherit the default full
      -    # toolset so the schema bytes match the parent's.
      -    assert captured["enabled_toolsets"] == "UNSET", (
      -        f"Review fork narrowed the toolset schema (got {captured['enabled_toolsets']!r}), "
      -        "which breaks prefix-cache parity with the parent."
      +    assert captured["enabled_toolsets"] == agent.enabled_toolsets, (
      +        f"enabled_toolsets mismatch: {captured['enabled_toolsets']!r} "
      +        f"vs expected {agent.enabled_toolsets!r}"
      +    )
      +    assert captured["disabled_toolsets"] == agent.disabled_toolsets, (
      +        f"disabled_toolsets mismatch: {captured['disabled_toolsets']!r} "
      +        f"vs expected {agent.disabled_toolsets!r}"
           )
       
       

1 PR closed without merging

From the close comments: merged via another change 1

  • #29568 fix(background_review): inherit parent's toolset config to keep tools[] cache-stable (~50% fewer cache-write tokens on long sessions) · merged via another change → #29704
Closed issues · 1
  • Closed#29567background_review fork sends wider tools[] than parent, fragments Anthropic prefix cache (~50% wasted cache-write on long sessions)
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#29704
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
21 May 2026

For your agent

The same facts, typed.