<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.3"
```
## 工作原理
一次运行就是状态之间的转移。每个状态都携带可序列化的 `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、两类存储、实时消息与 Middleware 边界清晰,可任意替换。
- **内置 Provider** — Anthropic 与 OpenAI 兼容端点(DeepSeek、OpenAI 及各类兼容服务),均为流式。
- **沙箱抽象** — 触达外部世界的工具统一经 `Sandbox::execute`,自带 `HostSandbox`,换隔离方案不改工具代码。
- **类型安全的工具** — 参数 struct 经 `schemars` 自动生成 JSON Schema;只读工具之间自动并发调度。
- **Skills** — 按 `SKILL.md` 约定从目录发现技能,模型按需加载,不必把说明一次性塞满上下文。
- **中间件** — 洋葱模型的生命周期钩子,可改写请求/消息/工具结果,或在工具执行前短路。
- **可观测** — 按 OpenTelemetry GenAI 语义约定产出嵌套 span(`agent.run` / `user_input` / `gen_ai.chat` / `tool.call`),含完整输入输出、工具入参结果与 token 用量;由宿主程序决定是否接入 OTLP、日志或监控平台。
- **子 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())
}
// 只需实现 remove_transcript(删单个会话);delete_transcript 由 trait 提供,
// 默认会沿 subsession 递归级联删除整棵子会话树。
async fn remove_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 / check / kill / 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 命令,`register_default_tools` 也会注册文件读写与 shell 工具;这套组合适合本地试跑和受信任环境。对外提供服务时,请实现隔离型 `Sandbox`,并按业务白名单手动注册工具。
## 两种构造方式
上面的 `AgentRunTimeBuilder` 是链式写法。等价地,你也可以先组一个 `AgentRuntimeConfig`,再用 `AgentRunTime::from_config` 构造——当配置来自外部(反序列化、依赖注入、需要在多个 runtime / 子 agent 间复用同一份配置)时更顺手。
```rust
use std::sync::Arc;
use tiny_agent::{
AgentRunTime, AgentRuntimeConfig, AgentRuntimeConfigInput, AgentRuntimeOptions,
AgentRuntimeStorage,
middleware::MiddlewareChain,
};
let config = AgentRuntimeConfig::new(AgentRuntimeConfigInput {
provider,
model: "deepseek-chat".to_string(),
storage: AgentRuntimeStorage::shared(store.clone()),
sandbox: sandbox.clone(),
options: AgentRuntimeOptions {
tools,
system_prompt: Some("你是一个简洁高效的助手。".to_string()),
middleware: MiddlewareChain::new().with(Arc::new(Guardrail)),
temperature: Some(0.2),
max_iter: 20,
skill_dirs: vec!["./skills".into()],
..Default::default()
},
});
let runtime = AgentRunTime::from_config(config)?;
```
`AgentRunTimeBuilder` 内部也只是收集这些字段后调 `from_config`,两者完全等价,按喜好选用。
## 可选 feature
tiny-agent 默认不带文件存储和 exporter,应用可按需打开:
| Feature | 提供内容 |
| --- | --- |
| `file-storage` | `storage::FileStorage`,把 checkpoint / transcript 以 JSON 落盘 |
| `otel-tempo` | `telemetry::init_tempo_from_env` / `init_tempo`,通过 OTLP/gRPC 导出 tracing span |
```toml
tiny-agent = { version = "0.3", features = ["file-storage", "otel-tempo"] }
```
```rust
use std::sync::Arc;
use tiny_agent::{AgentRuntimeStorage, storage::FileStorage};
let store = Arc::new(FileStorage::new("./.agent_storage").await?);
let storage = AgentRuntimeStorage::shared(store);
```
## 核心概念
### Agent 状态机
| 状态 | 含义 |
| --- | --- |
| `Ready` | 准备进入下一轮推理 |
| `Reasoning` | 正在调用模型 |
| `ToolExecuting` | 正在执行模型请求的工具 |
| `WaitingForUser` | 工具要求用户交互(如追问),已停车 |
| `Success` / `Fail` | 终态,运行结束清理 checkpoint |
| `Interrupted` | 被取消,已写快照,可 `resume` |
驱动入口:
| 方法 | 作用 |
| --- | --- |
| `create_session()` | 新建会话 |
| `submit(session_id, text)` | 追加一条用户消息,返回 `Ready` |
| `run(agent, token)` | 把状态机跑到停车点 |
| `run_turn(session_id, text, token)` | 可选会话 id + 用户输入,一站式追加并跑完一轮 |
| `resume(session_id, token)` | 从中断的 checkpoint 续跑 |
| `run_session(session_id, token)` | 一站式:有断点就恢复,否则从头跑 |
```rust
// 传 None 自动新建会话;传 Some(id) 复用已有会话(缺失则按该 id 初始化)。
let turn = runtime
.run_turn(Some(existing_session_id), "继续分析这个问题", CancellationToken::new())
.await?;
println!("session id: {}", turn.session_id);
```
### 可插拔的 trait
| Trait | 职责 | 自带实现 |
| --- | --- | --- |
| `LlmProvider` | 流式 chat completion | `AnthropicProvider`、`OpenAiCompatibleProvider` |
| `Sandbox` | 工具执行环境(按会话 open / execute / save / fork) | `HostSandbox` |
| `CheckpointStorage` | 可恢复状态快照的存取 | `FileStorage`(需 `file-storage`)或自行实现 |
| `TranscriptStorage` | 喂给模型的消息历史 | `FileStorage`(需 `file-storage`)或自行实现 |
| `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`、`check`、`kill`、`askFollowupQuestion`。
此外 `register_spawn_agent` 提供子 agent,`register_read_skill` 提供 skill 加载(配置 skill 目录时自动注册)。
`register_default_tools` 是便捷入口,会一次性注册文件读写、shell、后台任务查询与追问工具。它不会替你做权限控制;生产环境通常应从 `ToolRegistry::new()` 开始,只注册当前场景允许的工具,或用 `Middleware::before_tool` 做二次拦截。也可以先注册一套全集,再用 `registry.subset(&["read_file", "bash"])` 按能力边界裁剪出某个(子)agent 可见的子集(保留各工具的 callable / schema / 异步策略);`contains` / `names` 可用于检视已注册的工具。
长耗时工具可以用结构化注册声明为 `asynchronous`:工具在 `yield_after` 窗口内完成时直接返回完整结果;超过窗口则登记为后台工具任务,立即返回 `{id, status:"running", output}`,之后由 `check` 轮询、`kill` 终止。工具执行期间可通过 `ctx.progress` 发增量状态,这些状态会实时推给 UI,也会被 `check(id)` 按增量读回。
`ToolCtx` 对外只暴露只读的 `session_id()` 与 `progress`;工具不要直接操作 runtime 的消息通道或后台任务表。
```rust
use std::time::Duration;
use tiny_agent::tools::{AsyncToolConfig, ToolSpec};
tools.register_with_ctx_tool(
ToolSpec::new("slow_report", "生成一份较慢的报告", false)
.asynchronous(AsyncToolConfig::new(Duration::from_secs(2))),
|_sandbox, args: ReportArgs, ctx| async move {
ctx.progress.emit_text("started\n");
let report = build_report(args).await?;
ctx.progress.emit_text("finished\n");
Ok(serde_json::json!({ "report": report }).into())
},
);
```
`bash` 只是内置的一个 asynchronous 工具,默认让步窗口是 2 秒。后台工具任务表存在于当前 runtime 进程内:进程重启后 checkpoint 和 transcript 可以恢复,但已经在后台运行的宿主机进程不会被 tiny-agent 重新挂接,旧的任务 id 也会失效。
### 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::{
AgentRunTimeBuilder, AgentRuntimeConfig, AgentRuntimeConfigInput, AgentRuntimeOptions,
AgentRuntimeStorage,
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(AgentRuntimeConfigInput {
provider: provider.clone(),
model: "deepseek-chat".to_string(), // 子 agent 可用更便宜/更快的模型
storage: AgentRuntimeStorage::shared(store.clone()),
sandbox: sandbox.clone(),
options: AgentRuntimeOptions {
tools: child_tools,
system_prompt: Some("你是子任务执行者,直接完成被委托的任务并返回简洁结论。".to_string()),
..Default::default()
},
});
// 2. 父 agent 的工具集:默认工具 + spawn_agent
let mut parent_tools = ToolRegistry::new();
register_default_tools(&mut parent_tools);
register_spawn_agent(&mut parent_tools, child_config);
// 3. 父 runtime 使用 parent_tools,模型即可调用 spawn_agent { task: "..." } 派生子 agent
let parent_runtime = AgentRunTimeBuilder::new()
.provider(provider)
.model("deepseek-chat")
.checkpoint_storage(store.clone())
.transcript_storage(store)
.sandbox(sandbox)
.tools(parent_tools)
.system_prompt("你是主 agent,可以直接处理任务,也可以把独立子任务委托给 spawn_agent。")
.build()?;
```
几个关键点:
- 子 agent 拥有**独立的会话与 transcript**(互不污染上下文),但**共享父 agent 当前的 sandbox**——子任务里 `write_file`、`bash` 的副作用落在同一个工作环境,父 agent 后续可见。注册时配置里的 `sandbox` 会在调用时被父 sandbox 覆盖,所以那一项填什么都行。
- 子会话会**登记为父会话的 subsession**(父 transcript 的 `subsessions` 里记下子会话 id),便于按一次 turn 下钻到各子 agent 会话;级联删除父会话时子会话树会被一并清掉。
- **取消会向下传播**:子 agent 用父 run 取消令牌派生的 child token 跑,父任务被取消时子 agent 也会干净地一并取消(走 Interrupted / 存档),而非被硬掐断。
- 工具入参只有一个 `task` 字段;返回 `child_session_id` 与子 agent 的最终结果(含失败 / 等待用户 / 中断等状态)。
- 子配置同样可挂自己的 skills、中间件、工具集,实现「专用子 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)
}
}
```
### 可观测:tracing / OpenTelemetry
tiny-agent 不内置观测存储,也不替宿主程序初始化全局 exporter。运行时把一轮对话建模成一棵**嵌套 span 树**,按 OpenTelemetry GenAI 语义约定产出,可直接被 Tempo / Jaeger / Langfuse 等 OTLP 后端渲染:
```text
agent.run 一轮运行(root)
├─ user_input 用户提交本轮输入
├─ gen_ai.chat 一次 LLM 调用
│ gen_ai.request.system / .model / .temperature / .max_tokens
│ gen_ai.input.messages 发给模型的完整输入(system + 历史 + 本轮,全量 JSON)
│ gen_ai.output.messages 模型输出(thinking / 回答 / 工具调用,全量)
│ gen_ai.response.finish_reasons
│ gen_ai.usage.input_tokens / .output_tokens / .total_tokens
├─ tool.call 一次工具调用
│ gen_ai.tool.name / .call.id
│ gen_ai.tool.call.arguments 工具入参(全量 JSON)
│ gen_ai.tool.call.result 工具结果(全量 JSON)/ error
└─ gen_ai.chat …
```
`tracing` event 只作为轻量阶段标记挂在对应 span 上(`user_message`、`reasoning_started`、`assistant_message`、`tool_started`、`tool_completed`、`succeeded` 等),只携带 id、模型、工具名、数量、字节数、错误标记等元数据;完整 prompt / 消息 / 工具入参 / 工具结果只记录在 span attribute 上。框架自身的补充统计放在 `tiny_agent.*` 属性下。是否记录、采样、脱敏、落盘或导出,全部由你的应用侧 `tracing_subscriber` / OpenTelemetry exporter 决定——库不会和宿主已有的 tracing 配置争抢全局 subscriber。
打开 `otel-tempo` feature 后,可用便捷函数按环境变量接入本地 Collector / Tempo:
```rust
let _telemetry = tiny_agent::telemetry::init_tempo_from_env("my-agent")?;
```
设置 `TINY_AGENT_OTEL=1` 或 `OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317` 后会启用导出;未设置时 `init_tempo_from_env` 返回 `Ok(None)`。如果宿主已有 subscriber,可用 `build_provider` / `tempo_layer` 自行组合。
> **⚠️ 隐私(自 0.2 起的行为变化)**:span attribute 现在会记录**完整原文**——系统提示、对话历史、模型输出、工具入参与结果都会进入 tracing 属性。这便于审计与排查,但意味着 trace 里会含敏感数据。如不需要全量内容,请在应用侧 subscriber 过滤 `tiny_agent` target 或相应字段、调低采样、或在 Collector 侧脱敏;只要不接 exporter,则什么都不会离开进程。
## 配置
`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` | 空链 | 可多次调用追加 |
## 兼容性
需要 Rust 1.85 及以上(edition 2024)。运行时基于 [Tokio](https://tokio.rs)。
## 许可证
采用 [MIT](LICENSE-MIT) 或 [Apache-2.0](LICENSE-APACHE) 双许可,任选其一。
除非你明确声明,你有意提交并被本仓库收录的贡献,均按上述双许可授权,不附加额外条款。