OpenProgram Docs

Agent 调用流程(权威设计)#

这是 agent 调用的核心框架——所有 turn / LLM 调用都按这个流程走。后续加功能在此基础上往对应节点插入,不改骨架。

总览图#

agent-call-flow.svg 画的是目标(统一后):两个入口(用户消息 / 函数体内)汇到同一条主干——① render_context 读上下文 → ② open_call_node → ③ agent_loop → ④ close_call_node;工具体内再调 exec = 回到主干顶(嵌套)。各入口的特殊处理(聊天的前端流式/标题/压缩、共享的重试/计量)是挂在主干旁的可选钩子。底部列出"现状已共享"与"合并要做的 7 步"。

现状 vs 目标:下面正文描述的是现状(两个入口各管各的外围、各写各的记录,只在 agent_loop 汇合)——作为合并的起点参照。统一成上面那条主干的具体步骤,见 dag/overview.md 第八节。

骨架一句话(现状):两个平级入口(dispatcher · runtime.exec)各管各的外围、各写各的 DAG 记录,只在共享的 agent_loop 汇合;exec 在 loop 之下 → 工具体内可再调 LLM 形成嵌套

真实拓扑:收敛分叉,不是三层嵌套#

代码实证(agent_loop 全仓只有两个调用点:dispatcher/__init__.py:902agent/agent.py:418):dispatcher 不经过 exec,exec 不经过 dispatcher。它们是平级的两个入口,只共享 agent_loop。

入口 A: 用户消息 → dispatcher ──直接─────────────┐
                                                  ├─→ agent_loop(共享引擎)
入口 B: @agentic_function 体内 → runtime.exec ──经 AgentSession─┘
入口 职责 到 agent_loop 的路径 写什么 DAG 记录 实现
A · dispatcher turn 生命周期:session、user 节点、attach Runtime、resolve model、解析工具、持久化、前端广播 直接 agent_loop(...)(__init__.py:902) 顶层回复 → assistant 会话消息(SessionDB, persistence.py:207) agent/dispatcher/__init__.py
B · runtime.exec 一次 LLM 调用:开/关 llm 节点、构建上下文 AgentSessionsession.run(runtime.py:1576)→ agent.py:418 这次调用 → role=llm DAG 节点 agentic_programming/runtime.py
共享 · agent_loop tool loop 引擎:调模型 → 执行工具 → 喂回 → 循环到纯文本 工具的 code 节点 called_by → 当前 llm 节点 agent/agent_loop.py:114

为什么是两个入口,不是一个#

dispatcher 和 exec 服务两种不同场景,外围插件不重叠:dispatcher 要 session 管理 + 前端 WebSocket 广播 + 标题/压缩;exec 要 DAG llm 节点 + AgentSession retry-rollback。硬合并会让一方背上另一方不需要的逻辑;实际折叠时会撞到模型分叉和节点重复(见下文"为什么 dispatcher 不折叠进 runtime.exec")。共享 agent_loop,因为"调模型→执行工具→循环"这个引擎对两者一样。

关键:exec 在 agent_loop 之下,所以能嵌套#

当 dispatcher 跑 turn,模型调了 @agentic_function 工具(如 wiki_agent)→ 工具体内 runtime.exec → 又起一个 agent_loop。所以 exec 既是"入口 B",又被 agent_loop 里的工具反向调用:

dispatcher → agent_loop → 模型调工具 → @agentic_function 体 → runtime.exec → agent_loop(嵌套)→ ...

这是 agentic programming 的根本能力(函数体内可嵌套调 LLM),也是 wiki_agent 递归的来源。如果 exec 在 loop 之上,工具就无法自己再调 LLM。

每个节点内部的有序步骤#

A · dispatcher:1 建/载 session(沿 active branch 取历史)→ 2 写 user 节点 → 3 attach Runtime(_store + _current_runtime)→ 4 resolve model(agent profile + override)→ 5 解析工具(channel/plan/审批 包装)→ 6 直接调 agent_loop → 7 持久化 + finalize(assistant 节点、标题、auto-compact)。

B · runtime.exec:1 开 llm 节点(running,_call_id 指向它,外含 timeout/retry 循环)→ 2 构建上下文【a 选历史节点 render_context → b render 成消息 + 当前 turn → c 解析工具集 toolset/policy/unattended-deny → d 拼 system + skills → e 建 AgentSession(选 stream_fn)】→ 3 经 AgentSession 调 agent_loop → 4 关 llm 节点(回填 output,success)。

共享 · agent_loop:每轮调模型前【a convert_to_llm → b memory prefetch → c deferred-tool re-split】→ 调模型/流式 → 判断 tool_use? → 是:执行工具 → 结果喂回 → 再调模型;否(纯文本):退出循环。

横向对比#

OpenCode OpenClaw Hermes OpenProgram (统一后)
一次 LLM 调用的抽象 Step(一个节点,tool loop 在内部) runId 配对(llm_input/llm_output,tool 嵌套在内) 无层级,平铺列表 exec(一个 llm 节点,tool loop 在内部)
记录方式 配对:Step.Started → Step.Ended 配对:llm_input hook → llm_output hook 只追加,不标记 配对:写 running → 回填 output
工具调用归属 ToolPart 在 Step 内部(子部分) 嵌套在同一个 runId 下(子 hook) 平级 tool 消息 code 节点的 called_by 指向 llm 节点(子节点)
聊天 vs 编程调用 统一(一条路径) 统一(同一个 hook 系统) 统一(一个 run_conversation) 统一(都走 runtime.exec)

OpenProgram 采用 OpenCode/OpenClaw 的模型:一次调用 = 一个节点,配对写入,工具是子节点。

问题(现状)#

现在"调一次 LLM"有三条路径:

路径 入口 tool loop DAG 写 llm 节点
常规聊天 dispatcher → agent_loop agent_loop 管 dispatcher 写 SessionDB 消息(不是 DAG llm 节点)
exec legacy exec → self._call() 没有 exec 写 llm 节点
exec providers exec → session.run → agent_loop agent_loop 管 没写(bug)

具体表现#

  1. exec providers 路径不写 llm 节点_call_via_providerssession.run 返回后直接 return,没调 _append_model_call_node。wiki_agent 走这条路径,DAG 里 wiki_agent 直接连 wiki_agent,中间没有 LLM 节点。

  2. dispatcher 路径写的不是 DAG llm 节点。dispatcher 调 persist_assistant_message 写的是 SessionDB 的 assistant 消息(带 token 统计),不是 DAG 的 Call(role=llm) 节点。

  3. legacy 路径没有 tool loop。模型只能返回纯文本,不能调工具。

设计#

核心原则#

一次 runtime.exec = 一个 llm 节点。

llm 节点和 code 节点是同一个抽象:一次调用,进入时 running,结束时回填 output。内部发生了什么(tool loop 跑了几轮、调了哪些工具)都是内部过程,不拆成多个节点。

进入 exec  → 写 llm 节点 (status=running, output=None)
exec 内部  → agent_loop 跑 LLM + tool loop
             (工具的 code 节点 called_by 指向此 llm 节点)
exec 返回  → 回填 llm 节点 (output=最终回复, status=success)

调用树示例#

wiki_agent 递归场景,统一后的 DAG:

llm (dispatcher 调 exec,模型决定调 wiki_agent)
  code wiki_agent d1 (工具执行)
    llm (wiki_agent 内部调 exec,模型又调 wiki_agent)
      code wiki_agent d2 (递归)
        llm (又一次 exec)
          code wiki_agent d3
            ...

每两个 code 节点之间都有一个 llm 节点,调用链完整。

统一路径#

