fix(kv-cache): inject subdirectory hints as tail messages - #9767
Conversation
f448a44 to
6cf6710
Compare
Goose KV Cache PR: Provider Cost AnalysisWhat the PR ChangedSource: Fix 1: System prompt hourly timestamp removed (
|
| Version | Common prefix | Cached tokens |
|---|---|---|
| OLD | [U1..A_{k-2}] — diverges at U_{k-1} (MOIM merged in, absent next turn) |
P + (k-2)·Δ |
| NEW | [U1..A_{k-1}] — diverges at U_k (MOIM at tail) |
P + (k-1)·Δ |
The PR recovers one expected Δ tokens of cached history per API call (Δ = C/N on average).
The old code was not a complete cache break — it lost one turn of cached history per request.
The per-second MOIM timestamp (%H:%M:%S) is safe with tail-append because it lives in the
last user message, which is never cached in either approach.
Fix 3: Subdirectory hints moved out of system prompt (prompt_manager.rs, agent.rs)
When goose explored a directory containing .goosehints/AGENTS.md, those hints were appended
to system_prompt_extras, permanently mutating the system prompt for all subsequent turns.
Each mutation caused a full cache reset from position 0.
This is the dominant cache invalidation source in real coding sessions. A typical session
exploring 5–15 subdirectories triggered 5–15 full cache resets. The PR injects hints as one-time
user messages at the tail instead, leaving the system prompt (and its cache entry) stable.
How Goose Caches Anthropic Conversations
Source: crates/goose/src/providers/formats/anthropic.rs lines 311–381.
Goose uses a sliding window strategy with up to 4 cache_control: {type: ephemeral} breakpoints
per request:
- System prompt — always marked; establishes the base cache entry
- Last tool spec — caches all tool definitions as a single prefix
- Second-to-last user message — reads the growing cache from the previous turn
- Last user message — writes a new cache entry for the next turn
The full conversation history is cached and grows each turn. At turn k WITH the PR, the cached
prefix is P + (k-1)·Δ tokens.
Session Parameters
| Parameter | Value | Notes |
|---|---|---|
| Context window (C) | Model limit; capped at 200k | Upper bound; typical sessions ~30–50k |
| Turns (N) | 100 | |
| Tool calls | 50 | Embedded across turns |
| Output per turn | 1,000 tokens | |
| System prefix (P) | 8,000 tokens | System prompt + tool schemas |
| Turn delta (Δ) | C/N = 2,000 tokens | Average tokens added per turn |
Total input billed without caching: Σ(k=1..N)(P + k·Δ) ≈ 10.9M tokens for a 200k session.
Provider Caching Survey
Discount figures are expressed as cost fraction of the normal input rate (lower = cheaper).
For example, Anthropic's 10% means cache reads cost $0.30/MTok when input is $3.00/MTok — a 90% saving.
| Provider | Caching | Cache read cost (fraction of input rate) | TTL | Source |
|---|---|---|---|---|
| Anthropic | Explicit cache_control |
10% (write = 125%) | 5 min or 1 hr | Docs |
| OpenAI | Automatic prefix caching | 50% (gpt-4o, o3, gpt-4.1-mini) | ~1 hr | Docs |
| Google Gemini 2.5 | Implicit automatic |
25% | Unspecified | Docs |
| Groq | Automatic prefix caching | 50% | 2 hours | Docs |
| Together AI | Selective (model-dependent) | 11–58% on select models | Unspecified | Pricing |
where the accumulated prompt crosses the 200k token tier boundary will be billed at higher rates
for those turns; this analysis uses the ≤200k rate throughout.
Together AI's automatic caching applies to select models only (Qwen3.5-397B, DeepSeek-V4-Pro,
MiniMax M3). Qwen3-235B-A22B has no cached rate listed as of 2026-06-15.
Groq's 2h TTL means a session spanning >2 hours incurs one cache expiry even WITH the PR
(stable prefix, but cache evicted). Effect: ~$0.01–$0.03 extra; negligible.
Cost Model
WITH PR — turn 1 cold start, turns 2–N with stable cache hits:
- Hit turn k (k ≥ 2):
(P + (k-1)·Δ) × read_rate + Δ × full_rate
WITHOUT PR — three additive sources of extra cost vs WITH PR:
- MOIM 1-turn lag (every turn, k ≥ 2): cached prefix is P + (k-2)·Δ instead of P + (k-1)·Δ
- Hourly resets (2 per 2h session): full cache miss at turns ~33 and ~67
- Subdir hint resets (session-dependent): full cache miss at each new directory visited
At a full cache reset turn k:
- Anthropic:
P × write_rate + k·Δ × full_rate - OpenAI / Groq / Gemini:
(P + k·Δ) × full_rate
Pricing Reference
| Model | Provider | C | Input $/MTok | Output $/MTok | Cache Write | Cache Read |
|---|---|---|---|---|---|---|
| claude-opus-4-8 | Anthropic | 200k | $5.00 | $25.00 | $6.25 | $0.50 (10%) |
| claude-sonnet-4-6 | Anthropic | 200k | $3.00 | $15.00 | $3.75 | $0.30 (10%) |
| claude-haiku-4-5 | Anthropic | 200k | $1.00 | $5.00 | $1.25 | $0.10 (10%) |
| gpt-4o | OpenAI | 128k | $2.50 | $10.00 | — | $1.25 (50%) |
| o3 | OpenAI | 200k | $2.00 | $8.00 | — | $0.50 (25%) |
| gpt-4.1-mini | OpenAI | 200k | $0.40 | $1.60 | — | $0.10 (25%) |
| gemini-2.5-pro | 200k | $1.25† | $10.00† | implicit | $0.3125 (25%) | |
| gemini-2.5-flash | 200k | $0.30 | $2.50 | implicit | $0.075 (25%) | |
| llama-4-scout-17b | Groq | 128k | $0.11 | $0.34 | — | $0.055 (50%) |
| llama-3.3-70b | Groq | 128k | $0.59 | $0.79 | — | $0.295 (50%) |
| Qwen3-235B-A22B | Together | 200k | $0.20 | $0.60 | unconfirmed | — |
† Gemini 2.5 Pro ≤200k tier. Sessions where total context exceeds 200k tokens are billed at
higher rates for those turns (approximately 2× for tokens beyond 200k).
Sources: Anthropic · OpenAI · Google · Groq · Together
Cost Tables
Session: N=100 turns, 50 tool calls, 1,000 output tokens/turn, 200k context (128k where noted).
Scenario A — Best case: 2 hourly resets, no subdir visits, MOIM lag only
| Model | C | Without PR | With PR | Savings | % |
|---|---|---|---|---|---|
| claude-opus-4-8 | 200k | $10.73 | $8.90 | $1.84 | 17.1% |
| claude-sonnet-4-6 | 200k | $6.44 | $5.34 | $1.10 | 17.1% |
| claude-haiku-4-5 | 200k | $2.15 | $1.78 | $0.37 | 17.1% |
| gpt-4o | 128k | $10.58 | $10.25 | $0.33 | 3.1% |
| o3 | 200k | $7.17 | $6.56 | $0.61 | 8.5% |
| gpt-4.1-mini | 200k | $1.43 | $1.31 | $0.12 | 8.5% |
| gemini-2.5-pro |
200k | $4.98 | $4.60 | $0.38 | 7.6% |
| gemini-2.5-flash |
200k | $1.21 | $1.11 | $0.09 | 7.5% |
| llama-4-scout (groq) | 128k | $0.46 | $0.44 | $0.01 | 3.2% |
| llama-3.3-70b (groq) | 128k | $2.34 | $2.26 | $0.08 | 3.3% |
| Qwen3-235B (together) | 200k | $2.24 | $2.24 | $0.00 | 0.0% |
Scenario B — Realistic: 5 subdir visits + 2 hourly resets + MOIM lag
| Model | C | Without PR | With PR | Savings | % |
|---|---|---|---|---|---|
| claude-opus-4-8 | 200k | $13.03 | $8.90 | $4.14 | 31.7% |
| claude-sonnet-4-6 | 200k | $7.82 | $5.34 | $2.48 | 31.7% |
| claude-haiku-4-5 | 200k | $2.61 | $1.78 | $0.83 | 31.7% |
| gpt-4o | 128k | $11.00 | $10.25 | $0.75 | 6.8% |
| o3 | 200k | $7.92 | $6.56 | $1.36 | 17.1% |
| gpt-4.1-mini | 200k | $1.58 | $1.31 | $0.27 | 17.1% |
| gemini-2.5-pro |
200k | $5.45 | $4.60 | $0.85 | 15.6% |
| gemini-2.5-flash |
200k | $1.32 | $1.11 | $0.20 | 15.4% |
| llama-4-scout (groq) | 128k | $0.47 | $0.44 | $0.03 | 6.9% |
| llama-3.3-70b (groq) | 128k | $2.44 | $2.26 | $0.18 | 7.2% |
| Qwen3-235B (together) | 200k | $2.24 | $2.24 | $0.00 | 0.0% |
Scenario C — Heavy: 15 subdir visits + 2 hourly resets + MOIM lag
| Model | C | Without PR | With PR | Savings | % |
|---|---|---|---|---|---|
| claude-opus-4-8 | 200k | $17.01 | $8.90 | $8.12 | 47.7% |
| claude-sonnet-4-6 | 200k | $10.21 | $5.34 | $4.87 | 47.7% |
| claude-haiku-4-5 | 200k | $3.40 | $1.78 | $1.62 | 47.7% |
| gpt-4o | 128k | $11.72 | $10.25 | $1.47 | 12.6% |
| o3 | 200k | $9.21 | $6.56 | $2.65 | 28.8% |
| gpt-4.1-mini | 200k | $1.84 | $1.31 | $0.53 | 28.8% |
| gemini-2.5-pro |
200k | $6.26 | $4.60 | $1.66 | 26.5% |
| gemini-2.5-flash |
200k | $1.51 | $1.11 | $0.40 | 26.3% |
| llama-4-scout (groq) | 128k | $0.51 | $0.44 | $0.06 | 12.8% |
| llama-3.3-70b (groq) | 128k | $2.61 | $2.26 | $0.35 | 13.3% |
| Qwen3-235B (together) | 200k | $2.24 | $2.24 | $0.00 | 0.0% |
Key Findings
1. Savings are session-behavior-dependent, ranging from 3–17% (quiet session, few directories)
to 13–48% (heavy coding session with many directory visits). The dominant variable is how many
new directories the agent explores.
2. The subdirectory hints fix (Fix 3) is the most financially significant change. Hourly
resets are bounded at 2 per session. MOIM lag costs one expected Δ tokens per call. Subdir resets
are unbounded — each new directory visited caused a full reset from position 0. In a 15-directory
session, Fix 3 accounts for the majority of savings.
3. Anthropic benefits most (17–48%) because cache reads cost only 10% of the normal input
rate (a 90% saving). OpenAI and Groq have a 50% read discount, producing proportionally smaller
savings per reset event. Together AI Qwen3-235B-A22B has no confirmed billing discount for
cached tokens, so the PR produces no cost savings for that specific model.
4. For PR documentation: A conservative honest range is 17–48% cost reduction for Anthropic
users on long sessions approaching the 200k context limit. Percentage savings from Fix 3
(subdir resets) are approximately session-size-independent. Fix 1 (hourly resets) saves a fixed
absolute amount per session that represents a larger share of shorter sessions.
Worked Example: Subdirectory hint reset (claude-sonnet-4-6, turn 50)
This a cost breakdown example for ONE turn (turn 50).
WITHOUT PR — new directory entered, system_prompt_extras mutated:
| Token category | Tokens | Rate | Cost |
|---|---|---|---|
| System prompt (cache write)† | 8,000 | $3.75/MTok | $0.030 |
| Conversation history (cache miss) | 100,000 | $3.00/MTok | $0.300 |
| Turn total | 108,000 | $0.330 |
† When the system prompt changes, Anthropic re-writes the cache at 125% of input rate. The
conversation history (k·Δ = 50×2,000 = 100,000 tokens) is billed at full input rate because
the system prompt change invalidated the entire prefix cache.
WITH PR — hint injected as user message, system prompt unchanged:
| Token category | Tokens | Rate | Cost |
|---|---|---|---|
| Prior context (cache read) | 106,000 | $0.30/MTok | $0.032 |
| New user message + hint (Δ) | 2,000 | $3.00/MTok | $0.006 |
| Turn total | 108,000 | $0.038 |
Cache read covers P + (k-1)·Δ = 8,000 + 49×2,000 = 106,000 tokens.
Single reset savings at turn 50: $0.292 (8.7× cheaper).
|
these look good to me - especially not editing system prompt with agents/time - I think it makes sense. |
|
I think @jamadeo and @jh-block had some thoughts/Q's to clarify, but I think overall this makes sense. I don't actually think we lose much and in happy cases save a lot. MOIM right near the tail is probably ok but could be better. I think worthwhile pushing with (still need to check that it really isn't mutating things accidentally) |
DOsinga
left a comment
There was a problem hiding this comment.
I looked at this in some detail:
- removing the timestamp from the prompt manager is fine, but I'll note that it wasn't used so shouldn't have impact on the caching
- the addition to the hook system seems unrelated. let's drop
- I have concerns about the MOIM handling. it belongs to the last real user message and in my benchmarking this version (which was the previous version) hurts
- the real catch here is the discovered working dirs. I think the instinct is right, but I have questions about the implementation
| )) | ||
| } | ||
|
|
||
| /// Add text content visible only to the agent (not shown in the UI). |
There was a problem hiding this comment.
I think drop this method. it is only used in MOIM where it doesn't really help and the rest of the code uses .with_visibility(false, true)
| /// full response text. Hooks may observe it for logging, kb ingestion, etc. | ||
| /// They MUST NOT block — fired via [`HookManager::emit`], not | ||
| /// `emit_blocking`. | ||
| AssistantResponse, |
There was a problem hiding this comment.
Let's drop this from the PR, it doesn't seem related to the stated change.
| .rposition(|m| m.role == Role::Assistant) | ||
| .unwrap_or(0); | ||
| messages.insert(idx, Message::user().with_text(moim)); | ||
| messages.push(Message::user().with_agent_text(moim)); |
There was a problem hiding this comment.
I think this will confuse the agent. the MOIM context message annotates the last real user message, not toolresponses. large models will probably recover from this, but if the model does something like
cd some_folder* && pwd
we will now append the message about the current folder as part of that tool response.
also we should not rely on fix conversation. we should compose correct interactions.
| /// Returns new subdirectory hint text to be injected as a tail message, | ||
| /// without mutating the system prompt. Callers should append the returned | ||
| /// text as an agent-visible message so the system prompt stays stable. | ||
| pub fn collect_new_subdirectory_hints(&mut self, working_dir: &Path) -> Option<String> { |
There was a problem hiding this comment.
this does not belong in prompt_manager.rs if it doesn't make changes to the prompt
|
|
||
| { | ||
| let has_new_hints = self | ||
| let hint_text = self |
There was a problem hiding this comment.
I think this is the right instinct -- these discovered hints should be part of the message that they discover, but I see some issues here:
- this gets saved to the session manager, but not the current conversation, so it won't impact the current conversation until we reload the session
- it gets added as user visible, to the user now sees a potentially very large AGENTS.md
- it doesn't check whether we already have this AGENTS.md in the conversation. so the agent will add this multiple times, one for each toolcall
how did you test this? the stats on the PR suggests this all saves tokens, but if we hit this path multiple times, I am not sure it wouldl
|
FYI I'm finding that the MOIM injection on every turn is confusing agents (Qwen 3.6 27B). They're reasoning about <info_msg> in their thinking. It's wasting tokens and distracting. It IS generally useful for the .goosehints though and cwd changes. So this is no longer about prefix caching though...I now think that injecting the time and context (and TODO) usage is a bad idea here. I'm going to remove it in my build. Want me to raise a separate PR for that or incorporate it here? @DOsinga will address your comments shortly. |
|
Really like the direction here — not mutating the system prompt every hour is exactly the kind of thing that quietly wastes a lot of cache, and it's great to have someone digging into the actual cost mechanics. The thing I most want to see land is the issue you surfaced around Your point that the per-turn MOIM injection actively confuses smaller models (Qwen) is also a good catch — agreed that we shouldn't be relying on No rush — happy to give you room to push the changes you mentioned. I'll snooze this for a few days so it comes back to us once you've had a chance to iterate. |
|
Hey @mcelrath — friendly nudge on this one. Back on 2026-06-17 you mentioned you'd address the review comments shortly, and we snoozed to give you room, but there hasn't been any new activity since. The core idea here — especially stopping the Could you let us know whether you'd like to keep iterating on this? If you've got bandwidth in the next few days, great — we'll pick the review back up. Otherwise we may close this and take a fresh run at it ourselves so the improvement doesn't get lost, and you'd of course be credited for the original idea. Just let us know which works for you. 🙏 |
|
Yes, I'll finish it up. Have been out of town for a few days, just got home. |
6cf6710 to
19dd4f5
Compare
90054ec to
26a2c8b
Compare
When a tool touches a new subdirectory, hints from .goosehints/AGENTS.md in that subtree were appended to system_prompt_extras, permanently mutating the system prompt and forcing a full KV recompute on every subsequent turn. The hints are now injected as a message instead, leaving the system prompt (the cached prefix) stable. Each directory is loaded at most once per session via the dedup tracker. The hint is pushed onto the in-flight conversation (messages_to_add) so it reaches the current turn, and marked agent-visible-only via with_visibility(false, true) so a potentially large AGENTS.md is not dumped into the user-facing transcript. Subdirectory-hint state and collection now live on SubdirectoryHintTracker (owned by the Agent), not PromptManager: since hints are a tail message rather than a system-prompt mutation, neither the tracker nor the new collect_new_hints() belongs in PromptManager. PromptManager is back to being purely about prompt construction. Also removes the vestigial current_date_timestamp field from PromptManager: the system.md template stopped rendering it when the per-turn time moved into MOIM, so this is dead-code cleanup with no behavioral effect. The now-redundant for_test() constructor (it only forwarded to new()) is dropped; tests call PromptManager::new() directly. The MOIM tail-append and UserPromptSubmit/AssistantResponse hook changes from earlier revisions of this branch are dropped: MOIM was rewritten upstream (aaif-goose#9636) and the hooks belong in a separate PR. Adds an integration test that drives the reply loop with tool calls touching a subdirectory and asserts the hint is injected exactly once, agent-only, and is observed by a later provider call (i.e. reaches the live conversation, not just the session store). Signed-off-by: Bob McElrath <mcelrath@users.noreply.github.com>
26a2c8b to
ddf05e1
Compare
DOsinga
left a comment
There was a problem hiding this comment.
Thanks for the rework — this now cleanly resolves the earlier comments (MOIM/hook changes dropped, dead timestamp removed, logic moved to SubdirectoryHintTracker, hints injected agent-only into the live conversation and deduped within a turn). Two things before we can land it, one of which is a blocker.
Blocker — the dedup has to survive a session resume. loaded_dirs lives only in-memory on the Agent and is never persisted or rehydrated. On resume you get a fresh Agent with an empty set, so the next tool call that touches sub/ re-injects a hint block that's already present in the restored history — the "exactly once" guarantee only holds within a single process lifetime. We need this to be stateless: on injection, derive the already-loaded set from the conversation rather than trusting in-memory state.
Concretely (option 1 from our discussion): keep the subdir_hints:<dir> key on the injected message so it's identifiable in history, and before injecting, seed/skip against the directories already present in the conversation. A simple approach: tag the injected message with a stable marker per directory, and on each turn (including the first after resume) scan the current conversation for those markers to reconstruct loaded_dirs, instead of relying on the in-memory HashSet alone. Right now collect_new_hints throws the key away and pushes a plain user-text message, so there's nothing to match against — that needs to change.
| /// joined into a single block, or None if nothing new was discovered. | ||
| /// Intended to be injected as an agent-visible tail message so the system | ||
| /// prompt stays stable. | ||
| pub fn collect_new_hints(&mut self, working_dir: &Path) -> Option<String> { |
There was a problem hiding this comment.
This drops the subdir_hints:<dir> key and pushes the content as an anonymous user message, so there's no way to tell later which directories are already represented in the conversation. For the resume case we need the injected message to stay identifiable per directory — please preserve the key (e.g. as a marker line/prefix in the message, or a message-metadata field) so loaded_dirs can be reconstructed from history.
| } | ||
| } | ||
|
|
||
| fn resolve_to_parent_dir(token: &str, working_dir: &Path) -> Option<PathBuf> { |
There was a problem hiding this comment.
Non-blocking nit: the dedup HashSet<PathBuf> compares paths lexically and this never canonicalizes, so sub/a.txt, ./sub/b.txt, and sub/../sub/c.txt all resolve to distinct keys for the same directory and each triggers a fresh injection. Since load_hints_from_directory already gates on is_dir(), canonicalizing the resolved dir before comparison (it exists on disk) would collapse these and also handle symlinks.
Port audited changes from aaif-goose/goose#9767. Upstream commit: ddf05e194 fix(kv-cache): inject subdirectory hints as agent-only tail messages. Local audit: patch applied cleanly; adapted the new integration test to gosling's Provider, AgentConfig, and SessionConfig APIs without changing the production behavior. Gate: source bin/activate-hermit && cargo fmt && cargo test -p goose --test subdirectory_hints
|
Thanks for working on this. The remaining change is solving a real problem, so I captured the desired behavior and implementation constraints in #10330. The current PR still has the unresolved blocker from the core-team review: deduplication of injected subdirectory hints needs to survive resumed sessions, not just the current in-memory Since there has not been movement on that blocker after the review, I am going to close this PR for now. Please feel free to reopen it when it is ready to address the resume-safe deduplication requirement, or someone else can pick up the linked issue. |
Background
LLM inference backends (llama.cpp, vLLM, etc.) cache key-value attention tensors for stable token prefixes. Any insertion or mutation before the tail of the current conversation history forces a full KV recomputation from the changed position forward. Goose has three distinct sites that trigger this on every turn.
UPDATE: Two of the three original fixes are now redundant with upstream and have been dropped from this PR:
What remains is the subdirectory-hints fix: stop .goosehints/AGENTS.md discovered from tool-touched directories from permanently mutating the system prompt; inject them once per directory as a tail message instead.
=======
Three changes that together make Goose KV-cache-neutral across turns:
Remove timestamp from system prompt (prompt_manager.rs) The system prompt previously embedded the current date, causing it to change every hour and invalidating all cached KV tensors. The timestamp is removed entirely from the system prompt — it is no longer needed there since MOIM (below) delivers accurate time on every turn.
MOIM: tail-append with second-resolution timestamp (moim.rs, extension_manager.rs) inject_moim() previously inserted the working-directory context message before the last assistant message, shifting KV positions for all tail messages on every turn. Changed to messages.push() at the tail, which fix_conversation() merges with the existing last user message. Also updates the MOIM timestamp from minute-granularity ("%H:%M:00") to second-resolution ("%H:%M:%S"), replacing the timestamp removed from the system prompt with a more accurate value that is cache-neutral because it lives at the tail. [OBSOLETE by Update Moim #9636 09c8d2b]
Subdirectory hints as user-visible tail messages (prompt_manager.rs, agent.rs) When tools touch a new subdirectory, hints from .goosehints/AGENTS.md in that subtree were appended to system_prompt_extras, permanently mutating the system prompt for all subsequent turns. Changed to return the hint text from collect_new_subdirectory_hints() and inject it as a plain user message (with_text) at the tail. The message is visible in the UI so users can see when new subdirectory instructions are loaded. Each directory is loaded at most once per session (dedup tracker).
Also adds Message::with_agent_text() to goose-providers for agent-only content injection (used by MOIM), and HookEvent::AssistantResponse + emit_collect() to HookManager for settled-response hook support.
Summary
Testing
Related Issues
Fixes #9766
Discussion: LINK (if any)
Screenshots/Demos (for UX changes)
Before:
After: