tiny-agent 0.3.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.3"

工作原理

一次运行就是状态之间的转移。每个状态都携带可序列化的 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、两类存储、实时消息与 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、日志或监控平台。
  • 子 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())
    }
    // 只需实现 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(())
}
# 默认指向 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 间复用同一份配置)时更顺手。

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
tiny-agent = { version = "0.3", features = ["file-storage", "otel-tempo"] }
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) 一站式:有断点就恢复,否则从头跑
// 传 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 AnthropicProviderOpenAiCompatibleProvider
Sandbox 工具执行环境(按会话 open / execute / save / fork) HostSandbox
CheckpointStorage 可恢复状态快照的存取 FileStorage(需 file-storage)或自行实现
TranscriptStorage 喂给模型的消息历史 FileStorage(需 file-storage)或自行实现
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)、bashcheckkillaskFollowupQuestion。 此外 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 的消息通道或后台任务表。

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:一个目录配一个 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::{
    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_filebash 的副作用落在同一个工作环境,父 agent 后续可见。注册时配置里的 sandbox 会在调用时被父 sandbox 覆盖,所以那一项填什么都行。
  • 子会话会登记为父会话的 subsession(父 transcript 的 subsessions 里记下子会话 id),便于按一次 turn 下钻到各子 agent 会话;级联删除父会话时子会话树会被一并清掉。
  • 取消会向下传播:子 agent 用父 run 取消令牌派生的 child token 跑,父任务被取消时子 agent 也会干净地一并取消(走 Interrupted / 存档),而非被硬掐断。
  • 工具入参只有一个 task 字段;返回 child_session_id 与子 agent 的最终结果(含失败 / 等待用户 / 中断等状态)。
  • 子配置同样可挂自己的 skills、中间件、工具集,实现「专用子 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)
    }
}

可观测:tracing / OpenTelemetry

tiny-agent 不内置观测存储,也不替宿主程序初始化全局 exporter。运行时把一轮对话建模成一棵嵌套 span 树,按 OpenTelemetry GenAI 语义约定产出,可直接被 Tempo / Jaeger / Langfuse 等 OTLP 后端渲染:

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_messagereasoning_startedassistant_messagetool_startedtool_completedsucceeded 等),只携带 id、模型、工具名、数量、字节数、错误标记等元数据;完整 prompt / 消息 / 工具入参 / 工具结果只记录在 span attribute 上。框架自身的补充统计放在 tiny_agent.* 属性下。是否记录、采样、脱敏、落盘或导出,全部由你的应用侧 tracing_subscriber / OpenTelemetry exporter 决定——库不会和宿主已有的 tracing 配置争抢全局 subscriber。

打开 otel-tempo feature 后,可用便捷函数按环境变量接入本地 Collector / Tempo:

let _telemetry = tiny_agent::telemetry::init_tempo_from_env("my-agent")?;

设置 TINY_AGENT_OTEL=1OTEL_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

许可证

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

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