The agent runtime substrate — SPEC.md §7D.
One axiom: an Agent IS a Tool — (system prompt × a filtered toolkit view ×
the §8 loop), invocable, returning ONLY its final text plus
{:agent :turns :total-tokens}. Everything below is the machinery that makes
that safe to do recursively: a tree of handles, three loud backpressure gates,
hierarchical budgets, and §10 suspension that escalates one hop at a time.
A Handle is a live agent: a state machine (idle → running → idle|suspended| closed, and suspended → running ONLY via the Answer to its pending Request),
an inbox held as AGENT state (never a language mailbox), a carved budget, and a
deterministic parent-scoped id (root/coordinator.1/explore.2). The runtime
exposes exactly six host verbs — spawn post wake wait interrupt
close — plus the read-only handles / inspect views, and owns the
cross-cutting infrastructure: ONE conversation store for every handle
(conversation id = handle id, so transcripts genuinely survive turns), an
injectable clock, and the handle table.
SPEC pins TRANSITIONS, never scheduling. Conformance is identical per-handle
transition traces on a virtual clock — which is why trace is a first-class
return value here and why every timer goes through :clock.
(def rt (runtime/create-runtime
{:registry {"writer" {:name "writer" :does "writes" :soul "…"}}
:llm {:base-url "http://127.0.0.1:9999" :model "m"}}))
(def h (runtime/spawn rt runtime/root "writer"))
(runtime/wake rt h "draft the intro")
(:text (runtime/wait rt h))
THREE THINGS THIS PORT DOES DIFFERENTLY, all forced and all recorded:
root/writer.1;
the mutable tree lives in one atom. Every verb is then a compare-and-set
transaction over the whole table, which is how "admission is atomic with
the verb" is implemented rather than merely asserted. It also keeps the
capability rule honest: holding the string IS holding the capability.wait blocks; wake does not. §7D's contract is written in promises
because JS has no other choice. Clojure does: wake starts the turn on a
koine.process/run-async! thread and returns, wait derefs a promise. The
observable contract (next-or-last result, timeout leaves the child running)
is unchanged.interrupt
therefore lands between attempts, and the SPEC is explicit that only
abort LATENCY may differ: the outcome (idle + restored inbox + an
interrupted result to waiters) is identical.No future (a library may not hold its consumer's process open), no java.*,
no reader conditionals.
The agent runtime substrate — SPEC.md §7D.
One axiom: **an Agent IS a Tool** — (system prompt × a filtered toolkit view ×
the §8 loop), invocable, returning ONLY its final text plus
`{:agent :turns :total-tokens}`. Everything below is the machinery that makes
that safe to do recursively: a tree of handles, three loud backpressure gates,
hierarchical budgets, and §10 suspension that escalates one hop at a time.
A Handle is a live agent: a state machine (`idle → running → idle|suspended|
closed`, and `suspended → running` ONLY via the Answer to its pending Request),
an inbox held as AGENT state (never a language mailbox), a carved budget, and a
deterministic parent-scoped id (`root/coordinator.1/explore.2`). The runtime
exposes exactly six host verbs — `spawn` `post` `wake` `wait` `interrupt`
`close` — plus the read-only `handles` / `inspect` views, and owns the
cross-cutting infrastructure: ONE conversation store for every handle
(conversation id = handle id, so transcripts genuinely survive turns), an
injectable clock, and the handle table.
SPEC pins TRANSITIONS, never scheduling. Conformance is identical per-handle
transition traces on a virtual clock — which is why `trace` is a first-class
return value here and why every timer goes through `:clock`.
(def rt (runtime/create-runtime
{:registry {"writer" {:name "writer" :does "writes" :soul "…"}}
:llm {:base-url "http://127.0.0.1:9999" :model "m"}}))
(def h (runtime/spawn rt runtime/root "writer"))
(runtime/wake rt h "draft the intro")
(:text (runtime/wait rt h))
THREE THINGS THIS PORT DOES DIFFERENTLY, all forced and all recorded:
1. **Handles are ids, not objects.** A handle is the string `root/writer.1`;
the mutable tree lives in one atom. Every verb is then a compare-and-set
transaction over the whole table, which is how "admission is atomic with
the verb" is *implemented* rather than merely asserted. It also keeps the
capability rule honest: holding the string IS holding the capability.
2. **`wait` blocks; `wake` does not.** §7D's contract is written in promises
because JS has no other choice. Clojure does: `wake` starts the turn on a
`koine.process/run-async!` thread and returns, `wait` derefs a promise. The
observable contract (next-or-last result, timeout leaves the child running)
is unchanged.
3. **Cancellation is cooperative**, checked either side of every LLM round
trip — the same tier §7D's table gives python and java. `interrupt`
therefore lands *between* attempts, and the SPEC is explicit that only
abort LATENCY may differ: the outcome (`idle` + restored inbox + an
`interrupted` result to waiters) is identical.
No `future` (a library may not hold its consumer's process open), no java.*,
no reader conditionals.(agent-tool rt def-name)The axiom's other direction: an AgentDef AS a Tool, droppable into any
toolkit's :tools.
{name, description: does, inputSchema:{prompt}, execute: run its loop} —
returning ONLY the agent's final text plus {:agent :turns :total-tokens}.
The caller sees a tool; what is behind it is a whole agent with its own soul,
its own tool view and its own budget, and it cannot tell the difference. That
is §7A/§7B's symmetry closed locally.
The axiom's other direction: an AgentDef AS a Tool, droppable into any
toolkit's `:tools`.
`{name, description: does, inputSchema:{prompt}, execute: run its loop}` —
returning ONLY the agent's final text plus `{:agent :turns :total-tokens}`.
The caller sees a tool; what is behind it is a whole agent with its own soul,
its own tool view and its own budget, and it cannot tell the difference. That
is §7A/§7B's symmetry closed locally.(close rt id)(close rt id {:keys [force reason] :as opts})Graceful shutdown, LEAF-FIRST: stop accepting, close children first, let a
running turn finish bounded by :shutdown-ms (then escalate to an abort), run
:on-close, notify waiters, and KEEP THE FINAL STATE QUERYABLE.
close ≠ loss. inspect still answers, wait still returns the recorded
result, and a successor may be spawned from the checkpoint. Stop-all is
(close rt runtime/root).
{:force true} skips the grace period and aborts immediately.
Graceful shutdown, LEAF-FIRST: stop accepting, close children first, let a
running turn finish bounded by `:shutdown-ms` (then escalate to an abort), run
`:on-close`, notify waiters, and KEEP THE FINAL STATE QUERYABLE.
close ≠ loss. `inspect` still answers, `wait` still returns the recorded
result, and a successor may be spawned from the checkpoint. Stop-all is
`(close rt runtime/root)`.
`{:force true}` skips the grace period and aborts immediately.(create-runtime opts)Build a runtime.
:registry agent definitions by name — the task tool resolves
targets here (REQUIRED for delegation)
:llm {:base-url :style :model :api-key} for every handle's
client; a def's own :model overrides :model unless
it is "inherit"
:http-client the LLM transport (fn [url headers body] response) —
the hermetic-test seam, and the thing the turn gate
wraps. Same shape as koine.http/post-json
:clock the time source; default system-clock
:store the ONE conversation store for every handle
(conversation id = handle id); default in-memory
:inbox-cap gate 1 — inbox capacity per handle (default 8)
:max-concurrent-turns gate 3 — concurrent LLM calls tree-wide (default 8)
:shutdown-ms graceful-close bound before close escalates to an
interrupt (default 200)
:hooks §8 lifecycle callbacks applied to EVERY agent, unless
that agent's def sets its own. Forwarded verbatim
:on-metric §8 observability sink, same resolution, resolved
INDEPENDENTLY of :hooks
:on-budget the optional §7D host budget callback,
(fn [info] "stop"|"extend"|"suspend"). Consulted
ONLY when a limit would stop a turn; absent ⇒ the limit
always stops it, byte-identically. See budget-decision!
An AgentDef is a map:
{:name :does :soul :model :tools :team :budget :wait-for :on-spawn :on-close :hooks :on-metric}
:on-spawn is (fn [runtime handle-id]), :on-close is
(fn [runtime handle-id reason]) and :wait-for is (fn [request] answer).
A handle here is an id, so a lifecycle callback that wants to DO anything
needs the runtime as well — hence two arguments where the other ports pass
one object.
A Budget is a map of any of :max-turns :max-tokens :max-tool-calls :max-wall-ms :max-children :max-concurrent :max-depth. Money is deliberately
absent — it is vendor data, and a host converts tokens to money itself.
Build a runtime.
:registry agent definitions by name — the `task` tool resolves
targets here (REQUIRED for delegation)
:llm {:base-url :style :model :api-key} for every handle's
client; a def's own `:model` overrides `:model` unless
it is "inherit"
:http-client the LLM transport (fn [url headers body] response) —
the hermetic-test seam, and the thing the turn gate
wraps. Same shape as `koine.http/post-json`
:clock the time source; default `system-clock`
:store the ONE conversation store for every handle
(conversation id = handle id); default in-memory
:inbox-cap gate 1 — inbox capacity per handle (default 8)
:max-concurrent-turns gate 3 — concurrent LLM calls tree-wide (default 8)
:shutdown-ms graceful-close bound before `close` escalates to an
interrupt (default 200)
:hooks §8 lifecycle callbacks applied to EVERY agent, unless
that agent's def sets its own. Forwarded verbatim
:on-metric §8 observability sink, same resolution, resolved
INDEPENDENTLY of `:hooks`
:on-budget the optional §7D host budget callback,
`(fn [info] "stop"|"extend"|"suspend")`. Consulted
ONLY when a limit would stop a turn; absent ⇒ the limit
always stops it, byte-identically. See `budget-decision!`
An AgentDef is a map:
{:name :does :soul :model :tools :team :budget
:wait-for :on-spawn :on-close :hooks :on-metric}
`:on-spawn` is `(fn [runtime handle-id])`, `:on-close` is
`(fn [runtime handle-id reason])` and `:wait-for` is `(fn [request] answer)`.
A handle here is an id, so a lifecycle callback that wants to DO anything
needs the runtime as well — hence two arguments where the other ports pass
one object.
A Budget is a map of any of `:max-turns :max-tokens :max-tool-calls
:max-wall-ms :max-children :max-concurrent :max-depth`. Money is deliberately
absent — it is vendor data, and a host converts tokens to money itself.(execute-turn! rt id input one-shot-wait-for)ONE turn: the handle's client runs the §8 loop over its conversation.
The caller has already admitted the handle and drained its inbox into input
(both inside the verb's transaction), so what is left here is genuinely just
the Run. Failures cross the handle boundary as RESULTS — never as exceptions —
for the parent's model to judge.
On a durable pending the stored transcript is REWOUND to its pre-turn
snapshot: a persisted §10 placeholder would make the resumed parent believe it
already delegated, and skip re-invoking task. Idempotency for delegated work
comes from task-key reattachment, not from reading a transcript.
ONE turn: the handle's client runs the §8 loop over its conversation. The caller has already admitted the handle and drained its inbox into `input` (both inside the verb's transaction), so what is left here is genuinely just the Run. Failures cross the handle boundary as RESULTS — never as exceptions — for the parent's model to judge. On a durable pending the stored transcript is REWOUND to its pre-turn snapshot: a persisted §10 placeholder would make the resumed parent believe it already delegated, and skip re-invoking `task`. Idempotency for delegated work comes from task-key reattachment, not from reading a transcript.
(gate-stats rt)Turn-gate observability: LLM calls in flight and the high-water mark. A
fixture asserts gate 3 with :max-observed, which is the only honest way to
test a limit — a test that never reaches the cap proves nothing.
Turn-gate observability: LLM calls in flight and the high-water mark. A fixture asserts gate 3 with `:max-observed`, which is the only honest way to test a limit — a test that never reaches the cap proves nothing.
(handles rt)(handles rt id)Read-only snapshot of every handle in tree order, root excluded.
NOT named list — clojure.core/list exists on both hosts and shadowing a
core name is how a whole namespace gets rejected by cljgo's interop scan.
Read-only snapshot of every handle in tree order, root excluded. NOT named `list` — `clojure.core/list` exists on both hosts and shadowing a core name is how a whole namespace gets rejected by cljgo's interop scan.
(inspect rt id)Read-only detail view of one handle.
Read-only detail view of one handle.
(interrupt rt id)Abort the in-flight Run → idle, with the DRAINED INBOX ITEMS RESTORED. It is
never a kill: the handle stays alive, its transcript intact, ready to be woken
again. On a suspended handle it cancels the pending Request → idle — the
operator's escape hatch from a suspension nobody is going to answer.
Waiters receive a uniform interrupted result, never an exception.
Landing is cooperative here (checked either side of each LLM round trip), so an interrupt takes effect at the next checkpoint rather than mid-socket. §7D says only abort LATENCY may differ between ports; the outcome does not.
Abort the in-flight Run → `idle`, with the DRAINED INBOX ITEMS RESTORED. It is never a kill: the handle stays alive, its transcript intact, ready to be woken again. On a `suspended` handle it cancels the pending Request → `idle` — the operator's escape hatch from a suspension nobody is going to answer. Waiters receive a uniform `interrupted` result, never an exception. Landing is cooperative here (checked either side of each LLM round trip), so an interrupt takes effect at the next checkpoint rather than mid-socket. §7D says only abort LATENCY may differ between ports; the outcome does not.
(post rt id item)Append an item to a handle's inbox. NO state transition — an inbox item is data waiting for a turn, not a trigger.
Gate 1 is LOUD: at capacity the post is REJECTED SYNCHRONOUSLY to the sender. Silently dropping it (or growing without bound) are the two failure modes this gate exists to make impossible.
item = {:from "root/coordinator.1"|"external"|"clock" :channel "peer"|"timer"|"external" :text "…"}.
Returns {:ok true} or {:ok false :error "…"}.
Append an item to a handle's inbox. NO state transition — an inbox item is
data waiting for a turn, not a trigger.
Gate 1 is LOUD: at capacity the post is REJECTED SYNCHRONOUSLY to the sender.
Silently dropping it (or growing without bound) are the two failure modes this
gate exists to make impossible.
`item` = `{:from "root/coordinator.1"|"external"|"clock"
:channel "peer"|"timer"|"external" :text "…"}`.
Returns `{:ok true}` or `{:ok false :error "…"}`.(release-child-slot! rt id)Gate 2's other half: free this Run's slot and TRANSFER it to queued sibling wakes, FIFO, re-checking budgets at dequeue time (a wake queued five minutes ago may be over budget by the time its slot arrives).
Gate 2's other half: free this Run's slot and TRANSFER it to queued sibling wakes, FIFO, re-checking budgets at dequeue time (a wake queued five minutes ago may be over budget by the time its slot arrives).
(resume rt answer)Route an Answer to the DEEPEST suspended handle and cascade upward.
The deepest handle resumes from its checkpoint (turns and usage GROW, never
reset), then each suspended parent re-runs; the parent's re-invoked task
reattaches to the child that already resumed and never spawns a duplicate.
Parked levels burn zero tokens while they wait.
Throws only when there is nothing suspended — the root is the one place §7D permits a throw to the host.
Route an Answer to the DEEPEST suspended handle and cascade upward. The deepest handle resumes from its checkpoint (turns and usage GROW, never reset), then each suspended parent re-runs; the parent's re-invoked `task` reattaches to the child that already resumed and never spawns a duplicate. Parked levels burn zero tokens while they wait. Throws only when there is nothing suspended — the root is the one place §7D permits a throw to the host.
The runtime root handle's id. close(root) is stop-all; spawn from it is
how a host makes its first agent.
The runtime root handle's id. `close(root)` is stop-all; `spawn` from it is how a host makes its first agent.
(run-agent rt def-name prompt)(run-agent rt parent-id def-name prompt)One-shot: spawn a handle for def-name, wake it with prompt, wait, close.
The §7D Level-1 .run(prompt).
A pending result is NOT closed — a suspended handle still has an Answer
coming, and closing it would discard the checkpoint that resume needs.
One-shot: spawn a handle for `def-name`, wake it with `prompt`, wait, close. The §7D Level-1 `.run(prompt)`. A `pending` result is NOT closed — a suspended handle still has an Answer coming, and closing it would discard the checkpoint that `resume` needs.
(spawn rt parent-id def-name)(spawn rt parent-id def-name budget)Create a child handle under parent-id with a DETERMINISTIC, parent-scoped id
(root/coordinator.1/explore.2) — never random, so two runs of one fixture
produce the same trace.
maxDepth, maxChildren and the live budget walk are all checked HERE. The id
is returned to the spawner alone: handles are capabilities — post and wake
what you hold, wait only on what you spawned.
Returns the child's id, or {:error "…"} (see verb-error?).
Create a child handle under `parent-id` with a DETERMINISTIC, parent-scoped id
(`root/coordinator.1/explore.2`) — never random, so two runs of one fixture
produce the same trace.
`maxDepth`, `maxChildren` and the live budget walk are all checked HERE. The id
is returned to the spawner alone: handles are capabilities — post and wake
what you hold, wait only on what you spawned.
Returns the child's id, or `{:error "…"}` (see `verb-error?`).The CLOSED result-status vocabulary (§7D). Identical strings in every port — a host that switches on these must never meet a seventh value.
done the turn produced a final answer
pending a §10 durable suspension; resume with resume
incomplete a §7D limit stopped the run, and the text NAMES the limit
interrupted the turn was aborted; the handle is idle and alive
closed the handle was closed
timeout a wait deadline expired — the child KEEPS RUNNING
error the run failed; failures cross a handle boundary as results
The CLOSED result-status vocabulary (§7D). Identical strings in every port — a host that switches on these must never meet a seventh value. done the turn produced a final answer pending a §10 durable suspension; resume with `resume` incomplete a §7D limit stopped the run, and the text NAMES the limit interrupted the turn was aborted; the handle is idle and alive closed the handle was closed timeout a `wait` deadline expired — the child KEEPS RUNNING error the run failed; failures cross a handle boundary as results
(system-clock)The default clock: real wall time, real sleeps.
:set-timeout is run-async! + sleep! rather than a timer object, because a
timer object is java.util.Timer on one host and a time.Timer on the other,
and neither is reachable from portable Clojure. The thread is a daemon on the
JVM and a goroutine on cljgo, so a pending timer never holds a consumer's
process open.
The default clock: real wall time, real sleeps. `:set-timeout` is `run-async!` + `sleep!` rather than a timer object, because a timer object is `java.util.Timer` on one host and a `time.Timer` on the other, and neither is reachable from portable Clojure. The thread is a daemon on the JVM and a goroutine on cljgo, so a pending timer never holds a consumer's process open.
(task-tool rt parent-id def-map)task {agent, prompt} = spawn→wake→wait→close, fused into one tool call.
The child runs on a FRESH transcript and the parent gains exactly one tool
message; the child's usage rolls up into the parent's. The description
advertises ONLY the caller's team, sorted by name and composed from each
agent's :does — an out-of-team target is an error that lists the team, never
a silent reach into the registry.
A re-invoked call REATTACHES to the existing child by task key (agent+prompt): settled ⇒ its recorded result, suspended ⇒ its pending (or an inline resume when the retry carries the Answer), running ⇒ await. Reattachment — not transcript inspection, not a completion cache — is the required idempotency mechanism, and it is what makes an upward resume cascade safe.
`task {agent, prompt}` = spawn→wake→wait→close, fused into one tool call.
The child runs on a FRESH transcript and the parent gains exactly one tool
message; the child's usage rolls up into the parent's. The description
advertises ONLY the caller's team, sorted by name and composed from each
agent's `:does` — an out-of-team target is an error that lists the team, never
a silent reach into the registry.
A re-invoked call REATTACHES to the existing child by task key (agent+prompt):
settled ⇒ its recorded result, suspended ⇒ its pending (or an inline resume
when the retry carries the Answer), running ⇒ await. Reattachment — not
transcript inspection, not a completion cache — is the required idempotency
mechanism, and it is what makes an upward resume cascade safe.(trace rt)The transition trace — the §0 conformance artifact. Every state transition, every refused verb, every escalation, in the order they committed.
The transition trace — the §0 conformance artifact. Every state transition, every refused verb, every escalation, in the order they committed.
(verb-error? x)True for the {:error "…"} shape a verb returns instead of a handle. Verbs
return failures, they do not throw — only the root may throw to the host.
True for the `{:error "…"}` shape a verb returns instead of a handle. Verbs
return failures, they do not throw — only the root may throw to the host.(virtual-clock)(virtual-clock start-ms)A clock whose time only moves when a test moves it.
Same two keys as system-clock, plus :advance! — ((:advance! c) 250) moves
time forward and fires every timer that came due, in DEADLINE order (insertion
order breaks ties), synchronously on the caller's thread. Deterministic by
construction: nothing about a trace recorded under this clock depends on how
fast a machine is.
A clock whose time only moves when a test moves it. Same two keys as `system-clock`, plus `:advance!` — `((:advance! c) 250)` moves time forward and fires every timer that came due, in DEADLINE order (insertion order breaks ties), synchronously on the caller's thread. Deterministic by construction: nothing about a trace recorded under this clock depends on how fast a machine is.
(wait rt id)(wait rt id {:keys [timeout-ms by]})Block until this handle's NEXT result — or answer immediately with its LAST one when it is already settled (idle with a recorded result, suspended with its pending, closed). Registration order is unobservable.
:timeout-ms yields an explicit timeout result and the CHILD KEEPS RUNNING —
a wait deadline is the waiter's deadline, never the child's. The timer goes
through the injectable clock, so a fixture on a virtual clock produces the same
trace every time.
:by enforces the capability rule: only the spawner may wait on a handle.
Block until this handle's NEXT result — or answer immediately with its LAST one when it is already settled (idle with a recorded result, suspended with its pending, closed). Registration order is unobservable. `:timeout-ms` yields an explicit `timeout` result and the CHILD KEEPS RUNNING — a wait deadline is the waiter's deadline, never the child's. The timer goes through the injectable clock, so a fixture on a virtual clock produces the same trace every time. `:by` enforces the capability rule: only the spawner may wait on a handle.
(wake rt id)(wake rt id prompt)idle → running. The turn's input is prompt plus the WHOLE drained inbox,
coalesced into one block.
Admission is ATOMIC with the verb: the budget walk, the concurrency slot, the
state flip and the drain all commit together. Over the parent's
:max-concurrent the wake QUEUES FIFO and a completing sibling transfers its
slot — a queued wake is deferred, never dropped.
Waking a suspended handle is a no-op: items buffer, and only the Answer to
its pending Request may move it. Waking a running handle is a no-op too —
the inbox drains on its next turn.
Returns {:ok true} or {:ok false :error "…"} and does NOT block; use
wait for the result.
`idle → running`. The turn's input is `prompt` plus the WHOLE drained inbox,
coalesced into one block.
Admission is ATOMIC with the verb: the budget walk, the concurrency slot, the
state flip and the drain all commit together. Over the parent's
`:max-concurrent` the wake QUEUES FIFO and a completing sibling transfers its
slot — a queued wake is deferred, never dropped.
Waking a `suspended` handle is a no-op: items buffer, and only the Answer to
its pending Request may move it. Waking a `running` handle is a no-op too —
the inbox drains on its next turn.
Returns `{:ok true}` or `{:ok false :error "…"}` and does NOT block; use
`wait` for the result.cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |