tiny-agent 0.1.0

一个小而完整的 Rust LLM Agent 运行时:可中断、可恢复、可观测、可插拔的 agent loop / A small but complete LLM agent runtime in Rust — an interruptible, resumable, observable, pluggable agent loop.
Documentation

tiny-agent 把 “调用模型 → 执行工具 → 回灌结果” 的循环建模成一个显式状态机,并将模型、沙箱、存储、可观测与拦截全部抽象为 trait。框架本身不绑定任何后端:用哪家模型、状态存到哪里、工具跑在何种隔离环境,皆由调用方决定。

它面向需要把 agent 嵌进真实服务的场景——进程会重启、用户会取消、工具有副作用、运行过程要审计——这些工程负担都被收敛进清晰的边界里,而不是散落在业务代码中。

[dependencies]
tiny-agent = "0.1"

工作原理

一次运行就是状态之间的转移。每个状态都携带可序列化的 AgentState,这是 checkpoint 与恢复能成立的基础。

 submit ─▶ Reasoning ─┬─▶ ToolExecuting ─▶ Reasoning   执行工具、回灌结果,继续推理
                      ├─▶ WaitingForUser                工具请求用户输入,停车等待
                      └─▶ Success                       模型给出最终回复(终态)

         cancel ─▶ Interrupted ─▶ resume               任意安全点取消并落 checkpoint,可续跑
         error  ─▶ Fail                                 超过重试 / 迭代上限(终态)

特性

  • 状态机式的 agent loopReady → 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 事件流记录运行全过程,用于实时推送、日志、回放与评测。
  • 子 Agentspawn_agent 把子任务委托给独立配置的子 agent。
  • 健壮性 — 区分可重试与永久错误,指数退避 + jitter,并限制最大连续失败与迭代次数。

快速开始

AgentRunTime 需要五样必填依赖:一个 Provider、一个 model 名、两类存储(checkpoint 与 transcript)、一个 sandbox。Sandbox 框架自带 HostSandbox;存储 trait 由你按后端实现(内存、文件、SQLite、Redis……)。

下面用内存存储跑通一轮完整的 agent loop,完整版见 src/main.rs

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(())
}
# 默认指向 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 间复用同一份配置)时更顺手。

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

Trait 职责 自带实现
LlmProvider 流式 chat completion AnthropicProviderOpenAiCompatibleProvider
Sandbox 工具执行环境(按会话 open / execute / save / fork) HostSandbox
CheckpointStorage 可恢复状态快照的存取 自行实现
TranscriptStorage 喂给模型的消息历史 自行实现
TrajectorySink 只读事件流 NoopSink
Middleware 生命周期钩子(拦截 / 改写 / 短路) 自行实现

工具

用参数 struct(实现 Deserialize + JsonSchema)注册,Schema 自动生成:

#[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_filewrite_file(write / replace / insert / append)、bashaskFollowupQuestion。 此外 register_spawn_agent 提供子 agent,register_read_skill 提供 skill 加载(配置 skill 目录时自动注册)。

Skills

Skill 让模型按需加载领域知识或操作手册,约定沿用 Agent Skills:一个目录配一个 SKILL.md,YAML frontmatter 写 namedescription,正文是给模型看的说明,同目录其余文件作为可选资源。

构建时用 skill_dir / skill_dirs 指定发现目录,发现规则:

  • dir/SKILL.md 存在 → dir 本身是一个 skill;
  • root/*/SKILL.md 存在 → 每个子目录各是一个 skill;
  • skill id 取目录名,name / description 取自 frontmatter。
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

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_filebash 的副作用落在同一个工作环境,父 agent 后续可见。注册时配置里的 sandbox 会在调用时被父 sandbox 覆盖,所以那一项填什么都行。
  • 工具入参只有一个 task 字段;返回 child_session_id 与子 agent 的最终结果(含失败 / 等待用户 / 中断等状态)。
  • 子配置同样可挂自己的 skills、中间件、trajectory,实现「专用子 agent」。

中间件

钩子按洋葱模型组合:before_* 正序、after_* 逆序。可改请求、改消息、给工具结果脱敏,或在工具执行前直接短路。

#[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

许可证

采用 MITApache-2.0 双许可,任选其一。

除非你明确声明,你有意提交并被本仓库收录的贡献,均按上述双许可授权,不附加额外条款。