Subagents
In AEL, the Agent Engineering Language, an agent spawns subagents at run time: short-lived agents that each do one task, return a typed result to the agent that spawned them and end, leaving nothing running. A subagent, also called a dynamic agent, is written once, in a file of its own, and spawned as often as the work needs, within the limits you declare. One agent can spawn more than a hundred subagents for a single request, while only a bounded number run at the same time.
The child agents an agent lists are fixed when you build; a subagent is how an agent starts agents while a request runs. Fanning out to subagents builds one step by step.
The subagent file
A subagent has a file of its own, with the role subagent:
| File | Holds |
|---|---|
issue_reader.subagent.ael | Exactly one subagent issue_reader(…) and its private helper functions. |
issue_reader.subagent.extended.ael | More private helper functions of the same subagent, in the same folder. |
issue_reader.subagent.extended.2.ael, issue_reader.subagent.extended.3.ael … | Further extension files, numbered from 2. |
- The subagent carries the file's name,
issue_readerhere, and the check refuses a declaration with another name. - An extension file holds only private helper functions: no second subagent, no header and nothing public.
- Every file stays under the hard limits of every AEL file: at most 5,000 bytes and at most 5,000 lines, comments included; see Limits.
The declaration
# src/issue_reader.subagent.ael
subagent issue_reader(issue: u64) -> u64 {
config {
nodes: [reader = read_issue];
starts: [reader];
completion: reader.result;
}
}
The declaration starts with subagent, then the name, the typed parameters and the result type: the agent that spawns it passes the parameters and receives the result, here an issue number in and a number of its own out, such as the priority it gives the issue. Its config block is an agent's, with its nodes, edges, starting points and completion (Workflows), and these rules apply on top:
- State per request. Every spawn starts with fresh state of its own. Two spawns of the same subagent never share state or a reply.
- No triggers. A subagent starts only when it is spawned, and the check refuses a trigger in its declaration.
- No placement of its own. A subagent runs where the agent that spawned it runs, unless it is isolated (Isolated subagents).
- Limits that fit. The limits a subagent declares must fit within those of the agent that spawns it. The check compares them where it knows both, and every spawn checks what is actually left.
- A team of its own. A subagent can own nodes, edges and child agents, like any agent.
- Never a listed child. A subagent is never listed in an agent's
agents:list of child agents, and the check refuses it there.
Private by default
A subagent is private. A file names one only after importing it in its header, as import "./issue_reader.subagent.ael"; does:
- The subagent's file is in the importing file's folder or in one of its ancestor folders, within the same project. A file in a sibling folder or in a folder below cannot import it.
- Nothing is imported automatically, through another file's import or with a wildcard.
- An extension file uses the header of the file it extends.
pub subagentis refused.
Functions and visibility describes imports.
Where you spawn
| Code | Spawning |
|---|---|
The body of an agent, a subagent, a node or main, and their private helper functions | Allowed. |
| Edge mappings, hooks, configuration records, global functions and reusable functions | Refused by the check. |
A helper function has the effects its component allows, so calling or importing a helper never lets other code spawn.
Spawning and joining
The operations, for a subagent whose result type is R:
spawn <subagent>(<arguments>) -> Spawned<R>
try_spawn <subagent>(<arguments>) -> Result<Spawned<R>, SpawnRefusal>
join(handle: Spawned<R>) -> Result<R, SpawnFailure>
join_all(handles: Vec<Spawned<R>, N>) -> Vec<Result<R, SpawnFailure>, N>
join_any(handles: Vec<Spawned<R>, N>) -> Result<R, SpawnFailure>
cancel(handle: Spawned<R>) -> ()
spawnchecks the arguments against the subagent's parameters and starts it. When as many children run as the limit allows, it waits for a free slot, within the deadline, and no paid work starts while it waits. Any other refusal, such as a budget that cannot fund the subagent, is an explicit failure of the spawn, never a handle without a subagent or a wait without end.try_spawnnever waits. When the subagent cannot start at once, it returns the reason as aSpawnRefusaland starts nothing.joinwaits until the subagent ends and returns its result. The wait is cooperative: other work runs meanwhile, and no operating-system thread or process is created for a spawn.join_allwaits for every subagent of a list and returns every result, in the order of the list.join_anyreturns the result of the first subagent to end, whether it succeeded or failed, cancels the others and finishes their cleanup before it returns. When several end at once, the earliest in the list wins. An empty list is refused.cancelstops a subagent and waits for its cleanup. Cancelling twice is the same as cancelling once, and a reply that arrives after a cancellation cannot bring the subagent back.
# src/triage.agent.ael, excerpt: a private helper of the triage agent
fn read_one(issue: u64) -> u64 {
let handle: Spawned<u64> = spawn issue_reader(issue);
match join(handle) {
Result::Ok(priority) => { return priority; }
Result::Err(_) => { return 0; }
}
}
The triage agent's file imports issue_reader in its header, and the helper spawns it, waits for it and handles both outcomes.
Handles
Spawned<R>stands for one spawned subagent and its reply. It is opaque: it cannot be copied, forged, detached from the code that spawned it, or saved and resumed later.joinandcanceluse the handle up, and the check refuses a use after that, as for any value that has moved; see Ownership.- Dropping a handle without joining or cancelling it requests the subagent's cancellation.
- A handle never outlives the code that spawned it. When a spawner ends, every subagent it still has running is cancelled and cleaned up before the spawner's end is reported.
Refusals and failures
SpawnRefusal says why try_spawn could not start a subagent:
| Refusal | Why the subagent cannot start |
|---|---|
SpawnRefusal::ChildrenExhausted | The spawner has admitted as many children as its limit allows. |
SpawnRefusal::DepthExceeded | One more level would go deeper than the depth limit. |
SpawnRefusal::ConcurrencyFull | As many children run at once as the limit allows; spawn waits for a slot instead. |
SpawnRefusal::BudgetUnfundable | What is left of the spawner's budget cannot fund the subagent's share. |
SpawnRefusal::Unavailable | The subagent cannot run here, such as an isolated subagent on a host without isolation. |
SpawnFailure says how a subagent ended without its result:
| Failure | What happened |
|---|---|
SpawnFailure::Failed(error) | The subagent failed with its declared error. |
SpawnFailure::Cancelled | The subagent was cancelled. |
SpawnFailure::TimedOut | Its deadline passed. |
SpawnFailure::Exhausted | It used up its budget or another of its limits. |
SpawnFailure::Unknown | Whether it finished, or what its effects outside the program did, cannot be established. |
- A subagent's failure is returned to its spawner as a value. It never fails the spawner by itself: your code decides what the failure means.
SpawnFailure::Unknownstays unknown: no retry and no new spawn turns it into a success or a failure.- When the subagent's result type is itself a
Result<T, E>,joinreturnsResult<Result<T, E>, SpawnFailure>: your own result keeps its shape inside the outer one.
Results and errors shows how these results fit the rest of the library.
Lifecycle
admitted -> running -> succeeded | failed | cancelled | timed_out | exhausted
- A spawn waiting for a free slot is admitted. A subagent waiting in its own
joinis running. - A subagent does its work, returns its typed result to the agent that spawned it, and ends.
- Nothing of it survives: no state, no trigger, no listener, no mailbox and nothing to resume.
- A subagent cannot pause for durable approval. The check refuses an operation that waits for human approval in a subagent, in its helpers and in anything they call; see Durable runs and approval.
Budget, deadline and authority
A subagent draws on the budget, deadline and cancellation of the agent that spawned it, and holds only the permissions and resources that agent grants it, or, when it is isolated, only the resources it declares itself.
- A part of the spawner's budget. Each spawn reserves its share of what is left of the spawner's budget before the subagent starts: never a copy of the whole budget and never a new one. What the subagent spends settles into the spawner's budget, and usage that cannot be known stays counted at its largest.
- Every spawn counts. Each spawn counts once toward the number of children the spawner may admit, a spawn that waits for a slot included. Spawning again never resets what has been spent or extends a deadline.
- The earliest deadline. A subagent's deadline is the earlier of its spawner's deadline and its own declared duration, counted from when it is admitted. Waiting for a slot, a retry or a restart never extends it.
- Cancellation goes down the tree. Cancelling a spawner cancels every subagent it spawned, and theirs in turn.
- Never wider permissions. Everything a subagent is granted is a part of what its spawner holds.
- Finite limits. How many subagents run at once, how many a run admits in total and how deep they nest are limits you declare, never unlimited; see Budgets, retries and validation.
Resources
An agent, subagent or node declares the memory, skills, knowledge, storage and database it uses, each bound to a package you select, and the check refuses any use it did not declare. A subagent has no storage point of its own: storage, memory and knowledge reach a subagent that is not isolated only as explicit grants from the agent that spawns it. An isolated subagent receives no grant: it holds only the bindings it declares itself, and never a storage point of its own. Bindings describes the record.
A subagent also uses its own nodes and edges, model calls, tools and MCP servers, under the same rules as any agent; see Tools and MCP. Choosing a subagent or importing its file starts nothing and grants nothing.
Communication with the spawner
Every edge is a typed, two-way channel: AEL generates the communication between nodes, agents and subagents from your edge declarations, with no client or server code for you to write. Between a subagent and the agent that spawned it, the channel needs no edge of yours: the arguments of the spawn go one way, the typed result comes back, and a cancellation reaches the subagent. No listener or mailbox of a subagent stays open after it ends. Communication between agents describes the channel.
Isolated subagents
You mark an agent, node or subagent as isolated: it always runs in a sandbox in its own process, outside the shared communication layer, reachable only through the edges you declare and holding only the resources it declares itself. Isolation needs the isolation facilities of Linux and runs only on the Linux target the documentation names for it: on macOS, on Windows, on microcontroller boards, on other Linux targets and on any host without those facilities, an isolated component is refused, never run without isolation.
An isolated subagent receives no grant: it holds only the bindings it declares itself, and never a storage point of its own.
For a subagent, the modifier comes before the keyword: isolated subagent issue_reader(issue: u64) -> u64 { … }. Isolated components describes the rules.
Where subagents run
- Microcontroller boards create no agent at run time, so the check refuses
spawnon them. Supported targets describes each target. - AEL 1.0.0 is the full distribution; smaller distributions of the same version leave out components, such as support for other languages or for agents, and refuse a feature they leave out by naming it. A distribution without agent support refuses
spawn; see Distributions.