runtime.exec(content=[...])
  → 写 llm 节点 (status=running, output=None)
  → 构建上下文 (render_context + render_dag_messages + content)
  → 调 LLM,跑 tool loop 直到模型返回纯文本
      (tool loop 内部模型调的工具,其 code 节点 called_by 指向此 llm 节点)
  → 回填 llm 节点 (output=最终回复, status=success)
  → 返回文本

各层改动#

dispatcher:不再直接调 agent_loop,改成调 runtime.exec。保留的职责:

  • 写 user 节点
  • 设置 session 上下文(ContextVar)
  • 调 runtime.exec
  • turn 级别善后(标题生成、compaction 触发)
process_user_turn (改后)
  → 写 user 节点 (不变)
  → 设 _store / _current_runtime (不变)
  → runtime.exec(content=用户消息)  ← 改这里
  → finalize

runtime.exec:统一为一条路径。

  • legacy call=my_func 包装成轻量 provider adapter,统一走 _call_via_providers
  • _call_via_providers 补上 llm 节点写入(配对:进入写 running,返回回填)
  • 删掉 legacy 分支

agent_loop:不变。纯粹的"LLM 调用 + tool 执行"引擎,不关心 DAG。

现状详细分析#

路径 1: 常规聊天 (dispatcher)#

用户发消息
→ process_user_turn (dispatcher/__init__.py:96)
  → 写 user 节点到 DAG (db.append_message)
  → 设 _store ContextVar
  → _run_loop_blocking (dispatcher/__init__.py:640)
    → 构建 AgentContext (tools, system_prompt, history)
    → agent_loop([prompt], context, config) (agent_loop.py:232)
      → _stream_assistant_response → LLM 回复
      → 如果是 tool_use → _execute_tool_calls → tool.execute
        → 如果工具是 @agentic_function → wrapper 写 code 节点
        → 工具返回 → agent_loop 继续
      → 最终拿到纯文本回复
    ← 返回 final_text
  → persist_assistant_message (persistence.py:31)
    → 写 assistant 消息到 SessionDB (不是 DAG llm 节点)
  → finalize

路径 2: runtime.exec legacy#

@agentic_function 函数体里调 runtime.exec(content=[...])
→ exec (runtime.py:789)
  → self._call(content) → 用户自定义函数,返回文本
  → _append_model_call_node(reply=...) → 写 llm 节点到 DAG
← 返回文本

路径 3: runtime.exec providers#

@agentic_function 函数体里调 runtime.exec(content=[...])
→ exec (runtime.py:789)
  → _call_via_providers (runtime.py:1306)
    → 构建 AgentSession
    → session.run(current) → 内部跑 agent_loop (同路径 1 的代码)
    ← 返回 final assistant message
  → return _assistant_text(final)  ← 没有写 llm 节点!
← 返回文本

节点写入与 callable 路径#

exec 配对写 llm 节点#

exec 在 sync 和 async 两条路径上配对开关 llm 节点(_open_model_call_node / _close_model_call_node),函数体内发起的 LLM 调用因此出现在 session DAG 里。 _call_via_providers 中在 session.run 之前把 _call_id 切到 llm 节点,tool loop 的 工具 code 节点 called_by 正确指向 llm 节点 (test_tool_loop_subcall_attributes_to_llm_node)。

callable 路径走 provider model#

Runtime(call=fn) 被包装成一个 provider model,和其他模型一样走 _call_via_providers,因此不存在单独的 legacy 调用分支:

部件 是什么 文件
CallableModel adapter sync+async,把 pi-ai messages 转回 content 调用户 fn,返回单条 AssistantMessage,无 tool loop;补回 response_format→prompt suffix openprogram/providers/callable_model.py
Runtime 接线 Runtime.__init__call=self.api_model = CallableModel(call),callable runtime 强制 toolset="none" runtime.py init

_call_via_providers 不忽略 content:_render_history_messages(content) 把 content 作为当前 turn(runtime.py:1451),无 store 时 _build_pi_context(content)(:1456)。 所以 adapter 不需要特殊处理 content,AgentSession 会把 history+current 给 model, adapter 转回 content 调用户 fn。

