agnt
The smallest Rust agent runtime that's auditable as a single binary, structurally sandboxed against adversarial LLM output, and composable across async and sync callers without forcing a runtime choice on either.
[]
= "0.2"
Why agnt
Most Rust LLM agent libraries are thick wrappers around Python concepts: tokio everywhere, trait-object soup, opinionated orchestration frameworks. agnt is the opposite — a small, sync-first library that gives you a working agent loop, typed tool trait, and persistence layer, and gets out of your way.
agnt v0.2 |
rig-core | graniet/llm | langchain-rust | |
|---|---|---|---|---|
| LOC (library path) | ~2,200 | ~49,000 | ~20,000 | ~30,000 |
| Async runtime required | ❌ | ✅ tokio | ✅ tokio | ✅ tokio |
| WASM-capable kernel | ✅ agnt-core |
⚠ partial | ❌ | ❌ |
| Multi-backend | ✅ Ollama, OpenAI, Anthropic | ✅ 20+ | ✅ 12+ | ✅ |
| Parallel tool dispatch | ✅ thread::scope |
✅ tokio | ❌ | ❌ |
| Typed tool trait | ✅ TypedTool (macro-free) |
✅ + macro | ✅ | ⚠ |
| SQLite persistence | ✅ bundled, WAL mode | ❌ | ⚠ partial | ⚠ vector-only |
| µs tool profiling | ✅ built-in | ❌ | ❌ | ❌ |
| Structural sandbox | ✅ filesystem root + SSRF guard | ❌ | ❌ | ❌ |
| Tool output framing | ✅ <tool_output> envelope |
❌ | ❌ | ❌ |
| Lifecycle observer trait | ✅ | ⚠ OTel only | ⚠ hooks | ⚠ callbacks |
agnt is ~22× denser than the dominant Rust agent framework. The tradeoff: no tokio means no async I/O parallelism beyond what std::thread::scope gives you. For agents that spend most of their wall time waiting on LLM inference, this is a non-issue.
Quick start
use ;
use ;
The v0.2 security model
agnt v0.2 treats the LLM as an adversary. Every tool in the default set is structurally constrained:
-
Shellis off by default. Opt in viafeatures = ["tools-shell"]. The only constructor requires an explicit argv allowlist. Commands parse viashell-wordsand run directly viaCommand::new(argv[0])— neversh -c. Tokens containing$,`,|,;,&,>,<,(,)are rejected. -
Filesystem tools are sandbox-aware.
ReadFile,WriteFile,EditFile,ListDir,Glob,Grepall accept an optionalFilesystemRootthat canonicalizes paths, rejects..components, and follows symlinks before checking containment. Without a sandbox the tool docs explicitly warn about full host access. -
Fetchblocks SSRF. Scheme allowlist (http/https), DNS resolution, and rejection of any IP that is loopback / private / link-local / multicast / unspecified / broadcast / AWS IMDS / GCP metadata. HTTP redirects are disabled on the shared agent so a302 Location: http://169.254.169.254/cannot bypass the check. -
Tool outputs are framed as data. Every tool result is wrapped in a
<tool_output name="..." id="..." truncated="...">...</tool_output>envelope with 64KB cap before being persisted or fed back to the model. The system prompt should explicitly instruct the model that content inside these envelopes is data, not instructions. -
EditFileis atomic. Sidecar lockfile (target-file locking can't survive the atomic rename), re-read under lock, temp write + rename. Verified with a 4-thread × 100-round stress test. -
No panics. Every
.unwrap()/.expect()in the library path has been removed. Scoped-thread panics during tool dispatch are caught and converted to error strings. -
Secrets don't leak.
Backend::api_keyis private. ManualDebugimpl printsapi_key: <redacted>. Upstream error bodies are scrubbed ofAuthorizationandx-api-keyheaders before bubbling up.
Read the full threat model for what's covered and what isn't.
Performance posture (v0.2)
Measured (criterion, RTX 5090 host)
Agent::step end-to-end against a mock backend — this is the pure loop
overhead with zero LLM latency. In real use the ~2-second LLM inference
dominates; these numbers show the agent loop is effectively free.
| Prior history | Agent::step cost |
|---|---|
| 0 messages (cold) | ~479 ns |
| 10 messages | ~479 ns |
| 39 (borrow path, just-fits window) | ~3.4 µs |
| 40 (truncation path triggered) | ~3.5 µs |
| 100 messages | ~5.0 µs |
| 500 messages | ~13.4 µs |
| 1000 messages | ~24.2 µs |
At 1000-message history the loop costs 24 microseconds per step. A single inference turn is ~2,000,000 microseconds. The loop is 0.001% of the step cost.
Run it yourself: cargo bench -p agnt-core.
v0.1 → v0.2 improvements
| Operation | v0.1 | v0.2 | Notes |
|---|---|---|---|
Agent::step (short history) |
full clone of all messages | zero clones, borrows directly | P1 |
| HTTP request body on retry | cloned per attempt | serialized once, send_bytes(&[u8]) |
P2 |
SQLite append |
2 roundtrips, 2+N fsyncs | 1 roundtrip, 1 fsync via WAL + txn | P3 |
| SSE stream parser | String allocated per line |
single reused buffer | P4 |
| Retry backoff | deterministic 500/1000/2000ms | ±20% jitter (xorshift64*) | P5 |
| HTTP timeouts | unbounded | 10s connect / 120s read default | P6 |
Crate split
The v0.2 workspace ships five published crates:
| Crate | Purpose | Deps pulled |
|---|---|---|
agnt |
Flagship meta-crate — what you cargo add |
re-exports from the rest |
agnt-core |
Traits + types + Agent loop | serde, serde_json, tracing |
agnt-net |
HTTP backend (Ollama/OpenAI/Anthropic) | ureq, native-tls |
agnt-store |
Bundled-SQLite persistence | rusqlite/bundled |
agnt-tools |
Built-in sandboxed tools | walkdir, regex, glob, fs2, url, shell-words |
Minimal build (~1MB, WASM-compatible):
= { = "0.2", = false, = ["net"] }
Full build (same as v0.1):
= "0.2" # net + store + tools
Full build with shell (opt-in, CVE-class):
= { = "0.2", = ["tools-shell"] }
Typed tools (v0.2)
v0.2 adds a typed TypedTool trait alongside the existing erased Tool:
use ;
use ;
;
let mut reg = new;
reg.register_typed; // auto-wraps in ErasedAdapter
No from_str dance inside call; no stringification on the output path. Existing erased Tool impls keep working unchanged.
Observer hooks
Every step has five lifecycle hooks you can observe without forking the loop:
use ;
;
Attach via AgentBuilder::observer(Arc::new(AuditLog)). This is the integration point for HITL approval, NATS event publishing, OpenTelemetry spans via tracing-opentelemetry, or anything else you want to hang off the loop.
Observability
agnt emits tracing spans and events at key boundaries:
agnt.step { session, input_len }— every call toAgent::stepagnt.backend.chat { kind, model, message_count, streaming }— every LLM inference callagnt.tool { name, id }— every tool dispatch
Zero dependency on opentelemetry — use tracing-opentelemetry externally to export to Jaeger / Honeycomb / Datadog / Tempo if you want.
Roadmap
- v0.2 (current) — hardening + restructuring pass, typed tools, crate split, tracing
- v0.3 —
#[tool]proc-macro, MCP client, trust tier gating, fuzzing targets - v1.0 — API freeze
License
Dual-licensed under either:
- MIT License (LICENSE-MIT)
- Apache License, Version 2.0 (LICENSE-APACHE)
at your option.