Agent collaboration
Agents spawned by the worker never share a context — each opencode run session sees
only its prompt, its worktree and its own shell. Coordination happens through three
implemented mechanisms, all file-backed under .ralph/ and all toggled in the
collaboration: config section:
| Mechanism | Config key | Enabled by default |
|---|---|---|
Message bus |
|
yes |
Contract-first interface negotiation |
|
yes |
Change requests on merged tickets |
(part of the bus; panel-voted) |
yes |
The message bus
bus.py is a stdlib-only, append-only JSONL message log with flock-protected writes, safe
for concurrent processes. The orchestrator installs a wrapper at .ralph/bin/bus (put on
agents' PATH by the worker) that calls it with --me "$RALPH_TICKET", so an agent’s identity
is simply its ticket.
Message format
Each line of .ralph/bus/messages.jsonl:
{
"id": 1761012345678901234, // time.time_ns() - monotonic per message, used as cursor
"ts": "2026-10-21 15:30:12",
"from": "T02", // a ticket id, or "orchestrator"
"to": "T05" | "all" | "orchestrator",
"type": "say" | "ask" | "answer" | "request_change" | "system",
"text": "the question or statement",
"reply_to": 1761012345000000000, // set on answers
"target": "T02" // set on request_change
}
Commands available to agents
| Command | Effect |
|---|---|
|
broadcast to every agent ( |
|
directed question; |
|
reply to a question you received (posted to the original sender with |
|
unread messages addressed to you or to all; reading advances your cursor
( |
|
ask for a change to an already-merged ticket (see below) |
|
the whole conversation |
Orchestrator-side processing
Every loop iteration (collaboration.poll_seconds), the main thread runs process_bus on
new messages:
-
askto a ticket that is not running — the orchestrator answers automatically (auto_answer) with adescribe()of that ticket: its status, title, description, criteria, scope, its part of the binding contract, and the merged-work handoff (first 1500 chars). Agents can thus query future or finished tickets instead of guessing. -
request_change— handled in below.
Unread messages are also injected into each agent’s prompt (the collab_text block: bus help
+ binding contract + Unread messages for you), so the agent sees its inbox without running
bus inbox — though the prompt tells it to check again before finishing.
Contract-first negotiation
When a dependency wave contains ≥ 2 tickets that will run in parallel, the orchestrator
negotiates their interfaces before launching them (collab.ensure_contract, one contract
per wave group, cached in .ralph/contracts/group<k>.json).
provider's signature wins; missing provides added to the provider
(decided_by: owner) O->>O: write group
-
Each engineer is the ticket’s own agent model (
spec_for: the reviewer entry matchingauthor_id, else the planner model), prompted with its ticket plus the other tickets' titles/descriptions/scopes and their current drafts. The reply is one JSON object:{"provides": [{name, signature, notes}], "requires": [{name, from, signature}]}. -
conflicts()compares across drafts: a required interface nobody provides, or the same name with different (whitespace/case-normalised) signatures, produces a problem entry for both sides. -
After the rounds,
force_resolvesettles what remains mechanically: the provider’s signature wins (the requester adapts), and a required-but-missing interface is added to the provider’s list (notes: "added for T05"). These owner decisions are recorded in the contract’sresolvedlist. -
The binding contract is injected into every member’s prompt (
for_ticket): You MUST expose exactly these interfaces / You may rely on these interfaces, implemented in parallel by other tickets (NOT merged yet - code against exactly this). -
A failed negotiation never stops the run: the group simply has no contract, and the bus remains available.
Change requests
bus request_change <target> "<what is wrong>" asks for a modification of another ticket:
-
Target not merged yet — the request is forwarded to it as a
saymessage ("(change request) …") and the requester is told so. -
Target merged — the review panel votes (
CHANGE_SYSTEM: approve only if it is a real defect or missing capability the requester legitimately needs, within the target’s purpose). -
Rejected → the requester gets the panel’s reasons.
-
Budget exceeded (
collaboration.max_change_requests, default 5) → rejected. -
Approved → a dynamic follow-up ticket
<target>-fix<n>is created: same scope, complexity and acceptance command as the target, description = the request + the target’s description. Every pending ticket that depends on the target (and the requester) gets an added dependency on the fix ticket. The fix and its dependencies are persisted in.ralph/dynamic.jsonand survive restarts —loadand the dashboard apply them back onto the ticket list.
The fix ticket is then an ordinary ticket through the normal gate (The per-ticket worker).
Configuration
See configuration.adoc#collaboration — bus, contracts, contract_rounds,
max_change_requests, poll_seconds.