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
toolslist 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
taskthis way. - Incremental entries —
+nameadds,-nameremoves — merge over the base, derived entries included, so even-taskis 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:
aciis a same-name replacement. The defaulteditentry is removed andaci_edittakes 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.experienceis 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 theexperience_reviewhook 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,
deliverrouting, persistence — is Graph Engine. - The declaration file in the context of the other config files is in Installation.