Skip to content

Multi-Agent

ModexAgent runs multi-agent systems in Pool mode: agents live in persistent pools, stay resident between messages, and talk to each other over routed channels instead of ad-hoc function calls. Which pools exist, which agents live in them, and who peers with whom is not wiring — it is declared in config/scopes/bot.yml and compiled once per boot (Scope & Assembly). This page covers the other half of the story: what that declaration turns into at runtime — the routing chain from an inbound message to an agent turn, the three communication tools the compiler derives, and the gates that keep the topology sane.

The four shipped pools

The reference bot (examples/bot_project/) ships four pools. Each pool is a declaration tree with exactly one root; every other agent in the pool is a declared child of some node:

Pool Root agent Subagents Peers
default default office-expert opencode, review
coder orchestrator explore, general —
review reviewer explore, general default
opencode opencode (external) — default
flowchart TD
    subgraph PD["default pool"]
        direction TB
        MD["default<br/><small>root</small>"]
        OE["office-expert"]
        MD -->|task| OE
    end
    subgraph PR["review pool"]
        direction TB
        RVW["reviewer<br/><small>root</small>"]
        REX["explore"]
        RGN["general"]
        RVW -->|task| REX
        RVW -->|task| RGN
    end
    subgraph PC["coder pool"]
        direction TB
        ORC["orchestrator<br/><small>root</small>"]
        EXP["explore"]
        GEN["general"]
        ORC -->|task| EXP
        ORC -->|task| GEN
    end
    subgraph PO["opencode pool"]
        OPC["opencode<br/><small>root, external</small>"]
    end

    MD <-. peers .-> RVW
    MD <-. peers .-> OPC

The default pool is the peer hub of the shipped layout: its root peers with both review and opencode. The coder pool stands deliberately unpeered — it takes work from users directly. The opencode pool's root runs an external CLI agent (execution_strategy: external, provider_kind: opencode) — a single-node pool with no subagents of its own (see External Coding Agents).

Runtime: one inbox write path

Every inbound message walks the same chain, and every writer — user input, approval decisions, agent-to-agent sends — converges on the same door:

flowchart TD
    AD["Input adapters<br/><small>WebUI · CLI · IM</small>"]
    BK["MessageBroker"]
    RT["PoolRouter<br/><small>prefix to pool</small>"]
    TR["SessionTreeManager<br/><small>tree.deliver</small>"]
    BU["AgentMessageBus<br/><small>persist + wakeup</small>"]
    PL["InboxPoller<br/><small>single-flight</small>"]
    AG["Agent turn"]

    AD --> BK
    BK --> RT
    RT --> TR
    TR --> BU
    BU --> PL
    PL --> AG
    AG -->|"task · send_to_agent · send_to_peer"| TR
  • MessageBroker moves messages at the edge. The WebUI, CLI, and IM platforms (QQ, Telegram) all plug into the same broker, so swapping or adding a channel never touches the agents themselves.
  • PoolRouter keys routing by session prefix — the agent-independent segment before the first . of the session id. A persistent routing store maps prefix to pool. When a message names an agent, the router reconciles the routed pool against agent_pool_ownership — a compile-time lookup table (agent name → the pools declaring it) built once from the declaration, never a runtime scan of all pools. An agent no pool declares is dropped loudly rather than routed on to produce an orphan.
  • SessionTreeManager is the sole inbox write path. Every writer calls tree.deliver: it ensures the session's tree node, records message tracks for task requests and results, and forwards the envelope to the bus. There is exactly one door into an inbox, which is what makes the tree bookkeeping trustworthy.
  • AgentMessageBus is each pool's async channel. send persists the envelope to the session inbox, then signals a pool-level wakeup event — the single convergence point every inbox writer passes through.
  • InboxPoller — one long-lived poller per pool — is the sole between-turn driver. It is event-driven: a pending message is picked up within milliseconds of the bus signal. A 0.2 s tick remains as a defensive fallback for writers that bypass the bus. On each pass the poller starts a drain task for every idle session with pending input. Single-flight is enforced structurally: an inflight table maps session id to the running task, set synchronously before scheduling and popped in a finally block that re-signals the wakeup, so a message that arrived mid-turn is scanned immediately when the turn ends. A busy session is skipped; mid-turn arrivals are handled by fold-in (below).

Session ids carry the structure: {prefix}.{agent_name}. The prefix is the conversation; the suffix binds the agent. A subagent's prefix is the dispatch's invocation id; a peer send reuses the sender's prefix (see Session Group below). Because agents are persistent, a conversation picks up where it left off rather than cold-starting every turn.

The three communication tools

Agents never pick a transport or address a bus directly. The declaration compiles down to exactly three tools, one per relationship in the tree:

Tool Held by Contract
task any agent with declared children — root or mid-level dispatch a new subagent task, or continue an existing subagent session; targets are the holder's direct children only; asynchronous
send_to_agent non-root agents consult the parent that assigned the task; the deliverable itself is forwarded automatically
send_to_peer roots of pools with peer links cross-pool coordination between equals; never task delegation

task is the work-delegation tool. Its target list is exactly the holder's declared direct children — grandchildren are the child's own dispatch surface, and leaves hold no task tool at all. Two modes: a new dispatch (omit invocation_id) starts a subagent on a fresh, task-scoped session; passing the invocation_id carried by an earlier notification or consultation message continues that existing session with its prior context. Dispatch is asynchronous — the sender ends its turn, the subagent runs autonomously, and the result arrives as a notification in a later turn.

send_to_agent is the consultation tool. It reaches only the parent that assigned the current task, and is for consultation — asking a clarifying question or requesting a decision mid-task, then stopping to wait: the reply comes back as another system-reminder. The subagent's deliverable is its final reply text, which SubagentAutoSendHook forwards to the parent at the end of every logical turn — the sole result path, so a result arrives even though send_to_agent is never used for reporting.

send_to_peer is the coordination tool. A peer is a separate, independent assistant with its own conversation context and its own responsibilities — not a worker you assign tasks to. The tool is for communication and coordination only: replying to an assistant that contacted you first, requesting information or a decision only that assistant has, or handing over an entire responsibility. Peer sends are asynchronous with no automatic result forwarding — whether a peer replies is up to it.

All three tools are compiler-derived. The ScopeCompiler derives them from tree shape — task for agents with declared children, send_to_agent for non-roots, send_to_peer for roots of pools with peer links — and injects them into the compiled toolset as derived entries. They are never declared in the declaration file; the tree is their only source. The toolset merge can still remove them (-task is expressible), and the V6 validator stops the boot when a child-carrying agent's effective toolset drops task (see Scope & Assembly).

The topology gate

Two layers keep the communication graph readable and auditable:

  1. Boot-time declaration validation (V1–V11, covered in Scope & Assembly): parent references form no cycle, every agent is reachable, exactly one root per pool, peer links exist and are bidirectional, and the compiled toolsets are legal.
  2. Send-time TopologyPolicy — the single runtime enforcement point. A subagent sender may address exactly two parties: its parent (for replies and consultation) and its own declared children (for task dispatch). Anything else — a subagent-to-subagent send that is not a declared child, a subagent addressing a non-parent — is rejected with an error string at send time.

Three consequences follow:

  • Nested dispatch is legal. A mid-level agent holds task for its own children, so declaration trees of any depth work: a subagent can itself delegate part of its task. The tree, not a flat roster, is the dispatch surface.
  • Same-pool NORMAL→NORMAL messaging does not exist. Each pool has exactly one NORMAL agent — its root — so agent-to-agent messaging between NORMAL agents is always cross-pool peer traffic.
  • External pools are single-node. An external main agent has no tool surface to dispatch from, and pool assembly rejects a declared subagent on an external pool as a configuration error.

Peer messaging across pools

Each pool's root addresses the outside world through its CommunicationTargetStore. One entry describes one reachable agent:

Field Role
name Agent name, unique across all reachable pools
kind AgentCommKind — NORMAL or SUBAGENT
pool_name Owning pool (local pool or a declared peer)
tree_ref The target pool's SessionTreeManager; set for peer targets, None for local ones
execution_strategy How the target runs (react, external) — drives the reply contract
description Human-readable

Peer targets are resolved through the workspace bundle at materialize time: after every pool of the workspace is built, each declared peer link reads the peer pool's tree reference from the peer's PoolInstance in the same bundle and adds a NORMAL peer target to the root's store. Links are same-workspace only — both endpoints evict with the bundle as one unit, so a dangling cross-workspace reference is not constructible. A sender or peer pool absent from the bundle raises loudly; there is no silent fallback.

Session Group

When agent A (session convA.mainA) sends to peer agent C, C's receiving session is convA.mainC — same prefix. C replying routes to convA.mainA. Communication context therefore propagates across the session group — the implicit set of sessions, across peer pools, that share a session-id prefix. Agents see each other's contributions as if multiple people were in one room. This is a deliberate v1 design choice over pair-isolated sessions, which would lose bidirectional continuity.

Peer messages carry an explicit reply contract — the message tells the receiver exactly how to answer: the standard communication tools for a native agent, or the modexctl send CLI shim for an external one. Unlike subagent results, there is no automatic forwarding — a reply happens only if the peer chooses to send one.

Peer configuration is bidirectional by invariant: declaring B as a peer of A requires declaring A as a peer of B, enforced at boot (V5).

Fold-in and materialize

Two mechanisms keep pools responsive without spawning unnecessary turns:

  • Fold-in is the mid-turn consumption path. A turn already running drains its own inbox at turn start and again on each iteration (InboxFlushHook) and injects new inter-agent messages into the current turn's history as role=SYSTEM_REMINDER records — a uniform system-reminder wrapping of a markdown body that names the sender (Message from agent / peer agent / subagent '<name>':). It consumes only fold-eligible inter-agent types — not external_input, so a human DM always starts a fresh turn. The labour divides by session state: the poller owns an idle session's entire pending batch (each envelope becomes its own between-turn), fold-in owns a busy session — mutually exclusive in time, so a message is consumed exactly once.
  • Materialize builds a subagent's instance lazily, on the first turn of its session, rather than at dispatch time. The poller peeks — non-destructively — the first pending envelope to read the authoritative parent link, registers the session with that parent in one step, and builds the instance from its AgentTemplate. Materialization requires the compiled AssemblySpec (there is no roster re-derivation road) and runs the shared assemble_native_agent core — the same core the assembly pipeline's Stage 4 uses for main agents. A materialize failure leaves the messages in the inbox. Main agents, by contrast, are eager-registered at boot (a position-derived default — see Scope & Assembly).

Isolation

Each subagent gets its own Memory, ToolManager, and SkillManager, with a restricted, session-only memory window — no archive, core, or experience layers, regardless of what the node declares (position-derived, see Scope & Assembly). A subagent also starts with a fresh context: it cannot see the parent's conversation, reasoning, or earlier tool results, so everything it needs must be in the dispatch brief.

Where to next

  • The declaration side — one YAML tree, position-derived defaults, validation, plugin assembly — is Scope & Assembly; the session-mode vs graph-mode split is Scope & Assembly.
  • ReAct subagents run the Graph Engine runtime, so they can suspend for approval too. The external opencode root runs its own CLI harness and does not use the graph.
  • What each agent remembers is governed by the Memory tiers.
  • The external side of the opencode pool — shared serve process, modexctl, environment variables — is covered in External Coding Agents.
  • Set up your first bot in Installation or Get Started.