4 releases (breaking)
Uses new Rust 2024
| 0.4.0 | Feb 8, 2026 |
|---|---|
| 0.3.0 | Feb 8, 2026 |
| 0.2.0 | Feb 8, 2026 |
| 0.1.0 | Feb 7, 2026 |
#1209 in Template engine
Used in 3 crates
(2 directly)
690KB
16K
SLoC
saorsa-agent
Agent runtime with tool execution, session management, context engineering, and extension system for building AI coding agents.
Overview
saorsa-agent provides the runtime for AI agents that can execute tools, manage sessions, and integrate with terminal UIs. It builds on saorsa-ai for LLM communication and adds:
- Agent loop - Turn-based conversation with streaming, tool execution, and automatic continuation
- 7 built-in tools - bash, read, write, edit, grep, find, ls
- Session management - Tree-structured sessions with branching, forking, auto-save, and resume
- Context engineering - AGENTS.md/SYSTEM.md discovery, context compaction, merge strategies
- Skills system - On-demand capability injection from markdown files
- Templates - Prompt templates with variable substitution and conditionals
- Extension system - Lifecycle hooks, custom tools, commands, keybindings, and widgets
- Event system - Typed events for UI integration (text deltas, tool calls, turn lifecycle)
Quick Start
[dependencies]
saorsa-agent = "0.1"
saorsa-ai = "0.1"
tokio = { version = "1", features = ["full"] }
Note: saorsa-agent is provider-agnostic. Any Box<dyn saorsa_ai::StreamingProvider> works,
including in-process providers like saorsa_ai::MistralrsProvider (feature-gated behind
saorsa-ai's mistralrs feature).
Running the Agent Loop
use saorsa_agent::{AgentConfig, AgentLoop, default_tools, event_channel};
use saorsa_ai::{ProviderConfig, ProviderKind, ProviderRegistry};
#[tokio::main]
async fn main() -> saorsa_agent::Result<()> {
// Create the LLM provider
let config = ProviderConfig::new(
ProviderKind::Anthropic,
std::env::var("ANTHROPIC_API_KEY").expect("set ANTHROPIC_API_KEY"),
"claude-sonnet-4",
);
let registry = ProviderRegistry::default();
let provider = registry.create(config)?;
// Set up agent
let agent_config = AgentConfig::default();
let tools = default_tools(std::env::current_dir()?);
let (tx, mut rx) = event_channel(64);
let mut agent = AgentLoop::new(provider, agent_config, tools, tx);
// Consume events in a background task
tokio::spawn(async move {
while let Some(event) = rx.recv().await {
match event {
saorsa_agent::AgentEvent::TextDelta { text } => {
print!("{text}");
}
saorsa_agent::AgentEvent::ToolCall { name, .. } => {
eprintln!("[calling {name}...]");
}
_ => {}
}
}
});
// Run the agent
let response = agent.run("List the files in the current directory").await?;
println!("\nFinal: {response}");
Ok(())
}
Agent Loop
The AgentLoop is the core runtime. It sends messages to an LLM, streams responses, executes tool calls, and loops until the model stops or the turn limit is reached.
Turn Lifecycle
- TurnStart - Begin a new turn
- Stream response - Receive text deltas and tool call fragments
- TextComplete - Full text assembled
- Tool execution - If
StopReason::ToolUse, execute tools and add results to history - TurnEnd - Turn complete, loop if more tools needed
Configuration
use saorsa_agent::AgentConfig;
let config = AgentConfig::new("claude-sonnet-4")
.system_prompt("You are a helpful coding assistant.")
.max_turns(10) // Maximum tool-use turns per run()
.max_tokens(4096); // Max output tokens per completion
Defaults:
| Setting | Default |
|---|---|
model |
claude-sonnet-4-5-20250929 |
system_prompt |
"You are a helpful assistant." |
max_turns |
10 |
max_tokens |
4096 |
Built-in Tools
Tool Trait
All tools implement the async Tool trait:
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn input_schema(&self) -> serde_json::Value;
async fn execute(&self, input: serde_json::Value) -> Result<String>;
}
Tool Registry
use saorsa_agent::{ToolRegistry, default_tools};
// Create registry with all 7 built-in tools
let tools = default_tools("/path/to/working/dir");
assert_eq!(tools.len(), 7);
// Or build a custom registry
let mut registry = ToolRegistry::new();
registry.register(Box::new(my_custom_tool));
Bash Tool
Execute shell commands with timeout and output limits.
{ "command": "cargo test", "working_directory": "/project", "timeout_ms": 60000 }
| Limit | Value |
|---|---|
| Default timeout | 120 seconds |
| Max output | 100 KB |
| Shell | /bin/bash -c |
Captures both stdout and stderr. Output is truncated at safe UTF-8 boundaries if it exceeds the limit.
Read Tool
Read file contents with optional line ranges.
{ "file_path": "src/main.rs", "line_range": "10-20" }
| Feature | Detail |
|---|---|
| Line ranges | 10-20, 5- (from line 5), -10 (first 10 lines) |
| Max file size | 10 MB |
| Line numbers | Output includes N: content format |
Write Tool
Write content to files with automatic directory creation and diff display.
{ "file_path": "src/new_file.rs", "content": "fn main() {}" }
Creates parent directories automatically. Shows a unified diff when updating existing files. Reports "No changes" if content is identical.
Edit Tool
Surgical text replacement with ambiguity detection.
{ "file_path": "src/lib.rs", "old_text": "fn old_name()", "new_text": "fn new_name()", "replace_all": false }
| Behavior | Detail |
|---|---|
| Single match | Replaces the one occurrence |
| Multiple matches | Returns error with match count unless replace_all: true |
| No match | Returns error with the search text |
Grep Tool
Search file contents with regex patterns.
{ "pattern": "fn\\s+\\w+", "path": "src/", "case_insensitive": false }
| Feature | Detail |
|---|---|
| Pattern | Rust regex syntax |
| Scope | Recursive directory search |
| Output | file:line: content format |
| Limit | 100 matches max |
Find Tool
Find files by glob pattern.
{ "pattern": "*.rs", "path": "src/" }
| Feature | Detail |
|---|---|
| Pattern | Glob syntax (*.rs, test_?.log, **/*.toml) |
| Limit | 100 files max |
Ls Tool
List directory contents with metadata.
{ "path": "src/", "recursive": true }
| Feature | Detail |
|---|---|
| Output format | TYPE SIZE NAME per entry |
| Types | FILE, DIR, LNK |
| Size format | Human-readable (B, KB, MB, GB) |
Event System
The agent emits typed events for UI integration:
pub enum AgentEvent {
TurnStart { turn: u32 },
TextDelta { text: String },
TextComplete { text: String },
ToolCall { id: String, name: String, input: serde_json::Value },
ToolResult { id: String, name: String, output: String, success: bool },
TurnEnd { turn: u32, reason: TurnEndReason },
Error { message: String },
}
pub enum TurnEndReason {
EndTurn, // Model finished naturally
ToolUse, // Tools executed, continuing
MaxTurns, // Turn limit reached
MaxTokens, // Token limit reached
Error, // Error occurred
}
Events are delivered via a tokio mpsc channel:
let (tx, mut rx) = event_channel(64);
let mut agent = AgentLoop::new(provider, config, tools, tx);
// UI task reads events
while let Some(event) = rx.recv().await {
match event {
AgentEvent::TextDelta { text } => { /* stream to display */ }
AgentEvent::ToolCall { name, input, .. } => { /* show tool activity */ }
AgentEvent::ToolResult { success, .. } => { /* show result status */ }
AgentEvent::TurnEnd { reason, .. } => { /* update UI state */ }
_ => {}
}
}
Session Management
Session Storage
Sessions are persisted to disk in a structured format:
~/.saorsa/sessions/
<session-uuid>/
manifest.json # SessionMetadata (title, tags, timestamps)
tree.json # SessionNode (parent/child relationships)
messages/
0-user.json # Chronological message files
1-assistant.json
2-tool_call.json
3-tool_result.json
use saorsa_agent::{SessionId, SessionMetadata, SessionStorage};
let storage = SessionStorage::new()?;
let id = SessionId::new();
// Save/load metadata
storage.save_manifest(&id, &metadata)?;
let metadata = storage.load_manifest(&id)?;
// Save/load messages
storage.save_message(&id, 0, &message)?;
let messages = storage.load_messages(&id)?;
Tree-Structured Sessions
Sessions form a tree: forking creates a child session that shares history up to the fork point.
use saorsa_agent::{fork_session, build_session_tree, render_tree, TreeRenderOptions};
// Fork from an existing session
let child_id = fork_session(&storage, &parent_id)?;
// Build and render the session tree
let tree = build_session_tree(&storage)?;
let output = render_tree(&tree, &TreeRenderOptions::default());
println!("{output}");
Resume & Find
use saorsa_agent::{find_last_active_session, find_session_by_prefix, restore_session};
// Resume the most recent session
let id = find_last_active_session(&storage)?;
let messages = restore_session(&storage, &id)?;
// Find by 8-character prefix
let id = find_session_by_prefix(&storage, "a1b2c3d4")?;
Auto-Save
Sessions auto-save with debouncing and atomic writes (temp file + rename):
// Auto-fork when editing a message mid-conversation
let forked = auto_fork_on_edit(&storage, &session_id, edit_index)?;
Bookmarks
use saorsa_agent::{Bookmark, BookmarkManager};
let mut bookmarks = BookmarkManager::new(&storage);
bookmarks.add(Bookmark::new(session_id, "Important conversation"))?;
Export
use saorsa_agent::export_to_html;
let html = export_to_html(&storage, &session_id)?;
std::fs::write("session.html", html)?;
Context Engineering
AGENTS.md / SYSTEM.md Discovery
The agent searches for context files in precedence order:
- Current working directory (highest precedence)
- Parent directories (walking up to root/home)
~/.saorsa/(global, lowest precedence)
use saorsa_agent::ContextDiscovery;
let discovery = ContextDiscovery::new()?;
// Find all AGENTS.md files (highest precedence first)
let agents_files = discovery.discover_agents_md();
// Find all SYSTEM.md files
let system_files = discovery.discover_system_md();
Context Bundle
Combine discovered context into a single bundle:
use saorsa_agent::ContextBundle;
let context = ContextBundle::builder()
.agents(agents_context) // From AGENTS.md
.system(system_context) // From SYSTEM.md
.user("Additional context") // Ad-hoc context
.build();
SYSTEM.md Modes
| Mode | Behavior |
|---|---|
SystemMode::Replace |
Replace the default system prompt entirely |
SystemMode::Append |
Append after the default system prompt (default) |
Context Compaction
When conversations approach the context window limit:
use saorsa_agent::{CompactionConfig, CompactionStrategy, compact};
let config = CompactionConfig::default();
let compacted = compact(&messages, &config)?;
Skills System
Skills inject specialized knowledge on demand from markdown files:
use saorsa_agent::SkillRegistry;
// Discover skills from ~/.saorsa/skills/
let skills = SkillRegistry::discover_skills();
for skill in &skills {
println!("{}: {}", skill.name, skill.description);
}
Skill files are markdown with front matter for metadata (name, description, trigger keywords).
Templates
Prompt templates with variable substitution:
use saorsa_agent::{TemplateEngine, render_simple};
use std::collections::HashMap;
// Simple variable substitution
let mut ctx = HashMap::new();
ctx.insert("name".to_string(), "Alice".to_string());
ctx.insert("model".to_string(), "claude-sonnet-4".to_string());
let result = render_simple("Hello {{name}}, using {{model}}!", &ctx)?;
// "Hello Alice, using claude-sonnet-4!"
Template syntax:
- Variables:
{{name}} - Conditionals:
{{#if var}}...{{/if}} - Negated:
{{#unless var}}...{{/unless}}
Built-in templates are available via get_builtin() and list_builtins(). User templates are loaded from ~/.saorsa/templates/*.md.
Extension System
Extensions add custom functionality via lifecycle hooks:
use saorsa_agent::Extension;
pub trait Extension: Send + Sync {
fn name(&self) -> &str;
fn version(&self) -> &str;
fn on_load(&mut self) -> Result<()>;
fn on_unload(&mut self) -> Result<()>;
fn on_tool_call(&mut self, tool: &str, args: &str) -> Result<Option<String>>;
fn on_message(&mut self, message: &str) -> Result<Option<String>>;
fn on_turn_start(&mut self) -> Result<()>;
fn on_turn_end(&mut self) -> Result<()>;
}
Extension Registry
use saorsa_agent::{ExtensionRegistry, shared_registry};
// Thread-safe shared registry
let registry = shared_registry();
// Register an extension
{
let mut reg = registry.write().unwrap();
reg.register(Box::new(my_extension))?;
}
// Notify all extensions of events
{
let mut reg = registry.write().unwrap();
reg.notify_turn_start()?;
let responses = reg.notify_tool_call("bash", "{\"command\": \"ls\"}")?;
reg.notify_turn_end()?;
}
Specialized Registries
| Registry | Purpose |
|---|---|
CommandRegistry |
Custom slash commands |
KeybindingRegistry |
Custom keyboard shortcuts |
ExtensionToolRegistry |
Custom agent tools |
WidgetRegistry |
Custom UI widgets |
Extensions are loaded from ~/.saorsa/extensions/.
Custom Tools
Implement the Tool trait to add your own tools:
use saorsa_agent::Tool;
use async_trait::async_trait;
struct MyTool;
#[async_trait]
impl Tool for MyTool {
fn name(&self) -> &str { "my_tool" }
fn description(&self) -> &str {
"Does something useful"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "The query" }
},
"required": ["query"]
})
}
async fn execute(&self, input: serde_json::Value) -> saorsa_agent::Result<String> {
let query = input["query"].as_str().unwrap_or("");
Ok(format!("Result for: {query}"))
}
}
// Register it
let mut registry = saorsa_agent::ToolRegistry::new();
registry.register(Box::new(MyTool));
Error Handling
pub enum SaorsaAgentError {
Tool(String), // Tool execution error
Session(String), // Session storage error
Context(String), // Context engineering error
Provider(SaorsaAiError), // LLM provider error (from saorsa-ai)
Cancelled(String), // Operation cancelled
Io(std::io::Error), // File I/O error
Json(serde_json::Error), // Serialization error
Internal(String), // Internal error
Extension(String), // Extension error
}
Dependencies
| Crate | Purpose |
|---|---|
saorsa-ai |
LLM provider abstraction |
tokio |
Async runtime |
async-trait |
Async trait support |
serde / serde_json |
Serialization |
uuid |
Session IDs |
chrono |
Timestamps |
similar |
Unified diffs (edit/write tools) |
regex |
Grep tool patterns |
walkdir |
Recursive directory traversal |
globset |
Glob pattern matching (find tool) |
dirs |
User directory paths |
tracing |
Structured logging |
thiserror |
Error type derivation |
Development
# Run all tests
cargo test -p saorsa-agent
# Run integration tests
cargo test -p saorsa-agent --test tool_integration
cargo test -p saorsa-agent --test integration_tools
Minimum Supported Rust Version
The MSRV is 1.88 (Rust Edition 2024). This is enforced in CI.
License
Licensed under either of:
at your option.
Contributing
Part of the saorsa-tui workspace. See the workspace root for contribution guidelines.
Dependencies
~16–51MB
~723K SLoC