Skip to content

muninn v0.2.1

Choose a tag to compare

@scottlarkin scottlarkin released this 20 Aug 21:53
· 2 commits to main since this release

What's new

  • Prompt caching for the curator loop. The curator is a multi-turn tool
    agent: every turn re-sends the whole transcript, so a long run used to pay
    full input price for the same history N times (a 10-iteration run measured
    100,546 input tokens, ~10k per turn and rising). The transcript is now sent as
    ordered, append-only segments, so providers can serve the unchanged prefix
    from cache and only charge for the newest turn.

  • Provider-agnostic by design. The enabling change is that the prompt is
    append-only, which is what every provider's prefix cache needs:

    • anthropic — segments render as separate content blocks with an explicit
      cache_control breakpoint on the newest one.
    • openai-compatible / deepseek — caching is automatic with no request
      field; they get the same bytes they got before, just append-only.
    • ollama — reuses its own KV cache for a matching prefix; nothing to ask
      for.
      No provider is required to support caching for the loop to work.
  • Cache accounting in the logs. cache_read_tokens / cache_write_tokens
    now appear on every agent: model turn line, on agent: run finished, and on
    the one-line curator: run outcome record — mapped from whichever fields the
    provider reports (anthropic cache_read_input_tokens /
    cache_creation_input_tokens, openai prompt_tokens_details.cached_tokens,
    deepseek prompt_cache_hit_tokens). This matters because a run that caches
    nothing is otherwise indistinguishable from one that caches everything: same
    output, same graph writes, several times the bill.

  • Duplicate entities are judged on better evidence. The name-collision arm of
    the merge-candidate query never returned entity type, so the curator decided
    "same real-world thing?" from two names and two summaries. Those summaries are
    episodic — each describes what was happening when the entity was last written
    about — so one thing routinely has two nodes that read as two things. On the
    live graph this produced 37 declined clusters that included bifrost x3 (all
    service), AGENTS.md x2 (both document) and ai-client x2 (both
    component). Candidates now carry a_type/b_type (both arms), and the prompt
    states that matching name + matching type outweighs divergent episodic
    summaries, while a name shared across two different types still means two
    things. The do-not-merge-when-unsure rule is unchanged, and merges remain
    permanent.

    No cleanup is needed: ontology.reconfirm_after (336h) expires declines, so
    affected pairs are re-offered on their own — the earliest batch within days.

  • Declines record why. record_merge_decline now requires a reason and
    stores it on the MERGE_DECLINED edge (bounded, cut on a rune boundary).
    Previously the edge carried only source and declinedAt, so "why is bifrost
    still three nodes?" could only be answered by re-reading the summaries and
    guessing — there was no way to tell a considered decline from a reflexive one.
    An empty reason is still accepted at the storage layer: refusing the write
    would leave the pair un-memoed and re-offered forever, which is a worse
    failure than a missing note.

  • Catch-up no longer forgets downtime. The periodic sweep ran on a plain
    ticker, so a server that had been off for longer than
    serve.ontology_catchup_interval still waited a further full interval before
    its first sweep — time spent down was time the schedule silently skipped.
    Startup now checks each tenant's persisted lastFinished watermark (already
    in :CuratorState, no new state) 60 seconds after boot and submits any tenant
    whose last completed curation is overdue, then continues on the normal ticker.
    Bounded to the same per-tick cap, skipped when a curator claim is already held,
    and logged as trigger catchup_startup.

    The 60-second delay is load-bearing: it keeps the pass out of the restart burst
    and is what stops a crash-loop becoming a curation storm. The pass is also
    self-limiting — a completed run stamps lastFinished, so a restart inside one
    interval submits nothing.

  • llm.timeout_api raised 30s → 60s. It bounds one provider call, while
    agent.timeout (2m30s) bounds a whole curator run — so at 30s a single slow
    response was ending runs that still had two minutes of budget. Observed twice
    on unattended catchup runs: Client.Timeout exceeded while awaiting headers
    (the provider had not sent a byte) on the turn right after a large-result tool,
    where the prompt jumps and time-to-first-byte grows with it. One of those runs
    had already committed four mutations, which were then reported as a failure.
    Config validation now pins the relationship in both directions: above the value
    that failed, below the run budget.

  • agent.max_tokens (new, default 4096, was a hardcoded 2048). A turn
    that hits the output cap is truncated mid-reply and can lose its tool call
    entirely — the whole turn is wasted at output prices, which cost several times
    input. Observed in production at 2048 on a turn writing several entity
    summaries. Raise it if you still see truncated turns; it is a spend ceiling,
    since it multiplies by agent.max_iterations.

  • agent.prompt_cache (default true) turns the request off for
    troubleshooting. Leaving it on is safe everywhere; turning it off makes the
    loop re-pay for the transcript every turn.

Notes for operators

  • Nothing to configure. Caching is on by default and needs no key, header,
    or model change.

  • Caching engages on the runs that cost money, and skips the cheap ones.
    The prefix has to clear the provider's minimum cacheable size before an entry
    is written at all, and that minimum is model-dependent — on
    claude-haiku-4-5 (the default llm.model) it is the highest of the current
    models, 4096 tokens. Measured on a live run: a 3-iteration pass over a clean
    graph had prompts of 3717–3995 tokens and cached nothing, while a
    transcript above the threshold wrote 4347 tokens on turn 1 and read back all
    4347 on turn 2, paying only for the new growth. So a quiet tick stays
    uncached and cheap either way; a busy backlog run — the one that previously
    reached 100k input tokens — is where the reuse lands.

  • How to check it is working: run the curator and read one line —
    muninn worker curator '{"tenant":"default","trigger":"manual","force":true}',
    then look for cache_read_tokens on the curator: run outcome line. Zero on
    a run whose per-turn input_tokens stayed under the model minimum is
    expected; zero on a run well above it means the prefix is being invalidated
    and the run is paying full price. There is also a live end-to-end check that
    drives the real provider through muninn's own code and fails if reads stop
    accumulating: go test -tags live_cache ./internal/llm/ -run LivePromptCache -v.

  • A history trim resets the cache once. A run long enough to hit the
    transcript cap (48 messages, an internal agent default — not a config key)
    drops older turns from the middle, and the turn after that cannot reuse the
    cached prefix. Expect one cold turn, not a permanent loss.

  • Savings are proportional, not dramatic in absolute terms on the default
    Haiku model — roughly two-thirds off the input tokens of a long run, which is
    cents per run. It scales with run frequency and matters considerably more on a
    Sonnet- or Opus-tier model, where input tokens cost 3–5x.

Breaking changes

  1. llm.Request gained UserSegments, UserSuffix and CachePrefix;
    llm.Response gained CacheReadTokens and CacheWriteTokens.
    Additive — the existing
    single-shot callers (mint, recall, lesson, rewrite, health) are unchanged and
    still send an identical wire body. Setting both User and UserSegments is
    rejected rather than silently concatenated.

  2. agent.Completer now takes a request struct instead of positional
    (system, user, model, maxTokens) arguments. Internal interface; only
    internal/llm's bridge and test fakes implement it.

Fixed

  • The JSON tool protocol appended its "reply with the next JSON object"
    instruction after the transcript, inside the region being cached. A constant
    tail keeps its position relative to the end, so it shifted forward every turn
    and defeated prefix caching on every provider. It is now sent as a separate
    uncached tail after the cached segments, which keeps it the last thing the
    model reads: the prompt text is byte-identical to before caching existed,
    and only the block boundaries changed. That property is pinned by a test, so
    "make it cacheable" cannot quietly double as "reprompt the agent".

Install

No schema migration and no config changes. Replace the binary and restart
muninn serve (or nothing, if you only use the detached worker).