Skip to content

Scope & Assembly

Every pool and every agent in a ModexAgent workspace is declared in one file: config/scopes/bot.yml. That declaration is the single assembly source — a pure compiler turns it into per-agent assembly specs, a two-phase validator gates the boot, and a plugin assembly system builds each agent from the compiled spec. Nothing about an agent's shape lives in code wiring anymore; if it isn't in the declaration, it isn't assembled.

One declaration, one tree

The declaration is a tree: a workspace layer hosts pools, and each pool declares its agents. The workspace layer is also the resource-selection face — it picks the persistence backend, the data directory layout, and the shared MCP server set (every selection field is None = inherit from the service-level config), and it carries the cross-pool peer topology. The file is read once per workspace build, by explicit path — never a directory scan — so there is exactly one declaration and no misread files.

The tree replaces the former per-pool pool.yml plus a templates roster: one format, one file, every agent in it.

flowchart TD
    WS["workspace<br/><small>bot</small>"]
    PD["pool default"]
    PC["pool coder"]
    PR["pool review"]
    PO["pool opencode<br/><small>external</small>"]
    AD["default<br/><small>root</small>"]
    OE["office-expert"]
    ORC["orchestrator<br/><small>root</small>"]
    EXP["explore"]
    GEN["general"]
    RVW["reviewer<br/><small>root</small>"]
    REX["explore"]
    RGN["general"]
    OPC["opencode<br/><small>root, external</small>"]

    WS --> PD
    WS --> PC
    WS --> PR
    WS --> PO
    PD --> AD
    AD --> OE
    PC --> ORC
    ORC --> EXP
    ORC --> GEN
    PR --> RVW
    RVW --> REX
    RVW --> RGN
    PO --> OPC
    PD <-. peers .-> PO
    PD <-. peers .-> PR

The shipped declaration (trimmed to the workspace header, one pool with a nested subagent, and the external pool):

