fix(agent): move dynamic context after history to preserve prefix caching - #3321
Open
grrowl wants to merge 3 commits into
Open
fix(agent): move dynamic context after history to preserve prefix caching#3321grrowl wants to merge 3 commits into
grrowl wants to merge 3 commits into
Conversation
…hing buildDynamicContext() emits a minute-precision "## Current Time" (plus runtime, session and sender) into the single system message, which precedes all history. Prefix caching is positional, so a change anywhere in the system message invalidates every token after it — the entire conversation was re-prefilled once per minute. PicoClaw already tagged the block Stable:false / Cache:PromptCacheNone, but that is only honoured by the Anthropic adapter. openai_compat strips SystemParts entirely and prompt_cache_key is only sent to OpenAI's own endpoint, so for a local llama.cpp/Ollama backend the only caching that exists is byte-prefix matching, and the layout guaranteed a miss once per minute. Measured at ~2.2 ms per history token per turn: a 6,000-token history cost ~13 s of pure re-prefill every turn on a host where turns exceed a minute. The block now defaults to the tail — after history, carried on the current user message inside a <runtime_context> tag. The static system prompt, summary and full history stay byte-identical between turns, so prefix-matching backends hit their cache every turn. It also makes the static prompt identical across all users, sessions and cron runs, letting them share one cached prefix instead of each paying a cold prefill (~3x on the reported measurements). Anthropic and OpenAI are unaffected — their native mechanisms still apply. The block is deliberately NOT emitted as a trailing system message. Provider adapters hoist every system message to the front (anthropic, anthropic_messages, bedrock, gemini, both CLI providers), and openai_responses_common and antigravity keep only the last one, which would silently discard the static prompt. Prepending to the current user message also leaves the message count unchanged, so the len(messages)-1 current-turn boundary used for media resolution in pipeline_setup and turn_coord keeps working. Persisted history is unaffected: pipeline_setup builds its rootMsg from the raw user text. Two smaller wins from the same report: - The block is now ordered strictly by volatility — runtime, session, sender, then time — so under system placement a clock tick invalidates only the tail of the block rather than the session and sender lines above it. - The summary is emitted before the block instead of after, so it is no longer downstream of a per-minute value. New config, agents.defaults.dynamic_context: position "tail" (default) | "system" to restore the previous layout time "minute" (default) | "hour" to widen the reuse window | "off" Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…guides Adds the Dynamic Context Placement section to the zh, ja, fr, pt-br and vi configuration guides, matching the terminology each translation already uses for turn_profile (block/turn/system prompt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tool feedback published to chat channels showed the tail-placed
<runtime_context> preamble ahead of the user's own words:
🔧 read_file
Continuing the current task.: <runtime_context>
## Runtime
linux arm64, Go go1.26.5
...
</runtime_context>
can you check the log file
latestUserContent() walks back to the last user message and returns its raw
Content for the explanation line. Since the dynamic context moved to the tail,
that content carries the runtime block as a preamble the user never typed.
Promotes stripRuntimeContext() from a test helper to production code and
applies it in latestUserContent(), which is the single point where wire message
content becomes user-visible text. Stored history is unaffected — it was
already built from the raw user message.
stripRuntimeContext now leaves an unterminated block alone instead of
returning the content unchanged only when the open tag is missing, so a
malformed preamble cannot silently truncate the message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📝 Description
The per-request dynamic context block (
## Current Time,## Runtime,## Current Session,## Current Sender) currently sits inside the system message, ahead of the entire conversation history.Prefix caching is positional: changing any token invalidates every cached token after it. A minute-precision clock at the front of the prompt therefore invalidates the whole history roughly once per minute. On a host where a turn takes longer than a minute, that cost is paid on every single turn — measured at ~2.2 ms per history token, so a 6,000-token history burns about 13 s of pure re-prefill per turn before the model emits anything.
This PR moves the block to the tail by default: after the history, carried on the current user message inside a
<runtime_context>tag. The static system prompt and the full history then stay byte-identical from turn to turn, so backends doing byte-prefix matching keep their KV cache. It also makes the static prompt identical across all users, sessions and cron runs, so they share one cached prefix instead of each paying a cold prefill.This matters most for local backends — llama.cpp, Ollama, and other OpenAI-compatible endpoints with no native caching mechanism. Anthropic (per-block
cache_control) and OpenAI (prompt_cache_key) have their own mechanisms and are unaffected either way.New config under
agents.defaults.dynamic_context:{ "agents": { "defaults": { "dynamic_context": { "time": "minute", "position": "tail" } } } }position:tail(new default) orsystemto restore the current layout.time:minute(default, unchanged),hourto widen the reuse window, oroffto drop the clock entirely.I've made
tailthe default rather than making the feature opt-in. The reasoning is that the current layout is a straightforward cache pessimisation for every operator on a byte-prefix-matching backend, and most of them will never discover the knob — so defaulting tosystemwould leave the win unclaimed for the people who need it most.position: "system"restores the old layout exactly, for anyone who depends on the prompt shape.I'm happy to flip the default to
systemand make this purely opt-in if you'd rather not change behaviour in a point release — it's a two-line change toEffective()andDefaultDynamicContext(), plus doc updates. Just say which you prefer.Commits
move dynamic context after history— the core change, config plumbing, validation, and docs.document dynamic_context in the translated configuration guides— the same section in the fr / ja / pt-br / vi / zh guides.strip runtime context from tool feedback explanations— a required follow-up.latestUserContent()returns the raw wire content for the tool-feedback explanation line, so with a tail-placed block the<runtime_context>preamble leaked into channel messages as if the user had typed it. PromotesstripRuntimeContext()to production code and applies it at that single point. Stored history was never affected.One deliberate constraint worth flagging: the block is never emitted as a trailing system message. Provider adapters hoist system messages to the front and some keep only the last one, which would discard the static prompt entirely — hence carrying it on the user message instead.
🗣️ Type of Change
Marked as both: the caching behaviour is a fix, the
dynamic_contextconfig block is new surface area.🤖 AI Code Generation
Written with AI assistance and running on my own deployment, where the re-prefill stall was the symptom that prompted it.
🔗 Related Issue
None open that I could find.
📚 Technical Context (Skip for Docs)
prompt_cache_key, unaffected): https://platform.openai.com/docs/guides/prompt-caching🧪 Test Environment
whatsapp_nativebuild tag)📸 Evidence (Optional)
Click to view Logs/Screenshots
New tests cover both placements and the validation surface:
pkg/config/dynamic_context_test.go— resolution, defaults, and rejection of unsupportedtime/positionvalues.pkg/agent/dynamic_context_test.go— tail vs system rendering, the<runtime_context>wrapper, andstripRuntimeContext()including the unterminated-block case.pkg/agentcontext/cache/prompt tests updated for the new layout.Verified locally (darwin/arm64, Go 1.25):
make vetandmake lint-docspass.make testpasses except for three failures inpkg/tools—TestShellTool_RelativePathWithSlashAllowed,TestShellTool_DevNullAllowed,TestShellTool_FileURISandboxing. These are pre-existing and unrelated: they are macOS-only (the shell guard resolves/var/folders/...through the/var→/private/varsymlink and treats the temp workspace as outside the working dir), and they fail identically on an unmodifiedorigin/mainworktree.make lintreports 9 issues (8govet"Constant reflect.Ptr should be inlined", 1prealloc), all in files this PR does not touch (pkg/providers/,web/backend/api/config.go,pkg/agent/pipeline_llm.go). They also reproduce on unmodifiedorigin/mainand look like an artifact of a locally-installed golangci-lint newer than the version CI pins.☑️ Checklist
docs/guides/configuration.md, the five translated guides, andconfig/config.example.json.)