DeepSeek Harness:Cordis 插件树与 Agent 主链路

3 阅读45分钟

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)、Agentagent/* 实时)、Capabilityfs/*tools/* …)
⑤ 轮次流程inbox → claim → pre-step → step → llm/stream → tools → turn/end
⑥ 会话日志append-only ground truth;surface + deriveMessages() 供模型 history
⑦ 能力 seamDefinition + Provider + Consumer;换 Provider 即换实现
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

数据与控制主轴

用户输入 → 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 的 systemtoolsmessages 必须能从 Session log 重建。写:session.append(..., { surfaceOp });读模型 history:deriveMessages() 只读 surface,不是整份 log。

(3)依赖声明加载 — Cordis inject

插件 inject: ['tools','sessions',…],Cordis 在依赖 ACTIVE 后才 LOADING。agent-loop 声明 agentssessionsllmtoolssystemPrompt 五键齐备才进入 LOADING;tools 另 inject systemPrompt。cordis.yml 条目顺序不决定加载顺序——Fiber 依赖图决定。缺服务则 PENDING(等待,非报错)。

(4)扩展走事件与服务,优先不改 loop

三域事件 是大多数扩展的第一个决定:持久事实走 Session 事件;观察/拦截进行中 driver 走 agent/* ;seam 策略走 能力事件tools/*fs/* …)。

意图挂载点
改本 step 是否进模型agent/pre-step(waterfall)
改 provider/modelagent/request
审批/包装工具tools/pre-executetools/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 / 产物
Definitiondsh-shellctx.shellShellExecRequest / ShellExecSpecresolve(request)
Providerdsh-bash-local本机进程树执行
Providerdsh-bash-sandbox沙箱/隔离环境执行(profile 可切换)
Consumerdsh-tool-bash模型可见的 bash 工具;execute 里调 ctx.shell

模型发起一次 bash 调用时

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 文件极少,但职责边界清晰:

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):

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,而是 多层叠加

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 结果):

pnpm dsh --profile web --dump-config

examples/headless-agent/cordis.yml 片段(每条 name 对应一个 npm 包,Loader 会 import 并 mount):

- 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):

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-toolsexport default class ToolRuntime extends Servicectx.toolsdsh-tool-fsinject: ['tools','fs']applyctx.tools.register(...)
纯 apply 插件同上,由别的包 providedsh-bash-local provide ctx.shelldsh-tool-bash inject shell + tools
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 里有什么

ctxProxy 服务容器 + 事件总线 + 当前 Fiber 句柄。读 ctx.xxx 不是读普通对象属性,而是 Reflect 沿 Fiber 父链查 store

2.3.1 Cordis 内置(根 ctx)

键 / API来源
reflectregistryeventslogger根 Context 构造时创建
loaderLoader 插件 provide('loader', …)
plugin / inject / effect / on / emit / waterfallmixin 到 ctx 的便捷 API
fiber当前插件实例;根 Fiber 代表「应用根」

2.3.2 核心包:六个 ctx 键(必记)

这六个服务 全是插件,由 cordis.yml 挂载;agent-loop inject 五键齐备后才 ACTIVE。dsh-scope 无 ctx 键,提供按 agent 划分的作用域注册(agent.ctx)。

ctx 键提供方包(典型)职责
sessionsdsh-session仅追加 SessionEvent log;append / deriveMessages / fork / resume
systemPromptdsh-system-promptsection、变量、每 step assemble tools schema + prompt
toolsdsh-tools作用域化 register / view / execute 流水线
llmdsh-llm + 适配器插件适配器 seam、stream、prepareCall
agentsdsh-agentAgent 注册表、create / resume / withInitiator
agentLoopdsh-agent-loop默认 driver ReactLoopAgent;config 里可声明 agents[]
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 侧):

dsh-tool-fsConsumerinject: ['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能力 Definitiondsh-shell;Provider 如 dsh-bash-local
fs能力 Definitiondsh-fs-local + policy 插件
subagents能力dsh-subagent + spawn/fork Provider
sessionPersistenceSession 周边JSONL/SQLite;resume 用
jobs后台Task 工具、continuable subagent
commands交互用户命令,无需模型轮次
codeRuntime工具模式tools.mode: code 时的 SDK 渲染

2.3.4 TypeScript 类型 vs 运行时

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
  • PENDINGinject 列表里至少一个服务尚无 ACTIVE 提供方(或 Service.check() 为 false)。
  • LOADING:跑 apply 或 Service 构造函数;此阶段注册的 effect 在 unload 时逆序 disposer。
  • ACTIVEfiber.store 有效;其他插件可读该服务。

插件永远 PENDING 时:查是否漏 mount 提供方(常见:忘了 dsh-tools 或 llm 适配器)。

2.5 读 ctx.tools 时发生什么

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):

 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谁持有典型用途
loopCtxAgentLoop 插件 fiberdriver 构造、llm.stream、全局 listener
ownerCtx调用 agents.create 的 caller fiberlifecycle effect、ownership、取消
agent.ctx每个 Agent 实例extend({ agent: this })仅该 agent 的工具 / section
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 用法
waterfallagent/pre-stepagent/requesttools/execute — 须 next() 委托
emitagent/statussession/event
serialagent/turn-stopping(无 next)

effecttools.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 拼好插件树核心包 ACTIVESession 账本建立Agent 绑定并发布。本节讲清 ground truth、事件如何分层、多 Agent 如何共存——没有单独的「调度内核」,只有多个独立 driver 与委派关系。


3.1 调度前:核心包就绪与 Agent 从哪来

3.1.1 六插件依赖(核心包 inject 图)

Agent 跑起来之前,agent-loop 的 Fiber 必须等到五样 inject 全部 ACTIVE。static injectagent-loopagents / sessions / llm / tools / systemPrompttoolssystemPrompt

就绪顺序(典型)含义
sessions + agents + llm + tools + systemPrompt核心包服务 mount 完成
agentLoop LOADINGsetFactory(this),开始接受 create/resume
agents.create / config 启动进入发布事务
agent/session-start第一个可安全做启动注入的扩展点
followup → kick触发 driver 循环

要点agentLoop 是插件,但 ReactLoopAgent 不能通过 yml 换类——可替换边界是整包 AgentFactory;日常改行为走事件,不改 driver 源码。

3.1.2 三条创建入口

入口谁调用典型场景
ctx.agents.create / resumeUI、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 共用骨架

无论哪条入口,最终都走 setupAndPublishdsh-agent-loop):在 Agent announce 之前,Session 与 Agent 都不进全局注册表

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 JSONpayload 必须可无损快照(snapshotJsonValue

服务入口:ctx.sessionsdsh-session · SessionStore)。


3.3 四种视图:别混读

读 Session 时最容易混的是四种「视图」——它们都从同一份 log 来,但消费者不同:

 ┌──────────────────────────────────────┐
 │ 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/headerprovider/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/splicedInbox 队列变更(持久化排队
插件扩展todo/writehook/*fs/observed

3.4.2 模型可见 message(必须带 surfaceOp

只有三种 type 可上 surface:

事件surfaceOp投影结果
user/messageappendreplace { start, end }user 消息
assistant/message同上assistant(含 tool-call block)
tool/result同上tool-result

附加字段:

  • sourceEventSeqs — 溯源(如 assistant/message ← 多个 assistant/chunktool/resulttool/call
  • replace — 只改 surface 索引不删 log 里旧 seq(compaction 核心)

3.4.3 Agent 实时事件(不在 Session log)

事件作用
agent/statusPhase:idle / running
agent/inbox/inserted · discarded · claimedInbox 内存变更通知(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

surfaceOp: append
 → surface.nodes 尾部 +1 seq

surfaceOp: replace { start, end }
 → 从 nodes 去掉 [start..end] 覆盖的 seq
 → 换成 1 个新 seqlog 里被 replace 掉的旧事件仍在)

compactiondsh-compaction-basic 等)在 agent/pre-step 触发:生成 summary 事件,对旧 message 段做 surface replace从不改写历史 append 记录

3.5.2 走读示例:summary 如何 replace 一段 history

假设某 Session 的 surface.nodes(括号内为事件 type 简写):

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(简化):

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

[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 全量 transcriptappend-origin 事件,含被 replace 遮蔽前的原文
磁盘 log全部 seq,含 compaction bracket 与旧 message

3.5.3 deriveMessages 做什么

deriveMessages()
 → 只遍历 surface.nodes 当前 seq 列表
 → 每条 eligible 事件 → deriveEventMessage → Message 或 skip
 → replaceGeneration 变化时可能全量重建(增量优化对调用方透明)

agent-loop 在 user/message append 之后deriveMessages() 拼 LLM 请求——因此本 step 刚写入的用户话 一定在 messages 里

3.5.4 append 内部流水线(简化)

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 / announceagent-loop 发布事务
sessions.fork(source, boundary?, childId?)从已完成 turn 前缀 fork 子 Session(不自动创建 Agent)
sessions.flush(session)等 persistence listener checkpoint

3.6 Agent 与 Session 如何绑定

3.6.1 一个 id,两个注册表

 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 构造(简化):

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.ctxper-agent 注册域(工具、section 只服务此 agent)

3.6.3 create vs resume

createresume
Session 来源prepare(id, { meta, seed? }) 空 log 或带 seedsessionPersistence.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 子 SessionparentSession + 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 队列或内核线程。

每个 Agent = 一个 ReactLoopAgent(独立 kick → while(turn()))
每个 Agent = 一个 SessionId = 一份 Session log
AgentRegistry = 进程内 id → Agent live 表

3.7.1 多种「多 Agent」来源

场景怎么来的调度关系
配置多根 agentagentLoop.agents[] 各 create 一次彼此独立,各跑各的 loop
Web 多会话客户端多次 agents.create 不同 sessionId同上
subagent 委派Task / subagent 工具 → agents.create 子 id树状 runtime owner;见下
forksessions.fork 得 child Session → 再 create child agentSession 谱系在 header;≠ runtime owner
Jobs 后台jobs.start + one-shot 子 run父 step 不阻塞

3.7.2 三种关系不要混

关系记录位置含义
live 注册AgentRegistryagents.get(id) 是否存在
runtime owneragents.enter(agent, owner)谁创建了此 agent(subagent 父链)
持久谱系SessionHeader.parentSessionfork/resume 用的 Session 父子

根 agent:owner = undefinedagents.roots())。

3.7.3 并发模型:多 driver、非共享循环

不同 Agent = 不同 async driver Promise(OS 线程上并发 async,不是同一个 for 循环):

父 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 对比(调度前视角)

forkspawn 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 背后的 SubagentContinuationManagerpackages/subagent/subagent/src/continuation.ts)。

为什么需要单独一层

可继续子 agent 同时要满足:

需求负责方
持久 Session(跨重启)Session + persistence
进程内 live driveragent-loop
FIFO 轮次队列Agent Inbox(每个 agent 唯一)
何时物化 / 唤醒 / 冷恢复ContinuationManager
父何时能 disposeownedChildren 所有权图
子结束后如何告知父notifySettlement(不能靠外部 subagent/end listener——那时 child handle 可能已 dispose)

三个生命周期(不要混):

Continuable Session(持久,childId 稳定,log 里有 subagent/descriptor)
 └─ Activation(进程内,同一 childId 同一时刻 ≤1)
 ├─ AgentHandle + 子 driver
 ├─ inbox → agent-loop 执行 turn
 └─ ownedChildren → 尚未 settle 的子孙 Activation id
启动与后续消息

startContinuable(工具返回 childId + messageId 时):

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 锁 内:

情况行为
无 ActivationcoldResume → 读 descriptor + log → materialize → submit
Activation 正在 disposal等 teardown 结束 → 重试 / coldResume
有 live ActivationsubmitAdmitted → child.followup() + wake

鉴权:parent 必须是 live 对象,且 parent.id === child.session.header.parentSession(持久直接父)。

三态 residency(推导,非独立状态机)
stateOf(activation):
 running ← Agent.status === 'running' 或 accepted 非空(followup 已接受但未 claim 的窗口)
 waiting ← idle 且 ownedChildren 非空(自己 quiet,子孙还在)
 settled ← idle 且 ownedChildren 空 → 可 dispose handle
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())。

notifySettlementreleaseOwnership 之前 调用(避免父 watcher 误判已无子 agent):

父状态投递方式
父正在 teardowninject(不唤醒,避免 dispose 前多跑一轮 LLM)
父 idlefollowup(普通新 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.ctxtools.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 开始处理用户输入前,通常应满足:

□ 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 穿过哪些层

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
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——二者分工如下:

PhaseSession
是什么ReactLoopAgent内存状态机append-only 事件账本
会不会落盘(crash 丢失)(persistence)
典型数据idle / running / maintenanceturnstepAbortControllerturn/startuser/messagetool/result
给谁用协调「是否在跑、第几轮第几步、能否 cancel」重建 history、UI transcript、resume

Phase 三种形态(agent.ts):

type Phase =
 | { kind: 'idle'; lastTurn: number }
 | { kind: 'maintenance'; abort; lastTurn; wakeRequested }
 | { kind: 'running'; abort; turn; step; wakeRequested }
Phase对外 agent/status含义
idleidle无 driver 在跑;可 wakeDriver 开新 kick
runningrunningkick → turn → step 循环占用;持有 abort 供 cancel
maintenanceidle(外观)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/steerreturn;不设 latch — 当前 kick 会在 turn 边界自己 claim
maintenance 期间 又来带 wake 的消息phase.wakeRequested = true
cancel 尚未收敛到 idle 时又来带 wake 的消息(wakeAfterAbortphase.wakeRequested = true
dispose teardownreason.kind === 'disposed'不 latch — 避免 dispose 期间再开 turn

maintenancerunMaintenance):

仅 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):

setPhase({ idle, lastTurn: turn })
if (wakeRequested && inbox.hasPending) wakeDriver() // cancel / maintenance 后的延迟唤醒

连开多 turn 时turn() 在 inbox 仍有活时会 换新 AbortControllerwakeRequested = false(旧 latch 作废),由 同一 kick 继续下一 turn,不经 idle。


4.3 Inbox:持久排队与 claim 规则

Inbox(packages/core/agent/src/inbox.ts)是 Agent 上 唯一的 FIFO 输入队列,但分 两条车道

队列写入 API何时 claim
next-turnfollowup()新 turn 的第一个 preStep('next-turn')1 条
next-stepsteer()inject();工具 additionalContexts每个 step 的 preStep('next-step')全部

4.3.1 splice:先落 Session,再改内存

每次改队列都走 inbox.splice

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 边界领走输入

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队列是否 wakerunning 时何时处理
followupnext-turn下一 turn 的第一个 preStep
steernext-step本 turn 下一 step 的 preStep
injectnext-step同上,但不 kick idle driver

4.3.4 running 时又来 followup:只入队,不新 kick

wakeDriver() {
 if (phase.kind !== 'idle') return // running:Live drivers claim queued work themselves
 setPhase(running); withInitiator(this, () => kick())
}

时间线(agent 正在 step1 跑工具时用户又 followup):

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 收尾

cancel(cause, options = {}) {
 if (!options.keepInbox) {
 inbox.clear() // 默认:清空两队列
 if (phase.kind !== 'idle') phase.wakeRequested = false // 取消待唤醒 latch
 }
 if (phase.kind !== 'idle') phase.abort.abort(cause)
}
选项 / 效果行为
默认(无 keepInboxinbox.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 中用户点停止)

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/steersend 里的 wakingAfterAbort):

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() ,路径固定为:

followup / steer(wake)
 → wakeDriver() // 同步:守门 + 可能 setPhase(running)kick() // 异步:while (turn()) 直到 inbox 空turn() → step() …

4.4.1 概念对照

wakeDriverkick
是什么启动器(synchronous gate)工作循环(一次 activity 的 async 主入口)
谁调用followup / steer(带 wake)、kick.finally(补偿唤醒)、runMaintenance.finally wakeDriver(idle 时)
粒度一次「唤醒决策」一次唤醒到 idle 的整段会话
Phaseidle → running(或 latch wakeRequested持有 running,结束时 → idle
是否 await(fire-and-forget 安排 kick)(内部 await turn() 循环)
是否领 Inbox(领消息在 preStepclaim
是否调 LLM(调模型在 step

与相邻层级一起记(由外到内):

wakeDriver → kick → turn → step
 一次唤醒 一次 activity 一轮对话 一次模型请求+工具

4.4.2 wakeDriver:守门与开工

作用:在 idle(或 maintenance 结束)时,把 Agent 从「可接收输入」切到 running,并 异步启动一次 kick;在 已有 activity 时决定是 忽略重复唤醒 还是 latch 待会再跑

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 使用)
  • 设置 activityDonewhenIdle() / 父 subagent await 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。

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 负责的三件事

  1. 调度 turnturn() 返回 true(inbox 仍有 followup 等)→ 同一 kick 内开下一 turn,不必先回 idle 再 wake
  2. 错误边界turn / step 抛错或 abort 在 catch 收敛,避免未处理的 rejection;turn/end 仍带 aborted / error reason
  3. idle 收尾finally唯一 把 Phase 从 running 切回 idle 并 emit agent/status: idle;必要时触发 补偿 wakeDriver

kick 不负责的事:不解析用户消息内容、不决定 tool schema——只 驱动 turn 循环 直到队列排空。

4.4.4 协作流程图

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 生命周期简图

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 循环

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

steptargetclaim 到模型行为
1next-turn用户「请阅读 README…」Read
2next-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主要来源
systemrequest.systemrenderPrompt(assembly) — sections + variables
toolsrequest.toolsassembly.tools — 本 agent scope 可见 schema
messagesrequest.messagessession.deriveMessages() — surface 投影
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 按固定顺序发生:

① 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
 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

字段内容
sectionspersona、部署说明、工具 guidance…(scope shadowing)
contexts命名上下文块(可进 runtime 快照)
tools当前 step 可见 tool schemas(toolOrder 排序)
variables{{cwd}}{{model}} 等插值

最后走 system-prompt/assemble waterfall,插件可改 assembly。

工具 schema 五层漏斗(Harness 不替模型选工具,只决定 schema 列表):

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.contextsRuntimeContextProjection.project内容变化才生成 user/messagesource: plugin/system-prompt
B:pre-step 插件agent/pre-stepenter.messagestime-context 每 step 追加时间

compactioncompaction-basic @ pre-step):token 超阈值 → surface replacederiveMessages() 自然变短;loop 无特殊分支。

4.6.5 buildRequest:请求锚点

buildRequest 意图链:

  1. seedConfig — 首次用 AgentOptions;之后 requestProposal(session.requestHeader())
  2. agent/request waterfall — 改 provider/model/采样(不能改 messages
  3. llm.prepareCall — 适配器、stream、retry
  4. append request/headerrequest/context(epoch 变化时)
  5. deepFreeze(GenerateOptions) — 交给 llm.stream

request/header 与 messages 分离:改路由不必重写 history;compaction 设计也尽量保留 prefix 以利于 provider cache。

4.6.6 Context 扩展点地图

想改什么扩展点影响
系统提示词段落ctx.systemPrompt.section()request.system
工具列表tool provider + scoperequest.tools
每 step 动态文本contexts 或 pre-stepuser/message → messages
拦截本 step 输入agent/pre-stepenter 批次
改模型路由agent/requestconfig
压缩 historycompaction @ pre-stepderiveMessages()
工作区 AGENTS.mdagent-instructionsbaseline + fs 触发

4.7 step 内:stream、tool-call 与工具流水线

4.6preStep 拼好 Context 并 append user/messagestep(assembly) 负责 一次模型往返 + 该次返回的全部 tool-call 执行。工具能力在 ctx.tools(ToolRuntime) ,不在 loop 里硬编码——loop 只做 调度、写 Session、接 inbox

4.7.1 step() 在 turn 中的位置

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

 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/messageappendcanonical 助手回复;含 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 是否续步

 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,或工具声明 concludesTurnnextStep 空 → 可 turn/end
null有 tool-call 且 turn 被工具结束同一 turn step+1(常无新 user/message)
{ max-tokens }输出截断turn 可能结束

4.7.4 两条线:Loop 调度 vs ToolRuntime 流水线

Harness 故意把 多 call 调度单 call 策略/执行 拆开:

┌─ 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

callId ← block.id(模型权威 id;tool/result 必须对齐)
name ← block.name
arguments← JSON.parse;失败保留 raw
agent ← ctx.agents.requireInitiator()(须在 kick 的 withInitiator 内)
signal ← 与 step 共享的 AbortSignal

分组调度

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

1. appendToolCall
 session.append('tool/call', { turn, step, callId, name, arguments })
 → log-only,不进 surface(模型已在 assistant/message 见过 call2. 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-executewaterfall审批、策略 deny/ask
tools/post-executewaterfall改 result content、附加 meta
tools/resultemitUI 监听完成

deny / 无 approval 的 ask:仍写 synthetic tool/result(模型可见错误文本),保证 surface 配对完整。

code 模式:模型直接调非 run_code 的工具 → prepare 阶段即拒绝(collapse 规则)。

4.7.7 Session 与 surface:为何 tool/call 与 tool/result 分开

事件surfacederiveMessages
assistant/message(含 tool_calls)✅ assistant
tool/call❌ — 审计、UI pending、sourceEventSeqs 关联
tool/result✅ tool-result(callId 对齐

下一轮 deriveMessages() 自然序列:

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 之后,按模型顺序:

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 时:

  1. 已启动的 call:drain → commit 已有 result 或 abort 替换
  2. 未启动的 call:仍写 tool/call + synthetic tool/result(如 TOOL_ABORTED_BEFORE_DISPATCH),保证 replay 配对平衡
  3. 已 commit 的 additionalContexts 仍可进 next-step

surface 上不会出现「有 call 无 result」的悬空态。

4.7.10 无 Agent 的对比实验(greet demo)

不经过 turn/step 也可测 ToolRuntime 流水线——但没有 Session turn 边界

// 最小插件:inject tools,register greet,直接 execute
void ctx.tools.execute({
 callId: CallId('demo-1'), name: 'greet',
 arguments: { name: 'Harness' }, signal: abortSignal,
})

可见 tools/pre-executetools/result,但 turn/start。完整 Agent 路径必须走 executeToolCalls,才会把 tool/call、tool/result 与 turn/step 对齐写入 log。

4.7.11 贯穿例子:step1 调 Read

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=falsereturn 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 贯穿例子:事件因果收束

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/spliceddurable 排队;resume 可重放
user/message + surfaceOp设计点(2);进 derive
assistant/chunk vs assistant/message流式 UI vs canonical + surface
tool/call 不进 surface模型已在 assistant message 见过 call
tool/result + surfaceOpstep2 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典型调用方用户语义
followupWeb 发送、SDK prompt()「新一条任务 / 新一轮对话」
steerUI 中途改方向、ContinuationManager 结算通知(idle 父)「别那样做,下一步按我说的来」
inject插件、agent-instructions、Manager 结算(父 teardown 中)「先记着,等下次 step 带上」

Web / SDK 不直接碰 ReactLoopAgent,而是 ctx.agents.get(sessionId)?.followup(...)

5.1.1 running 中 steer 的典型时间线

T0 用户 steer「改读 package.json」
 → next-step += 消息;agent/inbox/inserted
 → wakeDriver → runningreturn(不新 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 父 teardowninject 结算通知 — 不唤醒,避免 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 的「删历史」:

cancel(cause, { keepInbox?: boolean })
选项InboxPhase典型场景
默认clear() 清空排队abort.abort(cause)用户点停止;UI 取消当前轮
keepInbox: true保留abortinterrupt_agent:停 turn,保留已排队 followup

效果链abort → stream/tool/preStep 处 throwIfAborted → turn/end { reason: aborted } → kick finally → idle

cancel 后又发 followupwakingAfterAbort 强制 next-turn + latch wakeRequested,等 idle 后新 kick — 不会把消息并进已死的 step。

与 steer 对比

steercancel + followup
当前 step跑完中断
新意图何时进模型下一 step 边界新 turn(abort 收敛后)
排队消息保留(除非 clear)默认 clear

5.3 SubAgent:模型委派与三条热路径

SubAgent 是 Capability seamctx.subagents Definition + spawn/fork Provider + dsh-tool-subagent Consumer(模型见 Task / subagent 工具)。

父 Session log 形态不变——仍是普通 tool/call + tool/result;差异在 工具 execute 内部是否阻塞父 driver

5.3.1 从父 step 到子 agent

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-shotrun_in_background: falsesubagents.startawait whenIdle()子 agent 最终输出
B 后台 one-shotone-shot + run_in_background: truejobs.start → 异步 subagents.startjobId
C continuablebackgroundMode: 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 结算与 notifySettlementSubagentContinuationManager 协调。工具侧结论:父 Session log 仍是普通 tool/call + tool/result;父 driver 不共享 子 loop。

5.3.4 concludesTurn:工具即最终答案

工具 execute 内可调 exec.concludeTurn() → result 带 concludesTurn: truestep() 返回 { completed } 即使刚跑完 tool — turn 不再开下一步

用于:goal 汇报、subagent 前台 one-shot 返回、某些「工具输出就是给用户看的终态」场景。与 路径 A 阻塞等待 配合:父 step 结束,turn 可关。


5.4 扩展点:三个事件域

Harness 扩展的 第一个决定:改的是 持久 Session进行中 Agent,还是 某能力 seam

持久?典型前缀何时用
Session 事件 — append 进 loguser/messagecompaction/*、插件扩展 SessionEventMap事实必须在 reload 后仍在
Agent 事件 — 实时agent/pre-stepagent/requestagent/status观察/拦截 当前 driver、inbox、请求
能力事件视插件tools/*fs/*llm/stream策略挂在 seam 上,避免 import 循环

Waterfall 须 next() (设计点 4):agent/pre-stepagent/requesttools/pre-executetools/post-execute 等。 Serial 无 nextagent/turn-stopping — turn 即将结束前的串行钩子。

Turn/step 在 Session log 中的事件顺序见第四部分 turn/step 流程与贯穿例子。

5.4.1 常见扩展:改什么挂哪

目标机制影响面
系统提示词 / personactx.systemPrompt.section()request.system
工具 schema 列表tool provider + preset / agent.ctx registerrequest.tools
本 step 是否进模型agent/pre-stepenter 批次 reject/改写
模型路由 / 采样agent/requestconfig(不能改 messages
工具审批 / denytools/pre-execute是否 dispatch body
包装 tool resulttools/post-executeresult content / meta
压缩 historycompaction @ pre-stepsurface → derive 变短
新持久事实扩展 SessionEventMap + append全链路 replay
模型可见动态上下文inject 或 pre-step / runtime 快照user/message
换 bash/FS 实现换 Capability Provider不改 Consumer 工具名
用户斜杠命令ctx.commands model turn
UI 渲染订阅 session/eventagent/*;工具 presentCall/presentResult表现层

不要改 loop:新行为应落在上表某一格。

5.4.2 Agent 事件速查(调试/UI 常订阅)

事件何时
agent/statusidle ↔ running
agent/inbox/inserted · discarded · claimedInbox 变更
agent/errorstep/turn 边界失败
agent/session-startcreate/resume 发布后
agent/turn-stoppingturn 将结束前
subagent/start · subagent/end父 scope 内观测子 run(非 Session)

5.5 调试:从插件树到 Session log

5.5.1 配置与 spine 是否就绪

pnpm dsh --profile web --dump-config # 本机有效 cordis.yml(patch 叠加后)
pnpm dsh --profile headless --dump-config

对照检查:是否有 sessiontoolsllm 适配器、agent-loopagent;Consumer 工具(dsh-tool-fs 等)是否 mount。

5.5.2 运行时断点

断点位置观察什么
agent.ts preStepclaim 批次、assemble、pre-step 决策
agent.ts stepderiveMessages 前后surface 投影是否含刚 append 的 user/tool result
agent.ts buildRequestrequest/header、frozen GenerateOptions
tool-calls.ts executeToolCalls并行组、tool/call·result 顺序
inbox.ts splice / claimnext-turn vs next-step 消费

读 log 方法:按贯穿例子的事件因果表预测下一 type,再对照 JSONL/SQLite 导出;UI transcript 读 append-origin,模型 history 看 surface/derive。

5.5.3 持久化与 resume

Backend典型路径用途
JSONLprofile 配置 dsh-session-persistence-jsonl人类可读逐行事件
SQLitedsh-session-persistence-sqlite结构化查询、chunk 打包

ctx.sessions.flush(session) — checkpoint 完成后再依赖磁盘。resume:新 driver + 旧 log,Inbox 从 agent/inbox/spliced 重放。

5.5.4 无 API Key 的分层实验

层级做法能验证什么
仅 ToolRuntimegreet demo — ctx.tools.executepre-execute、execute、post-execute
完整 Agentpnpm dsh --profile headless "…"(需 DEEPSEEK_API_KEYturn/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 可概括为一条主轴:

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.mdCordis 插件模型、Fiber、inject、waterfall
docs/subsystems/shell.zh.mdbash 执行 capability seam 规范范例
docs/agent-lifecycle.mdAgent 生命周期时序
docs/tool-execution-pipeline.md工具执行流水线
docs/event-producer-consumer.md事件生产方与消费方映射

源码锚点

路径内容
vendor/cordis/src/Cordis 核心(Context、Fiber、Reflect、Events)
vendor/loader/src/config/entry.tsLoader 单条 Entry 加载路径
packages/core/agent-loop/src/agent.tsReactLoopAgent:Phase、Inbox、kick/turn/step、preStep
packages/core/agent-loop/src/tool-calls.tsexecuteToolCalls 并行调度
packages/core/agent/src/inbox.tsInbox splice / claim
packages/subagent/subagent/src/continuation.tsSubagentContinuationManager
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、扩展点、调试