Tasks: typed task graphs with fan-out, join and pause/resume # Agents as task nodes > Run an agent as a task node with TaskAgent, or implement TaskNode to turn its stop payload into typed output. You wrap an agent as a task node, first with the `TaskAgent` adapter and then as your own `TaskNode` that hands the next node a typed value read from the agent’s stop payload. `TaskAgent` is the shortest form: ```rust /// Adds an entry to the top of `CHANGELOG.md`. /// /// # Arguments /// - `entry`: The Markdown entry to add, including its heading. #[swiftide::tool] async fn add_changelog_entry( context: &dyn AgentContext, entry: &str, ) -> Result { let executor = context.executor(); let changelog = executor .exec_cmd(&Command::read_file("CHANGELOG.md")) .await? .into_string_lossy(); executor .exec_cmd(&Command::write_file( "CHANGELOG.md", format!("{entry}\n\n{changelog}"), )) .await?; Ok(ToolOutput::text("Entry added.")) } /// An agent wrapped in `TaskAgent` as the only node of a task. pub async fn update_changelog(openai: OpenAI) -> anyhow::Result<()> { let checkout: Arc = Arc::new(LocalExecutor::new("/srv/checkout")); let agent = Agent::builder() .llm(&openai) .context(DefaultContext::from_executor(checkout)) .system_prompt("You keep the changelog of this repository up to date.") .tools([add_changelog_entry()]) .build()?; let mut task = Task::::new(); let changelog = task.register_node(TaskAgent::from(agent)); task.starts_with(changelog); task.register_transition(changelog, task.transitions_to_finish())?; // `TaskAgent` returns `()`: the result is the file the tool wrote. match task.run("Add an entry for the 0.33 release.").await? { TaskRunOutcome::Completed(()) => println!("CHANGELOG.md is updated"), TaskRunOutcome::Paused => unreachable!("no node in this task pauses"), } Ok(()) } ``` `TaskAgent::from(agent)` turns the agent into a task node. Its input is a `Prompt`, its output is `()` and its error is `AgentError`. Each evaluation calls `query` on the agent with the prompt. `TaskAgent` gives you no way to read the agent’s answer. It fits agents whose work is a side effect, like the changelog entry that `add_changelog_entry` writes through the tool executor. When the next node needs data from the agent, write your own node. The adapter keeps the agent behind an `Arc>`. Branches that share one `TaskAgent` run one at a time and add to the same [message history](/agents/context-and-history/#message-history). The `swiftide-agents` feature flag turns on `swiftide-tasks` as well: ```sh cargo add swiftide@0.34 --features swiftide-agents,openai ``` The custom node below also needs serde and schemars 1 (the version swiftide re-exports as `swiftide::reexports::schemars`), plus `thiserror` for its error type: ```sh cargo add serde --features derive cargo add schemars@1 --features derive cargo add serde_json thiserror ``` ## Custom agent node The workspace-architect showcase fans out one agent per publishable crate of a Cargo workspace, and each branch returns a `CrateSummary`. The node holds the model client, not an agent: examples/workspace-architect/src/analyst.rs ```rust /// A task node that documents one workspace crate with a freshly built agent. /// /// The node is registered once and used by every fan-out branch: each branch evaluates it with a /// different [`WorkspaceCrate`], and every evaluation builds its own agent scoped to that crate's /// directory, so branches share nothing and can run in parallel. #[derive(Clone)] pub struct CrateAnalyst { llm: L, progress: Progress, } impl CrateAnalyst { pub fn new(llm: L, progress: Progress) -> Self { Self { llm, progress } } } ``` The `TaskNode` implementation builds an agent, queries it and reads what the agent passed to `stop`: examples/workspace-architect/src/analyst.rs ```rust /// How often an agent is told to fix a missing or malformed report before the crate fails. const MAX_CORRECTIONS: usize = 2; #[async_trait] impl TaskNode for CrateAnalyst { type Input = WorkspaceCrate; type Output = CrateSummary; type Error = AnalystError; async fn evaluate( &self, _node_id: &DynNodeId, member: &WorkspaceCrate, ) -> Result { self.analyse(member).await.map_err(|cause| AnalystError { crate_name: member.name.clone(), cause, }) } } impl CrateAnalyst { async fn analyse(&self, member: &WorkspaceCrate) -> Result { let analysis = self.progress.analysis(&member.name); let mut agent = self.agent_for(member, analysis.clone())?; agent.query(brief(member)).await?; // Models occasionally answer in prose or nest the `stop` arguments; say what went wrong. let mut corrections = 0; let report = loop { match submitted_report(&agent) { Ok(report) => break report, Err(problem) if corrections < MAX_CORRECTIONS => { corrections += 1; analysis.corrected(&problem); agent.query(problem.correction()).await?; } Err(problem) => return Err(problem.into()), } }; analysis.finished(&report); Ok(report.reconciled_with(member)) } } /// The report the agent passed to `stop`, checked against the `CrateReport` schema. fn submitted_report(agent: &Agent) -> Result { match agent.stop_reason() { Some(StopReason::RequestedByTool(_, Some(arguments))) => { Ok(CrateReport::deserialize(arguments)?) } other => Err(ReportProblem::Missing { stop_reason: other.cloned(), }), } } ``` `evaluate` borrows its input and reaches the node through `&self`. Every branch of the fan-out evaluates the same node instance. That is why the agent is a local variable of `analyse` and not a field. After `query` returns, `agent.stop_reason()` says why the agent stopped. A call to the stop tool with arguments gives `StopReason::RequestedByTool(_, Some(arguments))`. `CrateReport::deserialize` reads the report straight from the borrowed `serde_json::Value`. Any other stop reason, or a payload that does not match, becomes a `ReportProblem`. The node then checks the report against `cargo metadata` and returns a `CrateSummary`. The transitions that follow are typed, so the compiler checks that the next node accepts it. ## Stop schema The agent’s stop tool requires a `CrateReport`: examples/workspace-architect/src/summary.rs ```rust /// What an analyst agent reports about one crate when it calls `stop`. /// /// The doc comments become field descriptions in the JSON schema the model sees, so they double /// as instructions. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct CrateReport { /// Two to four sentences: the problem this crate solves and the role it plays in the /// workspace. Describe responsibilities, not files. pub purpose: String, /// Four to eight of the most important public items a newcomer must know (traits, structs, /// enums, macros or functions), each formatted as "`Name`: what it is for". Use names exactly /// as they appear in the source. pub key_types: Vec, /// One entry per workspace crate listed as a dependency in the brief. Empty if the brief /// lists none. pub depends_on: Vec, /// One to three sentences on feature flags, extension points (traits meant to be /// implemented) and non-obvious design decisions. pub notes: String, } /// How the documented crate uses one of its workspace dependencies. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct DependencyUsage { /// The dependency's crate name, exactly as listed in the brief. pub crate_name: String, /// What the documented crate uses it for. pub usage: String, } /// A `stop` tool whose arguments must match [`CrateReport`]. pub fn summary_stop_tool() -> StopWithArgs { StopWithArgs::with_parameters_schema(schema_for!(CrateReport)) } ``` `summary_stop_tool` replaces the default `stop` tool with one whose arguments must match `CrateReport`. [Stopping and structured results](/agents/stopping-and-structured-results/) explains how the payload reaches `StopReason::RequestedByTool`. ## One agent per input `agent_for` builds a fresh agent for every evaluation: examples/workspace-architect/src/analyst.rs ```rust fn agent_for( &self, member: &WorkspaceCrate, analysis: Analysis, ) -> Result { // Every command the tools run executes inside the crate directory. let executor: Arc = Arc::new(LocalExecutor::new(&member.dir)); let tools: [Box; 4] = [ list_files(), read_file(), grep(), summary_stop_tool().into(), ]; Agent::builder() .name(member.name.as_str()) .llm(&self.llm) .context(DefaultContext::from_executor(executor)) .system_prompt(SYSTEM_PROMPT) // Replace the default `stop` tool with one that requires a `CrateReport`. .without_default_stop_tool() .tools(tools) .before_tool(move |_agent, tool_call| { analysis.tool_called(tool_call.name(), tool_call.args().unwrap_or_default()); Box::pin(async { Ok(()) }) }) .limit(MAX_TURNS) .build() } ``` Each agent gets a [`LocalExecutor`](/agents/executors/#local-executor) whose working directory is its crate’s directory, so relative paths in its tools resolve inside that crate. This is not a sandbox. Absolute paths and `..` still reach the rest of the file system. The showcase’s own tools reject absolute paths and `..`, but they follow symlinks. `without_default_stop_tool()` takes effect in the next `.tools(...)` call, which builds the tool set without the default `stop`. `summary_stop_tool()` then adds the one bound to `CrateReport`. The order matters, as the gotchas below explain. The `before_tool` [hook](/agents/hooks/) reports progress. `limit(MAX_TURNS)` caps the completions of a single query at 30. The branches share no agent, executor or message history. That makes it safe to run them under `ConcurrencyModel::Parallel`, as [Fan-out and join](/tasks/fan-out-and-join/#concurrency-model) shows. ## Corrections A model sometimes answers in prose or nests its report. The node then queries the same agent again with a correction, up to `MAX_CORRECTIONS` (2) times. For a payload that does not match the schema, the correction quotes the serde error. For a missing stop, it is a fixed reminder to call `stop` now. The agent keeps its message history between the queries, so it sees its own mistake. [Workspace architect](/showcases/workspace-architect/#output) shows the captured retries. ## Shared agent `examples/tasks.rs` keeps one agent for the life of the node instead: ```rust /// A domain-specific task step around an `Agent`. /// /// We do not use `TaskAgent` here on purpose. `TaskAgent` is useful when you simply want /// "run this prompt through an agent" behavior. In many applications, however, an agent step has /// its own responsibility and its own input/output shape. That is when implementing `TaskNode` /// directly becomes the clearer API. #[derive(Clone, Debug)] struct BriefingAgent { agent: Arc>, } impl BriefingAgent { fn new(agent: Agent) -> Self { Self { agent: Arc::new(Mutex::new(agent)), } } } /// The structured payload that our custom stop tool expects from the agent. /// /// Returning a typed value like this is the main reason to implement a task node around an agent: /// downstream task steps can now work with domain data instead of unstructured free text. #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] #[schemars(deny_unknown_fields)] struct BriefingDecision { audience: String, summary: String, next_steps: Vec, } #[derive(Debug)] enum BriefingAgentError { Agent(AgentError), MissingStructuredStop, InvalidStructuredStop(serde_json::Error), } impl std::fmt::Display for BriefingAgentError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Agent(error) => write!(f, "{error}"), Self::MissingStructuredStop => { write!(f, "agent stopped without the structured stop payload") } Self::InvalidStructuredStop(error) => { write!( f, "agent returned an invalid structured stop payload: {error}" ) } } } } impl std::error::Error for BriefingAgentError {} impl From for BriefingAgentError { fn from(value: AgentError) -> Self { Self::Agent(value) } } impl From for BriefingAgentError { fn from(value: serde_json::Error) -> Self { Self::InvalidStructuredStop(value) } } #[swiftide::reexports::async_trait::async_trait] impl TaskNode for BriefingAgent { type Input = String; type Output = BriefingDecision; type Error = BriefingAgentError; async fn evaluate( &self, _node_id: &NodeId< dyn TaskNode, >, brief: &Self::Input, ) -> Result { // The previous node produced a short brief. This node asks the agent to turn that brief // into a structured hand-off plan and to stop with a JSON payload we can deserialize. let handoff_prompt = Prompt::from( "Turn this brief into a structured hand-off plan.\n\ Brief:\n\ {{brief}}\n\n\ Call the `stop` tool once you have produced the final plan.", ) .with_context_value("brief", brief.clone()); let mut agent = self.agent.lock().await; agent.query_once(handoff_prompt).await?; let Some(StopReason::RequestedByTool(_, Some(payload))) = agent.stop_reason() else { return Err(BriefingAgentError::MissingStructuredStop); }; Ok(serde_json::from_value(payload.clone())?) } } ``` `BriefingAgent` locks the agent, calls `query_once` and deserializes the stop payload into a `BriefingDecision`. `query_once` runs one turn of the agent loop instead of looping until the agent stops. If the model calls another tool in that turn, the agent stops with `StopReason::NoNewMessages` and the node returns `MissingStructuredStop`. Use `query` for an agent that needs tools before it stops. The mutex serializes evaluations, and the message history carries over from one evaluation to the next. Use this shape when the node runs once per task, or when later evaluations should see earlier ones. ## Gotchas * Every call to `query` starts the `limit` count from zero. With corrections, one crate in the showcase can use up to `(1 + MAX_CORRECTIONS) * MAX_TURNS` completions. * An agent that reaches its limit stops with `StopReason::NoNewMessages`, not with a stop payload. The payload itself is an `Option`, so handle `None` too. * Call `without_default_stop_tool()` before `.tools(...)`. Called after it, or without any `.tools(...)` call, the default `stop` stays. Tools are unique by name, so your own `stop` passed later is dropped and the agent stops with `RequestedByTool(_, None)`. * `build()` returns an `AgentBuilderError`, and `query` returns an `AgentError`. Wrap both in one error type that names the input. `AnalystError` prints “analysing `{crate_name}` failed: …”, so a failed branch says which crate it was working on. ## Reference * [`TaskAgent`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.TaskAgent.html) * [`Agent`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.Agent.html), [`Agent::query_once`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.Agent.html#method.query_once) and [`AgentBuilder`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html) * [`StopReason`](https://docs.rs/swiftide/0.34/swiftide/agents/enum.StopReason.html) * [`StopWithArgs`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.StopWithArgs.html) * [`LocalExecutor`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/local_executor/struct.LocalExecutor.html) and [`DefaultContext`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html) * [`ToolExecutor`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ToolExecutor.html) * [`Prompt`](https://docs.rs/swiftide/0.34/swiftide/prompt/struct.Prompt.html) * [`AgentError`](https://docs.rs/swiftide/0.34/swiftide/agents/errors/enum.AgentError.html) * [`Task`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Task.html) and [`TaskRunOutcome`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.TaskRunOutcome.html) * [`TaskNode`](https://docs.rs/swiftide/0.34/swiftide/tasks/trait.TaskNode.html) and [`DynNodeId`](https://docs.rs/swiftide/0.34/swiftide/tasks/type.DynNodeId.html) * [`ConcurrencyModel`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.ConcurrencyModel.html) ## Next [Built-in nodes](/tasks/built-in-nodes/) # Built-in nodes > Register a SimplePrompt, ChatCompletion or ToolExecutor trait object, or a plain closure, as a task node without writing a type. You end with a model call, a shell command and a reusable closure each running as a task node, and a rule for when to write your own `TaskNode` instead. Swiftide implements `TaskNode` for `Arc` and `Box` trait objects of `SimplePrompt`, `ChatCompletion` and `ToolExecutor`, and for the `SyncFn` and `AsyncFn` closure wrappers. The snippets use the OpenAI client and the local tool executor, so enable those feature flags next to `swiftide-tasks`: ```sh cargo add swiftide@0.34 --features swiftide-tasks,swiftide-agents,openai ``` ## Prompt nodes A closure builds a `Prompt`, and the model client answers it: ```rust let mut task: Task = Task::new(); let ask = task.register_node_fn(|topic: &String| { Ok::<_, NodeError>( Prompt::from("Explain {{ topic }} in two sentences.") .with_context_value("topic", topic.as_str()), ) }); // The cast turns the concrete client into a node: `Prompt` in, `String` out. let answer = task.register_node(Arc::new(openai) as Arc); task.starts_with(ask); task.register_transition(ask, move |prompt| answer.transitions_with(prompt))?; task.register_transition(answer, task.transitions_to_finish())?; if let TaskRunOutcome::Completed(text) = task.run("a task node").await? { println!("{text}"); } ``` The closure fills a template and returns the `Prompt`. The next node is the model client itself. The cast matters. Swiftide implements `TaskNode` for `Arc` and `Box`, not for each client type. `register_node(openai)` on its own does not compile, because the concrete client is not a node. A `SimplePrompt` node has `Input = Prompt`, `Output = String` and `Error = LanguageModelError`. Each evaluation clones the input `Prompt` and calls `prompt` on the client, which renders the template. See [Prompts and templates](/models/prompts-and-templates/) for building the `Prompt`. One `Arc` can back several nodes, as in the `examples/tasks.rs` excerpt in [Nodes and transitions](/tasks/nodes-and-transitions/#transitions). The workspace-architect showcase uses the same pattern. Its join node builds one `Prompt` from every crate summary, and a `SimplePrompt` node renders it into an `ARCHITECTURE.md` draft. In the approved run from `OUTPUT.md`, the render step took 28 seconds for an 11 KB draft. The model was `claude-sonnet-5-5`, called through Chat Completions on an OpenAI-compatible gateway (Bifrost in front of AWS Bedrock): ```text [ 40.5s] join 10 summaries; 230s of agent work in 41s wall-clock (5.7x) [ 40.5s] render drafting ARCHITECTURE.md [ 68.8s] review draft ready (11210 bytes), waiting for approval ``` ## Chat completion nodes When a step needs a system message, a full message history or tool specs, use a `ChatCompletion` node instead: ```rust let mut task: Task = Task::new(); let ask = task.register_node_fn(|question: &String| { Ok::<_, NodeError>(ChatCompletionRequest::from(vec![ ChatMessage::new_system("Answer in one sentence."), ChatMessage::new_user(question.clone()), ])) }); let chat: Arc = Arc::new(openai); let complete = task.register_node(chat); let answer = task.register_node_fn(|response: &ChatCompletionResponse| { Ok::<_, NodeError>(response.message().unwrap_or_default().to_string()) }); task.starts_with(ask); task.register_transition(ask, move |request| complete.transitions_with(request))?; task.register_transition(complete, move |response| answer.transitions_with(response))?; task.register_transition(answer, task.transitions_to_finish())?; if let TaskRunOutcome::Completed(text) = task.run("What does a task node do?").await? { println!("{text}"); } ``` An `Arc` or `Box` node has `Input = ChatCompletionRequest<'static>`, `Output = ChatCompletionResponse` and `Error = LanguageModelError`. It calls `complete` with the request. Task payloads must be `'static`, so the request has to own its messages. `ChatCompletionRequest::from(Vec)` does that, and so does `ChatCompletionRequest::builder()` with an owned `Vec`. To keep a borrowed request, call `to_owned()` on it first. The response is the whole `ChatCompletionResponse`. A node after it reads `message()`, which returns `Option<&str>`, or `tool_calls()`. The node does not run tools. To act on tool calls in a loop, use an agent. [Models overview](/models/overview/) covers the clients that implement `ChatCompletion`. ## Tool executor nodes A `ToolExecutor` node runs one command. Pair it with a closure that builds the `Command`: ```rust let mut task: Task = Task::new(); let command = task.register_node_fn(|count: &usize| { Ok::<_, NodeError>(Command::shell(format!("git log --oneline -n {count}"))) }); let shell: Arc = Arc::new(LocalExecutor::new(".")); let run = task.register_node(shell); task.starts_with(command); task.register_transition(command, move |cmd| run.transitions_with(cmd))?; task.register_transition(run, task.transitions_to_finish())?; match task.run(5_usize).await { Ok(TaskRunOutcome::Completed(output)) => { for chunk in output.stdout() { print!("{}", String::from_utf8_lossy(chunk)); } } Ok(TaskRunOutcome::Paused) => unreachable!("no node in this task pauses"), Err(TaskError::NodeError(failed)) => { // An exit status, a spawn failure or an I/O error. The text can sit in either stream. if let Some(CommandError::NonZeroExit(output)) = failed.node_error.downcast_ref() { eprintln!("{}", output.to_string_lossy()); } return Err(TaskError::NodeError(failed).into()); } Err(other) => return Err(other.into()), } ``` An `Arc` or `Box` node has `Input = Command`, `Output = CommandOutput` and `Error = CommandError`. It calls `exec_cmd` on the executor. `LocalExecutor` runs commands on the local machine. [Executors](/agents/executors/) covers the executors and their commands. Build the input with `Command::shell`, `Command::read_file` or `Command::write_file`. Chain `with_current_dir` or `with_timeout` to set a directory or a time limit for one command. Since 0.33, `CommandOutput` keeps standard output (stdout) and standard error (stderr) apart. `stdout()` and `stderr()` iterate over the chunks of one stream, in the order the executor read them. `to_string_lossy()` and `into_string_lossy()` merge both streams into one text. A command that exits with a non-zero status does not produce output for the next node. `LocalExecutor` returns `CommandError::NonZeroExit` with the captured output, and the run fails with `TaskError::NodeError`. Its `node_error` field holds the original `CommandError`, so `downcast_ref` gets the output back, as the snippet does. `NonZeroExit` also carries every I/O error, such as a command that fails to start or a missing file for `Command::read_file`. In that case the error text sits in a stdout chunk, not in stderr. The snippet prints both streams with `to_string_lossy()` for that reason. A command that runs past its timeout fails with `CommandError::TimedOut`, which the snippet passes on with the rest. ## Closure adapters `register_node_fn` wraps a closure in `SyncFn`, and `register_node_async_fn` wraps one in `AsyncFn`. Both types are public. Build one yourself when you want to name the node’s type, store it in a struct, or register the same adapter more than once: ```rust let mut task: Task = Task::new(); let double = SyncFn::new(|n: &i32| Ok::<_, NodeError>(n * 2)); let first = task.register_node(double.clone()); let second = task.register_node(double); task.starts_with(first); task.register_transition(first, move |n| second.transitions_with(n))?; task.register_transition(second, task.transitions_to_finish())?; assert_eq!(task.run(3).await?, TaskRunOutcome::Completed(12)); ``` `SyncFn::new` takes a closure `Fn(&I) -> Result`. `AsyncFn::new` takes a closure that returns `Box::pin(async move { .. })`. Both are `Clone`, and every `register_node` call adds a separate node. The error type defaults to `NodeError`, but a closure still needs its error type written out; see [Closure nodes](/tasks/nodes-and-transitions/#closure-nodes). ## Custom node or built-in Use a built-in node when the input and output types already fit the step. A prompt in and text out is a `SimplePrompt` node. A command in and its output out is a `ToolExecutor` node. Small conversions between steps are closures. Implement `TaskNode` yourself when the step: * takes or returns your own domain types * calls more than one service, or calls one in a loop * keeps state that the caller reads from outside the task * needs structured output from a model * should fail with an error that names its input The showcase’s `CrateAnalyst` runs an agent and returns a typed `CrateSummary`. Its `ApprovalGate` keeps a draft that the command-line interface (CLI) approves from outside. Both are custom nodes. [Agents as task nodes](/tasks/agents-as-nodes/#custom-agent-node) and [Pause and resume](/tasks/pause-and-resume/#approval-gate) show them. ## Gotchas * A `SimplePrompt` node clones its `Prompt` on every evaluation. Keep large values out of the template context if the node runs often. * A `ToolExecutor` node runs commands in the executor’s working directory. With `LocalExecutor`, a relative `with_current_dir` path resolves against that directory. * To branch on a failed command instead of failing the run, write your own node that calls the executor and returns an enum. Node outputs must be `Clone`, and `CommandError` is not, so map the error to a type of your own first. ## Reference * [`SimplePrompt`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.SimplePrompt.html) * [`ChatCompletion`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ChatCompletion.html) * [`ChatCompletionRequest`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ChatCompletionRequest.html) * [`ChatCompletionResponse`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ChatCompletionResponse.html) * [`LanguageModelError`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/errors/enum.LanguageModelError.html) * [`ToolExecutor`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ToolExecutor.html) * [`Command`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.Command.html) * [`CommandOutput`](https://docs.rs/swiftide/0.34/swiftide/traits/struct.CommandOutput.html) * [`CommandError`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.CommandError.html) * [`LocalExecutor`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/local_executor/struct.LocalExecutor.html) * [`SyncFn`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.SyncFn.html) * [`AsyncFn`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.AsyncFn.html) * [`Prompt`](https://docs.rs/swiftide/0.34/swiftide/prompt/struct.Prompt.html) * [`TaskNode`](https://docs.rs/swiftide/0.34/swiftide/tasks/trait.TaskNode.html) * [`NodeError`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.NodeError.html) ## Next [How indexing works](/indexing/overview/) # Fan-out and join > Fan a task out into one branch per item, optionally run the branches in parallel, and collect their typed outputs in one join node. You end up with a task that runs one task node per item of a list, all at the same time, and collects every result in a single join node. The task types need the `swiftide-tasks` feature flag ([Tasks overview](/tasks/overview/#feature-flag)). The code comes from the workspace-architect showcase, which analyses each crate of a Cargo workspace with its own agent. examples/workspace-architect/src/graph.rs ```rust // 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()) })?; ``` examples/workspace-architect/src/graph.rs ```rust // 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) } })?; ``` The `discover` node returns the workspace members, and its transition folds them into one fan-out with a branch per crate. Every branch evaluates the same `analyse` node with a different `WorkspaceCrate`, in parallel, and `join_with` names the node that waits for the whole group. The `analyse` node registers one transition to the join target, `task.register_transition(analyse, join.join())`, and every branch uses it. Transitions belong to nodes, not branches, and each node has [exactly one](/tasks/nodes-and-transitions/#transitions). The join node is a closure over `&JoinInput`, registered with `task.register_node_fn` in the `nodes` region of `graph.rs`. It runs once every branch has delivered its `CrateSummary`, and its `Prompt` goes to the render node with an ordinary `transitions_with`. [Built-in nodes](/tasks/built-in-nodes/#prompt-nodes) covers the render node. ## Fan-out `Transition::fan_out(&node, input)` starts a `FanOutTransition` with one branch. `.and(&node, input)` adds another. Branches can target the same node or different nodes. Each input has to convert into the target node’s input type, or the code does not compile. A `FanOutTransition` becomes a `Transition` only when you call `join_with`, so a fan-out without a join does not compile. `fan_out` needs a first target, so handle an empty list first. The showcase returns `Transition::error(..)`, which fails the run with a `TaskError::NodeError`. With branches known up front, write them out. This example starts two branches on two different nodes: ```rust // The fan-out transition defines the branch group and the join that waits for that group. task.register_transition(start, move |input| { Transition::fan_out(&increment, input) .and(&double, input) .join_with(join.join()) })?; // Each branch still declares where its own output should go. Branches can do any amount of work // before eventually transitioning to the join. task.register_transition(increment, join.join())?; task.register_transition(double, join.join())?; task.register_transition(join, task.transitions_to_finish())?; ``` A branch can pass through intermediate nodes before it joins. Only the last node on the branch registers the join target. If a branch joins a different node than the one its fan-out named, the run fails with `TaskError::InvalidState`. ## Join node A join node takes `JoinInput` as its input, where `T` is the payload every branch contributes. `NodeId::join()` exists only on nodes with that input type. `JoinInput` has three methods: * `iter()` yields the payloads in the order the branches were created, not the order they finished. * `len()` returns the number of payloads. * `is_empty()` returns true when there are none. The showcase relies on that order: the prompt lists the crates as `discover` found them, although `swiftide-core` finished last after a retry. A branch whose output does not convert into `T` does not compile. ## Payload mapping A branch does not have to return the payload type itself. `JoinTarget::map` converts the branch output on its way into the join: ```rust let mut task = Task::::new(); let start = task.register_node_fn(|text: &String| -> Result { Ok(text.trim().to_string()) }); let count = task.register_node_fn(|text: &String| -> Result { Ok(text.split_whitespace().count()) }); let length = task .register_node_fn(|text: &String| -> Result { Ok(text.chars().count()) }); let shout = task .register_node_fn(|text: &String| -> Result { Ok(text.to_uppercase()) }); let join = task.register_node_fn(|parts: &JoinInput| -> Result { Ok(parts.iter().cloned().collect::>().join("; ")) }); task.starts_with(start); task.register_transition(start, move |text: String| { Transition::fan_out(&count, text.clone()) .and(&length, text.clone()) .and(&shout, text) .join_with(join.join()) })?; // `count` returns a usize; `map` turns it into the join's String payload. task.register_transition( count, join.join().map(|words: usize| format!("{words} words")), )?; // An async mapping is registered with `register_transition_async`. task.register_transition_async( length, join.join() .map_async(|chars: usize| async move { format!("{chars} characters") }), )?; // `shout` already returns a String, so it joins as is. task.register_transition(shout, join.join())?; task.register_transition(join, task.transitions_to_finish())?; // "2 words; 11 characters; HELLO TASKS" let TaskRunOutcome::Completed(summary) = task.run(" hello tasks ").await? else { anyhow::bail!("nothing in this task pauses"); }; ``` `map` turns the `usize` from `count` into the `String` the join expects. `JoinTarget::map_async` does the same with an async closure and is registered with `Task::register_transition_async`. `shout` already returns a `String`. Any output that converts into the payload type with `Into` needs no mapping. The task returns `"2 words; 11 characters; HELLO TASKS"`, in branch creation order. ## Mixed payloads When branches return different types and the join needs all of them, give the join node an `AnyJoinInput`. Use `join_any()` in place of `join()`, both in the fan-out and for each branch: ```rust let mut task = Task::::new(); let start = task.register_node_fn(|text: &String| -> Result { Ok(text.trim().to_string()) }); let count = task.register_node_fn(|text: &String| -> Result { Ok(text.split_whitespace().count()) }); let shout = task .register_node_fn(|text: &String| -> Result { Ok(text.to_uppercase()) }); let join = task.register_node_fn(|parts: &AnyJoinInput| -> Result { let words: usize = parts.iter::().sum(); let shouted = parts.iter::().cloned().collect::>(); Ok(format!("{} ({words} words)", shouted.join(" "))) }); task.starts_with(start); task.register_transition(start, move |text: String| { Transition::fan_out(&count, text.clone()) .and(&shout, text) .join_with(join.join_any()) })?; task.register_transition(count, join.join_any())?; task.register_transition(shout, join.join_any())?; task.register_transition(join, task.transitions_to_finish())?; // "HELLO TASKS (2 words)" let TaskRunOutcome::Completed(summary) = task.run(" hello tasks ").await? else { anyhow::bail!("nothing in this task pauses"); }; ``` `AnyJoinInput::iter::()` downcasts each payload and skips the ones of other types. `iter_any()` yields every payload as a type-erased value. The compiler no longer checks that a branch delivers a type the join reads, so prefer `JoinInput` with `map` where you can. ## Concurrency model `ConcurrencyModel` has two variants. `Sequential` is the default, and `Parallel` is opt-in. Set the default for the whole task with `with_default_concurrency_model`: ```rust let mut task = Task::new().with_default_concurrency_model(ConcurrencyModel::Parallel); let start = task.register_node_fn(|input: &i32| -> std::result::Result { Ok(*input) }); let increment = task .register_node_fn(|input: &i32| -> std::result::Result { Ok(*input + 1) }); let double = task .register_node_fn(|input: &i32| -> std::result::Result { Ok(*input * 2) }); let join = task.register_node_fn( |input: &JoinInput| -> std::result::Result { Ok(input.iter().copied().sum::()) }, ); task.starts_with(start); ``` To override the model from a given step on, call `concurrency_model` on a `Transition`, `MarkedTransition` or `FanOutTransition`. A branch keeps the override for every later step until another transition overrides it, and the branches of a fan-out inherit its model. The join node always runs with the task’s default model. Under `Sequential`, the runtime evaluates one node at a time. Fan-out branches take turns: after a node finishes, the branch’s next step goes to the back of the queue. Under `Parallel`, the branches run concurrently inside the future that `run` returns, as the [overview](/tasks/overview/#crate-contents) explains. Waiting on input/output (I/O), such as model calls, overlaps. Synchronous, compute-only closures do not. This is the showcase’s captured run, with `claude-sonnet-5-5` as the model for all ten analysts. The `model` and `discover` lines before it and the render steps after it are left out: ```text [ 0.0s] analyse started swiftide-core [ 0.0s] analyse started swiftide-langfuse [ 0.0s] analyse started swiftide-macros [ 0.0s] analyse started swiftide-indexing [ 0.0s] analyse started swiftide-integrations [ 0.0s] analyse started swiftide-query [ 0.0s] analyse started swiftide-tasks [ 0.0s] analyse started swiftide-agents [ 0.0s] analyse started swiftide-agents-contrib [ 0.0s] analyse started swiftide [ 14.0s] analyse finished swiftide-langfuse in 14.0s (6 tool calls) [ 14.3s] analyse finished swiftide-macros in 14.3s (6 tool calls) [ 14.7s] analyse finished swiftide in 14.7s (5 tool calls) [ 19.2s] analyse finished swiftide-query in 19.1s (10 tool calls) [ 19.7s] analyse retry swiftide-agents-contrib: the `stop` arguments do not match the schema: unknown field `arguments`, expected one of `purpose`, `key_types`, `depends_on`, `notes` [ 20.2s] analyse finished swiftide-agents in 20.2s (10 tool calls) [ 24.4s] analyse finished swiftide-indexing in 24.4s (10 tool calls) [ 24.9s] analyse finished swiftide-tasks in 24.9s (10 tool calls) [ 27.1s] analyse finished swiftide-agents-contrib in 27.1s (11 tool calls) [ 31.4s] analyse finished swiftide-integrations in 31.4s (13 tool calls) [ 33.9s] analyse retry swiftide-core: the `stop` arguments do not match the schema: unknown field `arguments`, expected one of `purpose`, `key_types`, `depends_on`, `notes` [ 40.5s] analyse finished swiftide-core in 40.5s (15 tool calls) [ 40.5s] join 10 summaries; 230s of agent work in 41s wall-clock (5.7x) ``` All ten branches start at 0.0s, and the fan-out takes as long as its slowest branch. The join line sums the agent time of every branch: 230 seconds of work in 41 seconds. `OUTPUT.md` estimates close to four minutes for the same analyses run one after another. ## Limits * A branch cannot start a second fan-out while it belongs to an active join group. The run fails with `TaskError::InvalidState`. Fan out again from the join node instead. * A branch cannot finish the task on its own. If a branch inside a group registers `task.transitions_to_finish()`, the task output counts as an arrival at the join, unmapped. When that type differs from the join’s payload type, the join fails at runtime with `TaskError::InvalidState`. Route every branch to the join and finish from there. * The first branch that fails fails the run. The other in-flight branches are dropped and never complete. * All branches share one node instance; keep per-branch state inside `evaluate` ([Agents as task nodes](/tasks/agents-as-nodes/#custom-agent-node)). ## Reference * [`Transition`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Transition.html) * [`Transition::error`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Transition.html#method.error) * [`FanOutTransition`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.FanOutTransition.html) * [`MarkedTransition`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.MarkedTransition.html) * [`JoinInput`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.JoinInput.html) * [`JoinTarget`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.JoinTarget.html) * [`MappedJoinTarget`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.MappedJoinTarget.html) * [`AnyJoinInput`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.AnyJoinInput.html) * [`AnyJoinTarget`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.AnyJoinTarget.html) * [`ConcurrencyModel`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.ConcurrencyModel.html) * [`NodeId`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.NodeId.html) * [`NodeId::join_any`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.NodeId.html#method.join_any) * [`Task`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Task.html) * [`Task::register_transition_async`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Task.html#method.register_transition_async) * [`TaskError`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.TaskError.html) ## Next [Pause and resume](/tasks/pause-and-resume/) # Nodes and transitions > Register closures and TaskNode types as nodes and connect them with typed transitions from start to finish. Here you build a linear task from two closures and a node type of your own, run it, and find out which node failed when something goes wrong. Import the task types from `swiftide::tasks`. [Tasks overview](/tasks/overview/#feature-flag) shows the feature flag to turn on. ```rust use std::num::ParseIntError; use swiftide::{ reexports::async_trait::async_trait, tasks::{DynNodeId, NodeError, Task, TaskError, TaskNode, TaskRunOutcome, Transition}, }; ``` Register the nodes, connect them, and run the task: ```rust let mut task: Task = Task::new(); // A sync closure node. `Ok::<_, NodeError>` names the error type. let trim = task.register_node_fn(|text: &String| Ok::<_, NodeError>(text.trim().to_string())); // A node type that implements `TaskNode`. let count = task.register_node(CountWords); // A closure that returns a pinned, boxed future. Copy what it needs before `async move`. let label = task.register_node_async_fn(|words: &usize| { let words = *words; Box::pin(async move { Ok::<_, NodeError>(format!("{words} words")) }) }); task.starts_with(trim); task.register_transition(trim, move |text: String| { if text.is_empty() { return Transition::error("there is no text to count"); } count.transitions_with(text).into() })?; task.register_transition(count, move |words| label.transitions_with(words))?; task.register_transition(label, task.transitions_to_finish())?; match task.run(" nodes and transitions ").await? { TaskRunOutcome::Completed(summary) => println!("{summary}"), TaskRunOutcome::Paused => println!("paused; call task.resume() to continue"), } ``` The run prints `3 words`. `Task` fixes the types at both ends. Each node declares its own input and output, so a `String` becomes a `usize` and then a `String` again. `register_node_fn` turns a synchronous closure into a node, and `register_node_async_fn` does the same for a closure that returns a pinned, boxed future (`Box::pin(async move { .. })`). `register_node` takes any value that implements `TaskNode`. All three return a `NodeId`, the typed handle you wire the node with. `NodeId` is `Copy`, so you move it into transition closures without cloning. `id()` returns its number, assigned in registration order from 0. `starts_with` picks the start node, and `register_transition` says where to go after a node. `transitions_with` moves on to another node with a value, and `transitions_to_finish` hands the task its output. Inside a fan-out branch, that output goes to the join instead of ending the task. `run` returns a `TaskRunOutcome`. `Completed` carries the output. `Paused` is covered in [Pause and resume](/tasks/pause-and-resume/). ## Task nodes A task node is a type that implements `TaskNode`: ```rust /// Counts the words in a text. #[derive(Clone, Debug)] pub struct CountWords; #[async_trait] impl TaskNode for CountWords { type Input = String; type Output = usize; type Error = std::convert::Infallible; async fn evaluate( &self, _node_id: &DynNodeId, text: &Self::Input, ) -> Result { Ok(text.split_whitespace().count()) } } ``` `Input` is what the node receives and `Output` is what it returns. `Error` can be any `std::error::Error + Send + Sync + 'static`; `CountWords` cannot fail, so it uses `Infallible`. `evaluate` borrows both the node and the input. The `&DynNodeId` argument is the node’s own id with its concrete type erased, and most nodes ignore it. Write the impl with `#[async_trait]`, which Swiftide re-exports as `swiftide::reexports::async_trait`. `Input` and `Output` must implement `NodeArg`, which covers every type that is `Send + Sync + Debug + Clone + 'static`. A missing derive shows up as an unmet `NodeArg` bound. `register_node` also needs the node itself to be `Clone`. A run does not clone your node. Every evaluation calls the same instance through `&self`, so keep per-run state out of the struct. ## Closure nodes The workspace-architect showcase registers its nodes in one block. `discover` and `write` are closure nodes: examples/workspace-architect/src/graph.rs ```rust let discover = task.register_node_fn({ let progress = progress.clone(); move |root: &WorkspaceRoot| { 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| { progress.joined(summaries.len()); architecture_prompt(summaries) } }); // `Arc` is a task node out of the box: `Prompt` in, `String` out. let render = task.register_node(Arc::new(llm.clone()) as Arc); 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| { 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) }) } }); ``` * `discover` is a sync closure. `Ok::<_, workspace::DiscoveryError>(members)` pins its error type, so the `?` inside converts into it. * `write` is a plain closure that returns `Box::pin(async move { .. })`. The other four nodes have their own pages: [Agents as task nodes](/tasks/agents-as-nodes/), [Fan-out and join](/tasks/fan-out-and-join/), [Built-in nodes](/tasks/built-in-nodes/) and [Pause and resume](/tasks/pause-and-resume/). A closure node needs a concrete error type. Name it with `Ok::<_, E>(..)` or a return type such as `-> Result`, unless the closure already returns a typed `Result`, as `str::parse` does. The closure is `Fn` and runs once per evaluation, so each future needs its own copy of the captures. That is why `write` clones `output` and `progress` before `async move`. The future may still borrow the input, here `draft`. For reusable domain logic, implement `TaskNode` instead. ## Transitions `examples/tasks.rs` chains a prompt node, an agent node and a second prompt node: ```rust // Tasks are typed from input to output. This task starts with a `Prompt` and finishes with the // final teammate-facing hand-off note. let mut task: Task = Task::new(); // Convenience helper: `Arc` already implements `TaskNode`, so prompt-model // steps can be registered directly without any wrapper code. let prompt_step: Arc = Arc::new(openai.clone()); let brief_prompt_id = task.register_node(prompt_step.clone()); let handoff_prompt_id = task.register_node(prompt_step); // Custom node: the agent step owns the stop-tool contract and returns structured task data. let agent_id = task.register_node(BriefingAgent::new(agent)); task.starts_with(brief_prompt_id); // Step 1: turn the incoming request into a short brief that the agent can work from. task.register_transition(brief_prompt_id, move |brief| { agent_id.transitions_with(brief) })?; // Step 2: use the structured stop payload to drive a final LLM rendering step. task.register_transition(agent_id, move |decision: BriefingDecision| { Transition::next( &handoff_prompt_id, Prompt::from( "Write a teammate-facing hand-off note.\n\ Audience: {{audience}}\n\ Summary: {{summary}}\n\ Next steps:\n\ {% for step in next_steps %}- {{ step }}\n{% endfor %}", ) .with_context_value("audience", decision.audience) .with_context_value("summary", decision.summary) .with_context_value("next_steps", decision.next_steps), ) })?; // Step 3: the rendered hand-off note becomes the task output. task.register_transition(handoff_prompt_id, task.transitions_to_finish())?; ``` A transition closure receives the node’s output by value and returns where to go next. `agent_id.transitions_with(brief)` is shorthand for `Transition::next(&agent_id, brief)`. Both return a `MarkedTransition` that carries the target’s type, so the compiler checks the value against the target’s `Input`. Each node has exactly one transition, and registering a second one returns `TaskError::InvalidState`. To branch, return different transitions from that one closure. When the arms point at nodes of different types, or one arm pauses or fails, convert each `MarkedTransition` into a `Transition` with `.into()`, as the linear task does. `Transition::error` fails the whole run, drops any in-flight fan-out branches and leaves nothing to resume. It accepts anything that converts into `Box`, including a `&str`. `Transition::pause()` pauses the branch. When the decision needs to await something, use `register_transition_async`. Its closure returns a future, such as `move |value| async move { next.transitions_with(value) }`. ## Start and finish The start node’s `Input` must be the task’s `Input`, or `starts_with` does not compile. `run` takes `impl Into`, so the linear task can pass a `&str` to a `Task`. `transitions_to_finish()` returns a closure that takes the task’s `Output`. The finish constructor on `Transition` is private, so the compiler checks the value against that task’s output type. A finish closure taken from a different task still compiles, and `run` then fails with `TaskError::TypeError`. Code from 0.32 called `transitions_to_done` and got an `Option` from `run`. [Upgrading](/reference/upgrading/#tasks-crate) lists the 0.33 changes. ## Errors Match on the result of `run` to tell a failing node from a wiring mistake: ```rust let mut task: Task = Task::new(); let parse = task.register_node_fn(|text: &String| text.trim().parse::()); task.starts_with(parse); task.register_transition(parse, task.transitions_to_finish())?; match task.run("forty-two").await { Ok(TaskRunOutcome::Completed(number)) => println!("parsed {number}"), Ok(TaskRunOutcome::Paused) => println!("paused"), Err(TaskError::NodeError(failed)) => { // `node_error` is the error the node returned, boxed. if let Some(error) = failed.node_error.downcast_ref::() { eprintln!("node {} could not parse the input: {error}", failed.node_id); } } Err(wiring @ (TaskError::NoSteps | TaskError::MissingTransition(_))) => { // `run` checks the graph before the first node runs. panic!("the task is wired wrong: {wiring}"); } Err(other) => return Err(other.into()), } ``` A failing node makes `run` return `TaskError::NodeError`. The `NodeError` inside carries `node_id`, the number of the failing node, and `node_error`, the node’s own error boxed. Downcast `node_error` to read your error type. The error displays as `Node error in node 0: invalid digit found in string`. A `Transition::error` produces the same variant, with the id of the node whose transition failed. `run` and `resume` check the graph before any node runs. Without a start node you get `TaskError::NoSteps`. When any registered node has no transition, you get `TaskError::MissingTransition`, even when no path reaches that node, with a message such as `Node 3 is missing a transition`. `TaskError::Incomplete` means all work drained without a finish or a pause. ## Reference * [`Task`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Task.html) * [`TaskNode`](https://docs.rs/swiftide/0.34/swiftide/tasks/trait.TaskNode.html) * [`NodeId`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.NodeId.html) * [`DynNodeId`](https://docs.rs/swiftide/0.34/swiftide/tasks/type.DynNodeId.html) * [`NodeArg`](https://docs.rs/swiftide/0.34/swiftide/tasks/trait.NodeArg.html) * [`Transition`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Transition.html) * [`MarkedTransition`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.MarkedTransition.html) * [`TaskRunOutcome`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.TaskRunOutcome.html) * [`TaskError`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.TaskError.html) * [`NodeError`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.NodeError.html) ## Next [Fan-out and join](/tasks/fan-out-and-join/) # How tasks work > A task is a typed graph of nodes and transitions that runs functions, prompts and agents, with fan-out, join and pause. 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](/start/your-first-task/) builds a two-node task step by step if you want a smaller start. ## Showcase graph The workspace-architect showcase drafts an `ARCHITECTURE.md` for a Cargo workspace. This function builds its whole task: examples/workspace-architect/src/graph.rs ```rust /// Builds the task: `WorkspaceRoot` in, the written ARCHITECTURE.md out. pub fn architect_task( llm: &L, settings: ArchitectSettings, progress: &Progress, ) -> Result, 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| { 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| { progress.joined(summaries.len()); architecture_prompt(summaries) } }); // `Arc` is a task node out of the box: `Prompt` in, `String` out. let render = task.register_node(Arc::new(llm.clone()) as Arc); 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| { 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) } ``` The task has the type `Task`. 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 metadata` and 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 sets `ConcurrencyModel::Parallel`, so the branches run at the same time. Each branch runs a fresh agent that reads one crate and returns a `CrateSummary`. * **join** waits for every branch. It receives a `JoinInput` and builds the prompt for the writer. * **render** is an `Arc` registered as a node. It sends the prompt to the model and returns Markdown. * **review** is `ApprovalGate`. Until someone approves the draft, its transition returns `Transition::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`: ```text [ 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: ```sh export OPENAI_API_KEY=sk-... cargo run -p workspace-architect ``` The command-line program starts the task with `task.run(WorkspaceRoot)`. That returns `TaskRunOutcome::Completed(WrittenDocument)` or `TaskRunOutcome::Paused`: examples/workspace-architect/src/main.rs ```rust 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?; } } } ``` On a pause, the program asks you to approve the draft and then calls `resume()`. ## Task graph ![Task graph](/diagrams/diagram-task-graph.svg) 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 an `Input`, an `Output` and an `Error` type, and its `evaluate` method turns a borrowed input into an output. [Nodes and transitions](/tasks/nodes-and-transitions/#task-nodes) 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()` or `task.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::Parallel` on the fan-out or the task to run them at the same time. * A **join** node receives the branch outputs as one `JoinInput`. [Fan-out and join](/tasks/fan-out-and-join/) covers both. * A **pause** stops the run, and `run` returns `TaskRunOutcome::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](/tasks/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](/tasks/nodes-and-transitions/#errors) covers the checks `run` makes before any node runs. ## Crate contents The `swiftide-tasks` crate holds the graph and its runtime: * `Task`, `TaskNode`, `NodeId` and `Transition` * fan-out and join, with `JoinInput` and `AnyJoinInput` * `ConcurrencyModel::Sequential` (the default) and `ConcurrencyModel::Parallel` * pause, resume and reset, with `TaskRunOutcome` * `TaskError` and `NodeError` * closure nodes through `register_node_fn` and `register_node_async_fn` * [built-in nodes](/tasks/built-in-nodes/) for `SimplePrompt`, `ChatCompletion` and `ToolExecutor` trait 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 Turn on the `swiftide-tasks` feature flag: ```sh cargo add swiftide@0.34 --features swiftide-tasks ``` The `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](/reference/upgrading/#tasks-crate) 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 An [agent](/agents/overview/) 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](/tasks/agents-as-nodes/) shows how. The [workspace-architect showcase](/showcases/workspace-architect/) walks through the whole program. ## Reference * [`swiftide::tasks`](https://docs.rs/swiftide/0.34/swiftide/tasks/index.html) * [`Task`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Task.html) * [`TaskNode`](https://docs.rs/swiftide/0.34/swiftide/tasks/trait.TaskNode.html) * [`NodeId`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.NodeId.html) * [`Transition`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Transition.html) * [`JoinInput`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.JoinInput.html) * [`AnyJoinInput`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.AnyJoinInput.html) * [`ConcurrencyModel`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.ConcurrencyModel.html) * [`TaskRunOutcome`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.TaskRunOutcome.html) * [`TaskError`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.TaskError.html) * [`NodeError`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.NodeError.html) * [`TaskAgent`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.TaskAgent.html) * [`SimplePrompt`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.SimplePrompt.html) * [`ChatCompletion`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ChatCompletion.html) * [`ToolExecutor`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ToolExecutor.html) * [Feature flags](/reference/feature-flags/) * [Crates](/reference/crates/) ## Next [Nodes and transitions](/tasks/nodes-and-transitions/) # Pause and resume > Pause a task at an approval gate, inspect the paused node, and resume or rerun it with the same Task value. You end up with a task that stops before a side effect, waits for a person or an outside event, and then continues from the node where it stopped. The workspace-architect showcase uses this to hold its generated `ARCHITECTURE.md` until someone approves it: examples/workspace-architect/src/graph.rs ```rust task.register_transition(review, move |decision| match decision { Review::Approved(draft) => write.transitions_with(draft).into(), Review::AwaitingApproval => Transition::pause(), })?; ``` The `review` node returns a `Review`. When the draft is approved, the transition sends it on to the `write` node. When it still waits for approval, the transition returns `Transition::pause()`, and `run` comes back with `TaskRunOutcome::Paused` instead of an output. The `.into()` on the first arm is the same `MarkedTransition` conversion described in [Nodes and transitions](/tasks/nodes-and-transitions/#transitions). The types live in `swiftide::tasks`, behind the `swiftide-tasks` [feature flag](/tasks/overview/#feature-flag): ```sh cargo add swiftide@0.34 --features swiftide-tasks ``` A task pause is not the same as an agent pause. To stop an agent until someone approves a single tool call, see [Human in the loop](/agents/human-in-the-loop/). ## Approval gate The `review` node is `ApprovalGate`, a task node that remembers the draft and whether someone approved it: examples/workspace-architect/src/approval.rs ```rust /// Lets a draft through once it is approved, and otherwise keeps it for review. /// /// The graph pauses the task when the gate answers [`Review::AwaitingApproval`]. A paused branch /// re-runs its node on `resume()`, so after [`ApprovalGate::approve`] the same draft passes /// through. The gate is cheap to evaluate, which is why the pause sits here and not on the /// expensive render node. #[derive(Debug, Clone)] pub struct ApprovalGate { state: Arc>, progress: Progress, } #[derive(Debug, Default)] struct GateState { approved: bool, pending: Option, } impl ApprovalGate { pub fn new(pre_approved: bool, progress: Progress) -> Self { let state = GateState { approved: pre_approved, pending: None, }; Self { state: Arc::new(Mutex::new(state)), progress, } } /// The draft waiting for a decision, if any. pub fn pending_draft(&self) -> Option { self.state().pending.clone() } pub fn approve(&self) { self.state().approved = true; } fn state(&self) -> MutexGuard<'_, GateState> { self.state.lock().unwrap_or_else(PoisonError::into_inner) } } #[async_trait] impl TaskNode for ApprovalGate { type Input = ArchitectureDraft; type Output = Review; type Error = Infallible; async fn evaluate( &self, _node_id: &DynNodeId, draft: &ArchitectureDraft, ) -> Result { let mut state = self.state(); if state.approved { state.pending = None; self.progress.step("review", "draft approved"); return Ok(Review::Approved(draft.clone())); } state.pending = Some(draft.clone()); self.progress.step( "review", format_args!( "draft ready ({} bytes), waiting for approval", draft.as_str().len() ), ); Ok(Review::AwaitingApproval) } } ``` A paused branch keeps its current node and that node’s input. On `resume()`, the runtime evaluates the same node again with the same input, and then runs its transition again. The gate relies on this. The first evaluation stores the draft and answers `AwaitingApproval`. After `approve()`, the second evaluation answers `Approved` with the same draft. Put the pause on a cheap node. The showcase could pause right after `render`, but then `resume()` would call the model and draft the document a second time. The gate keeps its state in `Arc>`. The `Mutex` is there because `evaluate` and `approve` take `&self`, so plain fields cannot change. The `Arc` is there because a task node must be `Clone`, and `Mutex` is not. With the `Arc`, clones of the gate share one state. The gate’s error type is `Infallible`, because waiting is an answer, not a failure. ## Resume loop The command-line interface (CLI) runs the task and loops until it completes or the user declines the draft: examples/workspace-architect/src/main.rs ```rust 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?; } } } ``` On `TaskRunOutcome::Paused`, `approve_paused_draft` (in the same file) finds the gate with `task.current_nodes::()`, shows the draft and asks for confirmation. On yes it calls `gate.approve()` and returns `true`, and the loop calls `task.resume()`. On no it returns `false`, and `draft_architecture` returns `Ok(None)` and drops the paused task. Nothing is written. This is the end of a captured run, with `claude-sonnet-5-5` as the writer. The draft between the review line and the question is trimmed: ```text [ 68.8s] review draft ready (11210 bytes), waiting for approval ... Write this ARCHITECTURE.md? [y/N] [ 68.8s] review draft approved [ 68.8s] write .../target/ARCHITECTURE.md (11210 bytes) Wrote .../target/ARCHITECTURE.md (11210 bytes) in 68.8s. ``` The `--yes` flag creates the gate already approved, so the first evaluation answers `Approved` and the task never pauses. ## Current nodes `Task::current_nodes::()` gives you the paused node, so you can read or change its state before you resume. This helper delivers the CI result from the next section through the task: ```rust /// Delivers a CI result through the task, for callers that hold the task instead of the node. pub fn deliver_through_task(task: &Task, result: &str) -> anyhow::Result<()> { let CurrentNodes::One(ci) = task.current_nodes::() else { anyhow::bail!("the task is not waiting on exactly one CI result"); }; ci.deliver(result); Ok(()) } ``` The method returns the current nodes of type `T` as a `CurrentNodes`: * `CurrentNodes::None` when no current node has that type. * `CurrentNodes::One(&T)` for exactly one. * `CurrentNodes::Multiple(Vec<&T>)` for more than one. Paused branches take precedence. The method returns runnable nodes only when no branch is paused. In a parallel fan-out several branches can pause on the same node type, so handle `Multiple` there. The references point at the registered node instances. ## External callbacks The same pattern waits for another system, such as a continuous integration (CI) webhook. The node reads a slot that the webhook handler fills, and the transition pauses while the slot is empty: ```rust /// Waits for a CI result that a webhook delivers from outside the task. #[derive(Debug, Clone, Default)] pub struct AwaitCi { result: Arc>>, } impl AwaitCi { /// Called by the webhook handler. Clones share the slot, so any clone can deliver. pub fn deliver(&self, result: impl Into) { let mut slot = self.result.lock().unwrap_or_else(PoisonError::into_inner); *slot = Some(result.into()); } } #[async_trait] impl TaskNode for AwaitCi { type Input = String; type Output = CiStatus; type Error = Infallible; async fn evaluate( &self, _node_id: &DynNodeId, commit: &String, ) -> Result { let slot = self.result.lock().unwrap_or_else(PoisonError::into_inner); Ok(match slot.as_deref() { Some(result) => CiStatus::Finished(format!("{commit}: {result}")), None => CiStatus::Pending, }) } } pub async fn wait_for_ci() -> anyhow::Result { let ci = AwaitCi::default(); let mut task = Task::::new(); let push = task .register_node_fn(|commit: &String| -> Result { Ok(commit.clone()) }); let wait = task.register_node(ci.clone()); task.starts_with(push); task.register_transition(push, move |commit| wait.transitions_with(commit))?; task.register_transition(wait, { let finish = task.transitions_to_finish(); move |status| match status { CiStatus::Finished(report) => finish(report), CiStatus::Pending => Transition::pause(), } })?; assert_eq!(task.run("4f2a91c").await?, TaskRunOutcome::Paused); // One task value holds one run: a second `run` while paused is refused. assert!(matches!( task.run("77b0e3d").await, Err(TaskError::TaskActive) )); // Later, the webhook handler that holds `ci` receives the result, // and the same task value continues. ci.deliver("all checks passed"); let TaskRunOutcome::Completed(report) = task.resume().await? else { anyhow::bail!("the CI result was delivered, so the task should finish"); }; ``` The excerpt registers `ci.clone()` and keeps `ci` itself. In a server, `ci` goes to the webhook handler, and the comment marks where that handler runs. The clone shares the `Arc`, so `deliver` fills the slot that the registered node reads. A handler that holds the task instead can use `deliver_through_task` from the previous section. Keep the same `Task` value alive between the pause and the resume. The runtime state is not serializable. If the process restarts, the paused run is gone, so store what you need to start again, such as the commit. In a server, keep paused tasks in a map keyed by job. One task value holds one run. While the task has paused or queued work, `run` returns `TaskError::TaskActive`. Build a new task for the next job, for example with a function like the showcase’s `architect_task`. ## Reruns after reset `reset()` discards the current run and queues a new one at the start node, with the last input passed to `run`. A `resume()` after it runs that queued run: ```rust let mut task = Task::::new(); let double = task.register_node_fn(|n: &i32| -> Result { Ok(n * 2) }); task.starts_with(double); task.register_transition(double, task.transitions_to_finish())?; assert_eq!(task.run(21).await?, TaskRunOutcome::Completed(42)); // A completed run leaves nothing to continue. assert!(matches!(task.resume().await, Err(TaskError::NotResumable))); // `reset` queues a new run at the start node with the last input. task.reset(); // That queued run counts as active work, so `run` with another input is refused. assert!(matches!(task.run(5).await, Err(TaskError::TaskActive))); assert_eq!(task.resume().await?, TaskRunOutcome::Completed(42)); // A clone has the same graph and a fresh runtime of its own. let mut copy = task.clone(); assert_eq!(copy.run(5).await?, TaskRunOutcome::Completed(10)); ``` * After a completed run there is nothing to continue, so `resume()` returns `TaskError::NotResumable`. * `reset()` also discards a paused run. Use it to start over with the same input instead of continuing. * The queued run counts as active work, so `run(other_input)` after `reset()` returns `TaskError::TaskActive`. To start over with a different input, use `task.clone()`. Calling `starts_with` again also clears the runtime state. * `reset()` before the first `run` has no input to queue. The `resume()` that follows returns `TaskError::NotResumable`. * A failed run leaves no state to resume. `reset()` and `resume()` retry it with the same input. * `task.clone()` copies the graph with a fresh runtime, without the paused run. Node state behind an `Arc` is shared between the copies. ## Pauses in parallel branches A pause stops the whole task, not only its branch. When a branch asks to pause, the runtime starts no new branches. Parallel branches that are already running finish their current node and transition, and their next steps wait in the queue. Then `run` returns `Paused`, unless one of those running branches reaches the finish transition. In that case `run` returns `Completed` and the paused branch is discarded. If a running branch fails after another branch asked to pause, `run` returns that error. The paused run is lost, and `resume()` has nothing to continue. `resume()` puts the paused branches back in the queue in the order they were created and continues with all queued work. A join runs only when every branch has arrived, so a paused branch also holds back its join. [Fan-out and join](/tasks/fan-out-and-join/#join-node) covers branches and joins. ## Reference * [`Transition::pause`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Transition.html#method.pause) * [`MarkedTransition`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.MarkedTransition.html) * [`Task::run`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Task.html#method.run) * [`Task::resume`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Task.html#method.resume) * [`Task::reset`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Task.html#method.reset) * [`Task::current_nodes`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Task.html#method.current_nodes) * [`CurrentNodes`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.CurrentNodes.html) * [`TaskRunOutcome`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.TaskRunOutcome.html) * [`TaskError`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.TaskError.html) * [`TaskNode`](https://docs.rs/swiftide/0.34/swiftide/tasks/trait.TaskNode.html) ## Next [Agents as task nodes](/tasks/agents-as-nodes/)