Session Loop
For an agent capsule, the Session Loop is the native execution environment built into the runtime. It runs the inference loop and manages its extension points — tools, hooks, and the inference driver.
mur run
│
├── Staging Phase (before Session Loop exists)
│ Hook binding: on-stage
│
└── SESSION LOOP ────────────────────────────────────────────────────────
│
├─ Hook: session-start (once per capsule launch)
│
│ ┌─ TASK ─────────────────────────────────────────────────┐
│ │ Hook: task-start (if present; e.g. memory log reload) │
│ │ │
│ │ ┌─ INFERENCE STEP (repeats until task completes) ─┐ │
│ │ │ │ │
│ │ │ • Model call → response │ │
│ │ │ • Hook: inference │ │
│ │ │ │ │
│ │ │ • [tool call] → Hook: tool-call │ │
│ │ │ • [shell call] → Hook: shell │ │
│ │ │ │ │
│ │ │ • Token threshold crossed? │ │
│ │ │ → Hook: compaction (blocking, replaces │ │
│ │ │ conversation history) │ │
│ │ └─────────────────────────────────────────────────┘ │
│ │ │
│ │ Hook: task-end (if present; e.g. memory log close) │
│ └────────────────────────────────────────────────────────┘
│ ↑ Loops to next TASK if task_acceptance: queue
│ and a new message arrives; runs once for none/single
│
└─ Hook: session-end (once on process exit)
Each task runs an inference loop: a sequence of LLM calls that continues until the model
signals it is done, or the per-capsule turn limit is reached. One turn is one round-trip to
the inference driver — the runtime sends the current conversation history, tool list, and
system prompt; the driver returns a response. If the model requests a tool call, the runtime
executes it, appends the result, and starts the next turn. When the model returns end_turn
(or max_tokens) the result is written to workdir/out/result.txt and the loop exits.
MURMUR.md
For agent capsules, the runtime writes MURMUR.md to the capsule workdir root — an
onboarding file covering the capsule's identity, model and context budget, directory layout,
installed tools and skills, how to call a tool, available shell commands, and how to write
checkpoints.
Turn limit (inference.max_turns)
The turn limit caps how many inference calls a single task may make. It defaults to 10
and is set per-capsule in the manifest. When the limit is reached, the loop exits with
exit_status: "max_turns_reached".
Task reopening (commit_policy: reopen-task)
A hook bound to on-task-end with commit_policy: reopen-task can veto a task's outcome
instead of just observing it. When it returns reopen-task(reason), the runtime does not
finalize the task: it re-runs the task's agent loop with reason injected into the task
content as feedback, then fires on-task-end again so the hook can re-inspect the new result.
This repeats up to the reopen limit set by lifecycle.max_task_reopens (default 1; 0
disables reopening entirely — unlike inference.max_turns, an explicit 0 is accepted).
Reopening never grants extra turns: every attempt of a task shares one cumulative turn count
against the capsule's inference.max_turns limit, so a task cannot out-run its turn budget just
because a hook keeps asking for another try.
If the reopen limit or the turn limit is used up while a hook still wants to reopen, the task
ends with its own exit status — exit_status: "reopen_budget_exhausted" rather than an
ordinary "ok"/"failed" — and the task registry / A2A task state records it like any other
failed task.
Every reopen is written to trace.jsonl as a task_reopened event (the hook's name, its
feedback text, and a 1-based ordinal), and the terminal task_end record carries a
reopen_count field — 0 for a task that ran once. See Session trace
(trace.jsonl) schema for the exact shapes.
The reopen limit applies per task: in a task_acceptance: queue session, each task starts
fresh at 0 reopens used, regardless of what an earlier task consumed.
name: murmur-hook-gatekeeper
version: 1.0.0
runtime: hook
binding: on-task-end
execution_mode: blocking
commit_policy: reopen-task
description: "Rejects a task's result until its own checks pass."
Context seeding (commit_policy: seed-context)
A hook bound to on-task-start with commit_policy: seed-context gives a capsule memory. It
returns a list of messages, oldest first, and the runtime places them at the head of the task's
message list — ahead of any conversation history the task loads and ahead of the task itself — so
they are in the very first request the driver sees. The first bound hook to return a seed wins.
Two manifest keys govern how much a seed may occupy:
| Key | Controls |
|---|---|
context.seed_budget |
Fraction of context.max_tokens the seed may occupy. The product, rounded down, is the seed's ceiling and is sent to the hook as task-start-event.budget-tokens |
context.seed_overflow_margin |
How far over that ceiling a seed may go before the runtime spends an inference call summarizing it instead of simply dropping its oldest messages |
What the runtime does with a proposal, checked in this order:
| Condition | Result |
|---|---|
The session runs inference.transport: process |
Nothing is seeded — that transport's CLI owns its own conversation |
The capsule declares no context.max_tokens |
Nothing is seeded — there is no ceiling to enforce |
| One message alone is wider than the whole ceiling | Nothing is seeded — no trim can fit it |
| The proposal is more than three times the ceiling over it | Nothing is seeded — a seed that far over is a broken hook, not a full memory |
| The proposal fits the ceiling | All of it is seeded |
It is over by no more than context.seed_overflow_margin of the ceiling |
The oldest messages are dropped from the front until the rest fits |
It is over by more than that, and a on-compaction hook is bound |
The overflowing front is summarized by that hook, and the summary becomes the seed's first message |
It is over by more than that, and no on-compaction hook answers |
The oldest messages are dropped from the front until the rest fits |
A seed that cannot be committed never fails the task: the task runs without it. Every outcome is
written to trace.jsonl as a
context_seed event and to
workdir/logs/bootstrap.log.
name: murmur-hook-memory
version: 1.0.0
runtime: hook
binding: on-task-start
execution_mode: blocking
commit_policy: seed-context
description: "Reloads what earlier sessions recorded."
Capsule lifecycle
The lifecycle: manifest block controls how long a capsule stays running and how many tasks
it accepts over its lifetime:
task_acceptance |
after_task |
Behaviour |
|---|---|---|
none |
exit |
Runs from task.md if present, then exits. All incoming messages are rejected. |
single (default) |
exit (default) |
Accepts one A2A task, runs it, exits. Classic ephemeral capsule. |
queue |
sleep |
Accepts a queue of tasks, processes them serially, sleeps between tasks; the host decides when to shut it down on idle. |
queue+sleep is the canonical mode for persistent capsules — it parks between tasks rather
than holding an OS thread. session-start/session-end still fire once per capsule launch,
as shown in the session loop diagram above; it's task-start/task-end that fire once per
task iteration, and a capsule's hook components are loaded once at startup and reused across
all task iterations.
Queue lanes
A queued task waits in one of three lanes, read off its origin:
| Lane | Origins | What is waiting on the task |
|---|---|---|
user |
user |
A person |
peer |
peer |
Another capsule, with a task of its own blocked on the answer |
bg |
schedule, event, completion, system |
Nothing |
The runtime takes the front of the highest non-empty lane: every waiting user task runs before
any peer task, and every peer task before any bg task. Within one lane the order is arrival.
A running task is never interrupted. A task that outranks it waits until it finishes, so lanes decide which task starts next and never which task stops.
A capsule with one source of tasks puts every task in the same lane and runs them in arrival order; lanes matter in proportion to how many sources a capsule takes work from at once.
Only peer and completion are accepted from an inbound request's x-murmur-task-origin header,
so an HTTP caller cannot put itself in the user lane — a request claiming user is read as
event and waits in bg. The lane each task ran in is on its task_start record and on the task
row of mur trace steps.
Detached shell
A shell command that runs longer than
lifecycle.shell_grace_secs — 10 seconds
by default — moves to the background and the turn carries on. The turn receives a handle of the
form wrk_<id> and the fact that the command is still running; the command's stdout and stderr
go to a file under the capsule workdir instead of into the
conversation, so a long command costs the same handful of tokens whatever it prints.
Every command starts in the foreground and the clock decides, so the model chooses nothing here and a command that finishes inside the grace period returns its output to the turn.
The handle is a label, not an address: nothing accepts a work id, so a capsule cannot spend turns
asking whether the command is done. When the command finishes, the runtime enqueues a task on the
capsule itself with origin completion under the contextId the command was started from, so
the result rejoins the conversation that asked for it. That task waits in the bg lane, behind
every user and peer task, and a failure arrives on the same path as a success.
Delivery depends on the capsule still being alive. Under the default after_task: exit the
session ends with the task that started the command, and a command still running is recorded as
shell_abandoned — once in trace.jsonl and once on stderr — with its result lost. A capsule
that means to hear back runs task_acceptance: queue with after_task: sleep.
What a resume reports about the session before it
Ending a session cleanly accounts for every command it backgrounded: each one gets a
shell_completed or a shell_abandoned. A runtime that is killed outright writes neither, and
leaves its shell_detached lines standing on their own.
mur run --resume <session> reads the resumed-from session's trace.jsonl and reports every such
command as one task with origin completion in the bg lane, naming the work id, the binary, the
command and when it was detached. It is a report of loss, not a result: there is no exit code, no
output and no log file for a command whose runtime died, and whether the command itself finished
is unknown. Nothing is recovered, nothing is adopted, and the command — which the kernel does not
kill along with the runtime — is left to run out on its own.
The report is a task the capsule generated for itself, so lifecycle.task_acceptance does not
gate it: a resume delivers it under none, single and queue alike, after the task the resume
was launched for.
Each reported command is marked shell_lost in the trace of the session that started it, so a
second resume of the same session reports nothing. A session that ended cleanly has nothing to
report either way.