stream_fn 注入#

stream_fn 参数穿过 exec → _call_via_providers → AgentSession.init → AgentOptions,让 exec 可注入流(test_exec_stream_fn_injection)。

为什么 dispatcher 不折叠进 runtime.exec#

_run_loop_blocking 改调 runtime.exec 行不通,有四个原因:

  1. 模型会分叉:dispatcher 用 _resolve_model(agent_profile, req.model_override)(agent profile + 用户选的模型),而 attached runtime 是 create_runtime() 无参自动探测的,api_model 不一致。走 exec 会用错模型。
  2. 上下文会冲突:dispatcher 用 context-engine 准备好了消息(prep.agent_messages),exec 自己从 DAG render 历史,两套会打架。
  3. 流式事件不兼容:exec 的 on_stream 发 flat dict,dispatcher 的 on_event 要 webui envelope。
  4. 最关键——会写重复节点:试着在 dispatcher 里用 _open/_close_model_call_node 加一个 llm 节点,结果 DAG 里出现 [user, assistant, assistant]——因为 dispatcher 的 assistant 会话消息(persist_assistant_message)本身就是顶层 LLM 调用的 DAG 记录。再加 llm 节点就重复了。test_dispatcher_integration.py::test_real_loop_text_only 直接抓到这个重复。

结论:dispatcher 顶层 LLM 调用已经有 DAG 表示(role=assistant 的会话消息节点),工具调用挂在它下面。不需要也不应该再加 llm 节点。"code→code 缺 llm 节点"问题只发生在 @agentic_function 内部 exec 的 tool loop,由上文的配对 llm 节点写入覆盖。dispatcher 保持现状。

dispatcher 和 exec 的关系不是"dispatcher 走 exec",而是两个并列的 LLM 调用入口,各自把顶层调用记进 DAG(dispatcher → assistant 会话节点;exec → llm 节点),共享下层的 agent_loop 引擎和 DAG 节点写入 API。这跟其他框架一致(OpenClaw 也有独立 dispatcher 层)。

Agent 类:不动#

Agent 在 exec 下层(exec → AgentSession → Agent → agent_loop),是循环驱动器不是平行 LLM 路径,只在 session.py:94 构造一处。折叠它等于重写循环,无收益。

事件层:无改动#

tool.before 只在 _execute_tool_calls(agent_loop.py:518)一处 fire,所有路径都经过它。CallableModel 无内部 tool loop(用户 fn 只返回 str),不会重复/丢失事件。

敏感点#

  1. dispatcher 测试 seam:stream_fn 穿过 4 层后,_run_loop_blocking 仍是 patch 入口,测试的 stream_fn 经 exec 流到 model。test_dispatcher_dag_attach.py 整体替换 _run_loop_blocking,不受影响。
  2. prompt-cache 前缀稳定:override 不能破坏 DAG-prefix 缓存。
  3. response_format:claude_call/gemini_call 的 JSON-mode 靠 adapter 补回 suffix。

统一"记一次模型回复"的写入#

同一件事两套写法#

"记录模型回复"现在有两套配对写节点(开占位 running → 回填结果)的实现,按入口分:

入口 开占位 回填 实现
dispatcher(聊天) insert_placeholder(_turn_lifecycle.py:65) persist_assistant_message(persistence.py) dispatcher 本地
exec(代码) _open_model_call_node(runtime.py:631) _close_model_call_node(runtime.py:656) runtime 原语

两套结构一模一样(open→close 配对),但一套散在 dispatcher、另一套在 runtime,同样的头尾写入存在两份。

关键事实(让统一变简单)#

存储层没有 ROLE_ASSISTANT——只有 user/llm/code(context/nodes.py:36-40)。聊天回复早就存成 ROLE_LLM:_msg_to_noderole="assistant" 映射成 ROLE_LLM,_node_to_msg 读回来默认还原成 "assistant"(_msg_adapter.py)。exec 的 _open_model_call_node 写的也是 ROLE_LLM,读回来同样默认 "assistant"。

两个原语在节点层早就用同一个 role。 唯一差异是 dispatcher 多写了 4 样元数据,exec 的裸原语没写。序列化只有一个收口点 _node_to_msg → 前端读到的还是 "assistant"。

一个通用配对原语,两个入口共用#

exec 的配对原语升级为通用配对原语(能装下 dispatcher 需要的字段),两个入口都用它,重复的 placeholder/persist 机制随之取消。

统一原语必须保留这 4 样(否则前端/分支/计量会断):

字段 谁要 风险
extra.blocks(thinking/text/tool 卡片顺序) 前端气泡主体(conv-mapper.ts) 最高
token 列 + token_model 计量 UI
metadata.parent_id=user_msg_id 分支/fork/rewind 的 active-branch 重建
cancelled/completed 终态(exec 当前写 success) 用户停止时的部分输出

原语签名升级(可选参数,exec 不传、dispatcher 传):

open_model_call_node(*, role="llm", parent_id=None, content_text="", model=None) -> node_id
close_model_call_node(node_id, *, reply, status="success", blocks=None, usage=None)

前端无需改动:_node_to_msg 仍输出 "assistant",前端读的字段(blocks/token/parent_id)都还在,只是改由统一原语写。

实现状态#

已具备:

  • exec 在 sync 与 async 两条路径上的配对 llm 节点写入,以及 _call_id 切换使工具 code 节点归属到 llm 节点
  • CallableModel adapter 及把 call= 路由到 _call_via_providers 的 Runtime 接线
  • stream_fn 穿过 exec → _call_via_providers → AgentSession → AgentOptions

统一配对原语尚未被 dispatcher 采用,落地顺序为:

  1. 升级 _open/_close_model_call_node,加 parent_id / blocks / usage / status 可选参数(exec 调用不变,默认行为不变)
  2. dispatcher 的 insert_placeholder 改成调 open_model_call_node(role="assistant", parent_id=user_msg_id)
  3. dispatcher 的 persist_assistant_message 改成调 close_model_call_node(blocks=..., usage=..., status="completed"/"cancelled")——只保留字段组装,删掉自己的 append/update
  4. _turn_lifecycle.py 的 placeholder/error-fold 里重复的写节点逻辑
  5. 验证:webui 端到端聊天(气泡、thinking/tool 卡片、token、停止、fork)全部正常 + 全量 pytest

涉及 legacy 调用 seam 的测试:test_openai.py:61test_anthropic.py:63test_gemini.py:68 去掉 _uses_legacy_call() is False;test_decision.py:24test_loop_options.py:153test_dispatcher_dag_attach.py:89 去掉 _uses_legacy_call→True override,保留 _call override。需重验的行为类:test_runtime_exec_dag.py:34test_functions.py_mock_call(按 content shape 分支,adapter 原样传 content)、conftest.pyecho_call/noop_call

相关文件#

  • openprogram/agentic_programming/runtime.py — exec / _call_via_providers / _open|_close_model_call_node(统一原语所在)
  • openprogram/providers/callable_model.py — CallableModel adapter
  • openprogram/agent/agent_loop.py — agent_loop / _execute_tool_calls
  • openprogram/agent/session.py — AgentSession
  • openprogram/agent/dispatcher/__init__.py — process_user_turn / _run_loop_blocking
  • openprogram/agent/dispatcher/persistence.py — persist_assistant_message(改调统一原语)
  • openprogram/agent/internals/_turn_lifecycle.py — insert_placeholder / fold_error(删重复写入)
  • openprogram/store/session/_msg_adapter.py — _node_to_msg(序列化收口点,role 还原)
  • openprogram/agentic_programming/function.py — @agentic_function wrapper
Last updated · 2026-08-13