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()唯一的共享循环内核
  • ChatDelegateJobDelegateContainerDelegate 是三种不同执行场景下的适配器
  • Chat / Job / Container 三者不是父子关系,也不是“Chat 的 sub loop”
  • 它们是三条并列执行路径,只不过共用同一个循环框架

换句话说,IronClaw 的设计不是“为每个场景写一套循环”,而是:

  1. 抽出一个稳定的 Agentic Loop 骨架
  2. 用 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.rsAgent::run(self)

这个函数做了几件非常关键的事:

  1. channels.start_all() 启动所有输入通道,得到统一的消息流
  2. 启动 self-repair 后台循环
  3. 启动 session prune 后台循环
  4. 如配置启用,启动 heartbeat
  5. 如配置启用,启动 routine engine 和 cron ticker
  6. 进入主消息循环,不断处理来自各 channel 的 IncomingMessage

也就是说,Agent::run() 不是 Chat Loop,而是整个 Agent Runtime 的事件主循环。

这点很重要,因为很多人第一次看时会误以为它只是“聊天主循环”。实际上它还负责:

  • 定时任务系统
  • 自愈逻辑
  • 会话清理
  • 文档提取后的存储
  • 所有入口消息的统一接入

四、一条普通聊天消息是怎么走进去的

理解 Chat 路径,最重要的是顺着一次消息实际调用链往下走。

一条正常用户消息大致会经过以下步骤:

  1. Agent::run()message_stream 读到消息
  2. 可选执行转写中间件、文档提取中间件
  3. 调用 handle_message(&message)
  4. handle_message 内部:
    • 处理 internal message 直通
    • 设置 message tool context
    • SubmissionParser::parse(&message.content)
    • 触发 BeforeInbound hooks
    • 如有外部 thread id,尝试 hydrate 历史线程
    • 通过 SessionManager::resolve_thread(...) 拿到 (session, thread_id)
    • 若 thread 正处于 auth mode,则拦截并走凭据流
  5. 若是普通用户输入,则走 thread_ops::process_user_input(...)
  6. 最终在 dispatcher.rs 中构造 ChatDelegate
  7. 调用共享的 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 响应

此外还有:

  • ThreadStateIdle / Processing / AwaitingApproval / Completed / Interrupted
  • PendingApproval
  • PendingAuth

这套模型支撑了很多重要行为:

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 控制流”

它做的不是业务逻辑,而是流程编排:

  1. 检查外部信号
  2. 执行前置钩子
  3. 调 LLM
  4. 处理文本或工具调用
  5. 决定继续还是结束

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_signals
  • before_llm_call
  • call_llm
  • handle_text_response
  • execute_tool_calls
  • on_tool_intent_nudge
  • after_iteration

这意味着:

  • Chat 场景可以有审批逻辑
  • Job 场景可以有状态持久化和跟随消息注入
  • Container 场景可以有 orchestrator 事件上报

但这些都不需要复制整套循环。

九、Chat 路径的 Delegate:ChatDelegate

Chat 的实现位于 src/agent/dispatcher.rs

在这个文件里,Agent::run_agentic_loop(...) 会:

  1. 读取 workspace system prompt
  2. 选择 active skills
  3. 构造 Reasoning
  4. 构造 chat 用的 JobContext
  5. 读取并缓存工具定义
  6. 构造 ChatDelegate
  7. 调用共享 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::TextRespondResult::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

这些都不是外围装饰,而是循环的一部分。

十六、如果你要继续读源码,建议按这个顺序

如果你想进一步把这套实现彻底读透,建议按下面顺序读:

  1. src/agent/agentic_loop.rs
  2. src/agent/dispatcher.rs
  3. src/agent/thread_ops.rs
  4. src/agent/session.rs
  5. src/agent/session_manager.rs
  6. src/worker/job.rs
  7. src/worker/container.rs
  8. src/llm/reasoning.rs

这样能先抓住主骨架,再理解每条路径的差异。

十七、最后总结

IronClaw 的 Agent 运行模型可以浓缩成一句话:

用一套共享的 Agentic Loop 骨架,承载三种并列的执行路径,并把交互、后台任务与容器执行这三种不同运行时语义,通过 LoopDelegate 注入同一个控制流框架中。

从源码设计上看,它最出色的地方不是“支持工具调用”,而是:

  • 明确地区分控制面与推理面
  • 明确地区分循环骨架与运行场景
  • 明确地区分 Chat / Job / Container 的边界

这让它既能做一个交互式聊天代理,也能做持续运行的后台任务系统,还能在容器沙箱里执行隔离任务,而不需要为每种形态重写一套完整运行模型。

这也是为什么这套代码读起来虽然模块多,但核心主线其实非常统一:
只有一套 Agentic Loop,剩下的差异都只是 Delegate。