Date: 2026-08-03 Type: phase-update
Five LLM characters are scheming, sneaking, and stumbling through a haunted mansion. Every turn produces events — dialogue, movement, asides, interactions. A human watching the UI sees a wall of text: disconnected actions scrolling past faster than they can read.
What’s missing is the voice that ties it together. The breathless cartoon narrator who says: “And JUST when our heroes thought the Kitchen was SAFE, the Hooded Claw slithers in with a smile SO sinister it could curdle MILK!”
This is Phase 2.7 of Wacky Manor — wiring a live LLM narrator that consumes the event stream and generates running commentary in real time.
The narrator pattern is a general multi-agent architecture concern: how do you turn a stream of low-level agent events into a coherent higher-level summary, continuously, without blocking the agents that produce the events?
This shows up in:
The narrator is the first consumer of the summarisation pipeline that isn’t a character agent — it’s an observer. That distinction drove most of the design decisions.
The obvious answer is “from the same observation pipeline the characters use.”
But the observation pipeline (PartitionedObservationService) is deliberately
scoped — it gives each character only events visible from their room, filtered
by aside privacy. The narrator is omniscient. Routing everything through a
visibility-filtered pipeline would require either a special “sees everything”
observer (defeating the point of partitioning) or aggregating all per-character
drains (losing compaction benefits).
The answer: direct collection on a separate pipeline. The narrator receives
raw ManorEvent objects at the same call sites that publish to the character
observation service, but accumulates them independently. Two pipelines, same
source, different consumers — characters get room-scoped filtered observations,
the narrator gets everything.
The surprise in this build was how much infrastructure already existed. The
SummarisationRunner in casehub-blocks implements exactly the narrator’s core
loop: accumulate events → trigger on a policy (count or timer) → compact →
summarise → publish output. It was designed for the L1-L2 summarisation
pipeline but the pattern is identical.
The narrator is essentially: a SummarisationRunner<ManorEvent, String> with
a narrator-specific Summariser implementation that calls an LLM.
But the narrator also surfaced gaps in blocks that needed filling before it could work:
| Gap | What was missing | Fix |
|---|---|---|
| Compaction SPI | No hook for mechanical event deduplication before LLM summarisation | Added Compactor<E> interface — applied between drain and summarise |
| Failure recovery | If the LLM call failed, drained events were silently lost | Added onFailure handler — logs batch, continues |
| Final drain | tick() respects WindowPolicy — at shutdown, remaining events below threshold were lost |
Added flush() — unconditional drain bypassing the policy |
| Thread safety | tick() wasn’t synchronized — concurrent callers could race on drain |
Synchronized tick(), added atomic drainIfReady() |
Seven issues total (casehubio/blocks#82), shipped in a single epic before the narrator implementation started. The narrator drove platform improvements — the demo-as-requirements-driver pattern working exactly as intended.
The design review (4 dimensions, 45 issues, $37) reshaped the spec significantly. Two changes stand out:
ManorEventDispatcher — the review’s structure dimension identified that
the narrator created a triple-publish pattern: every event site called
world.addEvent(), observationService.publishEvent(), and
narratorAgent.collect() separately. The fix: a centralized dispatcher that
receives one publish() call and fans out to all consumers. This dropped
CharacterAgentLoop’s parameter count from 9 to 7 and eliminated all knowledge
of downstream event consumers from the character decision loop.
AUTONOMOUS-only mode — the review’s coherence dimension caught that running the live narrator alongside SCRIPTED mode’s trigger-fired narration would produce double commentary on the same channel. The fix: the narrator only starts in AUTONOMOUS mode, where no trigger narration exists. Clean separation.
The narrator can’t call the LLM per-event — too expensive, too spammy. But a
pure timer misses bursts and narrates silence. The solution is
WindowPolicy.of(15_000, 5): narrate after 5 events accumulate OR after 15
seconds since the oldest buffered event, whichever comes first.
shouldEmit() returns false — no wasted LLM callsThe narrator thread polls tick() every second. WindowPolicy decides when to
actually fire. Between tick and narration, MechanicalCompactor collapses
superseded events (5 MOVE events for the same character become 1) so the LLM
prompt stays compact.
Platform gaps surface at consumption boundaries. The summarisation pipeline worked fine for its original use case. The narrator — a different kind of consumer — found 7 gaps. Building diverse consumers is how you find the missing abstractions.
Design review pays for itself in architecture. ManorEventDispatcher wasn’t in the original spec. It emerged from a structure review that cost $10. The alternative — discovering the coupling during implementation and refactoring after the fact — would have cost more in both time and test rework.
The narrator pattern is a reusable block. Accumulate → hybrid trigger →
compact → LLM summarise → dispatch. The wacky-manor-specific parts are the
compactor rules and the narrator prompt. Everything else is generic
SummarisationRunner infrastructure from blocks. A future monitoring narrator
or meeting summariser would swap the Summariser implementation and compactor
rules, reusing the rest.
Phase 2.7’s verdict gate: “narrator panel shows entertaining LLM-generated
running commentary.” The integration test exists (NarratorIntegrationTest,
@Tag("llm-eval")). Running it with a live LLM is the verdict — does the
narrator sound like a cartoon, or like a log file?
After that: Phase 2.8 (NPC system — scripted fixtures that drive quests) and Phase 2.9 (scale to 6 rooms — the full mansion).