DeepSeek Harness:Cordis 插件树与 Agent 主链路
很多 Agent 框架的架构图看起来差不多:输入进模型,模型选工具,结果回到模型,循环直到结束。Harness(dsh)的差异在于两件事同时成立------运行能力由 Cordis 插件树在启动时组装 (agent-loop 本身也是插件,没有需要打补丁的内核),运行事实写入 Session 仅追加事件账本 (模型 history 来自 deriveMessages() 对 surface 的投影,不是内存里的 messages[])。
本文沿这条主链做 源码级走读 :Profile/Bundle 如何拼树 → 核心包与 Session 如何成为 ground truth → followup 如何驱动 Inbox、kick/turn/step → 每 step 的 Context 如何组装 → 工具如何经 ctx.tools 落盘 → 打断、SubAgent 与扩展点如何挂接。贯穿例子只有一句:用户 followup("请阅读 README.md 并用一句话总结"),模型先调 Read,再文字回答------后文用这一条线把事件因果串起来。
五部分:架构总览 → Cordis 插件底座 → 核心包、Session 与调度前准备 → Agent 执行机制与 Context 工程 → 打断、SubAgent、扩展与调试。官方文档与源码锚点见文末。
第一部分 · 架构总览与设计原则
1.1 架构总览
Harness 可按 七个主题 理解:Cordis、Profile 与组合包、核心包、三域事件、轮次流程、会话日志、能力 seam。CLI / Web / SDK 等 入口 bin 与 UI 驱动 ctx.agents、订阅 session/event,不单独占一层编号。
| 主题 | 职责 |
|---|---|
| ① Cordis | 插件向共享 ctx 贡献服务、类型化事件、可逆副作用;无特权内核 |
| ② Profile 与组合包 | 启动时按 bundle → profile patch → home patch → --patch 叠加,拼出有效插件树 |
| ③ 核心包 | 六 ctx 键(sessions / systemPrompt / tools / llm / agents / agentLoop)+ dsh-scope 库 |
| ④ 三域事件 | Session (持久 log)、Agent (agent/* 实时)、Capability (fs/*、tools/* ...) |
| ⑤ 轮次流程 | inbox → claim → pre-step → step → llm/stream → tools → turn/end |
| ⑥ 会话日志 | append-only ground truth;surface + deriveMessages() 供模型 history |
| ⑦ 能力 seam | Definition + Provider + Consumer;换 Provider 即换实现 |
css
flowchart TB
subgraph boot["② Profile 与组合包"]
Bund["bundle 顺序叠加"]
Patch["cordis.patch.yml / --patch"]
Boot["app-boot → Loader mount"]
Bund --> Patch --> Boot
end
subgraph cordis["① Cordis"]
CTX["Context Proxy"]
Fiber["Fiber 生命周期"]
Ref["Reflect / Registry"]
Ev["Events · waterfall / serial"]
end
subgraph core["③ 核心包"]
Sess["ctx.sessions"]
SP["ctx.systemPrompt"]
Tools["ctx.tools"]
LLM["ctx.llm"]
Agt["ctx.agents"]
Loop["ctx.agentLoop"]
Loop --> Sess & SP & Tools & LLM & Agt
SP --> Tools
end
subgraph seams["⑦ 能力 seam"]
Cap["fs / shell / subagent / persistence / compaction ..."]
end
subgraph runtime["⑤ 轮次流程 · ⑥ 会话日志"]
IB["Inbox → kick/turn/step"]
Log["Session.append"]
Der["surface → deriveMessages"]
IB --> Log --> Der
end
subgraph ext["④ 三域事件(扩展点)"]
SE["Session 事件 · 持久"]
AE["agent/* · 实时"]
CE["能力事件 · seam 策略"]
end
subgraph io["入口与表现(扩展归属表)"]
Entry["dsh CLI · Web BFF · headless"]
UI["Web Client · SDK · ACP"]
end
Boot --> cordis
cordis --> core
Tools --> Cap
Loop --> runtime
ext -.-> runtime
ext -.-> core
ext -.-> seams
Entry --> boot
UI --> Agt
UI --> Log
数据与控制主轴:
bash
用户输入 → ctx.agents.followup/steer/inject
→ Inbox 持久化排队 → agent-loop kick/turn/step
→ Session.append(user/assistant/tool 等)
→ deriveMessages + systemPrompt.assemble → buildRequest
→ ctx.llm.stream + ctx.tools.execute
→ 再 append → 持久化 / UI 订阅 session/event
1.2 五条核心设计点
(1)Everything is a plugin --- 无特权内核
agent-loop、Session、tools、LLM 适配器与 Read/Shell 工具 同级 ,都是 cordis.yml 条目。换模型 = 换 llm 适配器插件;换持久化 = 换 session-persistence 插件;不必改 agent.ts。
(2)模型可见 ⟺ 已记录 --- Session 是唯一 context 源
进 LLM 的 system、tools、messages 必须能从 Session log 重建。写:session.append(..., { surfaceOp });读模型 history:deriveMessages() 只读 surface,不是整份 log。
(3)依赖声明加载 --- Cordis inject
插件 inject: ['tools','sessions',...],Cordis 在依赖 ACTIVE 后才 LOADING。agent-loop 声明 agents、sessions、llm、tools、systemPrompt 五键齐备 才进入 LOADING;tools 另 inject systemPrompt 。cordis.yml 条目顺序不决定 加载顺序------Fiber 依赖图决定。缺服务则 PENDING(等待,非报错)。
(4)扩展走事件与服务,优先不改 loop
三域事件 是大多数扩展的第一个决定:持久事实走 Session 事件 ;观察/拦截进行中 driver 走 agent/* ;seam 策略走 能力事件 (tools/*、fs/* ...)。
| 意图 | 挂载点 |
|---|---|
| 改本 step 是否进模型 | agent/pre-step(waterfall) |
| 改 provider/model | agent/request |
| 审批/包装工具 | tools/pre-execute、tools/execute |
| 改 prompt/tools 列表 | ctx.systemPrompt + system-prompt/assemble |
| 新持久事实 | 扩展 SessionEventMap + append |
改 packages/core/agent-loop 是最后手段。
(5)Capability seam 三件套 --- 可替换能力必须拆全
能力 seam 中的每一项(Shell、FS、Web、SubAgent...),在 Harness 里不是「一个工具包打天下」,而是 Service Definition + Service Provider + Consumer 三角色齐备,才构成完整的 capability seam 。缺一角就不是 seam,只是半成品插件。替换实现时 只换 Provider(例如 bash 本机执行 ↔ 沙箱执行),Definition 契约与 Consumer(模型看到的工具)保持不变。下文用 Shell 例子展开三者的分工与调用关系。
1.3 Capability seam 详解(设计点 5 展开)
设计点 (5) 只说了「三件套齐备才是 seam」。本节把 Definition / Provider / Consumer 分别是什么、如何协作 讲清楚;周边能力插件也建立在这一模式之上。
1.3.1 三件套是什么
| 角色 | 做什么 | 典型形态 |
|---|---|---|
| Service Definition | 定契约、占 ctx 键 --- 声明服务(如 ctx.shell)、Request/Result 类型、resolve() 如何补全参数 |
Cordis Service 子类,如 dsh-shell |
| Service Provider | 给实现 --- 在运行时挂载具体后端(本机 bash、沙箱、HTTP fetch...) | 独立插件包,可多个或互斥 |
| Consumer | 接到模型或产品 --- 通常是 ctx.tools.register 的工具;内部 inject Definition 并调用服务 |
如 dsh-tool-bash |
Consumer(模型看到的 bash 工具)
↓ inject + 调用
Service Definition(ctx.shell 上的统一 API)
↓ 运行时绑定
Service Provider(本机 or 沙箱执行)
单独一个 Provider 或单独一个 Tool 都不是 seam 。seam = Definition + 至少一个 Provider + 至少一个 Consumer 构成的 完整能力。
1.3.2 核心例子:Shell(bash 执行)
以 bash 执行 seam 为规范范例:
| 角色 | 包 | ctx / 产物 |
|---|---|---|
| Definition | dsh-shell |
ctx.shell --- ShellExecRequest / ShellExecSpec、resolve(request) |
| Provider | dsh-bash-local |
本机进程树执行 |
| Provider | dsh-bash-sandbox |
沙箱/隔离环境执行(profile 可切换) |
| Consumer | dsh-tool-bash |
模型可见的 bash 工具;execute 里调 ctx.shell |
模型发起一次 bash 调用时:
rust
sequenceDiagram
participant M as 模型
participant Loop as agent-loop
participant TB as dsh-tool-bash<br/>(Consumer)
participant SH as ctx.shell<br/>(Definition)
participant PR as dsh-bash-local<br/>(Provider)
M->>Loop: assistant/message 含 tool-call bash
Loop->>TB: ctx.tools.execute(name=bash, args)
TB->>SH: resolve(Request) → Spec
TB->>SH: 执行 API
SH->>PR: spawn、收 stdout
PR-->>SH: CollectedOutput
SH-->>TB: 结果
TB-->>Loop: tool result blocks
Loop->>Loop: session.append tool/result
各层 只管自己的话:
- Consumer --- 模型参数 →
ShellExecRequest;不管本机还是沙箱。 - Definition --- Request/Spec、超时、workdir、abort;不管 JSON Schema。
- Provider --- 按 Spec 真跑命令;不管 Session 与 turn/step。
1.3.3 换 Provider 为何不用改 Tool
profile 里把 dsh-bash-local 换成 dsh-bash-sandbox:
- Consumer
dsh-tool-bash不变 --- 模型仍见同一bash工具 - Definition
ctx.shell不变 --- 契约不变 - 仅 Provider 替换 --- 命令实际执行环境变
替换边界在 Provider ,而不是在 agent-loop 或 Tool 里写 if (sandbox)。Consumer 经 ctx.tools 注册,Provider 经能力 seam 挂载------二者通过 Definition 解耦。
1.3.4 不完整拆分的后果
| 做法 | 问题 |
|---|---|
在 Tool 里直接 child_process.spawn |
换沙箱要改 Tool;PTY、jobs 等无法复用 |
| 只有 Provider、没有 Definition | 无统一 Request/Spec,词汇无法共享 |
| 只有 Definition + Provider、没有 Consumer | 能力存在,模型不可见 |
FS、Web(search/fetch)、SubAgent(Task 工具)、LLM(ctx.llm + 适配器)均用同一三角色模式;差异主要在 Provider 能否 多个并存(SubAgent、Web search 可以;bash executor 通常单一 active)。
1.3.5 与设计点(4)如何配合
| 设计点 | 解决什么 |
|---|---|
| (5)Capability seam | 能力 由谁实现、如何整包替换 |
| (4)扩展走事件 | 同一次调用链上 如何插策略 (如 tools/pre-execute 审批 bash) |
Consumer 仍调 Definition → Provider;审批挂在 tools 流水线,不打破三角色边界。
第二部分 · Cordis 插件底座
Harness 的业务语义在 dsh-* 包;Cordis 只负责「插件怎么活」 ------依赖顺序、服务注册、事件分发、可逆副作用。源码 vendored 在 vendor/cordis/src/;Loader / Include / Group / HMR 在 vendor/*,本身也是插件,不是「内核外的加载器」。
2.1 源码模块架构:谁协作、谁不负责业务
Cordis core 文件极少,但职责边界清晰:
arduino
vendor/cordis/src/
context.ts Context 类 + Proxy 入口;extend / isolate / intercept
reflect.ts 服务 store;Proxy handler;provide / get 沿 Fiber 链解析
fiber.ts 单插件生命周期、inject 检查、effect/disposer、config 校验
registry.ts Plugin 形状归一化;ctx.plugin / ctx.inject
events.ts emit / waterfall / parallel / serial / bail
service.ts Service 基类;构造时 reflect.provide;intercept config 合并
logger.ts 日志服务
utils.ts symbols、DisposableList、callable 包装
index.ts 公共导出
实现层协作关系(读源码时的 mental model):
css
flowchart TB
subgraph boot["启动"]
YML["cordis.yml 条目"] --> LDR["Loader 插件"]
LDR --> IMP["dynamic import(name)"]
IMP --> REG["ctx.registry.plugin(plugin, config)"]
end
subgraph core["Cordis core"]
REG --> FIB["Fiber(PENDING→LOADING→ACTIVE)"]
FIB --> RUN["Service 构造 或 apply(ctx)"]
RUN --> PROV["reflect.provide('tools', instance)"]
RUN --> EFF["ctx.effect / ctx.on → disposer"]
CTX["Context Proxy"] --> REF["ReflectService"]
CTX --> EVT["EventsService"]
REF --> PROV
end
subgraph harness["Harness 插件(举例)"]
PROV --> T["ctx.tools = ToolRuntime"]
PROV --> A["ctx.agents = AgentRegistry"]
PROV --> S["ctx.sessions = SessionStore"]
EFF --> TR["dsh-tool-fs: tools.register(Read/Write)"]
end
FIB -->|"inject 依赖齐"| RUN
T --> AL["agent-loop inject tools 后 ACTIVE"]
| 模块 | 不负责什么 |
|---|---|
| Reflect | 不管 yml、不管 npm import、不管 Agent turn |
| Fiber | 不管 Session 事件、不管 LLM 协议 |
| Registry | 不解析 profile patch;那是 Loader |
| Events | 不持久化;Harness 的 session 事件在 dsh-session |
Cordis 回答:插件何时加载、服务挂在哪、如何查找、如何卸载;不回答「一句 followup 怎么跑」------那是 agent-loop 的事。
2.2 从 cordis.yml 到 ctx:完整链路
2.2.1 Profile 如何变成有效插件树
运行中的 dsh 不是读单个 yml,而是 多层叠加:
arduino
dsh --profile headless "任务"
→ app-boot 解析 profile
→ 按序叠加:bundle 组合包 → profile 的 cordis.patch.yml → home patch → --patch
→ 得到有效 Entry 树(每条:id / name / config / disabled / inject)
→ 根 Context 创建 → Loader 插件 mount → Loader 逐条 import + ctx.plugin
→ 依赖自发满足 → ctx 上出现 sessions / tools / agents / llm / agentLoop ...
→ 具体 bin(CLI / webserver)再 inject 所需服务并启动 I/O
验证本机实际树(不 boot 全应用也可看 patch 结果):
css
pnpm dsh --profile web --dump-config
examples/headless-agent/cordis.yml 片段(每条 name 对应一个 npm 包,Loader 会 import 并 mount):
yaml
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
- id: agent-spine
name: '@deepseek-ai/dsh-agent-spine-demo'
config:
agents: [{ id: main, provider: deepseek-official, model: deepseek-v4-flash, ... }]
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
2.2.2 单条条目:import → Fiber → provide
Loader 对每条 Entry 的核心路径(vendor/loader/src/config/entry.ts):
scala
Entry.init()
1. import(options.name) # 动态加载 @deepseek-ai/dsh-tools 等模块
2. unwrapExports(module) # 取 default / 命名导出插件对象
3. ctx.registry.plugin(plugin, config)
→ new Fiber(parentCtx, config, inject, runtime)
→ 子 ctx = parent.extend({ fiber })
→ _checkImpl:inject 的服务是否已有 ACTIVE 提供方
→ 依赖齐 → LOADING
4. LOADING 阶段
· class extends Service → new ToolRuntime(ctx, config)
→ super(ctx, 'tools') → reflect.provide('tools', this)
· 或 function apply(ctx) { ctx.inject(['tools'], child => { ... register ... }) }
5. ACTIVE → fiber.store 快照;依赖方 Fiber 被唤醒
两个具体形态(Harness 里极常见):
| 形态 | 谁提供 ctx 键 | 谁消费 / 注册工具 |
|---|---|---|
| Service 插件 | dsh-tools → export default class ToolRuntime extends Service → ctx.tools |
dsh-tool-fs:inject: ['tools','fs'],apply 里 ctx.tools.register(...) |
| 纯 apply 插件 | 同上,由别的包 provide | dsh-bash-local provide ctx.shell;dsh-tool-bash inject shell + tools |
rust
sequenceDiagram
participant Y as cordis.yml
participant L as Loader
participant M as dsh-tools 模块
participant R as Registry/Fiber
participant Ref as Reflect.store
participant TF as dsh-tool-fs
participant AL as dsh-agent-loop
Y->>L: Entry id=tools, name=dsh-tools
L->>M: import
M->>R: plugin(ToolRuntime, config)
R->>R: inject systemPrompt 满足 → LOADING
R->>Ref: provide('tools', ToolRuntime)
Note over Ref: ctx.tools 可读
Y->>L: Entry id=tool-fs, name=dsh-tool-fs
L->>TF: import + plugin(apply)
TF->>TF: inject tools, fs → LOADING
TF->>Ref: tools.register(Read/Write/Edit)
Y->>L: Entry agent-loop
L->>AL: plugin(AgentLoop)
AL->>AL: inject agents,sessions,llm,tools,systemPrompt
Note over AL: 五者 ACTIVE 后 agentLoop 才 LOADING
inject 顺序不是手写启动脚本 :agent-loop 声明 static inject = ['agents','sessions','llm','tools','systemPrompt'],缺任一服务则其 Fiber 保持 PENDING,直到对应插件 ACTIVE。
2.3 ctx 里有什么
ctx 是 Proxy 服务容器 + 事件总线 + 当前 Fiber 句柄 。读 ctx.xxx 不是读普通对象属性,而是 Reflect 沿 Fiber 父链查 store。
2.3.1 Cordis 内置(根 ctx)
| 键 / API | 来源 |
|---|---|
reflect、registry、events、logger |
根 Context 构造时创建 |
loader |
Loader 插件 provide('loader', ...) |
plugin / inject / effect / on / emit / waterfall |
mixin 到 ctx 的便捷 API |
fiber |
当前插件实例;根 Fiber 代表「应用根」 |
2.3.2 核心包:六个 ctx 键(必记)
这六个服务 全是插件 ,由 cordis.yml 挂载;agent-loop inject 五键齐备后才 ACTIVE。dsh-scope 无 ctx 键,提供按 agent 划分的作用域注册(agent.ctx)。
| ctx 键 | 提供方包(典型) | 职责 |
|---|---|---|
sessions |
dsh-session |
仅追加 SessionEvent log;append / deriveMessages / fork / resume |
systemPrompt |
dsh-system-prompt |
section、变量、每 step assemble tools schema + prompt |
tools |
dsh-tools |
作用域化 register / view / execute 流水线 |
llm |
dsh-llm + 适配器插件 |
适配器 seam、stream、prepareCall |
agents |
dsh-agent |
Agent 注册表、create / resume / withInitiator |
agentLoop |
dsh-agent-loop |
默认 driver ReactLoopAgent;config 里可声明 agents[] |
rust
flowchart TB
Loop["ctx.agentLoop<br/>dsh-agent-loop"] --> Sess["ctx.sessions"]
Loop --> SP["ctx.systemPrompt"]
Loop --> Tools["ctx.tools"]
Loop --> LLM["ctx.llm"]
Loop --> Agt["ctx.agents"]
Agt --> Sess
SP --> Tools
Consumer 插件如何挂到 tools 上(设计点 5 的 Consumer 侧):
less
dsh-tool-fs(Consumer)
inject: ['tools', 'fs', 'systemPrompt']
apply(ctx) {
ctx.tools.register(defineTool({ name: 'Read', execute: ... }))
ctx.tools.register(defineTool({ name: 'Write', ... }))
}
模型 永远看不见 ctx 对象本身;它只见 systemPrompt 组装出的 schema + tools.execute 的返回值。ctx.tools 是 运行时注册表,不是 prompt 字符串。
2.3.3 能力 seam 与其它常见 ctx 键
| ctx 键 | 层 | 说明 |
|---|---|---|
shell |
能力 Definition | dsh-shell;Provider 如 dsh-bash-local |
fs |
能力 Definition | dsh-fs-local + policy 插件 |
subagents |
能力 | dsh-subagent + spawn/fork Provider |
sessionPersistence |
Session 周边 | JSONL/SQLite;resume 用 |
jobs |
后台 | Task 工具、continuable subagent |
commands |
交互 | 用户命令,无需模型轮次 |
codeRuntime |
工具模式 | tools.mode: code 时的 SDK 渲染 |
2.3.4 TypeScript 类型 vs 运行时
typescript
declare module '@deepseek-ai/cordis' {
interface Context {
tools: ToolRuntime
agents: AgentRegistry
// ...
}
}
运行时 只有插件 provide 之后才有实现;未 mount dsh-tools 时读 ctx.tools 会抛「未 inject / inactive」。类型图方便 IDE;--dump-config 才是本机 ground truth。
2.4 Fiber 生命周期与 inject
PENDING ──依赖齐──► LOADING ──成功──► ACTIVE
▲ │
│ └──失败──► FAILED
└── 某 inject 服务 dispose ──► 回 PENDING / UNLOADING
ACTIVE ──dispose/HMR──► UNLOADING ──► DISPOSED
- PENDING :
inject列表里至少一个服务尚无 ACTIVE 提供方(或Service.check()为 false)。 - LOADING :跑
apply或 Service 构造函数;此阶段注册的effect在 unload 时逆序 disposer。 - ACTIVE :
fiber.store有效;其他插件可读该服务。
插件永远 PENDING 时:查是否漏 mount 提供方(常见:忘了 dsh-tools 或 llm 适配器)。
2.5 读 ctx.tools 时发生什么
scss
loopCtx.tools
→ Proxy get('tools')
→ waterfall('internal/get')
→ 从当前 Fiber 沿 parent 查 store['tools']( respect isolate 标签)
→ 命中 dsh-tools Fiber ACTIVE 时的 ToolRuntime 实例
Reflect 核心逻辑(vendor/cordis/src/reflect.ts):
rust
return ctx.events.waterfall('internal/get', ctx, prop, error, () => {
const key = target[symbols.isolate][prop]
let fiber = (ctx[symbols.shadow] as Context ?? ctx).fiber
while (true) {
const impl = fiber.store?.[prop]
if (impl) return getTraceable(ctx, impl.value)
...
fiber = fiber.parent.fiber
}
})
2.6 三种 ctx:别把 loopCtx 和 agent.ctx 混了
Agent 路径上会出现三个 Context;agents / tools 的注册位置取决于你在哪一个 ctx 上调用:
| ctx | 谁持有 | 典型用途 |
|---|---|---|
| loopCtx | AgentLoop 插件 fiber |
driver 构造、llm.stream、全局 listener |
| ownerCtx | 调用 agents.create 的 caller fiber |
lifecycle effect、ownership、取消 |
| agent.ctx | 每个 Agent 实例 | extend({ agent: this });仅该 agent 的工具 / section |
ini
AgentLoop 构造 ReactLoopAgent 时:
scope = createScope(loopCtx, agent)
agent.ctx = scope.ctx.extend({ agent })
在 agent.ctx 上 tools.register → 只对该 Agent 可见
在根 ctx 上 register → 全局继承(agent.ctx 解析时沿父链找到)
Preset、restrict、subagent 的「谁能看见哪些工具」都靠 注册 ctx + dsh-scope 层 ,不在 agent-loop 里写 if (preset === ...)。
2.7 Events 与 effect
| 模式 | Harness 用法 |
|---|---|
| waterfall | agent/pre-step、agent/request、tools/execute --- 须 next() 委托 |
| emit | agent/status、session/event |
| serial | agent/turn-stopping(无 next) |
effect :tools.register、事件监听器必须可逆 disposer;Fiber unload / HMR 时逆序执行,否则泄漏。
2.8 组合插件(Loader 家族)
| 插件 | 作用 |
|---|---|
| Loader | 维护 Entry 树;import、ctx.registry.plugin;!!js config 在 internal/config 插值 |
| Include | 嵌套 yml + patch 层(profile 叠加的基础) |
| Group | 整组 mount/unmount;配合 isolate 做 scope |
| HMR | 文件 / yml 变更 → Fiber dispose → 重新 import → remount |
Profile 叠加顺序:bundle → profile patch → home patch → --patch。yml 变更按 entry id diff,稳定 id 才能增量 HMR;缺 id 则任意编辑都可能 remount 全树。
第三部分 · 核心包、Session 与调度前准备
在 kick / turn / step 开始之前,Harness 要先完成:Profile 拼好插件树 → 核心包 ACTIVE → Session 账本建立 → Agent 绑定并发布。本节讲清 ground truth、事件如何分层、多 Agent 如何共存------没有单独的「调度内核」,只有多个独立 driver 与委派关系。
3.1 调度前:核心包就绪与 Agent 从哪来
3.1.1 六插件依赖(核心包 inject 图)
Agent 跑起来之前,agent-loop 的 Fiber 必须等到五样 inject 全部 ACTIVE。static inject :agent-loop → agents / sessions / llm / tools / systemPrompt;tools → systemPrompt。
| 就绪顺序(典型) | 含义 |
|---|---|
sessions + agents + llm + tools + systemPrompt |
核心包服务 mount 完成 |
agentLoop LOADING |
setFactory(this),开始接受 create/resume |
agents.create / config 启动 |
进入发布事务 |
agent/session-start |
第一个可安全做启动注入的扩展点 |
followup → kick |
触发 driver 循环 |
要点 :agentLoop 是插件,但 ReactLoopAgent 不能通过 yml 换类 ------可替换边界是整包 AgentFactory;日常改行为走事件,不改 driver 源码。
3.1.2 三条创建入口
| 入口 | 谁调用 | 典型场景 |
|---|---|---|
ctx.agents.create / resume |
UI、ACP、subagent、测试 | 指定 sessionId、可选 setup(agentCtx) |
agentLoop.config.agents[] |
cordis.yml 配置 | headless 预创建 main;Web profile 常为空,客户端按需 create |
ctx.agentLoop.create(...) |
示例 / 快捷 API | 内部 prepare → 立即 publish,随 AgentLoop fiber dispose |
应用与 UI 只面向 ctx.agents ,不 new ReactLoopAgent。
3.1.3 发布事务:create / resume 共用骨架
无论哪条入口,最终都走 setupAndPublish (dsh-agent-loop):在 Agent announce 之前 ,Session 与 Agent 都不进全局注册表。
rust
sequenceDiagram
participant Caller
participant AL as AgentLoop
participant SS as SessionStore
participant AR as AgentRegistry
participant Drv as ReactLoopAgent
Caller->>AL: create / resume
AL->>SS: prepare 或 persistence.prepare
Note over SS: Session 对象存在,未 enter
AL->>Drv: new ReactLoopAgent(loopCtx, id, options, session)
Note over Drv: Inbox 从 session.events 重放<br/>Phase 从最后 turn/start 恢复
AL->>AL: await setup(agentCtx)
Note over AL: 未发布:可在 agentCtx 注册 per-agent 工具/section
AL->>SS: enter(session)
AL->>AR: enter(agent, owner)
AL->>SS: announce → session/created
AL->>AR: announce → agent/created
AL->>Drv: agent/session-start { source }
AL-->>Caller: AgentHandle(配置启动无 handle)
任一步失败 → rollback:registry detach、scope dispose,不留半创建对象。
3.2 Session:append-only 账本
设计点(2)「模型可见 ⟺ 已记录」 的落地载体是 Session,不是 Agent 内存里的 messages[]。
| 原则 | 含义 |
|---|---|
| 事件溯源 | 只 append,不改旧事件;一切可 replay |
| log 是权威 | surface、deriveMessages、requestHeader 都是 解释 |
| 持久化是插件 | dsh-session 管内存接受与 session/event;JSONL/SQLite 订阅写盘 |
| Lossless JSON | payload 必须可无损快照(snapshotJsonValue) |
服务入口:ctx.sessions (dsh-session · SessionStore)。
3.3 四种视图:别混读
读 Session 时最容易混的是四种「视图」------它们都从同一份 log 来,但消费者不同:
perl
┌──────────────────────────────────────┐
│ Session.events(完整 append-only log) │
└──────────────────┬───────────────────┘
│
┌──────────────────────────┼──────────────────────────┐
▼ ▼ ▼
Session.surface deriveMessages() requestHeader()
nodes: seq[] → Message[] → EpochHeader
│ │ │
│ └─ 每 step buildRequest │
│ │
compaction 改这里 路由/epoch 快照
(log 不动) (与 messages 分离)
| 视图 | 是什么 | 谁消费 |
|---|---|---|
events |
全部已提交事件(冻结快照) | 持久化、UI 全量 transcript、replay、Inbox 重放 |
surface.nodes |
当前 模型可见 事件的 seq 有序列表 | deriveMessages()、compaction 选段 |
deriveMessages() |
把 surface 投影成 Message[] |
agent-loop buildRequest |
requestHeader() |
fold 最新 request/header |
provider/model 变更检测、resume 提案 |
人类 transcript vs 模型 history :UI 应读 append-origin 事件(读者已见历史不能被 surface replace 抹掉);模型只读 deriveMessages(compaction 后 history 变短,log 里原文仍在)。
3.4 事件分层:trace、模型可见、Agent 实时
3.4.1 执行 trace(进 log,通常不进 surface)
记录 怎么跑的 ,重建模型对话时不必全部进 messages:
| 事件 | 作用 |
|---|---|
turn/start · turn/end |
轮次边界;turn/end 带结束原因 |
step/start · step/end |
步骤边界 |
assistant/chunk |
流式 token 录像(UI/replay;不上 surface) |
tool/call |
工具调用审计(参数、callId;不上 surface) |
request/header · request/context |
本 epoch 路由、system/tools 快照元数据 |
llm/retry |
同 step 内 LLM 重试 |
compaction/* |
压缩 bracket(部分仅 log) |
agent/inbox/spliced |
Inbox 队列变更(持久化排队) |
| 插件扩展 | todo/write、hook/*、fs/observed... |
3.4.2 模型可见 message(必须带 surfaceOp)
只有三种 type 可上 surface:
| 事件 | surfaceOp |
投影结果 |
|---|---|---|
user/message |
append 或 replace { start, end } |
user 消息 |
assistant/message |
同上 | assistant(含 tool-call block) |
tool/result |
同上 | tool-result |
附加字段:
sourceEventSeqs--- 溯源(如assistant/message← 多个assistant/chunk;tool/result←tool/call)replace--- 只改 surface 索引 ,不删 log 里旧 seq(compaction 核心)
3.4.3 Agent 实时事件(不在 Session log)
| 事件 | 作用 |
|---|---|
agent/status |
Phase:idle / running |
agent/inbox/inserted · discarded · claimed |
Inbox 内存变更通知(UI) |
agent/created · agent/disposed |
注册表生命周期 |
进程 crash 后 Phase 与 Inbox 内存丢失 ;Session log 从磁盘 reload ,新 driver resume 后 Inbox 从 agent/inbox/spliced 重放。
3.5 Surface 机制与 deriveMessages
3.5.1 append 与 replace
bash
surfaceOp: append
→ surface.nodes 尾部 +1 seq
surfaceOp: replace { start, end }
→ 从 nodes 去掉 [start..end] 覆盖的 seq
→ 换成 1 个新 seq(log 里被 replace 掉的旧事件仍在)
compaction (dsh-compaction-basic 等)在 agent/pre-step 触发:生成 summary 事件,对旧 message 段做 surface replace ;从不改写历史 append 记录。
3.5.2 走读示例:summary 如何 replace 一段 history
假设某 Session 的 surface.nodes(括号内为事件 type 简写):
ini
seq: [10] [11] [12] [13] [14] [15] [16] [17]
u₀ a₀ t₀ a₁ u₁ a₂ t₁ u₂
└──────── 待压缩区(老) ────────┘ └──── retain 尾部 ────┘
- log(events) :seq 10--17 的 append 记录 永远保留(审计、UI 全量 transcript、replay)。
- token meter 测得 surface 总 token 超过阈值(默认约为 context window 的 80%)。
selectCompactableRange:从尾部往前保留约 16% window 的节点 → 压缩区为 seq 10--13;并 回退 cut 直到 tool call / tool result 配对平衡(不能把 assistant+tool_call 和 tool/result 拆开)。
压缩 commit 时 append(简化):
css
compaction/start
compaction/summary { shadowedSeqs: [10,11,12,13], ... }
user/message { content: "<compacted-summary>...</compacted-summary>" }
surfaceOp: replace { start: 10, end: 13 }
compaction/end
replace 之后 surface.nodes:
css
[18] [14] [15] [16] [17]
checkpoint_u u₁ a₂ t₁ u₂
deriveMessages() 此时约等于:[checkpoint_u, u₁, a₂, t₁, u₂] --- 模型 看不到 u₀/a₀/t₀/a₁ 原文,但 log 里 seq 10--13 仍在。
对比 阶段 A(tool result pruner) :对 单条 过长的 tool/result 做 单点 replace(head + marker + tail),不调 LLM;overflow 路径上常先于 summary 执行。
| 读者 | 读到什么 |
|---|---|
| 模型(deriveMessages) | replace 后的短 history |
| UI 全量 transcript | append-origin 事件,含被 replace 遮蔽前的原文 |
| 磁盘 log | 全部 seq,含 compaction bracket 与旧 message |
3.5.3 deriveMessages 做什么
scss
deriveMessages()
→ 只遍历 surface.nodes 当前 seq 列表
→ 每条 eligible 事件 → deriveEventMessage → Message 或 skip
→ replaceGeneration 变化时可能全量重建(增量优化对调用方透明)
agent-loop 在 user/message append 之后 才 deriveMessages() 拼 LLM 请求------因此本 step 刚写入的用户话 一定在 messages 里。
3.5.4 append 内部流水线(简化)
bash
session.append(type, data, { surfaceOp?, sourceEventSeqs? })
1. 校验 JSON、type 是否在 SessionEventMap、surface 规则
2. 分配 monotonic seq,写入 events 数组
3. 若有 surfaceOp → 更新 surface.nodes
4. emit session/event(UI、BFF、persistence 并行订阅)
Store 级 API:
| API | 作用 |
|---|---|
sessions.create(id?, { meta, seed? }) |
新建 live Session |
sessions.prepare / enter / announce |
agent-loop 发布事务 |
sessions.fork(source, boundary?, childId?) |
从已完成 turn 前缀 fork 子 Session(不自动创建 Agent) |
sessions.flush(session) |
等 persistence listener checkpoint |
3.6 Agent 与 Session 如何绑定
3.6.1 一个 id,两个注册表
ini
SessionId(进程内唯一)
│
┌─────────┴─────────┐
▼ ▼
SessionStore AgentRegistry
events / surface driver / Phase / Inbox 投影
│ │
└─────────┬─────────┘
▼
ReactLoopAgent
id === session.id
session: 构造注入的同一对象
硬约束:
agent.id === session.id === SessionId- 同一 id 同时只能有一个 live Session + 一个 live Agent
- Session 存事实 ;Agent 存行为(何时 kick、claim inbox、append 什么)
3.6.2 构造时绑定
ReactLoopAgent 构造(简化):
kotlin
constructor(loopCtx, id, options, session) {
this.inbox = new Inbox(session, notifications) // 从 session.events 重放 inbox
const lastTurn = session.events.findLast(e => e.type === 'turn/start')?.data.turn ?? 0
this.phase = { kind: 'idle', lastTurn }
this.scope = createScope(loopCtx, this)
this.ctx = this.scope.ctx.extend({ agent: this })
}
| 绑定 | 说明 |
|---|---|
agent.session |
全程向 同一 Session append |
Inbox(session) |
每次 splice 先 写 agent/inbox/spliced,再改内存队列 |
Phase.lastTurn |
从已有 log 恢复;resume 后不是「唤醒旧 Agent 对象」 |
agent.ctx |
per-agent 注册域(工具、section 只服务此 agent) |
3.6.3 create vs resume
| create | resume | |
|---|---|---|
| Session 来源 | prepare(id, { meta, seed? }) 空 log 或带 seed |
sessionPersistence.prepare(id) 冷加载 + 可选 crash repair |
| Agent 实例 | 总是新 driver | 总是新 driver(Phase → idle) |
session-start.source |
'startup' |
'resume' |
| 需要 persistence | 否 | 是(无盘则无法 resume) |
resume 语义:新 Agent 实例 + 旧 Session log,不是恢复 crash 前的内存 Phase。
3.6.4 Header 与 seed 边界
SessionHeader (创建时一次):{ id, version, cwd?, parentSession?, seedLength?, ... }
- fork 子 Session :
parentSession+seedLength标记继承前缀 session/end-seed:标记 constructor seed 结束;此前 seq 来自 fork/resume/replay,此后才是本进程 live append
request/header (每 epoch):{ config, system?, tools?, adapterDefaults? } + reason: initial | resume | change
- 与 messages 分离:改 provider/model 不必重写 history
3.6.5 setup 与调度前扩展
setup(agentCtx) (create/resume 选项):
- Agent 已构造、未 announce
- 可在
agentCtx上注册 仅该 agent 的工具 / system-prompt section - 不可
followup(driver 尚未发布)
发布后第一个扩展点 :agent/session-start --- 可 inject 首条上下文、触发启动逻辑。
3.7 多 Agent 场景
Harness 没有「多 Agent 中央调度器」------没有全局 turn 队列或内核线程。
ini
每个 Agent = 一个 ReactLoopAgent(独立 kick → while(turn()))
每个 Agent = 一个 SessionId = 一份 Session log
AgentRegistry = 进程内 id → Agent live 表
3.7.1 多种「多 Agent」来源
| 场景 | 怎么来的 | 调度关系 |
|---|---|---|
| 配置多根 agent | agentLoop.agents[] 各 create 一次 |
彼此独立,各跑各的 loop |
| Web 多会话 | 客户端多次 agents.create 不同 sessionId |
同上 |
| subagent 委派 | Task / subagent 工具 → agents.create 子 id |
树状 runtime owner;见下 |
| fork | sessions.fork 得 child Session → 再 create child agent |
Session 谱系在 header;≠ runtime owner |
| Jobs 后台 | jobs.start + one-shot 子 run |
父 step 不阻塞 |
3.7.2 三种关系不要混
| 关系 | 记录位置 | 含义 |
|---|---|---|
| live 注册 | AgentRegistry |
agents.get(id) 是否存在 |
| runtime owner | agents.enter(agent, owner) |
谁创建了此 agent(subagent 父链) |
| 持久谱系 | SessionHeader.parentSession |
fork/resume 用的 Session 父子 |
根 agent:owner = undefined(agents.roots())。
3.7.3 并发模型:多 driver、非共享循环
不同 Agent = 不同 async driver Promise(OS 线程上并发 async,不是同一个 for 循环):
arduino
父 driver: ... step → executeToolCalls → [await 子工具?] → ...
子 driver: kick → turn → step → ... (完全独立 Session log)
| 委派模式 | 父 driver 是否等待 | 典型 |
|---|---|---|
| 前台 one-shot subagent | 等 child.whenIdle() |
父 step 卡在 tool execute |
| 后台 one-shot(Jobs) | 不等 | execute 立刻返回 jobId |
| continuable | 几乎不等 | 子 agent 自主 FIFO;父通过 send_message / 读子 Session 跟进 |
同一 agent 内 不会两个 kick 真并行:wakeDriver 在 non-idle 时 latch。
withInitiator :每次 kick() 在 withInitiator(this, ...) 内运行,使 ctx.agents.requireInitiator() 指向当前 driver------subagent 并发时各自隔离 initiator 链。
3.7.4 fork 与 subagent 对比(调度前视角)
| fork | spawn subagent | |
|---|---|---|
| Session | 新 id,继承 prefix + 新 live append | 新 id,空 或 policy 定 seed |
| Agent | 需另行 create |
工具内 agents.create |
| 模型 context | 继承父 Session 已完成 turn 前缀 | 通常 fresh agent(fork 工具则继承) |
| 典型用途 | 分支探索、checkpoint 实验 | 并行任务、专家委派 |
3.7.5 ContinuationManager:continuable 子 agent 谁协调
one-shot subagent(父 step 里 await child.whenIdle())不经过 ContinuationManager。continuable 模式才需要它------ctx.subagents 背后的 SubagentContinuationManager (packages/subagent/subagent/src/continuation.ts)。
为什么需要单独一层
可继续子 agent 同时要满足:
| 需求 | 负责方 |
|---|---|
| 持久 Session(跨重启) | Session + persistence |
| 进程内 live driver | agent-loop |
| FIFO 轮次队列 | Agent Inbox(每个 agent 唯一) |
| 何时物化 / 唤醒 / 冷恢复 | ContinuationManager |
| 父何时能 dispose | ownedChildren 所有权图 |
| 子结束后如何告知父 | notifySettlement (不能靠外部 subagent/end listener------那时 child handle 可能已 dispose) |
三个生命周期(不要混):
bash
Continuable Session(持久,childId 稳定,log 里有 subagent/descriptor)
└─ Activation(进程内,同一 childId 同一时刻 ≤1)
├─ AgentHandle + 子 driver
├─ inbox → agent-loop 执行 turn
└─ ownedChildren → 尚未 settle 的子孙 Activation id
启动与后续消息
startContinuable (工具返回 childId + messageId 时):
markdown
1. 预留 childId,写 subagent/descriptor 到 seed
2. provider.prepareContinuable() → seed(仅 detached 数据)
3. materialize → agents.create 或(冷路径)agents.resume
4. submitMaterialized(initial prompt) → child.followup()
5. return { childId, messageId } // inbox **接受**即成功,不保证 turn 已开始
followup(parent, childId, content) (父后续发消息)在 per-child 锁 内:
| 情况 | 行为 |
|---|---|
| 无 Activation | coldResume → 读 descriptor + log → materialize → submit |
| Activation 正在 disposal | 等 teardown 结束 → 重试 / coldResume |
| 有 live Activation | submitAdmitted → child.followup() + wake |
鉴权:parent 必须是 live 对象,且 parent.id === child.session.header.parentSession(持久直接父)。
三态 residency(推导,非独立状态机)
ini
stateOf(activation):
running ← Agent.status === 'running' 或 accepted 非空(followup 已接受但未 claim 的窗口)
waiting ← idle 且 ownedChildren 非空(自己 quiet,子孙还在)
settled ← idle 且 ownedChildren 空 → 可 dispose handle
lua
stateDiagram-v2
direction LR
[*] --> running: materialize + submit
running --> waiting: whenIdle 且 ownedChildren 非空
running --> settled: whenIdle 且 ownedChildren 空
waiting --> running: followup 唤醒
waiting --> settled: 所有子 Activation dispose
settled --> [*]: dispose handle + notifySettlement
ownedChildren 图 :子 startContinuable 时,acquireOwnership(父, childId) 把 childId 记入 直接父 的 set。父在 waiting (子孙未 settle)时 不能 dispose,避免树半途解散。
结算:child-first dispose + 通知父
watchSettlement 每个 Activation 一个 async 循环:whenIdle → 若 stateOf === 'settled' → dispose (先递归 dispose 子孙,再 handle.dispose())。
notifySettlement 在 releaseOwnership 之前 调用(避免父 watcher 误判已无子 agent):
| 父状态 | 投递方式 |
|---|---|
| 父正在 teardown | inject(不唤醒,避免 dispose 前多跑一轮 LLM) |
| 父 idle | followup(普通新 turn,带 settlement 摘要) |
对 announced === true 的子(调用方曾拿到过 messageId)无条件 通知,不要求子调过 report。
continuable 模式下,父 driver 几乎不等 子跑完;父子通过 Manager + inbox + settlement 消息 异步协作,而不是共享一个 kick 循环。子 agent 自身的 turn/step 机制与根 agent 相同。
3.8 能力 seam 周边与表现(入口/UI)
能力 seam 周边 :FS、Shell、SubAgent、持久化等按 Capability seam 三角色组织(Shell 详例见上文)。它们在 Agent 创建前 mount 到 ctx;Consumer 在根或 agent.ctx 上 tools.register,决定 哪些 agent 看见哪些工具。
入口与表现 :CLI / Web BFF boot 插件树 ;Web Client、SDK、ACP 不嵌入 agent-loop --- 驱动 ctx.agents、订阅 session/event 渲染 transcript,订阅 agent/status / inbox 事件更新 UI。持久化插件同样 listen session/event,SessionStore 本身不打开文件。
Agent 开始处理用户输入前,通常应满足:
sql
□ cordis.yml 核心包 + 能力 seam Consumer 已 ACTIVE
□ Session 已 prepare/enter/announce(或 resume 加载)
□ Agent 已 enter registry,session-start 已处理
□ 用户 followup → Inbox splice 持久化
第四部分 · Agent 执行机制与 Context 工程(贯穿例子)
Session 账本与 Agent 绑定就绪后,本节从 followup 触发 driver 起,讲清 Phase / Inbox 如何协调调度 ,kick / turn 如何循环 ,以及 每 step 的 Context 如何组装 ------全部沿例子:followup("请阅读 README.md 并用一句话总结") → 模型调 Read → 文字总结。
4.1 总览:一条 followup 穿过哪些层
css
flowchart TB
subgraph input["输入层"]
FU["followup / steer / inject"]
IB["Inbox(next-turn / next-step)"]
FU --> IB
end
subgraph driver["Driver 层(内存)"]
WD["wakeDriver"]
K["kick: while(turn())"]
PH["Phase: idle ↔ running"]
WD --> K --> PH
end
subgraph turnloop["Turn / Step 层"]
PS["preStep: claim + assemble + pre-step"]
ST["step: derive + buildRequest + stream + tools"]
PS --> ST
end
subgraph ground["Ground truth(Session log)"]
SPL["agent/inbox/spliced"]
TR["turn/* step/* user/message ..."]
IB --> SPL
PS --> TR
ST --> TR
end
IB --> WD
K --> turnloop
arduino
followup
→ Inbox.splice('next-turn') + agent/inbox/spliced(持久排队)
→ wakeDriver(仅 idle 时新开 kick)
→ kick: while(turn())
turn: preStep → step/start + user/message → step() → step/end
→ turn/end → idle(或 inbox 仍有活 → 同一 kick 开下一 turn)
4.2 Phase:driver 内存状态机
Phase 不是 Session------二者分工如下:
| Phase | Session | |
|---|---|---|
| 是什么 | ReactLoopAgent 内 内存状态机 |
append-only 事件账本 |
| 会不会落盘 | 否(crash 丢失) | 是(persistence) |
| 典型数据 | idle / running / maintenance;turn、step;AbortController |
turn/start、user/message、tool/result... |
| 给谁用 | 协调「是否在跑、第几轮第几步、能否 cancel」 | 重建 history、UI transcript、resume |
Phase 三种形态(agent.ts):
css
type Phase =
| { kind: 'idle'; lastTurn: number }
| { kind: 'maintenance'; abort; lastTurn; wakeRequested }
| { kind: 'running'; abort; turn; step; wakeRequested }
| Phase | 对外 agent/status |
含义 |
|---|---|---|
| idle | idle |
无 driver 在跑;可 wakeDriver 开新 kick |
| running | running |
kick → turn → step 循环占用;持有 abort 供 cancel |
| maintenance | idle(外观) |
runMaintenance 独占 driver;inbox 可排队,结束后再 wake |
resume 后 :Phase 从 { idle, lastTurn } 起步(lastTurn 从 log 里最后一个 turn/start 推断);不是恢复 crash 前的 running。
setPhase 在状态变化时 emit agent/status,UI 据此显示「思考中 / 空闲」。
4.2.1 maintenance 与 wakeRequested latch
wakeRequested 是 running / maintenance Phase 上的 「待会再开 driver」 标志,不是 Inbox 队列的一部分。
wakeDriver() 在 phase !== idle 时通常直接 return,但有两种情况会 只 latch、不重复开 kick:
| 场景 | wakeDriver 行为 |
|---|---|
| running + 普通 followup/steer | return;不设 latch --- 当前 kick 会在 turn 边界自己 claim |
| maintenance 期间 又来带 wake 的消息 | phase.wakeRequested = true |
cancel 尚未收敛到 idle 时又来带 wake 的消息(wakeAfterAbort) |
phase.wakeRequested = true |
dispose teardown (reason.kind === 'disposed') |
不 latch --- 避免 dispose 期间再开 turn |
maintenance (runMaintenance):
scss
仅 idle 可进入 → setPhase(maintenance)(对外 status 仍显示 idle)
→ 跑独占 job(signal 可 abort)
→ finally: setPhase(idle)
→ 若 maintenance.wakeRequested && inbox.hasPending → wakeDriver()
maintenance 期间用户仍可 followup (消息进 Inbox 并持久化),但不会并行跑 turn;job 结束后若 latch 了 wake,再 一次性 开 kick。
kick 结束时补偿 (finally):
scss
setPhase({ idle, lastTurn: turn })
if (wakeRequested && inbox.hasPending) wakeDriver() // cancel / maintenance 后的延迟唤醒
连开多 turn 时 :turn() 在 inbox 仍有活时会 换新 AbortController 并 wakeRequested = false (旧 latch 作废),由 同一 kick 继续下一 turn,不经 idle。
4.3 Inbox:持久排队与 claim 规则
Inbox(packages/core/agent/src/inbox.ts)是 Agent 上 唯一的 FIFO 输入队列 ,但分 两条车道:
| 队列 | 写入 API | 何时 claim |
|---|---|---|
next-turn |
followup() |
新 turn 的第一个 preStep('next-turn') 取 1 条 |
next-step |
steer()、inject();工具 additionalContexts |
每个 step 的 preStep('next-step') 取 全部 |
4.3.1 splice:先落 Session,再改内存
每次改队列都走 inbox.splice:
markdown
1. append agent/inbox/spliced(target, deleteCount, insertMessages)
2. 更新内存 next-turn / next-step
3. emit agent/inbox/inserted | discarded | claimed
构造 Inbox 时 从 session.events 重放所有 agent/inbox/spliced------resume 后排队不丢。
4.3.2 claim:step 边界领走输入
arduino
claim(target, turn) {
const claimed = 清空并取出全部 next-step
if (target === 'next-turn') claimed.push(取出 next-turn 的 1 条)
for (m of claimed) notifications.claimed(m, turn)
return claimed
}
同 turn 内 :第一个 step 用 target='next-turn'(可领到 followup);后续 step 用 target='next-step'(不会 再碰 next-turn 里排队的下一条 followup)。
4.3.3 followup / steer / inject 对比
| API | 队列 | 是否 wake | running 时何时处理 |
|---|---|---|---|
followup |
next-turn |
是 | 下一 turn 的第一个 preStep |
steer |
next-step |
是 | 本 turn 下一 step 的 preStep |
inject |
next-step |
否 | 同上,但不 kick idle driver |
4.3.4 running 时又来 followup:只入队,不新 kick
scss
wakeDriver() {
if (phase.kind !== 'idle') return // running:Live drivers claim queued work themselves
setPhase(running); withInitiator(this, () => kick())
}
时间线(agent 正在 step1 跑工具时用户又 followup):
arduino
T1 followup → next-turn += 消息;wakeDriver → 非 idle → return(不新 kick)
T2 当前 step 结束 → step/end
T3 同 turn 若还有 next-step → step2(仍不碰 next-turn)
T4 turn/end
T5 inbox.hasPending → turn() return true → 同一 kick 开 turn+1(不经 idle)
T6 preStep('next-turn') → claim 到 T1 的消息 → user/message → 模型
同一 kick 可连开多 turn (inbox 不空就不回 idle);只有 turn() 返回 false、kick 结束,才变 idle------此时新 followup 才需要再次 wakeDriver。
4.3.5 cancel:对 Phase、Inbox 与 turn 收尾
scss
cancel(cause, options = {}) {
if (!options.keepInbox) {
inbox.clear() // 默认:清空两队列
if (phase.kind !== 'idle') phase.wakeRequested = false // 取消待唤醒 latch
}
if (phase.kind !== 'idle') phase.abort.abort(cause)
}
| 选项 / 效果 | 行为 |
|---|---|
默认 (无 keepInbox) |
inbox.clear() → 所有排队消息 discarded (写 agent/inbox/spliced + emit discarded);不会再进模型 |
keepInbox: true |
队列保留;用于如 interrupt_agent「停当前轮、保留排队」 |
phase.abort.abort(cause) |
running 中 turn/step 在下一处 signal.throwIfAborted() 停止 |
| Session log | 已 append 的 turn/step/message 不回滚 ;当前 turn 以 turn/end { reason: aborted } 收尾 |
cancel 时间线(running 中用户点停止) :
scss
T0 cancel(cause)
→ inbox 清空(默认)或保留(keepInbox)
→ abort.signal 触发
T1 当前 stream / tool execute / preStep 检测到 aborted → 抛错
T2 turn() catch → turnEnds = { kind: 'aborted', reason: cause }
T3 append turn/end(aborted)
T4 kick catch 吞掉 rejection(错误已通过 agent/error 等上报)
T5 kick finally → setPhase(idle)
→ 默认无 wakeRequested、inbox 空 → 结束
cancel 尚未 idle 时又发 followup/steer (send 里的 wakingAfterAbort):
c
const wakingAfterAbort = wakeup && phase.kind !== 'idle' && phase.abort.signal.aborted
const resolvedTarget = wakingAfterAbort ? 'next-turn' : target // 强制进下一轮,不进已死的 step
inbox.splice(resolvedTarget, ...)
if (wakeup) wakeDriver(wakingAfterAbort) // latch wakeRequested,等 kick 进 idle 后再开
要点:已 abort 的 turn 不会再 claim 新消息 ;新任务必须等 driver 边界收敛到 idle(或 latch 后在 kick.finally 补偿唤醒)后,从 新 turn 开始。
4.4 wakeDriver 与 kick
Agent 的 driver (驱动器)不是单个函数,而是 两层分工 :wakeDriver 负责「要不要开工、Phase 怎么切」;kick 负责「开工后连续干多少 turn、何时收工」。用户 followup 从不直接调 turn() ,路径固定为:
scss
followup / steer(wake)
→ wakeDriver() // 同步:守门 + 可能 setPhase(running)
→ kick() // 异步:while (turn()) 直到 inbox 空
→ turn() → step() ...
4.4.1 概念对照
| wakeDriver | kick | |
|---|---|---|
| 是什么 | 启动器(synchronous gate) | 工作循环(一次 activity 的 async 主入口) |
| 谁调用 | followup / steer(带 wake)、kick.finally(补偿唤醒)、runMaintenance.finally |
仅 wakeDriver(idle 时) |
| 粒度 | 一次「唤醒决策」 | 一次唤醒到 idle 的整段会话 |
| Phase | idle → running (或 latch wakeRequested) |
持有 running,结束时 → idle |
| 是否 await | 否(fire-and-forget 安排 kick) | 是 (内部 await turn() 循环) |
| 是否领 Inbox | 否 | 否 (领消息在 preStep → claim) |
| 是否调 LLM | 否 | 否 (调模型在 step) |
与相邻层级一起记(由外到内):
arduino
wakeDriver → kick → turn → step
一次唤醒 一次 activity 一轮对话 一次模型请求+工具
4.4.2 wakeDriver:守门与开工
作用 :在 idle (或 maintenance 结束)时,把 Agent 从「可接收输入」切到 running ,并 异步启动一次 kick ;在 已有 activity 时决定是 忽略重复唤醒 还是 latch 待会再跑。
scss
private wakeDriver(wakeAfterAbort = false): void {
if (phase.kind !== 'idle') {
// running:当前 kick 自己会 claim,直接 return
// maintenance / wakeAfterAbort:只设 wakeRequested,不叠第二个 kick
if (...) phase.wakeRequested = true
return
}
activityDone = new Promise(...) // 绑定本次 kick 生命周期
setPhase({ kind: 'running', abort, turn: lastTurn, step: 0, ... })
withInitiator(this, () => kick()).then(resolve activityDone)
}
wakeDriver 负责的事:
- 检查 Phase:同一时刻最多一个 kick(running 时不重复开)
- 创建本轮
AbortController(供cancel使用) - 设置
activityDone---whenIdle()/ 父 subagentawait child.whenIdle()等的是 kick 整段结束 - 在
withInitiator(this, ...)内启动 kick --- 工具/subagent 可通过requireInitiator()知道 谁发起的这轮 driver
wakeDriver 不负责的事 :不 claim inbox、不 append Session、不 assemble prompt、不调模型------这些都在 turn / step / preStep 里。
4.4.3 kick:一次 activity 的主循环
作用 :从 running 开始,循环 turn() 直到 inbox 没有待办(turn() 返回 false),然后在 finally 统一回到 idle------无论正常结束、LLM 报错还是 cancel。
csharp
private async kick() {
try { while (await this.turn()) {} }
catch { /* turn/step 错误已在边界上报;此处 containment */ }
finally {
if (phase.kind === 'running') {
setPhase({ idle, lastTurn: turn })
if (wakeRequested && inbox.hasPending) wakeDriver() // 补偿唤醒
}
}
}
kick 负责的三件事:
- 调度 turn ---
turn()返回true(inbox 仍有 followup 等)→ 同一 kick 内 开下一 turn,不必先回 idle 再 wake - 错误边界 ---
turn/step抛错或 abort 在catch收敛,避免未处理的 rejection;turn/end仍带aborted/errorreason - idle 收尾 ---
finally里 唯一 把 Phase 从 running 切回 idle 并 emitagent/status: idle;必要时触发 补偿wakeDriver
kick 不负责的事 :不解析用户消息内容、不决定 tool schema------只 驱动 turn 循环 直到队列排空。
4.4.4 协作流程图
rust
flowchart TD
START["followup / steer(wake)"] --> SPLICE["Inbox.splice + inbox/spliced"]
SPLICE --> WD{"wakeDriver<br/>(启动器)"}
WD -->|"phase !== idle"| LATCH["maintenance/aborted: wakeRequested latch<br/>running: 直接 return"]
WD -->|"phase === idle"| RUN["setPhase(running)<br/>activityDone = kick Promise<br/>withInitiator → kick()"]
RUN --> KICK["kick(工作循环)"]
KICK --> LOOP{"while await turn()"}
LOOP -->|"true: inbox 仍有活"| LOOP
LOOP -->|"false"| FIN["finally: setPhase(idle)<br/>wakeRequested && hasPending → wakeDriver again"]
4.4.5 生命周期简图
arduino
idle
│ followup + wakeDriver(同步 setPhase running,异步 kick)
▼
running ── kick 开始 ─────────────────────────────┐
│ turn 1 → turn 2 → ... → turn N │
│ (turn() false 时退出 while) │
▼ │
idle ◄── kick finally(emit status idle)─────────┘
│
└─ wakeRequested && hasPending → wakeDriver → 又一次 kick
与 maintenance / cancel 的衔接 :maintenance 期间 wakeDriver 只 latch;cancel 默认清 inbox 并 abort 当前 kick,由 kick.finally 收工到 idle;abort 期间新来的 followup 走 wakingAfterAbort latch,idle 后再补偿 kick。
4.5 turn:轮次内的 step 循环
rust
flowchart TD
TS["append turn/start; turn++"] --> INIT["target = next-turn"]
INIT --> PS["preStep(target)"]
PS --> REJ{"reject?"}
REJ -->|是| TE["turn/end blocked"]
REJ -->|否| EMPTY{"step0 且 messages 空?"}
EMPTY -->|是| TE2["turn/end completed(无 model call)"]
EMPTY -->|否| SS["append step/start"]
SS --> UM["append user/message × N"]
UM --> ST["step(assembly)"]
ST --> SE["append step/end"]
SE --> ENDCHK{"turnEnds && nextStep 空?"}
ENDCHK -->|是| SERIAL["agent/turn-stopping"]
ENDCHK -->|否| MORE{"turnEnds && nextStep 空?"}
MORE -->|是| BREAK["break 内层 loop"]
MORE -->|否| NS["target = next-step → 下一 step"]
NS --> PS
BREAK --> TEND["append turn/end"]
TEND --> PEND{"inbox.hasPending?"}
PEND -->|是| NEWAB["新 AbortController; step=0; return true"]
PEND -->|否| RET["return false → kick 结束"]
turn 与 step 语义:
- Step = 一次模型请求 + 它触发的工具执行(可能 0 个 tool-call)。
- Turn = 零个或多个 step;在领取首条输入前打开,在不再欠工作时关闭。
例子 turn1:
| step | target | claim 到 | 模型行为 |
|---|---|---|---|
| 1 | next-turn |
用户「请阅读 README...」 | 调 Read |
| 2 | next-step |
常为空 | 读 tool result,文字总结 |
step1 返回 null(还有 tool 后续工作)→ turn 继续;step2 返回 { completed } 且 nextStep 空 → turn/end。
4.6 Context 工程:每 step 三块如何拼
设计点(2) 在执行层的体现:进 LLM 的三块都必须能从 Session 重建 ;且 每个 step 重新 assemble,不是启动时拼一次。
4.6.1 三块与来源
| 块 | 写入 request | 主要来源 |
|---|---|---|
| system | request.system |
renderPrompt(assembly) --- sections + variables |
| tools | request.tools |
assembly.tools --- 本 agent scope 可见 schema |
| messages | request.messages |
session.deriveMessages() --- surface 投影 |
arduino
const system = renderPrompt(assembly)
const msgs = this.session.deriveMessages() // user/message append 之后
await buildRequest(turn, step, assembly.tools, system, msgs, signal)
4.6.2 preStep 组装流水线(Context 核心)
每个 step 在 preStep 按固定顺序发生:
scss
① inbox.claim(target) → 本 step enter 批次(用户话 / steer / 工具 additionalContexts)
② systemPrompt.assemble(agent) → sections + contexts + tools + variables
③ runtimeContext.project(...) → 动态快照(可选)→ 额外 UserMessage
④ agent/pre-step waterfall → 准入 reject | enter(可改写 messages)
⑤ step/start + user/message append(decision.messages)
⑥ step() → derive + buildRequest → LLM
kotlin
const claimed = this.inbox.claim(target, position.turn)
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
const decision = await this.dispatch.waterfall('agent/pre-step', { messages: claimed, ... },
() => ({ kind: 'enter', messages: context ? [...claimed, context] : claimed }))
例子 step1 enter 批次 (可能):[用户句, runtime 工作区快照?, time-context 时间块?]
4.6.3 systemPrompt.assemble 合并什么
ctx.systemPrompt.assemble({ agent, signal }) → PromptAssembly:
| 字段 | 内容 |
|---|---|
sections |
persona、部署说明、工具 guidance...(scope shadowing) |
contexts |
命名上下文块(可进 runtime 快照) |
tools |
当前 step 可见 tool schemas(toolOrder 排序) |
variables |
{{cwd}}、{{model}} 等插值 |
最后走 system-prompt/assemble waterfall,插件可改 assembly。
工具 schema 五层漏斗 (Harness 不替模型选工具,只决定 schema 列表):
c
Profile 挂载哪些 tool 插件
→ register 在根 ctx 还是 agent.ctx
→ tools.view(scope) + restrict
→ wireSchemas / mode(native | code | both)
→ toolOrder + assemble waterfall
4.6.4 动态注入两条路径
| 路径 | 机制 | 进 history 方式 |
|---|---|---|
| A:runtime 快照 | assembly.contexts → RuntimeContextProjection.project |
内容变化才生成 user/message(source: plugin/system-prompt) |
| B:pre-step 插件 | agent/pre-step 改 enter.messages |
如 time-context 每 step 追加时间 |
compaction (compaction-basic @ pre-step):token 超阈值 → surface replace → deriveMessages() 自然变短;loop 无特殊分支。
4.6.5 buildRequest:请求锚点
buildRequest 意图链:
- seedConfig --- 首次用 AgentOptions;之后
requestProposal(session.requestHeader()) agent/requestwaterfall --- 改 provider/model/采样(不能改 messages)llm.prepareCall--- 适配器、stream、retry- append
request/header、request/context(epoch 变化时) deepFreeze(GenerateOptions)--- 交给llm.stream
request/header 与 messages 分离:改路由不必重写 history;compaction 设计也尽量保留 prefix 以利于 provider cache。
4.6.6 Context 扩展点地图
| 想改什么 | 扩展点 | 影响 |
|---|---|---|
| 系统提示词段落 | ctx.systemPrompt.section() |
request.system |
| 工具列表 | tool provider + scope | request.tools |
| 每 step 动态文本 | contexts 或 pre-step | user/message → messages |
| 拦截本 step 输入 | agent/pre-step |
enter 批次 |
| 改模型路由 | agent/request |
config |
| 压缩 history | compaction @ pre-step | deriveMessages() |
| 工作区 AGENTS.md | agent-instructions |
baseline + fs 触发 |
4.7 step 内:stream、tool-call 与工具流水线
4.6 在 preStep 拼好 Context 并 append user/message;step(assembly) 负责 一次模型往返 + 该次返回的全部 tool-call 执行 。工具能力在 ctx.tools(ToolRuntime) ,不在 loop 里硬编码------loop 只做 调度、写 Session、接 inbox。
4.7.1 step() 在 turn 中的位置
bash
preStep → step/start → user/message append
→ step(assembly)
├─ buildRequest + llm.stream(可能 retry 循环)
├─ assistant/chunk* → assistant/message
├─ 无 tool-call → 返回 { completed }
└─ executeToolCalls → tool/call + tool/result → 返回 null | { completed }
→ step/end
step() 内部有 while (true) 包裹 LLM 调用:同一步内若 agent/request-error 决定 retry ,会 不 append 新 assistant/message 直接再 stream 一次。
4.7.2 LLM stream:chunk 与 assistant/message
arduino
for await (const chunk of stream) {
this.session.append('assistant/chunk', { turn, step, chunk })
assembler.push(chunk)
}
this.session.append('assistant/message', { message, ... },
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs })
| 事件 | surface | 作用 |
|---|---|---|
assistant/chunk |
通常 否 | 流式 UI、crash 中间态 replay;sourceEventSeqs 链到最终 message |
assistant/message |
append | canonical 助手回复;含 text / reasoning / tool-call blocks |
stream 结束后:
max-tokens→ step 返回{ kind: 'max-tokens' }(turn 结束 reason 可能 sticky)- 无 tool-call block →
{ kind: 'completed' }→ turn 可能在 step 边界结束 - 有 tool-call → 进入
executeToolCalls
4.7.3 step 返回值与 turn 是否续步
javascript
const toolCalls = message.content.filter(block => block.type === 'tool-call')
if (toolCalls.length === 0) return { kind: 'completed' }
const { concluded } = await executeToolCalls(...)
return concluded ? { kind: 'completed' } : null
step() 返回 |
含义 | turn 外层 |
|---|---|---|
{ completed } |
无 tool-call,或工具声明 concludesTurn |
nextStep 空 → 可 turn/end |
null |
有 tool-call 且 turn 未 被工具结束 | 同一 turn step+1(常无新 user/message) |
{ max-tokens } |
输出截断 | turn 可能结束 |
4.7.4 两条线:Loop 调度 vs ToolRuntime 流水线
Harness 故意把 多 call 调度 与 单 call 策略/执行 拆开:
vbscript
┌─ agent-loop(tool-calls.ts)────────────────────────────┐
│ 模型 tool-call 顺序、并行池、barrier │
│ Session:tool/call、tool/result append │
│ additionalContexts → inbox next-step │
└───────────────────────────┬────────────────────────────┘
│ 每个 call
▼
┌─ dsh-tools(ToolRuntime)───────────────────────────────┐
│ prepare → dispatch(execute) → post-execute → finalize │
│ tools/pre-execute、guard、approval、工具定义回调 │
└─────────────────────────────────────────────────────────┘
| 路径 | 写 Session turn/step? | 走 ToolRuntime 流水线? |
|---|---|---|
executeToolCalls(Agent loop) |
是 --- tool/call + tool/result |
是 |
手动 ctx.tools.execute |
否(除非插件自 append) | 是 --- 无 turn 边界 |
设计点(1)的体现:Read / bash / Task 等是 Consumer 注册的工具;loop 只调用 ctx.tools 的调度接口,不 spawn、不直接读文件。
4.7.5 executeToolCalls:规划、分组与并行
文件:packages/core/agent-loop/src/tool-calls.ts。
对每个 tool-call block 规划 PlannedCall:
scss
callId ← block.id(模型权威 id;tool/result 必须对齐)
name ← block.name
arguments← JSON.parse;失败保留 raw
agent ← ctx.agents.requireInitiator()(须在 kick 的 withInitiator 内)
signal ← 与 step 共享的 AbortSignal
分组调度:
csharp
while 还有未处理 call:
mode = executionMode(第一个未处理) // exclusive | parallel
group = parallel ? 连续 parallel-safe 段(受 barrier 截断): [当前一个]
await runGroup(group, mode)
| 模式 | 行为 |
|---|---|
| exclusive(默认) | 一次一个 call;形成 barrier |
| parallel | 仅当工具 isConcurrencySafe(args) === true;有界池(maxParallelToolCalls) |
并行池规则:工具 body 可重叠执行 ,但 tool/result 写入 Session 的顺序 = 模型 tool-call 顺序;池中若下一个变 exclusive → 先 drain 再开新 barrier。
4.7.6 ToolRuntime 单 call 生命周期
Loop 通过 TOOL_RUNTIME_SCHEDULER 驱动,不直接散落调用 tools.execute:
sql
1. appendToolCall
session.append('tool/call', { turn, step, callId, name, arguments })
→ log-only,不进 surface(模型已在 assistant/message 见过 call)
2. prepare(exec)
解析/快照 arguments
tools/pre-execute waterfall → allow | deny | ask
guard(单调,pre-execute 之后)
→ dispatch | post-result | final-result(参数错、deny、abort 等可跳过 body)
3. dispatch(exec) → ToolDefinition.execute(args, exec)
4. finalize / finish → post-execute、finalizeContent
5. appendToolResult
session.append('tool/result', { message }, { surfaceOp: 'append', sourceEventSeqs: [callSeq] })
Cordis 事件(实时,不写 Session) :
| 事件 | mode | 典型用途 |
|---|---|---|
tools/pre-execute |
waterfall | 审批、策略 deny/ask |
tools/post-execute |
waterfall | 改 result content、附加 meta |
tools/result |
emit | UI 监听完成 |
deny / 无 approval 的 ask :仍写 synthetic tool/result(模型可见错误文本),保证 surface 配对完整。
code 模式 :模型直接调非 run_code 的工具 → prepare 阶段即拒绝(collapse 规则)。
4.7.7 Session 与 surface:为何 tool/call 与 tool/result 分开
| 事件 | surface | deriveMessages |
|---|---|---|
assistant/message(含 tool_calls) |
✅ | ✅ assistant |
tool/call |
❌ | ❌ --- 审计、UI pending、sourceEventSeqs 关联 |
tool/result |
✅ | ✅ tool-result(callId 对齐) |
下一轮 deriveMessages() 自然序列:
sql
... user → assistant(含 tool_calls)→ tool-result → ...
callId 必须来自模型 block.id;post-execute 可改 content,不可改 id。
4.7.8 additionalContexts:工具结果之外的下一步输入
工具或 tools/post-execute 可返回 additionalContexts: UserMessage[] 。
Loop 在 该 call 的 tool/result 已 commit 之后,按模型顺序:
ini
context => inbox.splice('next-step', inbox.nextStep.length, 0, [context])
同一 step 内所有 tool/result 写完后 ,这些 context 才进 inbox;下一 step 的 preStep → claim('next-step') 把它们当作 enter 批次 append 为 user/message 。设计意图:tool/result 与注入 context 在 log 里 相邻,不插在两次 tool 结果中间。
4.7.9 cancel 与 skipped tool-call
step 的 signal abort 时:
- 已启动的 call:drain → commit 已有 result 或 abort 替换
- 未启动 的 call:仍写
tool/call+ synthetictool/result(如TOOL_ABORTED_BEFORE_DISPATCH),保证 replay 配对平衡 - 已 commit 的
additionalContexts仍可进 next-step
surface 上不会出现「有 call 无 result」的悬空态。
4.7.10 无 Agent 的对比实验(greet demo)
不经过 turn/step 也可测 ToolRuntime 流水线------但没有 Session turn 边界:
php
// 最小插件:inject tools,register greet,直接 execute
void ctx.tools.execute({
callId: CallId('demo-1'), name: 'greet',
arguments: { name: 'Harness' }, signal: abortSignal,
})
可见 tools/pre-execute、tools/result,但 无 turn/start 。完整 Agent 路径必须走 executeToolCalls,才会把 tool/call、tool/result 与 turn/step 对齐写入 log。
4.7.11 贯穿例子:step1 调 Read
rust
sequenceDiagram
participant S as step()
participant L as llm.stream
participant E as executeToolCalls
participant T as ToolRuntime(Read)
participant Log as Session
S->>L: deriveMessages + buildRequest
L-->>S: assistant/message + tool-call Read
S->>Log: assistant/message (surface)
S->>E: toolCalls[Read]
E->>Log: tool/call
E->>T: pre-execute → execute(README path)
T-->>E: content blocks
E->>Log: tool/result (surface)
E-->>S: concluded=false → return null
Note over S: turn step2:derive 含 tool result,无新 user/message
- step1 返回
null→ turn 内 step2 - step2
deriveMessages()≈ user + assistant(tool-call) + tool(result) → 模型文字总结 →{ completed }→turn/end
工具扩展挂点 (不改 loop):tools/pre-execute(审批 bash)、tools/post-execute(包装 result)、Capability seam 的 Consumer(新工具 register)。
4.8 贯穿例子:事件因果收束
bash
followup("请阅读 README...")
agent/inbox/spliced, agent/status → running
turn/start(1)
preStep(next-turn): claim → assemble(persona+tools) → pre-step enter
step/start(1,1) → user/message
request/header, request/context
assistant/chunk* → assistant/message(Read tool-call)
tool/call → tools.execute → tool/result(README)
step/end(1,1)
preStep(next-step): claim 常 ∅
step/start(1,2) → deriveMessages 含 tool result
assistant/message(总结)
step/end(1,2)
turn/end(1) → agent/status → idle
| 事件 | 为何存在 |
|---|---|
agent/inbox/spliced |
durable 排队;resume 可重放 |
user/message + surfaceOp |
设计点(2);进 derive |
assistant/chunk vs assistant/message |
流式 UI vs canonical + surface |
tool/call 不进 surface |
模型已在 assistant message 见过 call |
tool/result + surfaceOp |
step2 derive 必需 |
| step2 无 user/message | 续步靠 surface history,非 bug |
调试时可按上表预测下一 event type,再对照 JSONL/SQLite 导出与运行时断点。
第五部分 · 打断、SubAgent、扩展与调试
正常路径是 followup → kick → turn/step → LLM/tools 。本节补 运行中如何改道、如何委派子 agent、插件应挂哪、如何查 log------仍不改 agent-loop 内核,走 documented extension points。
5.1 followup、steer、inject:三种输入语义
三者都经 send(message, target, wakeup) 。从 调用方与用户意图 看:
| API | 典型调用方 | 用户语义 |
|---|---|---|
followup |
Web 发送、SDK prompt() |
「新一条任务 / 新一轮对话」 |
steer |
UI 中途改方向、ContinuationManager 结算通知(idle 父) | 「别那样做,下一步按我说的来」 |
inject |
插件、agent-instructions、Manager 结算(父 teardown 中) |
「先记着,等下次 step 带上」 |
Web / SDK 不直接碰 ReactLoopAgent,而是 ctx.agents.get(sessionId)?.followup(...)。
5.1.1 running 中 steer 的典型时间线
lua
T0 用户 steer「改读 package.json」
→ next-step += 消息;agent/inbox/inserted
→ wakeDriver → running → return(不新 kick)
T1 step1 跑完(可能已按旧意图调了 Read)
T2 preStep('next-step') claim steer 消息
T3 step2 user/message 含 package.json 意图 → 模型改调 Read
steer 不打断当前 stream/tool ------只影响 下一 step 边界 起的 enter 批次。若要硬停当前轮,用 cancel。
5.1.2 inject 的典型用途
| 场景 | 做法 |
|---|---|
| 插件塞动态上下文 | agent.inject(userMessage) --- 排队,等已有 driver 跑到 step 边界或等下次 followup wake |
| 插件塞模型可见上下文 | agent.inject() → 下一次获准请求的 enter 批次 |
| ContinuationManager 父 teardown | inject 结算通知 --- 不唤醒,避免 dispose 前多跑一轮 LLM |
idle + inject :消息进 next-step,但 不 wake --- 直到用户再 followup/steer 或外部 wakeDriver 条件满足。
5.1.3 与工具 additionalContexts 的关系
工具 execute 返回的 additionalContexts 也进 next-step ,与 steer/inject 同一 claim 队列 ------下一 step 的 preStep('next-step') 一次性领走(steer 文本 + 工具注入 context 可同批 enter)。
5.2 cancel:打断当前 activity
cancel 作用于 Phase.abort,不是 Inbox 的「删历史」:
scss
cancel(cause, { keepInbox?: boolean })
| 选项 | Inbox | Phase | 典型场景 |
|---|---|---|---|
| 默认 | clear() 清空排队 |
abort.abort(cause) |
用户点停止;UI 取消当前轮 |
keepInbox: true |
保留 | abort | interrupt_agent:停 turn,保留已排队 followup |
效果链 :abort → stream/tool/preStep 处 throwIfAborted → turn/end { reason: aborted } → kick finally → idle。
cancel 后又发 followup :wakingAfterAbort 强制 next-turn + latch wakeRequested,等 idle 后新 kick --- 不会把消息并进已死的 step。
与 steer 对比:
| steer | cancel + followup | |
|---|---|---|
| 当前 step | 跑完 | 中断 |
| 新意图何时进模型 | 下一 step 边界 | 新 turn(abort 收敛后) |
| 排队消息 | 保留(除非 clear) | 默认 clear |
5.3 SubAgent:模型委派与三条热路径
SubAgent 是 Capability seam :ctx.subagents Definition + spawn/fork Provider + dsh-tool-subagent Consumer(模型见 Task / subagent 工具)。
父 Session log 形态不变 ------仍是普通 tool/call + tool/result;差异在 工具 execute 内部是否阻塞父 driver。
5.3.1 从父 step 到子 agent
rust
sequenceDiagram
participant P as 父 step
participant T as tool-subagent
participant SA as ctx.subagents
participant C as 子 Agent driver
P->>P: assistant/message 含 tool-call Task
P->>T: executeToolCalls → execute
T->>SA: start | startContinuable | jobs.start
SA->>C: agents.create + followup(prompt)
alt 前台 one-shot
C->>C: kick → turn → ...
C-->>T: whenIdle → output
T-->>P: tool/result(父 step 继续)
else continuable
T-->>P: 立刻返回 childId(父几乎不等)
C->>C: 自主多 turn
end
5.3.2 三条热路径(tool-subagent 路由)
| 路径 | 条件 | API | 父 step 是否阻塞 | 工具返回 |
|---|---|---|---|---|
| A 前台 one-shot | run_in_background: false |
subagents.start → await whenIdle() |
是 | 子 agent 最终输出 |
| B 后台 one-shot | one-shot + run_in_background: true |
jobs.start → 异步 subagents.start |
否 | jobId |
| C continuable | backgroundMode: continuable(默认后台) |
startContinuable |
否(inbox 接受即返回) | childId + messageId |
spawn vs fork(Provider 差异,不是 tool 分支):
| Provider | 子 Session context | 典型用途 |
|---|---|---|
| spawn | 新 log,policy 定 seed | 并行专家、fresh child |
| fork | 继承父 已完成 turn 前缀 | 分支探索、checkpoint 实验 |
5.3.3 continuable 与 ContinuationManager
路径 C 的后续 followup(parent, childId, msg) 、冷恢复、ownedChildren 结算与 notifySettlement 由 SubagentContinuationManager 协调。工具侧结论:父 Session log 仍是普通 tool/call + tool/result;父 driver 不共享 子 loop。
5.3.4 concludesTurn:工具即最终答案
工具 execute 内可调 exec.concludeTurn() → result 带 concludesTurn: true → step() 返回 { completed } 即使刚跑完 tool --- turn 不再开下一步。
用于:goal 汇报、subagent 前台 one-shot 返回、某些「工具输出就是给用户看的终态」场景。与 路径 A 阻塞等待 配合:父 step 结束,turn 可关。
5.4 扩展点:三个事件域
Harness 扩展的 第一个决定 :改的是 持久 Session 、进行中 Agent ,还是 某能力 seam?
| 域 | 持久? | 典型前缀 | 何时用 |
|---|---|---|---|
| Session 事件 | 是 --- append 进 log | user/message、compaction/*、插件扩展 SessionEventMap |
事实必须在 reload 后仍在 |
| Agent 事件 | 否 --- 实时 | agent/pre-step、agent/request、agent/status |
观察/拦截 当前 driver、inbox、请求 |
| 能力事件 | 视插件 | tools/*、fs/*、llm/stream |
策略挂在 seam 上,避免 import 循环 |
Waterfall 须 next() (设计点 4):agent/pre-step、agent/request、tools/pre-execute、tools/post-execute 等。 Serial 无 next :agent/turn-stopping --- turn 即将结束前的串行钩子。
Turn/step 在 Session log 中的事件顺序见第四部分 turn/step 流程与贯穿例子。
5.4.1 常见扩展:改什么挂哪
| 目标 | 机制 | 影响面 |
|---|---|---|
| 系统提示词 / persona | ctx.systemPrompt.section() |
request.system |
| 工具 schema 列表 | tool provider + preset / agent.ctx register |
request.tools |
| 本 step 是否进模型 | agent/pre-step |
enter 批次 reject/改写 |
| 模型路由 / 采样 | agent/request |
config(不能改 messages) |
| 工具审批 / deny | tools/pre-execute |
是否 dispatch body |
| 包装 tool result | tools/post-execute |
result content / meta |
| 压缩 history | compaction @ pre-step | surface → derive 变短 |
| 新持久事实 | 扩展 SessionEventMap + append |
全链路 replay |
| 模型可见动态上下文 | inject 或 pre-step / runtime 快照 |
user/message |
| 换 bash/FS 实现 | 换 Capability Provider | 不改 Consumer 工具名 |
| 用户斜杠命令 | ctx.commands |
无 model turn |
| UI 渲染 | 订阅 session/event、agent/*;工具 presentCall/presentResult |
表现层 |
不要改 loop:新行为应落在上表某一格。
5.4.2 Agent 事件速查(调试/UI 常订阅)
| 事件 | 何时 |
|---|---|
agent/status |
idle ↔ running |
agent/inbox/inserted · discarded · claimed |
Inbox 变更 |
agent/error |
step/turn 边界失败 |
agent/session-start |
create/resume 发布后 |
agent/turn-stopping |
turn 将结束前 |
subagent/start · subagent/end |
父 scope 内观测子 run(非 Session) |
5.5 调试:从插件树到 Session log
5.5.1 配置与 spine 是否就绪
css
pnpm dsh --profile web --dump-config # 本机有效 cordis.yml(patch 叠加后)
pnpm dsh --profile headless --dump-config
对照检查:是否有 session、tools、llm 适配器、agent-loop、agent;Consumer 工具(dsh-tool-fs 等)是否 mount。
5.5.2 运行时断点
| 断点位置 | 观察什么 |
|---|---|
agent.ts preStep |
claim 批次、assemble、pre-step 决策 |
agent.ts step 内 deriveMessages 前后 |
surface 投影是否含刚 append 的 user/tool result |
agent.ts buildRequest |
request/header、frozen GenerateOptions |
tool-calls.ts executeToolCalls |
并行组、tool/call·result 顺序 |
inbox.ts splice / claim |
next-turn vs next-step 消费 |
读 log 方法 :按贯穿例子的事件因果表预测下一 type,再对照 JSONL/SQLite 导出;UI transcript 读 append-origin,模型 history 看 surface/derive。
5.5.3 持久化与 resume
| Backend | 典型路径 | 用途 |
|---|---|---|
| JSONL | profile 配置 dsh-session-persistence-jsonl |
人类可读逐行事件 |
| SQLite | dsh-session-persistence-sqlite |
结构化查询、chunk 打包 |
ctx.sessions.flush(session) --- checkpoint 完成后再依赖磁盘。resume:新 driver + 旧 log,Inbox 从 agent/inbox/spliced 重放。
5.5.4 无 API Key 的分层实验
| 层级 | 做法 | 能验证什么 |
|---|---|---|
| 仅 ToolRuntime | greet demo --- ctx.tools.execute |
pre-execute、execute、post-execute |
| 完整 Agent | pnpm dsh --profile headless "..."(需 DEEPSEEK_API_KEY) |
turn/step、Session 全链 |
| keyless snapshot | 仓库 pnpm run test:snapshot |
组装应用 transcript replay |
5.5.5 常见问题定位
| 现象 | 先查 |
|---|---|
| 插件永远 PENDING | --dump-config 缺 inject 提供方;Fiber 缺 dsh-tools / llm |
| followup queued 不处理 | Phase 是否 running;是否等 下一 turn claim |
| steer 没生效 | 是否等到 下一 step;当前 step 是否已 commit assistant |
| tool result 不进下一步 | tool/result 是否 surfaceOp;step2 是否 derive 到 |
| 子 agent 父一直 running | 是否前台 one-shot 在 await whenIdle()(路径 A) |
| history 突然变短 | compaction replace;log 全量仍在 |
结语
Harness 的 mental model 可概括为一条主轴:
arduino
Profile 拼 Cordis 插件树
→ 核心包 provide 服务
→ agent-loop:Inbox → turn/step → append Session → derive + assemble → LLM + tools
→ 能力 seam 与 UI 订阅同一 log
架构 回答「组件在哪一层、为何这样拆」;执行机制 回答「一句 followup 如何穿过 agent.ts」;Session 事件回答「运行时 ground truth 是什么」。三者对齐,即掌握 Harness 的主干。
参考与延伸阅读
官方文档
| 文档 | 内容 |
|---|---|
| docs/architecture.zh.md | 架构总览:Cordis、Profile/组合包、核心包、三域事件、轮次流程、会话日志、能力 seam |
| docs/cordis-primer.zh.md | Cordis 插件模型、Fiber、inject、waterfall |
| docs/subsystems/shell.zh.md | bash 执行 capability seam 规范范例 |
| docs/agent-lifecycle.md | Agent 生命周期时序 |
| docs/tool-execution-pipeline.md | 工具执行流水线 |
| docs/event-producer-consumer.md | 事件生产方与消费方映射 |
源码锚点
| 路径 | 内容 |
|---|---|
vendor/cordis/src/ |
Cordis 核心(Context、Fiber、Reflect、Events) |
vendor/loader/src/config/entry.ts |
Loader 单条 Entry 加载路径 |
packages/core/agent-loop/src/agent.ts |
ReactLoopAgent:Phase、Inbox、kick/turn/step、preStep |
packages/core/agent-loop/src/tool-calls.ts |
executeToolCalls 并行调度 |
packages/core/agent/src/inbox.ts |
Inbox splice / claim |
packages/subagent/subagent/src/continuation.ts |
SubagentContinuationManager |
packages/boot/app-boot/ |
Profile 与 boot 组装 |
本文结构索引
| 部分 | 主题 |
|---|---|
| 第一部分 | 架构总览、五条设计点、Capability seam |
| 第二部分 | Cordis 底座:Loader、ctx、Fiber、Events |
| 第三部分 | 核心包、Session 四视图、surface、多 Agent、ContinuationManager |
| 第四部分 | Phase/Inbox、kick/turn/step、Context 工程、工具流水线 |
| 第五部分 | followup/steer/inject、cancel、SubAgent、扩展点、调试 |