Async agentic runtime for Rust. Steerable agent loops, multi-provider LLM abstraction, semantic context management, virtual filesystem, and WASM-ready architecture.
soul-core is the engine that powers autonomous agent loops. It provides everything needed to build LLM-powered agents that can reason, use tools, manage context, persist sessions, and run on native OS or in a browser via WASM.
use soul_core::agent::{AgentLoop, RunOptions};
use soul_core::provider::anthropic::AnthropicProvider;
use soul_core::tool::ToolRegistry;
use soul_core::types::*;
// Configure
let model = ModelInfo::new("claude-sonnet-4-5-20250929", ProviderKind::Anthropic);
let config = AgentConfig::new(model, "You are a helpful assistant.");
let provider = AnthropicProvider::new();
// Build
let tools = ToolRegistry::new();
let mut agent = AgentLoop::new(Arc::new(provider), tools, config);
// Run
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (steering_tx, steering_rx) = mpsc::unbounded_channel();
let options = RunOptions {
session_id: "session-1".into(),
initial_messages: vec![Message::user("Hello")],
};
let messages = agent.run(options, event_tx, steering_rx).await?;| Module | Purpose |
|---|---|
agent |
Steerable agent loop with tool execution, compaction triggers, interruption |
provider |
Multi-provider LLM abstraction (Anthropic, OpenAI) with SSE streaming |
context |
Token-aware context window management with compaction and circuit breaker |
tool |
Async tool trait and registry |
hook |
Three-tier hook pipeline: modifying, void, persist |
permission |
Rule-based permission gate with glob patterns and risk levels |
cost |
Token/USD cost tracking with budget enforcement |
mcp |
Model Context Protocol client with JSON-RPC transport |
skill |
Config-driven tool definitions loaded from .skill/.md files |
executor |
Pluggable tool execution backends (shell, HTTP, MCP, LLM) |
session |
JSONL session persistence with lane serialization |
memory |
Hierarchical memory (MEMORY.md + topic files, bootstrap files) |
vfs |
Virtual filesystem: MemoryFs (WASM/tests), NativeFs (OS) |
vexec |
Virtual executor: MockExecutor (WASM/tests), NativeExecutor (OS) |
planner |
Task graph with dependencies, status tracking, timing, and display rendering |
observation |
Cross-session observation store — L3 memory, BugFix/Feature/Discovery/Decision typing |
snapshot |
Generic versioned snapshot log — append-only JSONL history with rollback |
soullog |
Multi-sink structured logging |
subagent |
Subagent spawner for parallel task delegation |
semantic_recursion |
Context graph, TF-IDF search, symlinks, semantic fragmentation |
rlm |
Recursive Language Model engine (document search DSL) |
types |
Core types: Message, Role, ContentBlock, ToolDefinition, AgentConfig |
error |
Error types with thiserror |
The core AgentLoop runs a think-act-observe cycle: send messages to an LLM, execute any tool calls, feed results back, repeat until the LLM responds without tool calls or a budget/turn limit is hit.
- Real-time event streaming via
mpscchannels - Mid-loop steering (inject messages, interrupt)
- Automatic context compaction when approaching token limits
- Optional cost tracking and budget enforcement
All storage (sessions, memory, skills) goes through VirtualFs, a trait with two implementations:
NativeFs— real OS filesystem viatokio::fs(behindnativefeature)MemoryFs— in-memory BTreeMap (works everywhere, including WASM)
Custom implementations can target IndexedDB, S3, or anything else.
Shell commands go through VirtualExecutor:
NativeExecutor— real subprocesses viatokio::process(behindnativefeature)MockExecutor— canned responses for testingNoopExecutor— returns error (for WASM environments)
Define tools as .skill or .md files with YAML frontmatter:
---
name: search_codebase
description: Search for a pattern
input_schema:
type: object
properties:
pattern:
type: string
required: [pattern]
execution:
type: shell
command_template: "rg '{{pattern}}' --json"
timeout_secs: 30
---
Search the codebase using ripgrep.Load and register as tools:
let loader = SkillLoader::new(fs, "skills");
loader.load_all().await?;
loader.register_all_as_tools(&mut registry, executor);Three tiers of hooks modify agent behavior without changing the loop:
- Modifying hooks — run sequentially, can alter or cancel operations (permission checks, prompt injection)
- Void hooks — run in parallel, fire-and-forget (logging, metrics)
- Persist hooks — synchronous transforms on the hot path (redaction, storage)
The semantic_recursion module implements a social graph for LLM context:
- Graph nodes for every piece of context (requests, responses, tool calls, compactions)
- Symlinks replace messages with 6-char hash references that LLMs can resolve
- Semantic fragmentation splits long messages into clusters via agglomerative clustering on TF-IDF embeddings
- Token-budgeted retrieval combines keyword relevance, vector similarity, and graph neighbor expansion
Rule-based permission gate with glob patterns:
let mut manager = PermissionManager::new();
manager
.add_rule(PermissionRule::allow("read_*"))
.add_rule(PermissionRule::deny("rm_*").with_reason("Destructive"))
.classify_risk("write_*", RiskLevel::Execution);Per-turn token counting with model-specific pricing and budget enforcement:
let tracker = CostTracker::new("session-1");
let enforcer = BudgetEnforcer::new(BudgetPolicy {
hard_limit_usd: Some(5.0),
..Default::default()
});
let agent = AgentLoop::new(provider, tools, config)
.with_cost_tracker(tracker)
.with_budget(enforcer);Connect to Model Context Protocol servers and expose their tools:
let transport = StdioTransport::new("npx", &["-y", "@modelcontextprotocol/server-filesystem"]);
let mut client = McpClient::new(Box::new(transport));
client.initialize().await?;
client.register_tools(&mut registry).await?;The Anthropic provider supports both API key and OAuth token auth, auto-detected from the key prefix:
use soul_core::provider::AnthropicProvider;
use soul_core::types::AuthProfile;
// API key auth (default)
let auth = AuthProfile::new(ProviderKind::Anthropic, "sk-ant-api03-...");
// OAuth token auth (auto-detected from sk-ant-oat prefix)
let auth = AuthProfile::new(ProviderKind::Anthropic, "sk-ant-oat01-...");
// Automatically adds: Bearer auth, beta headers, metadata.user_id, tool name remapOAuth tokens trigger automatic tool name remapping (greek_nature combos) to bypass Anthropic's semantic tool name filter.
All providers work in WASM browser environments via a transparent proxy:
use soul_core::provider::{ProxyConfig, AnthropicProvider, OpenAIProvider};
// Route through a transparent proxy (e.g. for WASM browser agents)
let proxy = ProxyConfig::new("https://your-app.example.com/api");
let anthropic = AnthropicProvider::with_base_url(proxy.anthropic_url());
let openai = OpenAIProvider::with_base_url(proxy.openai_url());
// Or passthrough mode (proxy is a direct stand-in for one API)
let proxy = ProxyConfig::passthrough("http://localhost:8081");
let anthropic = AnthropicProvider::with_base_url(proxy.anthropic_url());The library compiles to WebAssembly. Use --no-default-features --features wasm:
[dependencies]
soul-core = { version = "0.7", default-features = false, features = ["wasm"] }In WASM mode:
MemoryFsreplacesNativeFsNoopExecutorreplacesNativeExecutor- LLM providers work via configurable
base_urlpointing to a transparent proxy reqwestuses fetch APIuuidusesjsfeature for browser crypto
# Native (default) — full OS support
[dependencies]
soul-core = "0.7"
# WASM — browser target
[dependencies]
soul-core = { version = "0.7", default-features = false, features = ["wasm"] }
# Minimal — no runtime, no filesystem
[dependencies]
soul-core = { version = "0.7", default-features = false }cargo test # 667 tests (647 unit + 14 integration + 6 doc)
cargo test --no-default-features # verify non-native builds
cargo clippy -- -D warnings # lint
cargo fmt --check # format check
cargo doc --no-deps # build docsMIT