Skip to content

[Bug]: Prompt-cache hit rate collapses (85%+ → ~40%) for active goals — live-mutating counters injected into the cached system prompt #13

Description

@ghostbody

What happened?

After enabling @prevalentware/opencode-goal-plugin, our LLM prompt-cache hit rate dropped from ~85%+ to ~40% for any session that has an active goal. The drop is systematic (every turn) and starts the moment a goal becomes active; it disappears when no goal is set or after the goal is closed.

The cause: the plugin injects a per-turn mutating budget/status block into the cached system prompt, breaking the prefix stability that prompt caching relies on.

Expected behavior

The goal-mode system reminder should not destabilize the cacheable system-prompt prefix. Either it should be cache-stable (no values that change every turn), or it should live outside the cached prefix (e.g. appended after the cache breakpoint) so the static system prompt stays cacheable.

Steps to reproduce

  1. opencode plugin @prevalentware/opencode-goal-plugin
  2. Start a session with a caching-capable provider (e.g. anthropic/claude-sonnet-5).
  3. /goal implement X — goal becomes active.
  4. Send several follow-up turns and observe the provider-side cache-read stats collapse per turn (input tokens billed as non-cached instead of cache-read).
  5. /goal complete ... (or clear) — cache rate recovers once no active goal reminder is injected.

Root cause (analysis)

experimental.chat.system.transform runs on every turn and merges a reminder into the first system block — the same block the cache breakpoint sits on:

// src/server.ts
async "experimental.chat.system.transform"(input, output) {
  const goal = await getGoal(input.sessionID)            // calls snapshot()
  mergeSystemReminder(output, systemReminder(goal, ...)) // appends to output.system[0]
}

function mergeSystemReminder(output, reminder) {
  // ...
  output.system[0] = `${output.system[0]}\n\n${reminder}`  // mutates the cached prefix
}

getGoalsnapshot() recomputes live wall-clock time on every call:

// src/state.ts
const sampledAt = nowSeconds()                                       // changes every second
const activeSeconds = sampledAt - goal.lastAccountedAt
const timeUsedSeconds = goal.timeUsedSeconds + activeSeconds        // changes every turn

That snapshot flows into continuationPromptbudgetLines (src/prompts.ts), which contains values that change turn-over-turn:

- Time spent pursuing goal: ${goal.timeUsedSeconds} seconds   // wall-clock, changes each turn
- Tokens used: ${goal.tokensUsed}                             // monotonically increases
- Tokens remaining: ${goal.remainingTokens}                   // monotonically decreases
- Auto-continues used: ${goal.autoTurns}                      // increments

formatGoal() adds more mutating fields (Latest checkpoint, Last status, Tokens used: x/y, etc.).

Because the entire block is appended to output.system[0] — the cached prefix — a single differing character (e.g. 85 seconds86 seconds) invalidates the cache from that offset onward, every turn. That matches the observed 85%+ → ~40% drop.

Suggested fixes

In order of impact:

  1. Move the reminder out of the cached prefix. Push the reminder into a non-cached system block (after the cache breakpoint) or into the trailing user/system turn position, so the static system prompt stays byte-identical across turns.
  2. Drop the live counters from the system reminder. timeUsedSeconds, tokensUsed, remainingTokens, and autoTurns are the cache-killers. Keep only stable content (objective + fixed behavior instructions) in the per-turn system block. Budget progress can still be surfaced — but only inside continuationPrompt, which is injected as a separate user message (already a non-cached-prefix path) during auto-continue, not on every chat turn.
  3. Only inject the reminder on continuation turns, not on ordinary chat.message turns.

A minimal mitigation combining 2 + 3 (strip live values from budgetLines/formatGoal used by systemReminder; keep them only in continuationPrompt) should restore cache stability for normal turns while preserving the auto-continue budget signal.

Plugin version

0.1.1

OpenCode version

Behavior is version-independent (stems from plugin source, not the OpenCode runtime). Observed on a recent OpenCode build.

Operating system

macOS 15

Model/provider in use

anthropic (prompt-caching provider). Any provider with prefix caching is affected.

Plugin options

Default options (auto_continue enabled, no custom budget).

Relevant goal state or logs

N/A — root cause is determinable from source. Field references above point to the exact mutation sites.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions