Repository navigation
muninn v0.2.1
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_controlbreakpoint 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.
- anthropic — segments render as separate content blocks with an explicit
-
Cache accounting in the logs.
cache_read_tokens/cache_write_tokens
now appear on everyagent: model turnline, onagent: run finished, and on
the one-linecurator: run outcomerecord — mapped from whichever fields the
provider reports (anthropiccache_read_input_tokens/
cache_creation_input_tokens, openaiprompt_tokens_details.cached_tokens,
deepseekprompt_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 entitytype, 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 includedbifrostx3 (all
service),AGENTS.mdx2 (bothdocument) andai-clientx2 (both
component). Candidates now carrya_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_declinenow requires areasonand
stores it on theMERGE_DECLINEDedge (bounded, cut on a rune boundary).
Previously the edge carried onlysourceanddeclinedAt, 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_intervalstill waited a further full interval before
its first sweep — time spent down was time the schedule silently skipped.
Startup now checks each tenant's persistedlastFinishedwatermark (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 triggercatchup_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 stampslastFinished, so a restart inside one
interval submits nothing. -
llm.timeout_apiraised 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 byagent.max_iterations. -
agent.prompt_cache(defaulttrue) 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 defaultllm.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 forcache_read_tokenson thecurator: run outcomeline. Zero on
a run whose per-turninput_tokensstayed 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
-
llm.RequestgainedUserSegments,UserSuffixandCachePrefix;
llm.ResponsegainedCacheReadTokensandCacheWriteTokens. Additive — the existing
single-shot callers (mint, recall, lesson, rewrite, health) are unchanged and
still send an identical wire body. Setting bothUserandUserSegmentsis
rejected rather than silently concatenated. -
agent.Completernow 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).