Agents as task nodes
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:
/// 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<ToolOutput, ToolError> { 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<dyn ToolExecutor> = 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::<Prompt, ()>::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(())}Source: snippets/src/tasks/agents_as_nodes.rs#L15-L60
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<tokio::sync::Mutex<Agent>>. Branches that share one TaskAgent run one at a time and add to the same message history.
The swiftide-agents feature flag turns on swiftide-tasks as well:
cargo add swiftide@0.34 --features swiftide-agents,openaiThe custom node below also needs serde and schemars 1 (the version swiftide re-exports as swiftide::reexports::schemars), plus thiserror for its error type:
cargo add serde --features derivecargo add schemars@1 --features derivecargo add serde_json thiserrorCustom agent node
Section titled “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:
/// 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<L> { llm: L, progress: Progress,}
impl<L: ChatCompletion + Clone + 'static> CrateAnalyst<L> { pub fn new(llm: L, progress: Progress) -> Self { Self { llm, progress } }}Source: examples/workspace-architect/src/analyst.rs#L55-L70
The TaskNode implementation builds an agent, queries it and reads what the agent passed to stop:
/// 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<L: ChatCompletion + Clone + 'static> TaskNode for CrateAnalyst<L> { type Input = WorkspaceCrate; type Output = CrateSummary; type Error = AnalystError;
async fn evaluate( &self, _node_id: &DynNodeId<Self>, member: &WorkspaceCrate, ) -> Result<CrateSummary, AnalystError> { self.analyse(member).await.map_err(|cause| AnalystError { crate_name: member.name.clone(), cause, }) }}
impl<L: ChatCompletion + Clone + 'static> CrateAnalyst<L> { async fn analyse(&self, member: &WorkspaceCrate) -> Result<CrateSummary, AnalysisFailure> { 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<CrateReport, ReportProblem> { match agent.stop_reason() { Some(StopReason::RequestedByTool(_, Some(arguments))) => { Ok(CrateReport::deserialize(arguments)?) } other => Err(ReportProblem::Missing { stop_reason: other.cloned(), }), }}Source: examples/workspace-architect/src/analyst.rs#L138-L194
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
Section titled “Stop schema”The agent’s stop tool requires a CrateReport:
/// 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<String>, /// One entry per workspace crate listed as a dependency in the brief. Empty if the brief /// lists none. pub depends_on: Vec<DependencyUsage>, /// 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))}Source: examples/workspace-architect/src/summary.rs#L13-L48
summary_stop_tool replaces the default stop tool with one whose arguments must match CrateReport. Stopping and structured results explains how the payload reaches StopReason::RequestedByTool.
One agent per input
Section titled “One agent per input”agent_for builds a fresh agent for every evaluation:
fn agent_for( &self, member: &WorkspaceCrate, analysis: Analysis,) -> Result<Agent, AgentBuilderError> { // Every command the tools run executes inside the crate directory. let executor: Arc<dyn ToolExecutor> = Arc::new(LocalExecutor::new(&member.dir)); let tools: [Box<dyn Tool>; 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()}Source: examples/workspace-architect/src/analyst.rs#L75-L105
Each agent gets a LocalExecutor 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 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 shows.
Corrections
Section titled “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 shows the captured retries.
Shared agent
Section titled “Shared agent”examples/tasks.rs keeps one agent for the life of the node instead:
/// 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<Mutex<Agent>>,}
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<String>,}
37 collapsed lines
#[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<AgentError> for BriefingAgentError { fn from(value: AgentError) -> Self { Self::Agent(value) }}
impl From<serde_json::Error> 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<Input = Self::Input, Output = Self::Output, Error = Self::Error>, >, brief: &Self::Input, ) -> Result<Self::Output, Self::Error> { // 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())?) }}Source: examples/tasks.rs#L25-L126
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
Section titled “Gotchas”- Every call to
querystarts thelimitcount from zero. With corrections, one crate in the showcase can use up to(1 + MAX_CORRECTIONS) * MAX_TURNScompletions. - An agent that reaches its limit stops with
StopReason::NoNewMessages, not with a stop payload. The payload itself is anOption<Value>, so handleNonetoo. - Call
without_default_stop_tool()before.tools(...). Called after it, or without any.tools(...)call, the defaultstopstays. Tools are unique by name, so your ownstoppassed later is dropped and the agent stops withRequestedByTool(_, None). build()returns anAgentBuilderError, andqueryreturns anAgentError. Wrap both in one error type that names the input.AnalystErrorprints “analysing{crate_name}failed: …”, so a failed branch says which crate it was working on.