rpi-agent 0.3.8

Agent runtime + loop, Rust port of pi-agent-core core layer
Documentation
# rpi-agent

[![crates.io](https://img.shields.io/crates/v/rpi-agent.svg)](https://crates.io/crates/rpi-agent)
[![docs.rs](https://docs.rs/rpi-agent/badge.svg)](https://docs.rs/rpi-agent)

The provider-agnostic agent runtime: the agent loop, the `AgentTool` trait,
events, hooks, queues and cancellation. This is the layer you embed when you
want to build your own agent rather than use the `rpi` CLI.

## Install

```toml
[dependencies]
rpi-agent = "0.3"
rpi-ai = "0.3"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
```

For built-in file and shell tools, add
[`rpi-tools`](https://crates.io/crates/rpi-tools). For durable sessions and
compaction, add [`rpi-harness`](https://crates.io/crates/rpi-harness).

## Design

The only LLM boundary is `StreamFn`, which returns an
`AssistantMessageEventStream` synchronously. Everything else in the loop speaks
`AgentMessage` and never touches a provider wire format, so a provider, a test
double and a recorded transcript are interchangeable.

The invariants that the loop guarantees:

- Tools may finish in any order, but tool results are emitted in the order the
  calls appeared, so the transcript stays valid for the next request.
- A `MessageEnd` is emitted exactly once per assistant message, including when a
  late streaming update arrives after the run already settled.
- Truncated or failed tool calls never leave a dangling call without a result.

## Quick start

Deterministic, offline, no API key — the `faux` provider scripts one text reply:

```rust
use std::sync::Arc;

use rpi_agent::AgentBuilder;
use rpi_ai::providers::faux::{FauxProvider, FauxScript};
use rpi_ai::Provider;

#[tokio::main]
async fn main() {
    let provider = Arc::new(FauxProvider::new(
        FauxScript::new().with_text("Hello from the faux provider!"),
    ));
    let model = provider.default_model().clone();

    let agent = AgentBuilder::new()
        .model(model)
        .system_prompt("You are a helpful assistant.")
        .stream_fn(make_stream_fn(provider))
        .build()
        .expect("agent builds");

    let mut rx = agent.subscribe();
    agent.prompt("hi").await.expect("prompt completes");

    while let Ok(ev) = rx.try_recv() {
        // AgentEvent::MessageUpdate { .. }, AgentEvent::ToolEnd { .. }, …
        let _ = ev;
    }

    println!("{} messages after the run", agent.state().messages.len());
}

fn make_stream_fn(provider: Arc<FauxProvider>) -> rpi_agent::StreamFn {
    rpi_agent::stream_fn(move |model, ctx, opts| {
        let p = Arc::clone(&provider);
        let (model, ctx, opts) = (model.clone(), ctx.clone(), opts.clone());
        tokio::task::block_in_place(|| {
            tokio::runtime::Handle::current()
                .block_on(async move { p.stream_simple(&model, &ctx, &opts).await })
        })
    })
}
```

A runnable version of this lives in
[`examples/minimal`](https://github.com/bigfish1913/pi-rust/tree/main/examples/minimal)
(`cargo run -p minimal`), and a version that drives the real filesystem through
the built-in tools lives in
[`examples/tools`](https://github.com/bigfish1913/pi-rust/tree/main/examples/tools).

## Defining a tool

Tools implement `AgentTool`. A tool owns the provider-facing schema, a display
label, and the execution function; parameters are validated against that schema
before `execute` is called:

```rust
use std::sync::Arc;

use async_trait::async_trait;
use rpi_agent::{AgentError, AgentTool, AgentToolResult, ToolResultPartial};
use rpi_ai::types::Tool;
use tokio_util::sync::CancellationToken;

struct MyTool {
    schema: Tool,
}

#[async_trait]
impl AgentTool for MyTool {
    fn schema(&self) -> &Tool {
        &self.schema
    }

    fn label(&self) -> &str {
        "My tool"
    }

    async fn execute(
        &self,
        _tool_call_id: &str,
        params: serde_json::Value,
        _signal: CancellationToken,
        on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
    ) -> Result<AgentToolResult, AgentError> {
        // Long-running tools call `on_update(partial)` to stream progress.
        let _ = on_update;
        Ok(AgentToolResult::default())
    }
}
```

`execute` receives a `CancellationToken` for cancellation and an `on_update`
callback for partial results. Calls to `on_update` after the run has settled are
dropped by the loop, so a tool never has to track settlement itself. Returning
`Err(AgentError)` turns into an error tool result, not a failed run.

For real implementations, wrap the JSON Schema derivation with `schemars` and
start from [`rpi-tools`](https://crates.io/crates/rpi-tools), which contains
complete examples of a filesystem tool and a streaming shell tool.

## Observing a run

`agent.subscribe()` returns a broadcast receiver of `AgentEvent` covering the
run (`AgentStart`/`AgentEnd`), each assistant message
(`MessageStart`/`MessageUpdate`/`MessageEnd`), and each tool
(`ToolStart`/`ToolUpdate`/`ToolEnd`). Cancellation goes through `AbortHandle`.

## Related crates

| Crate | Role |
| ----- | ---- |
| [`rpi-ai`]https://crates.io/crates/rpi-ai | Provider-agnostic message and streaming types |
| [`rpi-tools`]https://crates.io/crates/rpi-tools | `read`/`write`/`edit`/`bash` and friends |
| [`rpi-harness`]https://crates.io/crates/rpi-harness | Sessions, JSONL persistence, compaction |
| [`rpi-cli`]https://crates.io/crates/rpi-cli | The `rpi` terminal agent built on all of the above |

## Documentation

- Crate docs: <https://docs.rs/rpi-agent>
- Architecture: [docs/architecture.md]https://github.com/bigfish1913/pi-rust/blob/main/docs/architecture.md
- Project structure guide: [docs/agent-project.md]https://github.com/bigfish1913/pi-rust/blob/main/docs/agent-project.md

## License

MIT. See [LICENSE](https://github.com/bigfish1913/pi-rust/blob/main/LICENSE)
and [NOTICE](https://github.com/bigfish1913/pi-rust/blob/main/NOTICE).