Derived from the implementation at commit 3ad72e1. This document describes what the
system actually does (observed behavior and invariants), not aspirations.
harness3 is a tiny distributed multi-agent LLM harness. Durable state lives entirely in an object store (local FS, S3, or GCS) accessed through a common conditional-put interface; there is no database and no Erlang distribution. Nodes coordinate purely through storage objects (CAS writes, leases) plus token-authenticated HTTP RPC.
| Module | Responsibility |
|---|---|
llm |
Provider-neutral request/response/event model, SSE stream decoding |
llm/anthropic_messages, llm/openai_chat_completions, llm/openai_responses |
Provider adapters: build HTTP requests, decode buffered/streamed responses into neutral events |
model_catalog |
Durable, versioned catalog of models (id → name/endpoint/type/credentials), CAS-committed |
plugin |
Plugin registry/runtime: system-prompt sections, tools, callbacks, activation hooks, JSON state per plugin |
plugin/cloud_storage/* |
Prefix-scoped durable text-object CRUD, pagination, and direct transfer URLs |
plugin/mcp/* |
Reusable MCP configuration/catalog, client, stdio and Streamable HTTP transports, per-agent connections, and harness plugin adapter |
agent |
Single agent: round loop (LLM call → tool execution → commit), streaming accumulator, plugin host actor, durable state (de)serialization |
agent_profile |
Node-local ETS registry of installed profiles (id → plugin registry, transport, observer, limits) |
agent_group |
Durable group of agents in one storage object; coordinator actor, lease/fencing, message delivery, cross-agent callbacks |
agent_group_registry |
Node-local ETS registry of running groups (id → pid, stop/send handles) |
storage |
Storage interface: get/head/put/list/delete + streaming, conditional puts |
storage/local, storage/s3, storage/gcs |
Backends implementing the interface |
cluster/core |
Per-node HTTP RPC listener (Erlang-term-over-HTTP), membership publication, recovery bootstrap |
cluster/distributed_lock |
Lease-based lock built on storage CAS |
cluster/recovery |
Leader-elected scanner that re-dispatches orphaned groups |
cluster/agent_group_rpc |
RPC methods: resume/wake/message/stop/force-stop agent groups, routing between nodes |
put(key, body, condition)withUnconditional,IfAbsent(fail withPreconditionFailedif the object exists) orIfUnchanged(version)(fail withPreconditionFailedif the stored version differs). Every correctness property of the system — leases, fencing, optimistic concurrency — reduces to these two conditional modes behaving atomically.VersionTokenis backend-specific:S3Etag(ETag string, quotes included, identical across put/head/get/list),GcsGeneration(decimal generation string), orLocalVersion(mtime_seconds, generation)wheregenerationis a durable per-key counter that increments on every put/delete, so tokens change even when identical bytes are rewritten.Metadata.modified_at_secondsis normalized to Unix epoch seconds on every backend (HTTP-date and RFC 3339 wire formats are parsed; 0 means missing/unparseable). It is informational only — concurrency control uses version tokens exclusively. On GCS,getmetadata comes from the media response's own headers so the version token is atomically bound to the returned body.list(prefix)is a raw string-prefix match over keys (no delimiter semantics) and must return every matching key. Both S3 and GCS paginate fully via the XML list API (IsTruncated/start-after); the local backend excludes its entire internal.harness3*namespace (generations, temporaries, the lock file and any leaked lock-break files) and sorts by key. Recovery correctness requires complete listings.- Deleting a missing key succeeds on all backends. Operations retry transport errors
and retryable HTTP statuses (408/421/425/429/5xx except 501/505) with jittered
exponential backoff starting at 100 ms and doubling per attempt, bounded at 8
attempts (7 sleeps, 100 ms → 6.4 s, ~13 s of total backoff) so a persistent outage
surfaces as an error instead of blocking the caller forever.
Conditional puts pass through the same retry path, so an ambiguous success can
resurface as
PreconditionFailed; every CAS caller that must not mistake its own applied write for a conflict compensates by exact-body read-back (group writes, group create and claim, running index, distributed lock, model catalog). - All S3 requests, including lists and their pagination cursors, are signed by the
in-repo
s3_signsigner, which canonicalizes queries with strict SigV4 encoding (unreserved-only, uppercase hex). The vendoredaws4_requestpackage only derives the credential material. - The local backend serializes every operation on an
O_EXCLlock file inside the root; condition check, generation bump, tmp-file write, and rename are atomic under it. A directory created for a nested key is never an object (is_regular, notis_file). Each holder stamps a random token into the lock file; the heartbeat refreshes only a lock still carrying its token, viafile:change_time, which fails on a missing file rather than recreating one (losing the refresh race at worst refreshes a successor's lock). Heartbeat cleanup, transaction cleanup, and every destructive commit — every generation-record write, including records minted while serving reads, plus object renames and deletes — verifies the token immediately beforehand, so a holder that stalls past the staleness horizon and loses the lock to a breaker fails its commit instead of silently overwriting the new holder's writes (the residual window is the instant between a verify and its following write, not the whole transaction). Interrupted writes stamp sentinel mtimes so crashes invalidate outstanding version tokens conservatively (false CAS failures, never false successes). Deleting a key leaves a small permanent generation tombstone — required for token uniqueness, because object mtimes are not monotonic (streaming puts commit pre-lock temp files; clocks can step back). - Streaming get/put are single-pass and backpressured (next chunk is not requested until
the consumer returns). Streaming puts never retry the body; ambiguous streaming
outcomes surface as
Transport.
cluster/membership/<ip>_<port>— one object per node, refreshed every 10 s.cluster/agent-groups/<percent-encoded group id>/<epoch>_<owner>— "running index": one durable record per group claim.cluster/locks/<percent-encoded key>— distributed lock records (notablyrecovery-leader).- Group objects and model-catalog objects live at caller-chosen keys.
Request = model name + messages + tools + optional max_output_tokens /
reasoning_effort + stream flag. Messages have roles System / Developer / User /
Assistant / ToolRole and content parts: Text, Image, Document, Reasoning (summary list +
optional provider-bound EncryptedReasoning), ToolCall (id, name, JSON arguments),
ToolResult (call id, nested content, is_error).
Providers are normalized to one event vocabulary: MessageStart, ContentStart(index, kind), TextDelta, ReasoningDelta, ReasoningEncrypted, RefusalDelta,
ToolCallStart(index, id, name), ToolCallArgumentsDelta, ContentStop(index),
Finished(reason), UsageReported(usage), MessageStop, UnknownEvent.
Guarantees required of adapters, relied on by the agent accumulator:
- Each concurrently-open content block has a distinct
index;ContentStopfrees it. TextDelta/RefusalDeltawithout a precedingContentStartare tolerated (a text part auto-starts);ReasoningDelta/ToolCallArgumentsDeltawithout a start are a protocol error (InvalidModelOutput).- Usage reports are cumulative snapshots within one response; missing fields keep
their previous value (
apply_usage). - Repeated
MessageStart/MessageStopmust be tolerated (some OpenAI-compatible providers repeat them). - Adapters may repeat
ContentStart/ToolCallStarton an already-open index (the Chat Completions adapter emits a reasoningContentStarton every reasoning chunk); the accumulator merges rather than resets.
StreamDecoder buffers arbitrary SSE chunks, splits frames on blank lines (normalizes
CRLF), joins multi-data: lines, treats [DONE] as MessageStop, and retains an
incomplete trailing frame across pushes.
The harness itself never performs LLM HTTP I/O. An agent.ModelTransport is injected
per profile; it receives the provider, the request, and a consume callback, and must
not produce event N+1 until consume of event N has returned (backpressure contract).
A transport classifies failures as RetryableTransportError or
PermanentTransportError. The agent retries retryable failures indefinitely, beginning
at 100 ms and doubling the delay up to a 10 s cap. Every attempt receives the identical
LLM request and a fresh event accumulator, so failed partial output is not committed and
prompt-cacheable request prefixes remain unchanged. A retryable failure therefore never
marks or interrupts the agent as failed. Permanent failures still return
ModelTransportFailed.
The server's buffered HTTP transport treats connection and timeout failures, HTTP 408/409/421/425/429, transient 5xx responses (excluding 501 and 505), and structured rate-limit/overload/server/timeout errors as retryable. A malformed successful provider response is also retried because no output was accepted. Invalid requests, authentication and other permanent HTTP failures, invalid URLs or methods, and event-consumer failures are permanent.
- A
Pluginhas a name, declared dependencies, an initial JSON state string, system-prompt sections, tools, named callbacks, and activation hooks. - Codebase-owned model tools use
snake_case_namespace.snake_case_name. This is a naming convention, not a registry validation rule for application plugins. registry(plugins)validates: unique plugin names, dependencies present, unique callback names per plugin, unique tool names globally, valid initial JSON state, and computes a topological activation order (cycle → error).activate(registry, persisted_states)seeds each plugin's state from persisted state (falling back to its initial state), validates JSON, then runs activation hooks in dependency order. Dormant-state preservation: persisted state for plugins not in the registry is carried through untouched, so a group can pass through a node with a smaller plugin set without losing state.activate_hosted(registry, persisted_states, host)additionally exposes the activating agent's durable identity —Host(group_id, agent_id, agent_attributes, group_attributes, peers), wherepeerslists the group's other agents as id/attributes pairs — to every hook, tool, and dynamic prompt section viahost(context).activateprovides an empty host. This is the channel through which one generic plugin instance serves agents whose per-session configuration lives in durable attributes. Agents activated throughagent_groupare always hosted on their group's identity.- System-prompt sections are static (
with_system_prompt) or dynamic (with_dynamic_system_prompt): a dynamic section is computed from the plugin's current state and context (including the host) every time the prompt is built, and must be total — unavailable content belongs in the section text, not in a raised error. A dynamic section whose plugin has no valid state is omitted. - A plugin may also register a release hook (
on_release), run by the agent's plugin host before it stops, to release ephemeral resources. Hooks are isolated from each other (a raising hook cannot skip the rest, nor make the host exit abnormally) and must not block: they are on the coordinator's path when an activated-but-unstarted agent is discarded, and the host stop is bounded (5 s), after which the host is unlinked and killed. Unlinking first is essential —killexits with reasonkilled, which a non-trapping linked caller does not survive. - Hooks are pure functions
state → (new_state, context, value). All state values must be valid JSON strings (validated after every hook). call_dependencylets a hook synchronously invoke a declared dependency's callback, threading state changes through the sharedContext. Undeclared targets are an error.call_agent_callbackdispatches to a plugin callback in another agent of the same group. It is available only while a tool (or a callback triggered by one) is running — never during activation.- Tool invocation resolves the tool by global name, runs the owning plugin's hook, and
returns
ToolOutput(content, is_error).
cloud_storage.new(storage, prefix)gives an agent read, write, list, delete, and direct upload/download URL tools over a namespace rooted at an independently configurable storage prefix. The prefix must be a non-empty safe relative path (a trailing slash is implied); logical keys are safe relative paths resolved under it, so agents cannot traverse outside their prefix or into another namespace. Plugins built with the same prefix share objects; different prefixes stay isolated.cloud_storage.new_resolved(storage, resolve)defers the scope to a per-invocation resolver over the invoking agent's context (typically its durable group attributes), so one plugin instance serves agents whose workspaces differ or change. A resolution error fails that invocation with an explanatory tool error while leaving the agent runnable.- Objects handled directly by the tools are UTF-8 text. Listing is lexicographic and keyset-paginated with opaque cursors. Transfer URLs are backend-provided and expire after five minutes where supported.
harness3-servermanages durable cloud storage workspaces (id, label, prefix) in a CAS-committed catalog atharness3-server/cloud-storage-workspaces, created lazily on the first mutation, and exposes add/edit/delete over its web API and UI. A blank prefix defaults toplugins/cloud_storage/workspaces/<id>/objects/; prefixes overlapping the harness control-plane namespaces (cluster/,harness3-server/) are rejected. Each session records an optional workspace association in its group attributes (an empty-string upsert clears it); associated sessions resolve the workspace's current prefix per tool invocation through the resolver-based plugin, while unassociated sessions get an isolated namespace atplugins/cloud_storage/sessions/<session id>/objects/. A workspace cannot be deleted while a session references it (the reference check re-runs inside the removal's commit retry loop), and its stored objects are never deleted with it. If a reference nonetheless dangles — an association landing in the same instant as a removal's final write — the session stays runnable and editable:cloud_storage.*invocations fail with a repair hint until the edit path re-points or clears the dead association.
- MCP configuration is application-owned and durable through a CAS catalog analogous
to the model catalog. Configurations contain one or more servers and are referenced
by stable ID. The broker plugin itself is stateless (its durable state is empty,
and it registers no activation hook): it is constructed with a configuration
loader, called on every connection discovery — which runs in the agent's own
plugin host, never the coordinator — so configuration edits reach running agents
without the plugin holding a snapshot. Loaders return
Revokedonly for authoritative "gone or disabled" answers (the one thing that tears down live transports); infrastructure faults crash the loader instead of being mapped to errors, taking the plugin host and its linked transports with it and handing recovery to the coordinator's tool journal. Credentials may be literal or environment-variable bindings. Stdio executables and working directories must be absolute; HTTP endpoints must be absolute HTTP(S) URLs. - The runtime supports MCP 2025-11-25 over newline-delimited stdio and Streamable HTTP.
It initializes each server, sends
notifications/initialized, follows paginatedtools/listresponses (bounded at 100 pages), and performs best-effort discovery when the plugin activates. Each successful server contributes its tools to the new manifest; failed servers are recorded and excluded without failing activation. Replaced and failed connections are closed. Tool calls are never retried because they may have side effects. Tool.input_schemaandTool.output_schemaare typedjson.Jsonvalues produced by object-only decoders. Catalog persistence andmcp.listreuse those values directly; there is no string-to-JSON cast. Server/tool names become deterministic, snake-casemcp.*broker identifiers with hash suffixes.- The model sees exactly two static broker tools:
mcp.listreturns the tools from reachable servers, their typed schemas, and any server failures;mcp.callinvokes one identifier returned bymcp.listwith an argument object. - The server loads optional global configuration from an absolute
HARNESS3_MCP_CONFIG_PATHas a first-run seed, validates and persists configuration without contacting external services, and discards persisted manifests at startup. Its web API and UI can add, edit, or remove servers with CAS-backed durable updates. Management responses return complete transport settings and bindings, including literal HTTP header values; the UI masks literal values by default and can reveal them in plaintext for editing; the management listing reads the durable catalog, the same source agents load from. Harness3-server folds every server from every enabled configuration into a stable aggregate configuration, computed on demand from the durable catalog and namespacing server IDs with an injective source-configuration prefix — the aggregate is never stored, in the catalog or in the runtime. The agent editor therefore exposes one MCP researcher resource profile, not one profile per configuration. A configured team assigns that researchermcp.list,mcp.call,team.message_agent, and the session'scloud_storage.*tools, but no filesystem or shell capability. Coding agents receivecoding.read,coding.write,coding.exec,team.message_agent, andcloud_storage.*, but no MCP tools. The lead may message all subagents; every subagent'steam.message_agentallow-list contains only the lead, preventing direct peer-to-peer subagent communication. Without an MCP configuration, the researcher remains a separate message-and-cloud-storage-only profile and is not granted local filesystem or shell tools. - harness3-server's three profiles (coding workspace, isolated researcher, MCP
researcher) are static node capabilities registered at boot, before the
cluster node advertises itself, so the recovery RPC path can place any group
on the node without a prior HTTP touch. Session-specific configuration
(workspace root, role, kind, roster, cloud storage association) reaches the
generic plugins through the durable group/agent attributes and the
activation host. Profiles are installed exactly once, at boot, and never
uninstalled or rewritten: the MCP researcher plugin holds no configuration
at all — a loader reads the durable MCP catalog from storage on every
connection discovery, so catalog edits reach agents on every node with no
profile or runtime synchronization. The catalog object always exists after
first boot (
start_mcppersists an empty catalog when none is found), so a read failure — including NotFound — is an infrastructure fault, never "no MCP configured", and crashes the reading process: an agent's plugin host (closing its linked transports; the tool journal recovers the call as outcome-unknown), or the web request that asked.
A versioned JSON object: revision + a list of models (id, name, endpoint, type ∈
{openai_chat_completions, openai_responses, anthropic_messages}, optional per-model
max_output_tokens (applied to every request the model serves), api-key credentials —
stored in plaintext in the object). create uses IfAbsent; commit bumps
revision and CAS-writes with IfUnchanged, mapping precondition failure to
ConcurrentUpdate. provider(model) instantiates the matching adapter. Groups store
only a catalog key; the catalog is re-read at wake and at every message injection, so
model renames/endpoint changes take effect on the next wake/injection without touching
group state (verified by test wake_loads_the_model_catalog_on_demand_test).
agent.State = id, profile_id, revision, model_id, round, messages,
pending_messages (durable inbox), optional tool_journal, token stats,
per-plugin JSON states, plugin_generation (monotonic counter for plugin-state freshness),
attributes (extended attributes: opaque application-owned string key/values,
persisted verbatim and never interpreted by the harness),
last_catalog_revision, status ∈ {Ready, Waiting, Completed, Failed(reason)}.
(Waiting is not produced by the core library itself; consumers use it for agents
that should not run until first messaged — harness3-server creates all session agents
as Waiting and relies on message injection to promote them to Ready.)
State round-trips through JSON inside the group object; the persisted messages never
include the synthesized system prompt.
Each round (the loop is internal to the worker; there is no public single-round entry point):
- Builds the request: plugin system prompt (all sections joined as
## name\n\nbody, dynamic sections evaluated against the plugin's current state and the agent's host) prepended as a System message when non-empty; tools = all plugin tools. - Runs the transport; retryable LLM failures repeat the identical request forever with
exponential backoff capped at 10 s. Each attempt gets a fresh per-round accumulator;
events are keyed by provider index, stored in stream order, and forwarded to the
profile's
observehook. - On stream end, parts become assistant content; tool-call arguments must parse as JSON
(
InvalidModelOutputotherwise). The finish reason is validated:Stop,ToolUse,Paused, or none pass;Length,ContentFilter,Cancelled,Failed, andOtherfail the round withInvalidModelOutput. - Before any tool executes, the complete Assistant message is durably committed with
an ordered journal whose calls are
Pending. Each call is committedRunningbefore invoking its plugin, thenCompleted(output)together with the latest plugin state. Calls execute sequentially; cross-agent callbacks still route through the coordinator. - Once every call is Completed, all matching ToolResult messages are appended as one ordered block and the journal is cleared. Only this final commit folds the durable inbox into the conversation. The next model request is never built while a journal remains unresolved.
- After a crash, a durable Running call becomes an error ToolResult saying its outcome is unknown and may be partial; later Pending calls become explicit not-executed results. Earlier Completed outputs are retained. This closes every ToolCall/ToolResult pair before pending user messages are appended, and never blindly replays a possibly side-effecting invocation. If the event observer fails at ToolStarted or ToolFinished, the journal is likewise closed (later calls are recorded as not executed) before the agent becomes Failed; observer errors are never silently swallowed.
- The plugin host is a dedicated actor owning the plugin runtime; the worker process,
spawned unlinked and then linked to the plugin host, waits on a release gate (60 s
max), then loops round →
commituntil Completed/Failed, stops the plugin host, and fireson_exit. - Commits go through an injected
Checkpointerwith the expected agent revision. The commit returns the authoritative state: if the coordinator merged queued inbox messages, the returned status is Ready and the worker immediately runs another round. - A failed round commits
Failed(reason); if the commit response comes back Ready (messages were queued meanwhile), the worker retries; otherwise it exits. agent.stopunlinks and kills the worker (the linked plugin host dies with it).
An agent keeps the full messages history durably, plus an optional
context_messages — the model-facing view after compaction. Compaction asks the
model for a <handover> summary of the session (no tools allowed) and replaces the
model-facing context with it; the full history is never truncated. Durable counters
compaction_requested/compaction_completed sequence requests; request_compaction
on the group durably records a request (clearing any prior failure) and starts a
worker for dormant agents. Compaction triggers when a request is pending, or
automatically when the last round's input tokens reach 80 % of the model's context
window — deliberately including right after a round that completed the agent, so a
later revival starts from the compacted handover. A failed attempt records
last_compaction_error and pauses the manual request (no per-round retries, no
restart-on-exit loops) until a new explicit request clears it; messages that arrived
during a failed compaction are still folded in by its commit and processed by normal
rounds. A worker exiting with an unattempted pending request is restarted by the
coordinator — unless a live replacement worker already exists (stale exit notices
must not start duplicates).
An optional plugin brokers Model Context Protocol servers to an agent as two tools
(mcp.list, mcp.call). Stdio servers run as child processes, streamable-HTTP
servers as sessioned HTTP clients.
Ownership is the load-bearing property. Configuration is global; connections are not:
mcp/runtimeis a node-wide registry of configurations only — the rolemodel_catalogplays for models. Its catalog is durable and CAS-committed with ambiguous-write read-back confirmation. It holds no connections and performs no discovery, so every call into it is served from memory and is safe from any process, including the group coordinator.mcp/connectionsholds one agent's connections and discovered tools as plain state, not a process. The value lives in the plugin's ephemeral resource (plugin.resource/plugin.set_resource) — a per-agent slot that, unlike plugin state, is never serialized and never leaves the agent. Servers are contacted from inside that agent's plugin host on first tool use, so the transport actors are spawn-linked to the host. A link alone is not sufficient: Erlang discards a normal exit signal at a non-trapping process, so a gracefully finishing host would leave its transports (and their OS children) running. The plugin's release hook (plugin.on_release, run by the host before it stops) closes them explicitly; the link covers only abnormal termination. Connections are therefore never shared: one agent's discovery cannot close a connection another agent is calling through. An intermediate owning actor would add a hop without adding isolation — it would be linked to the host too — and the host is already the single serialized caller, since an agent's tool calls run sequentially.- Activation never touches MCP servers. Activation hooks run inside the group coordinator, which also services lease renewal, so the hook only validates that the configuration exists and is enabled. Connecting, discovering, and calling all happen in the agent's plugin host.
- Within an agent, a manifest is reused for a TTL (5 min) while its connections are still alive, so repeated tool calls don't re-spawn servers; a dead connection forces re-discovery rather than failing every call until the TTL lapses.
Transport rules: child processes get SIGTERM before their port is closed (closing
first makes the signal a no-op and orphans the child); response correlation requires
the absence of a method field, since server-initiated requests share the client's
ID space; servers negotiating an older protocol revision are accepted when supported;
document size, tool count, and page count are bounded because everything listed is
serialized into the agent's context; HTTP sessions are deleted on close and a 404
clears the session id so the next call re-initializes.
AgentGroup = id, model_catalog_key, monotonically increasing revision, agent states,
group-level attributes (extended attributes: opaque application-owned string
key/values, like the per-agent ones), and execution ∈ Idle | Claimed(owner, epoch, lease_expires_at) | Completed. The whole group is one JSON object; every
mutation is a CAS (IfUnchanged) that bumps revision and (while owned)
extends the lease. peek reads and decodes the object without profiles,
validation, or claiming — how an application renders a view of a group, or
reconstructs the profiles a resume needs from the group's own attributes. The epoch increments on every claim,
is carried through Idle/Completed as well as Claimed, and is the fencing token
in the running-index key — releasing a claim must not reset it, or a later claim
could reuse a key a stale index entry still occupies. A claim takes its epoch from
the greater of the group's own epoch and the highest surviving running-index entry,
so a group written before the epoch was carried (or one whose entries outlived a
crash) still advances past anything already published. Every claim also carries a random nonce so
its body is unique per attempt — the ambiguous-CAS read-back confirmation must never
match a concurrent same-owner claim from the same wall-clock second.
create— validate,IfAbsentwrite. Creating is dormant: no processes, no registry entry, no index entry. Neithercreatenorresumewrites to the node's profile registry — registering profiles as node capabilities is the application's responsibility (agent_profile.install).resume/resume_registered— load + validate;resume_registeredreconstructs the profile list from the node's installed profiles (only for agents in Ready status).- Updates (
GroupUpdate) — a durable update command for the roster and/or extended attributes. Commands carry no agent state: attribute maps are upserts, and aSomeroster is a list ofRosterEntry(id, profile_id, model_id, attributes, initial_status)declaring the desired agent list. The group's single writer applies a command to the state it authoritatively holds —wake_detached_updatedatomically with the claim CAS (validated against the same profiles and catalog as the wake, fenced by the loaded version),update_groupthrough a live coordinator, serialized with commits and lease renewal — so a stale caller snapshot can never overwrite concurrent progress. An existing agent named by a roster entry keeps its durable state (history, plugin state, inbox, status); a new id is created from scratch withinitial_status; changing an agent's model scrubs provider-locked encrypted reasoning from its conversation. A live coordinator rejects roster commands (InvalidGroup): roster changes stop the group first and ride the next wake. Claimed groups fail a wake-applied update withAlreadyClaimed, so an active coordinator cannot be overwritten. Surviving agents' pending messages are folded in by the claim; removed agents' pending messages are dropped with them. wake(wake_as…) — re-reads the model catalog, validates models for Ready agents, requires the lease to be absent/expired (AlreadyClaimedotherwise), CAS-claims the group (epoch+1; queuedpending_messagesare folded intomessagesand agents made Ready), then starts the coordinator.- Claim publication order (invariant): local registry registration → synchronous,
acknowledged membership refresh →
IfAbsentwrite of the running-index entry (cluster/agent-groups/<id>/<epoch>_<owner>). Recovery relies on this order paired with its own reversed read order (index first, membership second). If the index write fails, the claim is rolled back (unregister, refresh again, kill coordinator) but the group object remains claimed until the lease expires. - One worker is started per agent in Ready status; agents in Completed/Failed status stay dormant.
wake/wake_asdeliberately link the started process tree to the waking process (embedders supervise the group with their own lifetime).wake_detachedruns the wake in a short-lived process instead — used by the RPC resume handler and the server — so a transient request handler's abnormal exit cannot tear down the group. Failed group starts stop any plugin hosts they had already activated.- The coordinator stops when it has no children and no callback helpers and the
configured minimum lifetime has elapsed; it then releases the group and deletes its
index entry — in that order, since a deleted index plus a still-
Claimedgroup is invisible to recovery, whereas a stale index entry for a released group is merely a spurious wake (execution →Completedif all agents terminal, elseIdle).stop()kills children/helpers first, then finishes the same way.
All group mutations (agent commits, message delivery, callback-state persistence, lease
renewal) are messages to a single coordinator actor, so storage CAS conflicts only occur
against foreign writers. On ConcurrentGroupUpdate or LostGroupOwnership the
coordinator abandons: kills children and helpers, unregisters, stops — without touching
the group object (fenced; the foreign owner wins). Lease renewal fires every
lease/2 seconds and refuses to renew an already-expired lease.
Ambiguous CAS absorption: a PreconditionFailed on a group write is confirmed by
reading back the object; if the stored body equals the intended body the write is
treated as having succeeded (handles retried-but-reported-failed writes; verified by
test ambiguous_successful_cas_is_idempotent…).
CommitAgent(id, expected_revision, new_state, mode): rejected when unowned
(LostGroupOwnership) or when expected_revision mismatches (StaleAgentCommit).
On success the agent revision increments. ToolProgressCommit requires an unresolved
journal and preserves the coordinator's current pending_messages without inserting
anything between ToolCall and ToolResult. RoundCommit requires the journal to be clear
and atomically appends the inbox to messages, clears it, and forces Ready when messages
were pending. If the committed state's plugin_generation is older than the currently
persisted one (a cross-agent callback advanced it mid-round), the persisted plugin states
win.
Empty/whitespace messages are rejected. If the target agent has a live worker, the
message is appended to the durable pending_messages inbox without bumping the agent
revision (the worker's next final round commit consumes it). Otherwise the inbox plus the
new message are folded into messages in one CAS (revision+1, status Ready) and a fresh
worker is started. With an unresolved tool journal, even a dormant worker keeps all new
deliveries in the inbox and restarts recovery first. A message is never inserted between
an Assistant ToolCall and its complete ToolResult block. When a worker exits leaving an
inbox or journal, the coordinator immediately restarts it. Journal-driven restarts are
budgeted: after 3 consecutive worker exits with no successful commit in between (only a
commit can close a journal, so this means storage writes are failing persistently), the
coordinator abandons the group instead of hot-looping; recovery re-dispatches it after
the lease expires, with the journal intact. Any successful commit resets the budget.
A tool in agent A may call plugin.call_agent_callback(ctx, "B", plugin, cb, input).
The dispatch travels A's plugin host → coordinator → spawned helper process → B's
plugin host (InvokeCallback) → back. Self-calls and calls from/to agents without live
workers fail with AgentCallbackUnavailable. After the callback runs, the helper
persists B's new plugin states via the coordinator (skipped if the persisted generation
is already ≥ the new one), then replies to A. Helpers are linked to the coordinator:
a helper crash tears the whole group tree down (crash-only recovery); graceful exits are
reaped via CallbackHelperExited.
The coordinator links to every worker; workers link to their plugin hosts; helpers link
to the coordinator; wake's caller is linked transitively (via actor.start). Any
abnormal exit anywhere collapses the whole tree (verified by
linked_agent_crash_terminates_process_tree_test); the lease then expires and cluster
recovery redispatches the group.
start opens a mist HTTP listener, generates a random 32-byte bearer token, writes the
membership object, starts a 10 s membership refresher, and starts the recovery
component. RPC: POST /rpc/<method> with Authorization: Bearer <token>
(constant-time compare) and an Erlang-term-encoded body (binary_to_term with safe).
Handlers are name-registered; exceptions map to 500, typed errors to 4xx/5xx. Membership
bodies carry token, ip, port, the node's live group ids, and refreshed_at; the object
is deleted on shutdown. refresh(cluster) / context_refresh_membership publish
membership synchronously and return only after the storage write is acknowledged.
Lock object under cluster/locks/; acquire is IfAbsent (or CAS-replace of an expired
record); renew/release are CAS on the last-seen version, PreconditionFailed → Lost.
Release writes an immediately-expired record. Liveness depends on wall-clock leases;
safety on storage CAS.
Every 10 s each node tries to acquire/renew the recovery-leader lock (30 s lease).
The leader scans: running-index entries first, then membership objects (this order pairs
with the claim-publication invariant). Nodes refreshed within 30 s are alive. Candidate
groups = indexed groups not listed by any alive node, reduced to the newest epoch per
group. Candidates are dispatched round-robin over shuffled alive nodes via the
resume_agent_group RPC; on success the group's index entries are re-listed and
only epochs strictly below the newest are deleted — the newest is either the fresh
claim's entry or (when the target answered "already running") the live claim itself,
which must survive so the group stays visible to recovery if its node later crashes.
A node that fails is skipped; a group is retried on the next scan. Stale index entries
of groups woken outside recovery are cleaned lazily the next time the group is not
listed by an alive node (at the cost of one spurious wake). Each candidate's index
entry is re-checked immediately before dispatch so a group that stopped cleanly after
the scan snapshot is not resurrected. Leadership is forfeited only on a confirmed
lock replacement (Lost); a transient storage error keeps the lock and merely skips
that scan cycle.
resume_agent_group(group_key)— if the group is already live locally, force a membership refresh and return; otherwiseresume_registered+ wake with the node token as owner (lease conflicts surface aswake_failed).wake_agent_group(group_id, group_key, visited)— routed to the current recovery leader (redirect with loop detection via thevisitedtoken list); the leader checks the running index + membership and either confirms the group is running or dispatches a resume to a random alive node.message_agent_group(group_id, agent_id, message, visited)— try the local registry; otherwise locate the host via running index (newest epoch) or membership listing and redirect once (loop-checked). Messaging never wakes a dormant group.inject_agent_tool_call(group_id, agent_id, tool_name, arguments, response, visited)— same host routing asmessage_agent_group, but the injection is a synthetic tool call: an assistantToolCall(generatedsynthetic-*id, caller-chosen name and JSON-object arguments, not necessarily in the agent's tool list) followed by itsToolResultcarrying the caller-chosen response. When the pair would start the conversation or directly follow an assistant message (including the uncertain tail behind an in-flight round with an empty inbox), a fixed user hint message is prepended — the two shapes providers reject. The hint can produce consecutive user messages, which every supported provider accepts. Never wakes a dormant group.update_agent_group(group_id, group_attributes, agent_attributes, visited)— host-routed extended-attribute upserts, applied through the owning coordinator (same routing asmessage_agent_group). It never wakes a dormant group: a caller updates a dormant group by waking it with the update riding the claim CAS instead.force_stop_agent_group(group_id)— local registry force-stop.stop_agent_group(group_id, visited)— idempotent host-routed stop. A local live group is stopped directly; otherwise the newest running index and membership locate its host and redirect with loop detection. A group with no reachable live host (crashed owner, or an index entry pointing at this node whose registry has no such group) is durably finalized: its stale index entries are removed and an expired claim is released, so recovery cannot resurrect a group the caller was told is stopped. While the crashed owner's lease is unexpired the stop fails withstill_leasedrather than reporting success it cannot deliver; storage read failures propagate instead of masquerading as a completed stop. A dormant group with no index entries is already stopped and succeeds without waking it.
agent_group_registry and agent_profile are public named ETS tables created lazily by
a detached holder process. The group registry stores id → (pid, stop closure, send
closure, inject closure, compaction closure, attribute-update closure); reads
sweep entries whose pids died.
Registration of an id overwrites; unregistration only removes the exact (id, pid) pair.
(See §3 for the neutral contract. Adapter-specific mapping notes.)
- Role mapping. Anthropic: System and Developer messages are hoisted out of
messages(text-only, concatenated with\n) into the single top-levelsystemstring — mid-conversation position is lost; ToolRole →userwithtool_resultblocks. Chat Completions: System →system, Developer →developer, ToolRole →toolwith exactly one ToolResult per message. Responses: ToolRole content becomes top-levelfunction_call_outputitems; assistant text →output_text, other text →input_text. - Tool results.
is_erroris representable only on Anthropic; Chat Completions and Responses drop it silently. Chat/Responses tool results are text-only (Unsupportedotherwise); Anthropic allows text+image. - Encrypted reasoning is provider-locked: Anthropic and Responses hard-fail
(
InvalidRequest) on foreign or missing provider state; Chat Completions omits encrypted state and replays only concatenated summary text via a configurable field. - Index spaces. Anthropic uses native block indices; Responses uses
output_indexfor everything (collision-free); Chat Completions has no native blocks — each choice gets a disjoint range with basechoice × 1 000 000: text and refusal at the base, reasoning at base + 1 (with an explicitContentStart), tool calls at base + 2 + call_index. When a tool-call delta omits itsindexthe position in that delta's list substitutes — exact for buffered responses and for streamed single tool calls, but a streaming provider that omitsindexand emits parallel tool calls would collapse them onto one index (known limitation; OpenAI itself always sendsindex). Text emits noContentStart/ContentStop(relies on the accumulator's auto-start). - Lifecycle.
MessageStopcomes from[DONE](Chat),message_stop(Anthropic), or the terminalresponse.*event (Responses). Buffered (non-streaming) decodes synthesize the full event lifecycle. Finish-reason mapping includes Anthropicpause_turn→Paused,refusal→ContentFilter,model_context_window_exceeded→Length. - Usage. Cumulative-snapshot semantics everywhere; Anthropic reports on
message_startandmessage_delta; Chat streaming usage is requested viastream_options.include_usage; cache-write tokens exist only on Anthropic and OpenRouter-style extensions. - Notable defaults. Anthropic
max_tokensdefaults to 1024 when the model's catalog entry sets nomax_output_tokens(truncation risk for agent turns); Anthropic requests set a top-level ephemeralcache_control; Responses requests always sendstore: falseandinclude: ["reasoning.encrypted_content"], and full history is resent each round (previous_response_idis not used);reasoning_effortmaps to adaptive thinking +output_config.effort(Anthropic),reasoning_effort(Chat),reasoning.effort(Responses).
- Group state changes only via CAS on the single group object;
revisionstrictly increases; at most one owner mutates it (lease + fencing on every commit). - A message durably exists in exactly one of
messages/pending_messages. - Agent revision increments exactly once per worker commit; stale workers are rejected.
- Plugin state is always valid JSON; unknown plugins' state is preserved verbatim;
plugin_generationnever moves backwards in persisted state. - Claim visibility order: registry → acknowledged membership → running index; recovery reads index → membership. Together these prevent double-dispatch of a live group.
- Only expired leases can be re-claimed; epochs strictly increase per claim; the highest-epoch index entry identifies the latest owner.
- Tool names are globally unique per registry; dependencies are acyclic and must be declared to be callable.
- The persisted conversation never contains the synthesized system prompt.
- MCP discovery occurs at agent activation; failed servers are excluded without affecting server or agent availability. Every discovered schema remains typed JSON across discovery, persistence, broker listing, and tool invocation.