workspace:
  name: bot
  persistence:
    backend: sqlite
  paths:
    data_dir_name: .modex
  mcp:
  - playwright
  pools:
    default:
      peers:
      - opencode
      - review
      agents:
        default:
          description: >-
            General-purpose assistant. Handles broad conversation, web research
            and content retrieval, and Office-document work.
          max_steps: 50
          tool_supplements:
          - aci
          - experience
          hooks:
          - +reference_collector
          approval:
            enabled: true
            tools:
              write:
                allowed_paths:
                - ./*
              edit:
                allowed_paths:
                - ./*
          agents:
            office-expert:
              description: >-
                Creates, reads, edits, converts, and analyzes Office documents
                (Word, Excel, PowerPoint, PDF) using the OfficeCLI toolchain.
              max_steps: 100
              tool_supplements:
              - todo
              - aci
              context_mode: fresh
    opencode:
      peers:
      - default
      agents:
        opencode:
          description: >-
            Autonomous software engineering agent with full file and terminal
            access.
          execution_strategy: external
          provider_kind: opencode

Nesting is syntax sugar. Internally the model is flat: every agent is one AgentSpec carrying its name and a parent reference, and the loader flattens nested agents: mappings into that flat form. Which agent is a pool's root is never declared — the root is derived (the one node whose parent is None), and the validator enforces exactly one per pool.

Position-derived defaults

An agent's defaults come from its position in the tree, not from a type field. The old main/sub type split is gone: one spec type whose per-node behavior derives from being a root or a non-root.

Root Non-root
Memory family archive / core / experience eligible — layers off by default, toggled via a memory: block session-only — session and governance layers, no long-term layers, no experience
Approval eligible; a root may declare an approval: block refused — a declared block is a validation error (V9)
Registration eager — assembled at boot lazy — materialized on first dispatch
Toolset profile full read_write

Every default yields to the node's own declaration: toolset overrides the profile, eager overrides registration timing, and a memory: block tunes layer toggles within the eligible family only — a non-root stays session-only no matter what it declares. Approval eligibility is purely positional and not overridable.

Children need the task tool

An agent with declared children whose effective toolset drops task fails the boot (V6). A parent that cannot dispatch would leave its subtree silently unreachable — the validator would rather stop the boot than let that happen.

Profiles

A profile is a named bundle of declaration-field overrides. The framework ships the five toolset presets as frozen, code-level profiles:

Preset Tool selection
full all standard tools + bash + terminal
read_write read, write, edit, grep/glob + bash
read_only read + grep/glob + bash (prompt-constrained)
none no standard tools — communication tools only; MCP still loads
web web search + web reader (opt-in; not part of full)

Resolution is a three-layer chain — framework default ← profile ← local declaration. The agent's resolved toolset preset names the bound profile (the root position binds full, a non-root read_write, a declared toolset: binds that profile instead), and the profile's remaining fields sit between the framework defaults and the node's own declaration.

Two merge rules keep the chain predictable:

  • Nested blocks deep-merge. A memory: block merges field-wise across the profile and local layers — a locally declared field wins, undeclared fields keep the profile's values, and sibling fields are never silently dropped.
  • Lists replace wholesale. A set tools list is the whole list. Item-wise add/remove is the dedicated supplements mechanism (below).

Profiles build on framework defaults only — a profile referencing another profile is refused (V7), keeping the resolution single-level. And because every field's winning layer is recorded at compile time, the WebUI can show a per-field provenance bill: framework default, profile, or local declaration, plus the origin of every tool entry. The bill is recomputed from the YAML on each request — there is no boot-time cache to go stale.

Tool assembly syntax

The tools: list has two forms:

  • Unprefixed entries replace the base outright — the preset-derived selection and the derived communication entries. V6 catches the child-carrying agent that drops task this way.
  • Incremental entries — +name adds, -name removes — merge over the base, derived entries included, so even -task is expressible (and V6-guarded).

The base itself is the toolset preset, plus the three communication tools the compiler derives from tree shape (task for parents, send_to_agent for non-roots, send_to_peer for roots of pools with peer links — see Multi-Agent). They are never declared in a roster; the declaration tree is their only source.

On top of that, tool_supplements inject named tool bundles:

Supplement Adds
ast_grep ast_grep_search + ast_grep_replace
aci aci_edit, replacing the default edit
todo todo_read + todo_write
experience the experience tool + the experience_review hook

Two supplements behave specially:

  • aci is a same-name replacement. The default edit entry is removed and aci_edit takes its place — an upgrade with post-edit lint feedback, not an extra tool. The replacement is recorded at compile time as a provenance record (edit ← aci_edit) and surfaces in the boot log and the WebUI bill.
  • experience is name-merged and auto-wired. Its tool name joins the merge base directly, so +/- entries and wholesale lists control it exactly like a preset tool. When the name survives the merge, the compiler injects the experience_review hook bound to the compiled roster — unless the declaration explicitly removes it with -experience_review.

Hooks use the same incremental roster syntax (+reference_collector in the excerpt above) — a HOOK-slot component referenced from the declaration, dispatched at assembly.

Two-phase validation

The declaration is validated twice per boot: once on shape, before any derivation, and once on the compiled effective values. Both phases return all issues found — an empty list is the only valid state — and any non-empty list aborts the boot with every issue carried in the error, so you fix the whole file in one pass instead of one error per restart.

Rule Checks Phase
V1 parent references form no cycle 1 — shape
V2 every agent is reachable from a root 1 — shape
V3 exactly one root per pool 1 — shape
V4 legal kinds: workspace or pool; agents are pool-internal data 1 — shape
V5 peer links exist, stay in one workspace, and are bidirectional 1 — shape
V7 profiles never reference other profiles 1 — shape
V10 graph specs' (pool, agent) references resolve in the tree 1 — shape
V11 pool names and pool-internal agent names are unique 1 — shape
V6 child-carrying agents keep task in the effective toolset 2 — effective
V9 non-root agents declare no approval 2 — effective

(V8 — wholesale list replacement — is a resolution semantic, not a runtime rule.) Phase 1 runs on the loaded tree, including graph-spec cross-checks, so a typo'd agent name in a graph fails at boot, not mid-run. Phase 2 runs on the compiler's output, which is what makes V6 meaningful: it checks the toolset the agent will actually run with.

Plugin assembly

Building an agent from its compiled spec is a plugin assembly system. Its heart is the ComponentRegistry — ten slots, each naming one extension point a plugin factory can produce:

Slot Produces
tool tools (including the derived communication tools)
hook hooks
memory_system memory systems
llm_provider LLM providers
system_prompt_provider system-prompt providers
interceptor interceptor chains
command_handler command processors
execution_strategy pool-shape strategies
input_stage input pipeline stages
data_namespace data namespaces

Plugins are discovered from four sources with a fixed priority — user > project > entry_points > bundled — and a name collision across sources resolves by that rank, nearest-to-user winning. A collision within one source is treated as the packaging error it is and raises at load. A plugin is a small ABC: declare a frozen config_model, implement register(ctx), and register factories into the context — the context buffers them and flushes atomically, so a failing plugin never leaves a half-registered registry (and one plugin's failure doesn't stop the others).

Registration is restart-effective: a changed plugin set or declaration applies on the next boot. Hot reload is not shipped — the scope layer reserves a zero-cost seam for it (a spec hash plus a per-pool generation counter), but nothing consumes that seam yet.

The build itself is the AssemblyPipeline — four stages: workspace materialize, infra, pool, agent. Which stages run depends on the agent type:

Agent type Stages Path
native main 1 → 2 → 3 → 4 the pipeline builds the agent in Stage 4
external main 1 → 2 → 3 the execution strategy owns the build inside Stage 3
subagents — (never the pipeline) AgentTemplate.materialize

Subagents don't fit the per-pool pipeline: their construction needs per-invocation data (parent session, invocation id). Instead, AgentTemplate.materialize builds them directly, delegating to the shared assemble_native_agent core — the same core Stage 4 uses — so mains and subagents assemble through one code path. Materialization requires the compiled spec; there is no roster re-derivation road.

The WebUI scope editor

The WebUI's Settings → Scope page is a full editor over the declaration, in three panes:

  • Topology canvas — the declaration tree rendered as a graph.
  • Provenance bill — per agent: every field's winning layer (framework / profile / local), every tool entry's origin, and the same-name replacement records.
  • YAML editor — edit the declaration directly.

Saving validates the YAML and writes it back atomically to config/scopes/bot.yml. The write is restart-effective — the running agents keep their compiled shape until the next boot — but topology and bill are re-pulled from disk immediately, so an un-restarted edit shows up right away as what is now declared, against what is still running.

Dual execution models

The declaration feeds two runtimes, and they stay deliberately different: session mode is an actor model — long-lived agents with inboxes, session memory, and asynchronous task dispatch whose results flow back through the inbox — while graph mode is a dataflow model — declared DAGs with terminal states, deliver-based routing, and engine-driven recovery. The declaration is shared by both, and BotAgentNode is the only bridge: a graph node that binds a named agent from a named pool and drives each graph turn through the session tree (deliver, wait for quiesce). A never-dispatched lazy leaf is not an error — the inbox poller cold-starts it from its template, exactly as session mode would.

The graph side of the split — schedulers, persistence, interrupts — is covered in Graph Engine.

Where to next

  • The actor side — the three derived communication tools, fold-in, and peer messaging — lives in Multi-Agent.
  • The dataflow side — schedulers, deliver routing, persistence — is Graph Engine.
  • The declaration file in the context of the other config files is in Installation.