Built-in nodes
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<dyn ..> and Box<dyn ..> 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:
cargo add swiftide@0.34 --features swiftide-tasks,swiftide-agents,openaiPrompt nodes
Section titled “Prompt nodes”A closure builds a Prompt, and the model client answers it:
let mut task: Task<String, String> = 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<dyn SimplePrompt>);
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}");}Source: snippets/src/tasks/built_in_nodes.rs#L17-L35
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<dyn SimplePrompt> and Box<dyn SimplePrompt>, 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 for building the Prompt.
One Arc<dyn SimplePrompt> can back several nodes, as in the examples/tasks.rs excerpt in Nodes and 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):
[ 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 approvalChat completion nodes
Section titled “Chat completion nodes”When a step needs a system message, a full message history or tool specs, use a ChatCompletion node instead:
let mut task: Task<String, String> = 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<dyn ChatCompletion> = 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}");}Source: snippets/src/tasks/built_in_nodes.rs#L43-L66
An Arc<dyn ChatCompletion> or Box<dyn ChatCompletion> 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<ChatMessage>) 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 covers the clients that implement ChatCompletion.
Tool executor nodes
Section titled “Tool executor nodes”A ToolExecutor node runs one command. Pair it with a closure that builds the Command:
let mut task: Task<usize, CommandOutput> = Task::new();
let command = task.register_node_fn(|count: &usize| { Ok::<_, NodeError>(Command::shell(format!("git log --oneline -n {count}")))});
let shell: Arc<dyn ToolExecutor> = 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()),}Source: snippets/src/tasks/built_in_nodes.rs#L74-L102
An Arc<dyn ToolExecutor> or Box<dyn ToolExecutor> node has Input = Command, Output = CommandOutput and Error = CommandError. It calls exec_cmd on the executor. LocalExecutor runs commands on the local machine. 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
Section titled “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:
let mut task: Task<i32, i32> = 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));Source: snippets/src/tasks/built_in_nodes.rs#L110-L120
SyncFn::new takes a closure Fn(&I) -> Result<O, E>. 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.
Custom node or built-in
Section titled “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 and Pause and resume show them.
Gotchas
Section titled “Gotchas”- A
SimplePromptnode clones itsPrompton every evaluation. Keep large values out of the template context if the node runs often. - A
ToolExecutornode runs commands in the executor’s working directory. WithLocalExecutor, a relativewith_current_dirpath 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, andCommandErroris not, so map the error to a type of your own first.