Session.append pushes the event BEFORE notifying session/event listeners,
so a throwing listener leaves the event in the log while the line after
the append (a boolean flag) never runs. Both turn-balance decisions were
gated on such flags, so a throwing listener could strand an open turn or
skip a durability checkpoint.
- loop.ts: the outer catch decided "turn/end owed" from `turnStarted`.
A throwing listener on the turn/start append left turn/start logged but
the flag false → catch rethrew and skipped turn/end → permanently open
turn (violating ADR 0017). Now decided from the log (this turn's
turn/start present), so the turn is always balanced; only a genuine
pre-push failure (non-serializable trigger — turn/start never logged) is
rethrown to the runLoop backstop. Removed the now-dead `turnStarted`.
- agent.ts inject(): the idle one-shot-turn flush was gated on a
`turnRecorded` flag set after append('turn/end'); a throwing turn/end
listener skipped the flush, losing the balanced in-memory injection turn
on crash. Now the flush decision is read from the log, the synthetic
turn/end append contains a throwing listener (turn stays balanced), and
a failing idle flush is reported via agent/error (step 0 convention) AND
the logger — mirroring the loop's post-turn/end flush path — with a
throwing agent/error listener contained.
Rewrote the test that encoded the old (buggy) "turn/start listener throw
is rethrown, no turn/end" semantics to assert the balanced-turn contract,
and added regressions for the throwing-turn/end-listener flush and the
agent/error report. Updated Agent.inject JSDoc.
dsh-agent-loop
THE concrete agent plugin: LoopAgent and the loop driver. Implements the Agent interface and drives the session/turn/step lifecycle.
This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here.
Service: AgentLoop (ctx key: agentLoop)
Public API
ctx.agentLoop.create(id: string, options?: AgentOptions): LoopAgentCreate an agent, start its loop, and register it inctx.agents. Disposed with the calling fiber.
Injected services
agents, sessions, llm, tools, systemPrompt — all five interface services.
Configuration (schemastery)
interface Config {
agents: Array<{
id: string // required
model?: string
systemPrompt?: string
}>
}
Agents listed in config are auto-created at startup.
Classes
LoopAgent— the concreteAgentimplementation. Owns the inbox (Inbox), the per-stepAbortController, and the loop driver. Everything observable happens through session events and theagent/*event taxonomy.Inbox— per-agent queued + steering FIFOs (enqueue,steer,drainQueued,drainSteering,waitForQueued).
Loop lifecycle (loop.ts)
One invocation of runLoop() drives one agent for its whole lifetime:
forever:
wait for queued messages (idle)
TURN (error-contained):
drain queued → 'turn/start' → session('user/message')
STEP loop:
drain steering
assembly = systemPrompt.assemble()
request = waterfall agent/request
stream llm.stream(request) → session('assistant/chunk')
message = waterfall agent/step-result
session('assistant/message')
each tool-call: session('tool/call') → tools.execute() → session('tool/result')
drain steering → session('steering/message')
cont = waterfall agent/turn-continuation
if !cont: break
session('turn/end')
await session/flush
re-enqueue leftover steering as queued
idle unless more queued
Error containment: a throwing plugin ends the turn, never the loop. Dispose mid-turn emits agent/status('disposed') and ends with reason disposed.
What is NOT here
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
- Hooks:
agent/request,agent/step-result,tools/execute,agent/turn-continuation - Compaction:
agent/request - Sandbox, permission, plan mode:
tools/execute - Sub-agents: TODO seam on
AgentLoop.create() - Persistence:
session/event+session/flush - UI:
agent/stream-chunk+agent/*events