# 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<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`](https://github.com/bosun-ai/swiftide-website/blob/master/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](/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:

```rust title="examples/workspace-architect/src/analyst.rs"
/// 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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/workspace-architect/src/analyst.rs#L55-L70)

The `TaskNode` implementation builds an agent, queries it and reads what the agent passed to `stop`:

```rust title="examples/workspace-architect/src/analyst.rs"
/// 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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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

The agent's stop tool requires a `CrateReport`:

```rust title="examples/workspace-architect/src/summary.rs"
/// 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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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](/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:

```rust title="examples/workspace-architect/src/analyst.rs"
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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/workspace-architect/src/analyst.rs#L75-L105)

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<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>,
}

#[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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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

- 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<Value>`, 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/)