Browse the documentation

AEL 1.0.0

Fanning out to 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. In this tutorial a triage agent takes a batch of issue numbers from your issue tracker and spawns one subagent per issue to read it, then collects the summaries in order. Complete example agent systems and deployment recipes come with AEL 1.0.0.

Subagents is the reference for every rule this tutorial uses.

What you build

triage/
  main.ael                        # main triage(): starts the triage agent
  pack.ael
  metadata.ael
  src/
    issue_reader.subagent.ael     # the subagent: reads one issue
    triage.agent.ael              # the triage agent
    nodes/read_issue.node.ael     # the subagent's node
    nodes/read_all.node.ael       # spawns, joins and handles failures
  prompts/read_issue.md

Step 1: the subagent

# src/issue_reader.subagent.ael
subagent issue_reader(issue: u64) -> Str<1000> {
    config {
        nodes: [reader = read_issue];
        starts: [reader];
        completion: reader.result;
    }
}
  • The file is issue_reader.subagent.ael, and the subagent carries the file's name.
  • issue is what each spawn passes in, and the summary, a Str<1000>, is what comes back.
  • read_issue is an ordinary node with a prompt and a model binding, written as in Your first agent, end to end. It reads the issue from your issue tracker through a connector of your own, as Extending AEL describes.
  • Every spawn starts with fresh state, and the subagent has no triggers: it starts only when it is spawned.

Step 2: spawn one subagent per issue

The node read_all does the fan-out. It imports the subagent in its header, spawns one per issue and joins them all:

# src/nodes/read_all.node.ael
import "../issue_reader.subagent.ael";

type Summary = Str<1000>;

node read_all(issues: Vec<u64, 200>) -> Vec<Summary, 200> {
    let handles: Vec<Spawned<Summary>, 200> = spawn_readers(issues);
    let results: Vec<Result<Summary, SpawnFailure>, 200> = join_all(handles);
    return summaries(results);
}

fn spawn_readers(issues: Vec<u64, 200>) -> Vec<Spawned<Summary>, 200> {
    # For each issue, in order: spawn issue_reader(issue) and keep its handle.
}

fn summaries(results: Vec<Result<Summary, SpawnFailure>, 200>) -> Vec<Summary, 200> {
    # For each result, in order: its summary, or the note that describe returns.
}

fn describe(failure: SpawnFailure) -> Summary {
    match failure {
        SpawnFailure::Failed(_) => { return "The reader failed."; }
        SpawnFailure::Cancelled => { return "The reader was cancelled."; }
        SpawnFailure::TimedOut => { return "The reader ran out of time."; }
        SpawnFailure::Exhausted => { return "The reader used up its budget."; }
        SpawnFailure::Unknown => { return "The outcome is unknown."; }
    }
}
  • import "../issue_reader.subagent.ael"; names the subagent. Its file is in src/, an ancestor of the node's folder, so the node may import it; a file in a sibling folder could not.
  • spawn_readers spawns one subagent per issue of the list. One agent can spawn more than a hundred subagents for a single request, while only a bounded number run at the same time. When as many readers run as the limit allows, the next spawn waits for a free slot.
  • join_all waits for every reader and returns every result in the order of the handles, so each result belongs to the issue at the same place in the list.
  • A reader's failure comes back as a value, a SpawnFailure, and describe turns each kind into a note. The triage run does not fail because one reader did.
  • The bodies of spawn_readers and summaries are placeholders, as A first agent explains: they go through a list with the library's list operations.

Step 3: the triage agent

# src/triage.agent.ael
agent triage(issues: Vec<u64, 200>) -> Vec<Str<1000>, 200> {
    config {
        nodes: [fan_out = read_all];
        starts: [fan_out];
        completion: fan_out.result;
    }
}

The triage agent runs read_all as its one node. Its subagents are not among its child agents: they exist only while read_all runs. main calls the agent like a function, as Agents, nodes and edges shows.

Step 4: take the first answer

When any one summary is enough, join_any returns the result of the first reader to end and cancels the others:

# src/nodes/read_all.node.ael, excerpt: another private helper
fn first_summary(handles: Vec<Spawned<Summary>, 4>) -> Option<Summary> {
    match join_any(handles) {
        Result::Ok(summary) => { return Option::Some(summary); }
        Result::Err(_) => { return Option::None; }
    }
}
  • The first reader to end decides, whether it succeeded or failed: Result::Err is that reader's failure.
  • The other readers are cancelled, and their cleanup is finished before join_any returns.

Step 5: stop what you no longer need

Here the node reads an issue and its duplicate at the same time, and stops the second reader once the first has answered:

# src/nodes/read_all.node.ael, excerpt: one more private helper
fn read_with_duplicate(issue: u64, duplicate: u64) -> Option<Summary> {
    let first: Spawned<Summary> = spawn issue_reader(issue);
    let second: Spawned<Summary> = spawn issue_reader(duplicate);
    match join(first) {
        Result::Ok(summary) => {
            cancel(second);
            return Option::Some(summary);
        }
        Result::Err(_) => {
            match join(second) {
                Result::Ok(summary) => { return Option::Some(summary); }
                Result::Err(_) => { return Option::None; }
            }
        }
    }
}
  • cancel stops the second reader and waits for its cleanup. Like join, it uses the handle up.
  • A handle that is dropped without join or cancel also cancels its reader, and no handle outlives the node that spawned it.

Step 6: start a reader only when a slot is free

try_spawn never waits. When the reader cannot start at once, it returns the reason and starts nothing:

# src/nodes/read_all.node.ael, excerpt: a helper that never waits for a slot
fn read_now(issue: u64) -> Option<Summary> {
    let attempt: Result<Spawned<Summary>, SpawnRefusal> = try_spawn issue_reader(issue);
    match attempt {
        Result::Ok(handle) => {
            match join(handle) {
                Result::Ok(summary) => { return Option::Some(summary); }
                Result::Err(_) => { return Option::None; }
            }
        }
        Result::Err(_) => { return Option::None; }
    }
}

When as many readers run as the limit allows, the refusal is SpawnRefusal::ConcurrencyFull; the other refusals are ChildrenExhausted, DepthExceeded, BudgetUnfundable and Unavailable, listed in Refusals and failures.

Budget, deadline and cancellation

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. For the triage agent:

  • Each spawn reserves a share of what is left of the triage run's budget before its reader starts. A reader that does not fit is refused, never stopped halfway.
  • Retries, output repair, timeouts and cancellation draw on one shared budget, so nested retries cannot multiply cost. A reader's retries and repairs spend from that same budget, and spawning again never resets what the run has spent.
  • Every reader's deadline is the earlier of the triage run's deadline and its own. Waiting for a slot never extends it.
  • Cancelling the triage run cancels every reader still running, and their cleanup finishes before the run reports its end.
  • How many readers run at once, how many the run admits in total and how deep subagents nest are limits you declare; see Budgets, retries and validation.

Check the project

Once the helpers have their bodies, you check the project from inside the triage folder:

ael check

Among other things, the check refuses a spawn without the import, a spawn in an edge mapping or a hook, a subagent listed among an agent's child agents and a subagent that waits for approval.

Honest limits

  • Microcontroller boards create no agent at run time, so the check refuses spawn on them and this project does not build for them; Supported targets describes each target.
  • A subagent cannot pause for human approval: the check refuses an operation in it that waits for one.
  • A subagent has no storage point of its own. Nothing of a reader survives it, and a reader receives storage only as a grant 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.
  • The bodies of spawn_readers and summaries are placeholders, as A first agent explains: the project checks and runs once they have their bodies.