<h1 align="center">tiny-agent</h1>
<p align="center">
<strong>A composable LLM agent runtime for Rust.</strong><br>
可中断、可恢复、可观测、可插拔的 agent 运行时。
</p>
<p align="center">
<a href="https://crates.io/crates/tiny-agent"><img src="https://img.shields.io/crates/v/tiny-agent.svg" alt="Crates.io"></a>
<a href="https://docs.rs/tiny-agent"><img src="https://docs.rs/tiny-agent/badge.svg" alt="Docs.rs"></a>
<a href="#许可证"><img src="https://img.shields.io/crates/l/tiny-agent.svg" alt="License"></a>
<img src="https://img.shields.io/badge/rustc-1.85%2B-blue.svg" alt="MSRV">
</p>
---
tiny-agent 把 “调用模型 → 执行工具 → 回灌结果” 的循环建模成一个**显式状态机**,并将模型、沙箱、存储、可观测与拦截全部抽象为 trait。框架本身不绑定任何后端:用哪家模型、状态存到哪里、工具跑在何种隔离环境,皆由调用方决定。
它面向需要把 agent 嵌进真实服务的场景——进程会重启、用户会取消、工具有副作用、运行过程要审计——这些工程负担都被收敛进清晰的边界里,而不是散落在业务代码中。
```toml
[dependencies]
tiny-agent = "0.1"
```
## 工作原理
一次运行就是状态之间的转移。每个状态都携带可序列化的 `AgentState`,这是 checkpoint 与恢复能成立的基础。
```text
submit ─▶ Reasoning ─┬─▶ ToolExecuting ─▶ Reasoning 执行工具、回灌结果,继续推理
├─▶ WaitingForUser 工具请求用户输入,停车等待
└─▶ Success 模型给出最终回复(终态)
cancel ─▶ Interrupted ─▶ resume 任意安全点取消并落 checkpoint,可续跑
error ─▶ Fail 超过重试 / 迭代上限(终态)
```
## 特性
- **状态机式的 agent loop** — `Ready → Reasoning → ToolExecuting → …` 全程显式建模,每个状态都可序列化。
- **Checkpoint 与恢复** — 中断点写快照,`resume` 从断点续跑,终态自动清理。
- **协作式取消** — 传入 `CancellationToken` 即可在安全点停车;未应答的工具调用会被补齐占位结果,transcript 不留孤儿 `tool_use`。
- **后端无关** — Provider、Sandbox、三类存储、Trajectory、Middleware 全是 trait,可任意替换。
- **内置 Provider** — Anthropic 与 OpenAI 兼容端点(DeepSeek、OpenAI 及各类兼容服务),均为流式。
- **沙箱抽象** — 触达外部世界的工具统一经 `Sandbox::execute`,自带 `HostSandbox`,换隔离方案不改工具代码。
- **类型安全的工具** — 参数 struct 经 `schemars` 自动生成 JSON Schema;只读工具之间自动并发调度。
- **Skills** — 按 `SKILL.md` 约定从目录发现技能,模型按需加载,不必把说明一次性塞满上下文。
- **中间件** — 洋葱模型的生命周期钩子,可改写请求/消息/工具结果,或在工具执行前短路。
- **可观测** — Trajectory 事件流记录运行全过程,用于实时推送、日志、回放与评测。
- **子 Agent** — `spawn_agent` 把子任务委托给独立配置的子 agent。
- **健壮性** — 区分可重试与永久错误,指数退避 + jitter,并限制最大连续失败与迭代次数。
## 快速开始
`AgentRunTime` 需要五样必填依赖:一个 **Provider**、一个 **model** 名、两类**存储**(checkpoint 与 transcript)、一个 **sandbox**。Sandbox 框架自带 `HostSandbox`;存储 trait 由你按后端实现(内存、文件、SQLite、Redis……)。
下面用内存存储跑通一轮完整的 agent loop,完整版见 [`src/main.rs`](src/main.rs):
```rust
use std::{collections::HashMap, sync::{Arc, Mutex}};
use async_trait::async_trait;
use tiny_agent::{
Agent, AgentRunTimeBuilder, StorageError,
checkpoint::CheckpointStorage,
providers::openai_compatible::OpenAiCompatibleProvider,
sandbox::HostSandbox,
tools::{ToolRegistry, register_default_tools},
transcript::{Transcript, TranscriptStorage},
};
use tokio_util::sync::CancellationToken;
// 最小内存存储:同时实现 checkpoint 与 transcript 两个 trait。
// Agent 没有 Clone 但可序列化,所以 checkpoint 按 JSON 字符串存。
#[derive(Default)]
struct MemoryStore {
checkpoints: Mutex<HashMap<String, String>>,
transcripts: Mutex<HashMap<String, Transcript>>,
}
#[async_trait]
impl CheckpointStorage for MemoryStore {
async fn save_checkpoint(&self, sid: &str, agent: &Agent) -> Result<(), StorageError> {
let json = serde_json::to_string(agent).map_err(|e| StorageError::serde(e.to_string()))?;
self.checkpoints.lock().unwrap().insert(sid.into(), json);
Ok(())
}
async fn get_checkpoint(&self, sid: &str) -> Result<Option<Agent>, StorageError> {
match self.checkpoints.lock().unwrap().get(sid).cloned() {
Some(j) => Ok(Some(serde_json::from_str(&j).map_err(|e| StorageError::serde(e.to_string()))?)),
None => Ok(None),
}
}
async fn delete_checkpoint(&self, sid: &str) -> Result<(), StorageError> {
self.checkpoints.lock().unwrap().remove(sid);
Ok(())
}
}
#[async_trait]
impl TranscriptStorage for MemoryStore {
async fn save_transcript(&self, sid: &str, t: &Transcript) -> Result<(), StorageError> {
self.transcripts.lock().unwrap().insert(sid.into(), t.clone());
Ok(())
}
async fn get_transcript(&self, sid: &str) -> Result<Option<Transcript>, StorageError> {
Ok(self.transcripts.lock().unwrap().get(sid).cloned())
}
async fn delete_transcript(&self, sid: &str) -> Result<(), StorageError> {
self.transcripts.lock().unwrap().remove(sid);
Ok(())
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let provider = Arc::new(OpenAiCompatibleProvider::new(
std::env::var("LLM_API_KEY")?,
"https://api.deepseek.com/v1".to_string(),
));
let store = Arc::new(MemoryStore::default());
let mut tools = ToolRegistry::new();
register_default_tools(&mut tools); // read_file / write_file / bash / askFollowupQuestion
let runtime = AgentRunTimeBuilder::new()
.provider(provider)
.model("deepseek-chat")
.checkpoint_storage(store.clone())
.transcript_storage(store.clone())
.sandbox(Arc::new(HostSandbox::new()))
.tools(tools)
.system_prompt("你是一个简洁高效的助手。")
.build()?;
let session_id = runtime.create_session().await?;
let ready = runtime.submit(&session_id, "用 bash 执行 echo hello 并总结").await?;
if let Agent::Success(state) = runtime.run(ready, CancellationToken::new()).await? {
println!("{}", state.final_resp.unwrap_or_default());
}
Ok(())
}
```
```bash
# 默认指向 DeepSeek,可用 LLM_BASE_URL / LLM_MODEL 覆盖
LLM_API_KEY=sk-xxx cargo run -- "用 bash 执行 echo hello,然后用一句话总结"
```
> `HostSandbox` 会在**本机**直接执行 bash 命令,仅适合本地试跑;隔离场景请自行实现 `Sandbox`。
## 两种构造方式
上面的 `AgentRunTimeBuilder` 是链式写法。等价地,你也可以先组一个 `AgentRuntimeConfig`,再用 `AgentRunTime::from_config` 构造——当配置来自外部(反序列化、依赖注入、需要在多个 runtime / 子 agent 间复用同一份配置)时更顺手。
```rust
use tiny_agent::{AgentRunTime, AgentRuntimeConfig};
// 必填项走构造函数:provider / model / checkpoint / transcript / sandbox
let mut config = AgentRuntimeConfig::new(
provider,
"deepseek-chat",
store.clone(), // checkpoint_storage
store.clone(), // transcript_storage
sandbox.clone(),
)
.with_tools(tools)
.with_system_prompt("你是一个简洁高效的助手。")
.with_middleware(Arc::new(Guardrail))
.with_trajectory(sink);
// 其余参数是公开字段,按需直接设置
config.temperature = Some(0.2);
config.max_iter = 20;
config.skill_dirs.push("./skills".into());
let runtime = AgentRunTime::from_config(config)?;
```
`AgentRunTimeBuilder` 内部也只是收集这些字段后调 `from_config`,两者完全等价,按喜好选用。
## 核心概念
### Agent 状态机
| `Ready` | 准备进入下一轮推理 |
| `Reasoning` | 正在调用模型 |
| `ToolExecuting` | 正在执行模型请求的工具 |
| `WaitingForUser` | 工具要求用户交互(如追问),已停车 |
| `Success` / `Fail` | 终态,运行结束清理 checkpoint |
| `Interrupted` | 被取消,已写快照,可 `resume` |
驱动入口:
| `create_session()` | 新建会话 |
| `submit(session_id, text)` | 追加一条用户消息,返回 `Ready` |
| `run(agent, token)` | 把状态机跑到停车点 |
| `resume(session_id, token)` | 从中断的 checkpoint 续跑 |
| `run_session(session_id, token)` | 一站式:有断点就恢复,否则从头跑 |
### 可插拔的 trait
| `LlmProvider` | 流式 chat completion | `AnthropicProvider`、`OpenAiCompatibleProvider` |
| `Sandbox` | 工具执行环境(按会话 open / execute / save / fork) | `HostSandbox` |
| `CheckpointStorage` | 可恢复状态快照的存取 | 自行实现 |
| `TranscriptStorage` | 喂给模型的消息历史 | 自行实现 |
| `TrajectorySink` | 只读事件流 | `NoopSink` |
| `Middleware` | 生命周期钩子(拦截 / 改写 / 短路) | 自行实现 |
### 工具
用参数 struct(实现 `Deserialize + JsonSchema`)注册,Schema 自动生成:
```rust
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct EchoArgs { text: String }
tools.register(
"echo",
"原样返回文本",
true, // read_only:只读工具之间会被并发调度;有副作用的工具必须传 false
|_sandbox, args: EchoArgs| async move {
Ok(serde_json::json!({ "echo": args.text }).into())
},
);
```
内置工具:`read_file`、`write_file`(write / replace / insert / append)、`bash`、`askFollowupQuestion`。
此外 `register_spawn_agent` 提供子 agent,`register_read_skill` 提供 skill 加载(配置 skill 目录时自动注册)。
### Skills
Skill 让模型按需加载领域知识或操作手册,约定沿用 [Agent Skills](https://www.anthropic.com/news/skills):一个目录配一个 `SKILL.md`,YAML frontmatter 写 `name` 与 `description`,正文是给模型看的说明,同目录其余文件作为可选资源。
构建时用 `skill_dir` / `skill_dirs` 指定发现目录,发现规则:
- `dir/SKILL.md` 存在 → `dir` 本身是一个 skill;
- `root/*/SKILL.md` 存在 → 每个子目录各是一个 skill;
- skill id 取目录名,`name` / `description` 取自 frontmatter。
```rust
let runtime = AgentRunTimeBuilder::new()
// ...必填项
.skill_dir("./skills")
.build()?;
```
配置了 skill 目录后,框架自动注册 `readSkill` 工具:模型先看到所有 skill 的 `name` + `description` 索引,需要时再调用 `readSkill` 拉取某个 skill 的正文或某个资源文件——按需加载,避免把全部说明一次性塞进上下文。
底层的 `SkillManager` 也可独立使用(`discover_dirs` / `list` / `read`),用于在运行时之外检视或预加载技能。
### 子 Agent(spawn_agent)
`spawn_agent` 是一个工具:让父 agent 把一段独立子任务委托给一个**单独配置**的子 agent,子 agent 跑完后把结果回传给父 agent。
子 agent 的形态由你传入的一份 `AgentRuntimeConfig` 决定——它可以用**不同的 model、system prompt、工具集、中间件**,与父 agent 互不影响。注册时把这份子配置交给 `register_spawn_agent`:
```rust
use tiny_agent::{
AgentRuntimeConfig,
tools::{ToolRegistry, register_default_tools, register_spawn_agent},
};
// 1. 子 agent 的配置:可与父 agent 完全不同的一套
let mut child_tools = ToolRegistry::new();
register_default_tools(&mut child_tools);
let child_config = AgentRuntimeConfig::new(
provider.clone(),
"deepseek-chat", // 子 agent 可用更便宜/更快的模型
store.clone(),
store.clone(),
sandbox.clone(),
)
.with_tools(child_tools)
.with_system_prompt("你是子任务执行者,直接完成被委托的任务并返回简洁结论。");
// 2. 把子配置注册成父 agent 的一个工具
let mut tools = ToolRegistry::new();
register_default_tools(&mut tools);
register_spawn_agent(&mut tools, child_config);
// 3. tools 交给父 runtime,模型即可调用 spawn_agent { task: "..." } 派生子 agent
```
几个关键点:
- 子 agent 拥有**独立的会话与 transcript**(互不污染上下文),但**共享父 agent 当前的 sandbox**——子任务里 `write_file`、`bash` 的副作用落在同一个工作环境,父 agent 后续可见。注册时配置里的 `sandbox` 会在调用时被父 sandbox 覆盖,所以那一项填什么都行。
- 工具入参只有一个 `task` 字段;返回 `child_session_id` 与子 agent 的最终结果(含失败 / 等待用户 / 中断等状态)。
- 子配置同样可挂自己的 skills、中间件、trajectory,实现「专用子 agent」。
### 中间件
钩子按洋葱模型组合:`before_*` 正序、`after_*` 逆序。可改请求、改消息、给工具结果脱敏,或在工具执行前直接短路。
```rust
#[async_trait::async_trait]
impl Middleware for Guardrail {
fn name(&self) -> &str { "guardrail" }
async fn before_tool(&self, _ctx: &MiddlewareCtx<'_>, call: &ToolCall)
-> Result<ToolDecision, String>
{
if is_dangerous(call) {
return Ok(ToolDecision::ShortCircuit {
output: vec![ContentBlock::Text { text: "blocked".into() }],
is_error: true,
});
}
Ok(ToolDecision::Proceed)
}
}
```
### 可观测:Trajectory
运行时在每个关键节点同步 `emit` 一条事件(用户消息、推理开始、assistant 消息、工具调用与结果、中断、失败……)。事件序列化为带 `type` tag 的 JSON,按发生顺序到达,适合实时推送给前端,或落盘做日志、回放与评测。不设 sink 时为 `NoopSink`。
## 配置
`AgentRunTimeBuilder` 上的可调参数:
| `max_iter` | 10 | 单次运行的最大推理轮数,超限以 `MaxIter` 失败 |
| `max_consecutive_fail_count` | 3 | 最大连续失败次数 |
| `retry_backoff` | 200ms | 可重试失败的退避基数(指数 + jitter),`ZERO` 关闭 |
| `tool_timeout` | 120s | 单个工具调用超时,`ZERO` 关闭 |
| `temperature` / `max_tokens` | 无 | 透传给 provider |
| `system_prompt` | 无 | 系统提示 |
| `skill_dir` / `skill_dirs` | 无 | skill 发现目录 |
| `middleware` | 空链 | 可多次调用追加 |
| `trajectory` | `NoopSink` | 事件 sink |
## 兼容性
需要 Rust 1.85 及以上(edition 2024)。运行时基于 [Tokio](https://tokio.rs)。
## 许可证
采用 [MIT](LICENSE-MIT) 或 [Apache-2.0](LICENSE-APACHE) 双许可,任选其一。
除非你明确声明,你有意提交并被本仓库收录的贡献,均按上述双许可授权,不附加额外条款。