fix(background_review): inherit parent's reasoning_config to keep thinking/output_config cache-stable (~38% fewer cache-write tokens on long sessions) - #30532
Conversation
… Anthropic cache namespace Backport of upstream PR NousResearch#30532 to ziliang-v0.14.0. In v0.14.0, the background review fork lives in run_agent.py (not yet extracted to agent/background_review.py). The fix is identical: pass reasoning_config=getattr(agent, 'reasoning_config', None) to the AIAgent(...) constructor so the fork's thinking/output_config fields are byte-identical to the parent's and hit the same Anthropic cache namespace.
9169074 to
4194987
Compare
|
Rebased to latest @teknium1 @alt-glitch could you take a look? |
|
Also tagging @liuhao1024 since you reviewed the most recent background-review PR (#41708). |
|
Just rebased to the latest This addresses the last remaining cache-key slot in the background-review fork — @teknium1 could you take a look? This is a small, well-tested fix with measurable cost impact. |
|
Thanks for the focused cache-parity fix. The premise remains valid on current main. Problems
Suggested changes
Automated hermes-sweeper review. |
4194987 to
e78daea
Compare
|
Rebased to latest _fork_kwargs["reasoning_config"] = getattr(agent, "reasoning_config", None)All 4 cache-parity tests pass: Ready for re-review. |
… Anthropic cache namespace PR NousResearch#17276 painstakingly pinned `_cached_system_prompt`, `session_start`, `session_id`, and the toolset config on the background-review fork so its outbound request body would byte-match the parent's and hit Anthropic's exact-prefix cache. The contributor measured a ~26% end-to-end cost reduction on Sonnet 4.5. That optimization is currently being silently undone by a missing `reasoning_config` kwarg. The fork's `AIAgent(...)` call omits it, so the fork's `reasoning_config` defaults to `None`. `anthropic_adapter.build_anthropic_kwargs` (line ~2165) then short-circuits the `thinking` / `output_config` block, and the fork's request body lands in a DIFFERENT Anthropic cache namespace from the parent's. Result on the wire: 0 `cache_read_input_tokens`, full `cache_creation_input_tokens` of the entire parent prefix — every single background review. 7 days of midagent.db traffic from one host running stock Hermes against Anthropic Sonnet: ``` Background-review FIRST calls (the moment a review fork is born): count = 68 cache_write tokens = 7,004,297 cache_read tokens = 1,016,335 Cost on Sonnet ($3.75/M write vs $0.30/M read): Spent on these writes: $26.27 Cost if they had hit parent cache instead: $2.10 WASTED: $24.16 / week / user ``` That is from one user. Multiply by Hermes's installed base for the full impact. Tested against api.anthropic.com directly (see refs/api-tests/ in the attached investigation repo if needed): | pair | cache_r | cache_w | |---------------------------------------------|---------|---------| | parent fresh | 0 | 24,047 | | parent same again | 24,047 | 0 | | fork: appends 2 new tail msgs, thinking ON | 24,047 | 22 | | fork: appends 2 new tail msgs, thinking OFF | 0 | 24,047 | Same fork-shape request, only difference is `thinking`. With the fix, the fork hits the parent's full prefix and only writes the delta (the `Review the conversation above…` prompt block, ~3-5K tokens). One line in `agent/background_review.py`: pass `reasoning_config=getattr(agent, "reasoning_config", None)` to the `AIAgent(...)` constructor of the review fork. A short comment block above it explains why so the next person who reads this code doesn't re-introduce the regression. `tests/run_agent/test_background_review_cache_parity.py` already covers the system-prompt / session-id / toolset-config parity contracts that PR NousResearch#17276 introduced. I added: * a `reasoning_config` attribute to `_make_agent_stub` so the stub has a non-None parent value the test can verify is propagated. * `test_review_fork_inherits_parent_reasoning_config()` — asserts the fork's `AIAgent(...)` kwargs carry the parent's `reasoning_config`. Pre-fix this test fails with `None vs expected {'enabled': True, 'effort': 'medium'}`; post-fix all 4 tests in the file pass. ``` $ python -m pytest tests/run_agent/test_background_review_cache_parity.py -v test_review_fork_inherits_parent_cached_system_prompt PASSED test_review_fork_pins_session_start_and_session_id PASSED test_review_fork_inherits_parent_toolset_config PASSED test_review_fork_inherits_parent_reasoning_config PASSED ← new ``` Also runs against the broader background-review test suite: `test_background_review.py` (4), `test_background_review_summary.py` (8), `test_background_review_toolset_restriction.py` (3) — 19/19 pass. `agent/curator.py:1691` has the same omission for the umbrella-curation fork, but curator's prompt is "curate all skills" — it shares no prefix with any user conversation, so cache-parity is a non-issue there. Worth auditing if the curator ever takes a parent conversation as input, but not part of this PR. The `agent/auxiliary_client.py:1006` `reasoning_config=None` hardcode is intentional (title/summary one-shots on short prompts — per-call cost of namespace flip is negligible) and is also out of scope.
e78daea to
479d592
Compare
|
The two failing tests in Both fail with |
|
Merged to main via #64379 — your commit was cherry-picked verbatim (17cfa0f, authorship preserved) with one maintainer follow-up on top (8ef0069) that gates the reasoning_config inheritance on the non-routed path (mirroring the existing Thanks @ziliangpeng for the sharp root-cause analysis and the cache-write measurement — the |
fix(background_review): inherit parent's reasoning_config to keep
thinking/output_configcache-stableSummary
Fixes #30531. The background skill/memory-review fork (
agent/background_review.py) constructs its childAIAgentwithout propagatingreasoning_configfrom the parent. The fork's defaultreasoning_config=Nonecausesbuild_anthropic_kwargs(agent/anthropic_adapter.py) to omit the top-levelthinkingandoutput_configfields from the fork's request body. Anthropic's prompt cache is namespaced bythinkingpresence, so the fork lands in a different cache namespace from the parent and the entire shared prefix is re-written on every review-fork "birth" call.Same class of bug as #25322 / PR #17276 (
system-bytes invariant) and #29567 / PR #29704 (tools[]invariant) — this is the remaining cache-key slot.Goal
Eliminate cache-write overhead on the background review path. On a captured ~2-day Sonnet-class session, fork-birth requests account for ~38% of the session's total
cache_creation_input_tokenseven though they sharemessages[0..N],system, andtools[]with the parent byte-for-byte — the divergence inthinking/output_configalone is enough to push every fork-birth req into a cache miss. After the fix, fork-birth requests read from the parent's warmed cache instead of rewriting.Direct API verification (independent of Hermes) that
thinkingis part of the cache key:The safety contract from #15204 (the review fork must not dispatch terminal / send_message / delegate_task) is preserved: it is enforced by the post-construction
set_thread_tool_whitelist({memory, skills, …})call a few lines below, which gates dispatch, not what the request body transmits.Implementation
agent/background_review.py— one added kwarg in theAIAgent(...)call inside_spawn_background_review:Plus a three-line comment that calls out the cache-key dependency, matching the style of the existing toolset-parity comment one block above (added in PR #29704).
Symmetric inheritance: when the parent's value is
None, the fork's is alsoNoneand both omitthinkingidentically; when the parent has reasoning configured, the fork inherits it verbatim.Reproduction
Captured by routing the Anthropic API through a local HTTP-capture proxy (https://github.com/ziliangpeng/midagent — small FastAPI logger that records
request_body+response_bodyper request) and inspecting outbound/v1/messagestraffic during a long real-world session.Fork-shape requests are identifiable as: (a)
messages[-1]carries one of_SKILL_REVIEW_PROMPT/_MEMORY_REVIEW_PROMPT/_COMBINED_REVIEW_PROMPTas the last user message; (b) the top-levelthinkingandoutput_configkeys are absent (when the parent has reasoning enabled). The first such request after a_spawn_background_reviewis the "birth" call this PR addresses.Cost-impact measurement
From the captured 2-day window (~2,040
/v1/messagesrequests total, Sonnet-class model, parent has reasoning enabled):cache_creation_input_tokenstotalcache_read_input_tokenstotalFork-birth requests alone account for ~37.7% of the session's total
cache_creation_input_tokens— these are exactly the requests this PR converts fromcache_writetocache_read. The remaining fork cache-writes (4.14 M) come from the fork's own internal iteration loop and are not affected by this fix.The percentage is model-pricing-independent. On Opus the same wire pattern costs substantially more per token than on Sonnet, but the structural inefficiency is identical.
Testing
tests/run_agent/test_background_review_cache_parity.py— new positive assertiontest_review_fork_inherits_parent_reasoning_configconfirmsreasoning_configreaches the constructor with the parent's value. The stub gains a non-Nonereasoning_configso a regression that drops the kwarg surfaces (rather than spuriously passing because both sides default toNone) — same pattern PR #29704 used for the toolset stub.Sanity check: the new positive test fails on
origin/mainand passes with this PR.python -m pytest tests/run_agent/test_background_review.py \ tests/run_agent/test_background_review_summary.py \ tests/run_agent/test_background_review_toolset_restriction.py \ tests/run_agent/test_background_review_cache_parity.py -qOutput:
Scope
agent/background_review.py: one added kwarg + 3-line comment.tests/run_agent/test_background_review_cache_parity.py: one new positive test, stub updated.Related: extends PR #17276 / #25434 (
systemslot) and PR #29568 / #29704 (tools[]slot) to thethinking/output_configslot — completes the cache-key parity invariant for the background review fork.