Skip to content

[OMEGA-327] getContext prompt layout prevents provider prefix caching from taking effect #300

Description

@MartinEbner

Describe the bug

getContext (src/loop.metta) assembles the prompt with the most volatile section in the middle:

PROMPT · SKILLS · prompt-extensions · OUTPUT_FORMAT · SAVE_PERMANENT_FILES_DIR
  · LAST_SKILL_USE_RESULTS   ← changes completely every cycle, up to maxFeedback chars
  · HISTORY                  ← re-sliced tail of memory/history.metta
  · TIME

Providers with automatic prompt caching (OpenAI, DeepInfra, OpenRouter, …) reuse only a prefix
that is byte-identical to the previous request. The first byte that changes ends the reusable
prefix. Because LAST_SKILL_USE_RESULTS is both the most volatile section and one of the largest,
the reusable prefix ends after the static head — roughly the first 4 KB of a ~48 KB prompt — no
matter how stable everything after it is.

This also defeats work the repo has already done: providers/lib_llm_ext.py::_stable_cache_key is
used by providers/openai.py (prompt_cache_key) and providers/openrouter.py (session_id) to
pin cache identity across calls. Pinning the identity does not help while the payload's stable
part sits behind a volatile one.

There is a second, independent obstacle: getHistory returns the tail of an append-only file,
so the history window slides on every append and its bytes shift position each cycle. Reordering
alone therefore does not make history reusable — it needs a stable cut point as well.

To Reproduce

  1. Run with a provider that reports cached input tokens (we used DeepInfra and OpenRouter, model
    z-ai/glm-5.2).
  2. Log per-call usage (prompt_tokens, cached_tokens / prompt_tokens_details).
  3. Trigger a burst of consecutive cycles — e.g. send a message, so several loop iterations run
    within the provider's cache TTL.
  4. Compare cached_tokens against prompt_tokens.

Expected behavior

The reusable prefix covers everything that did not change since the previous call, so the cached
fraction is roughly proportional to the stable part of the prompt.

Actual behavior

On our instance the cached count sat at ~1,088 tokens per call — matching the static head almost
exactly — i.e. about 3% of input tokens were billed at the cached rate during multi-call
bursts, while ~40% of the prompt was in fact unchanged between those calls.

Measured section sizes from one production prompt (~48 KB total):

section bytes share
static head (PROMPT + SKILLS + OUTPUT_FORMAT + SAVE_PERMANENT_FILES_DIR) 4,208 8.8%
LAST_SKILL_USE_RESULTS 23,439 49.0%
HISTORY 20,157 42.1%
TIME 32 0.1%

Suggested fix

  1. Minimal, no behaviour change: order the prompt stable → volatile, i.e. move
    LAST_SKILL_USE_RESULTS after HISTORY, leaving TIME last.
  2. To make HISTORY itself reusable: cut it at a fixed byte-block boundary instead of a sliding
    tail. memory/history.metta is append-only, so the bytes below the largest block multiple
    strictly under the file size never move; emitting that part as a stable "archive" block plus a
    small recent tail keeps the archive byte-identical between block crossings, with no extra state
    and no change to how much history the model sees.

We ran (1) + (2) behind an opt-in flag on our own instance: cached fraction went from ~3% to ~40%,
which at DeepInfra's rates ($0.93 vs $0.18 per Mtok input, an 80.6% discount on cached tokens)
came to roughly a 33% reduction in input cost on burst cycles. Cycles spaced further apart than
the provider's cache TTL naturally gain nothing.

All figures above are measurements from our own deployment, not synthetic estimates.

Note for #284 (Context Frames)

The same principle applies to the frames layout, and the current draft would hit it: the proposed
getContext puts CURRENT_CONTEXT_FRAME_S_EXPR near the top, where a frame that changes each
cycle would invalidate everything after it. Keeping per-cycle-mutable sections last preserves
whatever caching the static head and the frame-independent sections could otherwise provide.

Additional context

  • OmegaClaw version: current main
  • Communication channel: Telegram
  • Model provider: OpenRouter and DeepInfra (z-ai/glm-5.2)

We have a working implementation of both parts, default-off so the flag-off prompt is
byte-identical to today's. Happy to open a PR if that would be useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    backlogThe issue has been included in the backlogbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions