Ironclaw 02 - IronClaw Agent 任务循环源码分析
IronClaw Agent 任务循环源码分析
如果只看功能描述,IronClaw 的 Agent 很容易被理解成“一个聊天机器人,加上一些工具调用”。但从源码实现上看,它其实更接近一个有明确运行时边界的 Agent 执行框架:
- 一套共享的任务循环内核
- 三种不同执行场景的 Delegate
- 一层会话与线程状态机
- 一层调度、定时任务、自愈与安全护栏
这篇文章不偏概念解释,而是尽量站在源码阅读者视角,梳理 src/agent/、src/worker/ 和 src/llm/ 中最重要的执行主线:Agent 任务循环是怎么设计实现的,以及 Chat、Job、Container 三种任务之间到底是什么关系。
一、先说结论:只有一个循环内核,三种执行路径并列
理解这套架构,最重要的一点不是先看 ChatDelegate,而是先建立下面这个心智模型:
src/agent/agentic_loop.rs中的run_agentic_loop()是唯一的共享循环内核ChatDelegate、JobDelegate、ContainerDelegate是三种不同执行场景下的适配器- Chat / Job / Container 三者不是父子关系,也不是“Chat 的 sub loop”
- 它们是三条并列执行路径,只不过共用同一个循环框架
换句话说,IronClaw 的设计不是“为每个场景写一套循环”,而是:
- 抽出一个稳定的 Agentic Loop 骨架
- 用 trait 把不同运行环境下的差异注入进去
这也是后面很多设计选择的基础。
二、从 Agent 顶层对象开始:它不是循环本身,而是协调器
顶层定义在 src/agent/agent_loop.rs。
AgentDeps 打包了 LLM、安全层、工具注册表、workspace、skills、hooks、成本守卫等共享依赖;Agent 再持有 channels、context manager、scheduler、router、session manager 等运行时对象。
从架构上看,Agent 的职责不是“直接完成所有工作”,而是:
- 启动各输入通道
- 启动后台治理任务
- 从消息流里读消息
- 把消息路由到正确的处理路径
- 在合适场景里构造对应的 Delegate 并驱动共享循环
因此,Agent 更像一个运行时编排器,而不是唯一的业务执行者。
三、真正的主入口:Agent::run()
主入口在 src/agent/agent_loop.rs 的 Agent::run(self)。
这个函数做了几件非常关键的事:
channels.start_all()启动所有输入通道,得到统一的消息流- 启动 self-repair 后台循环
- 启动 session prune 后台循环
- 如配置启用,启动 heartbeat
- 如配置启用,启动 routine engine 和 cron ticker
- 进入主消息循环,不断处理来自各 channel 的
IncomingMessage
也就是说,Agent::run() 不是 Chat Loop,而是整个 Agent Runtime 的事件主循环。
这点很重要,因为很多人第一次看时会误以为它只是“聊天主循环”。实际上它还负责:
- 定时任务系统
- 自愈逻辑
- 会话清理
- 文档提取后的存储
- 所有入口消息的统一接入
四、一条普通聊天消息是怎么走进去的
理解 Chat 路径,最重要的是顺着一次消息实际调用链往下走。
一条正常用户消息大致会经过以下步骤:
Agent::run()从message_stream读到消息- 可选执行转写中间件、文档提取中间件
- 调用
handle_message(&message) handle_message内部:- 处理 internal message 直通
- 设置 message tool context
- 调
SubmissionParser::parse(&message.content) - 触发
BeforeInboundhooks - 如有外部 thread id,尝试 hydrate 历史线程
- 通过
SessionManager::resolve_thread(...)拿到(session, thread_id) - 若 thread 正处于 auth mode,则拦截并走凭据流
- 若是普通用户输入,则走
thread_ops::process_user_input(...) - 最终在
dispatcher.rs中构造ChatDelegate - 调用共享的
run_agentic_loop(...)
到这一步,Chat 才真正进入 Agentic Loop。
五、为什么先做 SubmissionParser
src/agent/submission.rs 里的 SubmissionParser 是一层很容易被忽略,但实际上很关键的设计。
它做的事情是:在进入 LLM 之前,先把输入分成结构化的 Submission 类型。
包括:
/undo/redo/interrupt/compact/clear/status/cancel/help/model- 审批消息
- auth token 消息
- 普通
UserInput
这意味着 IronClaw 不是“所有输入都交给模型判断”,而是:
- 控制指令走显式状态机
- 普通自然语言才进入 Agentic Loop
这种设计的好处是:
- 控制命令更稳定
- 不把系统控制权交给 LLM 猜测
- 对线程状态管理更安全
六、会话模型:为什么有 Session / Thread / Turn 三层
src/agent/session.rs 定义了这套核心数据模型:
Session:用户级会话容器Thread:一条会话线程Turn:一次用户请求与 Agent 响应
此外还有:
ThreadState:Idle/Processing/AwaitingApproval/Completed/InterruptedPendingApprovalPendingAuth
这套模型支撑了很多重要行为:
1. Chat 不是“无状态对话”
每一次 turn 都是显式记录的,可 checkpoint,可撤销,可恢复。
2. Approval 不是临时 if-else
当工具需要审批时,线程会进入 AwaitingApproval,并把 pending request 正式挂在线程对象上。
3. Auth mode 是一条特殊分支
如果工具返回 awaiting_token,后续一条消息会被直接送去凭据存储,不进入普通 LLM / history / compaction 流程。
4. Thread 与外部 channel thread id 做了解耦映射
这个映射由 src/agent/session_manager.rs 负责,因此外部系统的 thread/conversation id 可以稳定映射到内部 UUID。
七、共享循环内核:run_agentic_loop() 才是整个架构的核心
真正的共享循环位于 src/agent/agentic_loop.rs。
函数签名如下:
pub async fn run_agentic_loop(
delegate: &dyn LoopDelegate,
reasoning: &Reasoning,
reason_ctx: &mut ReasoningContext,
config: &AgenticLoopConfig,
) -> Result<LoopOutcome, Error>
这里有四个核心参数:
delegate:场景适配器,决定每一步怎么做reasoning:LLM 推理封装器reason_ctx:本轮上下文状态config:最大迭代次数、是否允许 tool-intent nudge 等
1. 这不是“聊天循环”,而是“通用 Agentic 控制流”
它做的不是业务逻辑,而是流程编排:
- 检查外部信号
- 执行前置钩子
- 调 LLM
- 处理文本或工具调用
- 决定继续还是结束
2. 固定执行顺序
它的循环骨架大致是:
for iteration in 1..=config.max_iterations {
match delegate.check_signals().await { ... }
if let Some(outcome) = delegate.before_llm_call(...).await { ... }
let output = delegate.call_llm(...).await?;
match output.result {
RespondResult::Text(text) => { ... }
RespondResult::ToolCalls { tool_calls, content } => { ... }
}
delegate.after_iteration(iteration).await;
}
所以 run_agentic_loop() 是典型的模板方法模式:控制流统一,步骤实现可替换。
八、LoopDelegate 是什么:给共享内核留出的扩展点
LoopDelegate 定义了通用循环中每个阶段需要场景方实现的方法:
check_signalsbefore_llm_callcall_llmhandle_text_responseexecute_tool_callson_tool_intent_nudgeafter_iteration
这意味着:
- Chat 场景可以有审批逻辑
- Job 场景可以有状态持久化和跟随消息注入
- Container 场景可以有 orchestrator 事件上报
但这些都不需要复制整套循环。
九、Chat 路径的 Delegate:ChatDelegate
Chat 的实现位于 src/agent/dispatcher.rs。
在这个文件里,Agent::run_agentic_loop(...) 会:
- 读取 workspace system prompt
- 选择 active skills
- 构造
Reasoning - 构造 chat 用的
JobContext - 读取并缓存工具定义
- 构造
ChatDelegate - 调用共享
run_agentic_loop(...)
ChatDelegate 的特点
1. check_signals
检查当前 thread 是否被标记为 Interrupted,如果是则返回 LoopSignal::Stop。
2. before_llm_call
这是 Chat 路径里最有意思的一步:
- 在快到工具迭代上限时注入“请准备最终回答”的 system nudge
- 每轮刷新工具定义
- 应用 skills 的工具衰减
- 在接近上限时开启
force_text - 发送“Calling LLM…”状态到 channel
这里体现了 Chat 路径最重要的两个运行时控制点:
- 工具能力不是固定不变的,可以按轮刷新
- 循环到后期会强制收敛为文本输出,避免工具死循环
3. call_llm
调用前会先跑 cost_guard.check_allowed()
真正的 LLM 调用通过:
reasoning.respond_with_tools(reason_ctx)
如果遇到 ContextLengthExceeded,它会自动压缩消息再重试。
调用后,还会把 token usage 交给 cost_guard.record_llm_call(...) 计费与限流。
4. handle_text_response
对文本做一点清洗,比如剥掉 provider flattening 后可能泄漏的内部 tool-call 文本。
然后直接返回 LoopOutcome::Response(...)。
5. execute_tool_calls
这是 ChatDelegate 里最复杂的一段:
- 先把 assistant 的 tool_calls 写入上下文
- 记录 redacted tool args 到当前 turn
- 做 preflight 检查
- hooks
- approval requirement
- auth requirement
- 若遇到审批需求,构造
PendingApproval并返回NeedApproval - 否则执行工具
- 结果过安全管线
- 把结果写回上下文,供下一轮 LLM 消费
可以说,Chat 场景的“复杂性”主要都集中在 execute_tool_calls 里。
十、Job 路径的 Delegate:JobDelegate
Job 不是在 src/agent/ 下实现,而是在 src/worker/job.rs 中实现。
这点非常关键,因为它体现了模块边界:后台任务执行属于 worker 语义,而不属于聊天线程语义。
JobDelegate 的职责
Job 路径的注释写得很清楚:
- signal channel
- cancellation checks
- rate-limit retry
- parallel tool execution
- DB persistence
- SSE broadcasting
为什么要放在 worker/job.rs
因为它依赖很多 worker 专属的对象和行为:
WorkerMessage- job 状态机
- job event 持久化
- SSE 任务广播
- follow-up user message 注入
这些都不是 Chat 线程该拥有的概念。
JobDelegate 的行为特点
1. check_signals
会 drain 掉 worker 的消息通道,优先处理 Stop,也可以接收运行中注入的 UserMessage。
也就是说,后台任务运行中是可以“半路加一句话”的。
2. before_llm_call
重点是刷新工具定义,让运行中新增/修复的工具能被下一轮看到。
3. call_llm
Job 路径会优先尝试:
reasoning.select_tools(reason_ctx)
若 LLM 给出了明确工具选择,就直接构造 RespondResult::ToolCalls。
否则再退回:
reasoning.respond_with_tools(reason_ctx)
与 Chat 不同,Job 还内建了 rate-limit backoff 逻辑。
4. handle_text_response
会检查 LLM 是否显式表明“任务完成”,然后更新 job 状态。
5. execute_tool_calls
偏向后台任务语义:
- 记录任务事件
- 并行工具执行
- tool result 写入上下文
- 更新 DB / SSE
它不强调“用户审批的交互体验”,而强调“后台任务的可持续推进”。
十一、Container 路径的 Delegate:ContainerDelegate
Container 路径位于 src/worker/container.rs。
这条路径更特殊,因为它运行在 Docker 容器内部,是一个沙箱 runtime。
它的核心特征
- 不直接调用外部 LLM API,而是通过
ProxyLlmProvider和 orchestrator 通信 - 工具只注册 container-safe tools
- 事件通过 orchestrator 回流给 UI
- 凭据通过 orchestrator 拉取,再注入子进程环境
这说明 ContainerDelegate 不是“另一个聊天 loop”,而是受控执行环境里的 agent loop。
为什么它也要实现 LoopDelegate
因为虽然环境不同,但控制流完全可以复用:
- 检查信号
- 调 LLM
- 处理 text/tool
- 继续或结束
容器里和聊天里的差异,本质上仍然只是“每一步怎么做”不同,而不是“循环长什么样”不同。
十二、三种任务之间到底是什么关系
这是理解这套架构最容易误解的点。
错误理解
很多人会下意识认为:
- Chat 是主循环
- Job 是 Chat 的子任务循环
- Container 是 Job 的更深一层循环
这个理解不准确。
更准确的关系
三者关系应该理解为:
- Chat / Job / Container 是并列的三种执行路径
- 三者共用同一个 Agentic Loop 内核
- 三者在不同入口、不同生命周期、不同运行时上下文下运行
可以画成这样:
run_agentic_loop()
^
|
+----------------+----------------+
| | |
ChatDelegate JobDelegate ContainerDelegate
| | |
dispatcher.rs worker/job.rs worker/container.rs
哪一个最重要
从产品入口来看,Chat 是最常见的入口。
但从架构上讲,三者没有主从关系。
它们是不是 Sub Loop
也不是。
它们不会表现为:
- Chat loop 里面再嵌一个 job loop
- job loop 里面再嵌一个 container loop
而是:
- 某次 Chat 可能会创建一个 Job
- 某个 Job 可能使用 Container 运行时
- 但它们各自启动后,都有自己独立的执行生命周期
十三、为什么 Job / Container 不放到 src/agent/
这个问题很值得专门说一下。
表面上看,三者都是“Agent 的任务循环”,为什么不都放到 agent/?
答案是:共享的是循环骨架,不共享的是运行时责任。
如果强行都塞进 src/agent/
会导致:
agent模块直接依赖容器运行时与 orchestrator 协议- chat 线程模型和 job 状态机混在一起
- 模块耦合变重
- 场景特有逻辑污染通用路径
- 测试难度与变更风险上升
当前拆分方式的优点
agent/agentic_loop.rs只负责循环内核agent/dispatcher.rs负责交互式 chat 语义worker/job.rs负责后台任务语义worker/container.rs负责容器沙箱语义
这是典型的“共享框架 + 场景实现归属到拥有模块”的做法。
十四、Reasoning 在这个循环里扮演什么角色
Reasoning 定义在 src/llm/reasoning.rs。
它不是“循环”,也不是“上下文”,而是 LLM 推理调用的封装对象。
它持有:
LlmProvider- workspace system prompt
- skill context
- channel / model / group chat 元信息
- conversation_context
并提供:
complete(...)plan(...)select_tools(...)respond_with_tools(...)evaluate_success(...)
在 Agentic Loop 里,它主要负责:
- 根据
ReasoningContext发起一次带工具能力的 LLM 调用 - 把返回值封装成
RespondResult::Text或RespondResult::ToolCalls
因此,Reasoning 更像“推理引擎适配层”,而 run_agentic_loop 是“控制调度层”。
十五、这套设计最值得学习的几点
如果把这套实现抽象成架构经验,我认为有三点最值得借鉴。
1. 显式控制命令和自然语言输入分流
不是所有事情都交给模型猜。
SubmissionParser 先把控制面拿住,再让自然语言走 Agentic Loop。
2. 循环内核与场景语义解耦
把“流程控制”与“场景行为”分开,是这套代码最核心的设计亮点。
3. 护栏内建于循环,而不是事后补丁
例如:
- cost guard
- max iterations
- force_text
- tool-intent nudge
- context compaction retry
- approval state
这些都不是外围装饰,而是循环的一部分。
十六、如果你要继续读源码,建议按这个顺序
如果你想进一步把这套实现彻底读透,建议按下面顺序读:
src/agent/agentic_loop.rssrc/agent/dispatcher.rssrc/agent/thread_ops.rssrc/agent/session.rssrc/agent/session_manager.rssrc/worker/job.rssrc/worker/container.rssrc/llm/reasoning.rs
这样能先抓住主骨架,再理解每条路径的差异。
十七、最后总结
IronClaw 的 Agent 运行模型可以浓缩成一句话:
用一套共享的 Agentic Loop 骨架,承载三种并列的执行路径,并把交互、后台任务与容器执行这三种不同运行时语义,通过
LoopDelegate注入同一个控制流框架中。
从源码设计上看,它最出色的地方不是“支持工具调用”,而是:
- 明确地区分控制面与推理面
- 明确地区分循环骨架与运行场景
- 明确地区分 Chat / Job / Container 的边界
这让它既能做一个交互式聊天代理,也能做持续运行的后台任务系统,还能在容器沙箱里执行隔离任务,而不需要为每种形态重写一套完整运行模型。
这也是为什么这套代码读起来虽然模块多,但核心主线其实非常统一:
只有一套 Agentic Loop,剩下的差异都只是 Delegate。