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

collaboration.bus

yes

Contract-first interface negotiation

collaboration.contracts

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

bus say "text"

broadcast to every agent (to: all)

bus ask T05 "question" [--wait 120]

directed question; --wait blocks up to N seconds polling for the answer (1 s interval)

bus answer <MSG_ID> "text"

reply to a question you received (posted to the original sender with reply_to)

bus inbox [--peek]

unread messages addressed to you or to all; reading advances your cursor (.ralph/bus/cursor/<id>) unless --peek

bus request_change T02 "what is wrong"

ask for a change to an already-merged ticket (see below)

bus log

the whole conversation

Orchestrator-side processing

Every loop iteration (collaboration.poll_seconds), the main thread runs process_bus on new messages:

  • ask to a ticket that is not running — the orchestrator answers automatically (auto_answer) with a describe() 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).

sequenceDiagram participant O as Orchestrator participant E1 as engineer T2 (own model) participant E3 as engineer T3 (own model) participant P as review panel O->>E1: draft provides/requires (sees the other tickets' specs) O->>E3: draft provides/requires (sees the other tickets' specs) loop up to collaboration.contract_rounds (default 3) O->>O: conflicts(): requirement nobody provides, or signature mismatch alt conflicts found O->>E1: send back the specific problems O->>E3: send back the specific problems else no conflicts O->>P: vote on the whole rendered contract alt panel approves Note over O: contract agreed (decided_by: consensus) else panel rejects O->>E1: panel report as the problem list O->>E3: panel report as the problem list end end end Note over O: still conflicting after rounds → force_resolve:
provider's signature wins; missing provides added to the provider
(decided_by: owner) O->>O: write group.json + group.md
  • Each engineer is the ticket’s own agent model (spec_for: the reviewer entry matching author_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_resolve settles 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’s resolved list.

  • 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 say message ("(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.json and survive restarts — load and 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.