How tasks work
You will be able to read a task graph, enable the swiftide-tasks feature flag, and decide whether a job needs a task or a single agent. A task runs a fixed graph of steps, and each step can be a function, a prompt or an agent. Your first task builds a two-node task step by step if you want a smaller start.
Showcase graph
Section titled “Showcase graph”The workspace-architect showcase drafts an ARCHITECTURE.md for a Cargo workspace. This function builds its whole task:
/// Builds the task: `WorkspaceRoot` in, the written ARCHITECTURE.md out.pub fn architect_task<L>( llm: &L, settings: ArchitectSettings, progress: &Progress,) -> Result<Task<WorkspaceRoot, WrittenDocument>, TaskError>where L: ChatCompletion + SimplePrompt + Clone + 'static,{ let mut task = Task::new();
let discover = task.register_node_fn({ let progress = progress.clone(); move |root: &WorkspaceRoot| {3 collapsed lines
let members = workspace::discover(root, settings.selection)?; progress.step("discover", format_args!("{members} in {root}")); Ok::<_, workspace::DiscoveryError>(members) } });
let analyse = task.register_node(CrateAnalyst::new(llm.clone(), progress.clone()));
let join = task.register_node_fn({ let progress = progress.clone(); move |summaries: &JoinInput<CrateSummary>| {2 collapsed lines
progress.joined(summaries.len()); architecture_prompt(summaries) } });
// `Arc<dyn SimplePrompt>` is a task node out of the box: `Prompt` in, `String` out. let render = task.register_node(Arc::new(llm.clone()) as Arc<dyn SimplePrompt>);
let review = task.register_node(ApprovalGate::new(settings.pre_approved, progress.clone()));
let write = task.register_node_async_fn({ let output = settings.output; let progress = progress.clone(); move |draft: &ArchitectureDraft| {10 collapsed lines
let output = output.clone(); let progress = progress.clone(); Box::pin(async move { let written = draft.write_to(&output).await?; progress.step( "write", format_args!("{} ({} bytes)", written.path.display(), written.bytes), ); Ok::<_, std::io::Error>(written) }) } });
task.starts_with(discover);
// One branch per crate, all evaluating the same analyst node with a different input. task.register_transition(discover, move |members: WorkspaceMembers| { let mut crates = members.crates.into_iter(); let Some(first) = crates.next() else { return Transition::error("the workspace has no crates to document"); };
crates .fold(Transition::fan_out(&analyse, first), |fan_out, member| { fan_out.and(&analyse, member) }) .concurrency_model(ConcurrencyModel::Parallel) .join_with(join.join()) })?;
// Each branch hands its `CrateSummary` to the join; the join runs once all have arrived. task.register_transition(analyse, join.join())?; task.register_transition(join, { let progress = progress.clone(); move |prompt| { progress.step("render", "drafting ARCHITECTURE.md"); render.transitions_with(prompt) } })?;
task.register_transition(render, move |markdown: String| { review.transitions_with(ArchitectureDraft::from_model_output(&markdown)) })?;
task.register_transition(review, move |decision| match decision { Review::Approved(draft) => write.transitions_with(draft).into(), Review::AwaitingApproval => Transition::pause(), })?;
task.register_transition(write, task.transitions_to_finish())?;
Ok(task)}Source: examples/workspace-architect/src/graph.rs#L35-L134
The task has the type Task<WorkspaceRoot, WrittenDocument>. It takes a workspace root and returns the written document. Each register_node* call adds a node and returns its typed NodeId. Each register_transition call says where a node’s output goes next.
- discover is a closure node. It runs
cargo metadataand returns the member crates. - analyse is a custom task node,
CrateAnalyst. The fan-out sends every crate to it as its own branch. The fan-out setsConcurrencyModel::Parallel, so the branches run at the same time. Each branch runs a fresh agent that reads one crate and returns aCrateSummary. - join waits for every branch. It receives a
JoinInput<CrateSummary>and builds the prompt for the writer. - render is an
Arc<dyn SimplePrompt>registered as a node. It sends the prompt to the model and returns Markdown. - review is
ApprovalGate. Until someone approves the draft, its transition returnsTransition::pause()and the run stops. - write is an async closure node. It saves the file, and its transition,
task.transitions_to_finish(), ends the task.
In the captured run, ten analyst agents on claude-sonnet-5-5 finished in the time of the slowest one. The join line from OUTPUT.md:
[ 40.5s] join 10 summaries; 230s of agent work in 41s wall-clock (5.7x)Run it against the Swiftide workspace from the repository root:
export OPENAI_API_KEY=sk-...cargo run -p workspace-architectThe command-line program starts the task with task.run(WorkspaceRoot). That returns TaskRunOutcome::Completed(WrittenDocument) or TaskRunOutcome::Paused:
let mut outcome = task.run(root).await?;loop { match outcome { TaskRunOutcome::Completed(written) => return Ok(Some(written)), TaskRunOutcome::Paused => { if !approve_paused_draft(&task)? { return Ok(None); } outcome = task.resume().await?; } }}Source: examples/workspace-architect/src/main.rs#L125-L136
On a pause, the program asks you to approve the draft and then calls resume().
Task graph
Section titled “Task graph”The diagram shows a smaller task with the same parts. A start node fans out to two prompt nodes that run in parallel. A join node collects both outputs, and the task finishes.
A task has these parts:
- A task node implements
TaskNode. It declares anInput, anOutputand anErrortype, and itsevaluatemethod turns a borrowed input into an output. Nodes and transitions covers the trait. - A transition decides where a node’s output goes next. It is a closure that takes the output by value, or a ready-made target such as
join.join()ortask.transitions_to_finish(). Each node has exactly one transition. - The start node is set with
starts_with. Its input type is the task’s input type. - A fan-out sends one input to each of several branches. Every fan-out names the join node that waits for all of them. By default the branches run one after another. Set
ConcurrencyModel::Parallelon the fan-out or the task to run them at the same time. - A join node receives the branch outputs as one
JoinInput<T>. Fan-out and join covers both. - A pause stops the run, and
runreturnsTaskRunOutcome::Paused. The paused branch keeps the node whose transition paused.resume()runs that node again with the same input. This is why the showcase pauses on the cheap review node and not on render. Pause and resume covers the details.
When a branch reaches the finishing transition, run or resume() returns TaskRunOutcome::Completed with the task’s output. Nodes and transitions covers the checks run makes before any node runs.
Crate contents
Section titled “Crate contents”The swiftide-tasks crate holds the graph and its runtime:
Task,TaskNode,NodeIdandTransition- fan-out and join, with
JoinInputandAnyJoinInput ConcurrencyModel::Sequential(the default) andConcurrencyModel::Parallel- pause, resume and reset, with
TaskRunOutcome TaskErrorandNodeError- closure nodes through
register_node_fnandregister_node_async_fn - built-in nodes for
SimplePrompt,ChatCompletionandToolExecutortrait objects
The crate’s own code spawns no tasks or threads. Parallel branches run concurrently inside the future that run returns. Swiftide itself still depends on tokio.
TaskAgent, which wraps an agent as a node, lives in swiftide-agents. Import it from swiftide::agents::TaskAgent.
Feature flag
Section titled “Feature flag”Turn on the swiftide-tasks feature flag:
cargo add swiftide@0.34 --features swiftide-tasksThe swiftide-agents flag turns it on as well. Import the graph types from swiftide::tasks. The old path swiftide::agents::tasks still works, but it is deprecated since 0.32.1. Upgrading lists the 0.33 changes to tasks. A comment in the crate source says the tasks application programming interface (API) “is not stable yet” and “is subject to rapid change”, so check Upgrading when you move to a new minor version.
Tasks and agents
Section titled “Tasks and agents”An agent runs a loop. After every model response it decides which tool to call next. It stops when a tool or the model asks it to, when it reaches its completion limit, or when it fails.
A task fixes the order of steps in code. Every node declares its input and output types. A transition to a node with the wrong input type does not compile, and neither does a fan-out without a join.
Control flow lives in Rust closures, not in model output. The model can still make decisions inside a node, but the graph decides what happens with the result. Each node does one job. You can test a closure node without a model, swap the model behind a prompt node, or give an agent node its own tools and limits.
Use a task when you know the steps in advance, when some of them need no model, or when you want to fan out work and collect it in one place. Put an agent inside a node for the open-ended part, such as reading one crate, and let the task handle the order, the parallel branches and the approval. Agents as task nodes shows how. The workspace-architect showcase walks through the whole program.