Skip to content

fix(agent): move dynamic context after history to preserve prefix caching - #3321

Open
grrowl wants to merge 3 commits into
sipeed:mainfrom
grrowl:perf/dynamic-context-tail-placement
Open

fix(agent): move dynamic context after history to preserve prefix caching#3321
grrowl wants to merge 3 commits into
sipeed:mainfrom
grrowl:perf/dynamic-context-tail-placement

Conversation

@grrowl

@grrowl grrowl commented Aug 7, 2026

Copy link
Copy Markdown

📝 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) or system to restore the current layout.
  • time: minute (default, unchanged), hour to widen the reuse window, or off to drop the clock entirely.

⚠️ This changes default behaviour

I've made tail the 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 to system would 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 system and make this purely opt-in if you'd rather not change behaviour in a point release — it's a two-line change to Effective() and DefaultDynamicContext(), plus doc updates. Just say which you prefer.

Commits

  1. move dynamic context after history — the core change, config plumbing, validation, and docs.
  2. document dynamic_context in the translated configuration guides — the same section in the fr / ja / pt-br / vi / zh guides.
  3. 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. Promotes stripRuntimeContext() 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

  • 🐞 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 📖 Documentation update
  • ⚡ Code refactoring (no functional changes, no api changes)

Marked as both: the caching behaviour is a fix, the dynamic_context config block is new surface area.

🤖 AI Code Generation

  • 🤖 Fully AI-generated (100% AI, 0% Human)
  • 🛠️ Mostly AI-generated (AI draft, Human verified/modified)
  • 👨‍💻 Mostly Human-written (Human lead, AI assisted or none)

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)

🧪 Test Environment

  • Hardware: Raspberry Pi
  • OS: Debian 12 (linux/arm64)
  • Model/Provider: OpenAI-compatible endpoint (local)
  • Channels: WhatsApp (whatsapp_native build 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 unsupported time / position values.
  • pkg/agent/dynamic_context_test.go — tail vs system rendering, the <runtime_context> wrapper, and stripRuntimeContext() including the unterminated-block case.
  • Existing pkg/agent context/cache/prompt tests updated for the new layout.

Verified locally (darwin/arm64, Go 1.25):

  • make vet and make lint-docs pass.
  • make test passes except for three failures in pkg/toolsTestShellTool_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/var symlink and treats the temp workspace as outside the working dir), and they fail identically on an unmodified origin/main worktree.
  • make lint reports 9 issues (8 govet "Constant reflect.Ptr should be inlined", 1 prealloc), all in files this PR does not touch (pkg/providers/, web/backend/api/config.go, pkg/agent/pipeline_llm.go). They also reproduce on unmodified origin/main and look like an artifact of a locally-installed golangci-lint newer than the version CI pins.

☑️ Checklist

  • My code/docs follow the style of this project.
  • I have performed a self-review of my own changes.
  • I have updated the documentation accordingly. (docs/guides/configuration.md, the five translated guides, and config/config.example.json.)

grrowl and others added 3 commits August 7, 2026 16:40
…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>
@CLAassistant

CLAassistant commented Aug 7, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants