一切皆插件的 Agent 运行时
从配置组装到 Agent 运行,系统掌握 DeepSeek Harness 的核心架构、设计思想与调试方法
一、全景概览
什么是 Agent Harness
LLM 本身只是一个接收消息、生成消息的函数。真正让它成为能完成真实任务的 Agent 的,是模型外面那整套运行系统——系统提示词、工具、工作目录、权限、沙箱、会话、重试、子 Agent、UI,以及把它们组织起来的生命周期。这套运行系统就是 Agent Harness。
Agent ≈ Model + Harness
Model 负责推理和生成;Harness 负责上下文、工具、状态、执行环境和控制循环
Harness 需要回答的 8 个问题
上下文组装
本轮应把哪些规则、文件、历史发给模型?
模型路由
使用哪个 Provider、哪个 Model,什么参数?
工具系统
模型能调用什么?输入输出如何校验?
执行环境
Shell、FS、PTY、浏览器在哪里运行?
会话状态
消息、工具调用如何记录、恢复和分叉?
权限与安全
哪些操作允许、拒绝或需人工批准?
生命周期
组件重载时,监听器和资源如何清理?
可观测性
失败发生在哪一层?能否重试或回放?
Harness vs Framework:Agent Framework 更偏向编程框架(流程、节点、状态编排);Agent Harness 更偏向完整运行环境(接住模型调用、工具执行、会话持久化、权限、沙箱、UI 和扩展生态)。DSH 同时提供两者,但命名强调的是"承载多种 Agent 组合的运行底座"。
两条理解主线
理解 DSH 可以沿两条主线展开,它们分别回答不同的问题:
🔧 组装主线
回答:这个应用是怎样组装出来的?
- Profile 决定叠哪些 Bundle
- Bundle 提供基础 Patch 层
- Patch 描述 Entry 树的差异
- Loader 协调 Entry 与 Fiber
- Fiber 是插件的运行实例
⚡ 运行主线
回答:组装完成后一次任务怎样运行?
- 用户输入进入 Agent Inbox
- Agent Loop 驱动 Turn/Step
- Session 记录所有事件事实
- System Prompt 动态组装提示
- 工具经流水线执行并回写
五层架构
DSH 可以粗略分成五层,从入口到能力插件逐层深入:
CLI / Web / ACP / Python SDK"] B["组装层
Profile / Bundle / Patch / App Boot"] C["运行内核
Cordis / Loader / Include / HMR"] D["Agent Core
Scope / Session / Agent / Prompt / Tools / Loop"] E["能力插件
LLM / FS / Shell / Sandbox / Approval / Subagent / UI"] A --> B B --> C C --> D D --> E E -.注册 Service/Event 与 Effect.-> C style A fill:#4f46e5,stroke:#4338ca,color:#fff style B fill:#7c3aed,stroke:#6d28d9,color:#fff style C fill:#06b6d4,stroke:#0891b2,color:#fff style D fill:#10b981,stroke:#059669,color:#fff style E fill:#f59e0b,stroke:#d97706,color:#fff
Everything is a Plugin:模型适配器、工具注册表、Session、Agent Loop,乃至 Web 应用本身,都是配置树里的插件。插件可以被插入、替换、暂停或卸载;它提供的服务、事件监听器和其他副作用也会随生命周期一起撤销。
二、配置系统
DSH 的配置描述了整个应用的插件树。配置不是一个静态文件,而是由多层 Patch 叠加合成的最终 Entry 列表,再由 Loader 转换为运行中的插件树。
Entry / Patch / Overlay
Entry(条目)
一条可被 Loader 运行的插件配置,有 id、name、config、inject 等字段,是插件树的基本单元。
Patch(补丁)
对 Entry 列表的一次修改,可以按 id 修改已有 Entry,也可以 insert 新 Entry。字段基本都是可选的。
Overlay(叠加层)
一组 Patch 的集合,强调来源和优先级——它们覆盖在已有组合之上,是用途的称呼而非独立类型。
Entry 的核心字段
| 字段 | 作用 | 类比 |
|---|---|---|
id | Entry 的稳定身份,Patch、更新和诊断都依赖它 | 主键 |
name | 要加载的插件模块名或路径 | 模块地址 |
config | 传给插件的配置;group 为 true 时解释为子 Entry 列表 | 构造参数 |
disabled | 是否阻止该 Entry 及其子节点运行 | 开关 |
inject | 为该 Entry 增加 Service 依赖 | 依赖声明 |
intercept | 为消费的 Service 附加消费侧配置 | 消费偏好 |
isolate | 将指定 Service 放入独立或具名的隔离域 | 命名空间 |
Patch 不做深度合并:Patch 给出 config 时,替换的是整个 config 字段,不会自动深度合并。用户 Patch 必须重述希望保留的配置项。
Profile 与 Bundle
运行配置形态] --> B1[Bundle 1
dsh-base] P --> B2[Bundle 2
dsh-web-app] P --> PP[Profile Patch
cordis.patch.yml] B1 --> E1[Entry 1] B1 --> E2[Entry 2] B2 --> E3[Entry 3] PP --> E4[用户自定义] style P fill:#4f46e5,stroke:#4338ca,color:#fff style B1 fill:#7c3aed,stroke:#6d28d9,color:#fff style B2 fill:#7c3aed,stroke:#6d28d9,color:#fff style PP fill:#06b6d4,stroke:#0891b2,color:#fff
📁 Profile(配置形态)
一套可以按名称选择的运行配置
- package.json:Bundle 清单(dsh.profile.bundles)
- cordis.yml:Loader 的空根(内容固定为 [])
- cordis.patch.yml:Profile 自定义 Patch
- pnpm-workspace.yaml:外置插件解析环境
📦 Bundle(组合包)
插件配置的分发单位,可复用的一组 Patch
- dsh-base:模型、工具、Session、沙箱等公共能力
- dsh-web-app:增加 Web 应用
- dsh-headless:增加一次性 Runner
- 通过
dsh.bundle.patch声明 Patch 文件
为什么 cordis.yml 是空的?空文件不是漏写了配置,而是有意作为 Loader 实际挂载的 Include 入口、Profile 相对路径的解析基准,以及所有 Patch 的合成起点。真正的插件树由 Bundle 和用户 Patch 从空列表上逐层构造。每次启动都会重写它,避免配置写回固化。
配置层优先级
composeProfile() 按以下顺序合成配置,越靠后的 Patch 优先级越高:
查看最终配置:使用 dsh --profile web --dump-config 查看实际组合结果。--dump-config 不是便利功能,而是理解实际部署状态的重要入口——因为最终配置不等于任一源文件。
启动器内部 Patch 的特殊性
最后两层内部 Patch 不能写在 Bundle 或 Profile 中:
Agent 预设目录 Patch
Preset 的绝对路径随运行环境变化,启动器知道实际安装目录后才写入 agent-presets.roots
Telemetry 强制关闭 Patch
只有启动器最后追加才能保证:任何前置配置都无法重新开启 Telemetry(DSH_TELEMETRY_DISABLED)
三、Cordis 内核
Cordis 是 DSH 的插件运行内核,源自 Koishi 聊天机器人框架多年的工程经验。它解决的核心问题是:插件如何安全地加载、卸载,以及依赖变化时如何重新协调。
Cordis 的两个核心概念
Context 与 Fiber
Context 是 Proxy
插件拿到的 ctx 不是普通对象,而是一个 Proxy。属性读取会先检查真实属性;如果没有,就交给 ReflectService 按服务、Accessor、Inject 和 Isolate 规则解析。因此可以直接写 ctx.tools.register(...) 而不需要手动查找容器。
子 Context 使用原型继承
ctx.extend(meta) 以当前 Context 为原型创建新对象,再把 meta 作为自有属性写入。由此形成原型链:
rootCtx
↑ prototype
pluginCtx (fiber 在这里)
↑ prototype
scopedCtx (继续派生,仍属同一 Fiber)
常见误解纠正:不是"每派生一次 Context 就一定创建一个 Fiber"。ctx.plugin() 会创建 Fiber 及其主要子 Context;普通 ctx.extend() 还可以继续派生多个 Context,它们在没有覆盖 fiber 时仍属于同一个 Fiber 生命周期。
Fiber 状态机
| 状态 | 含义 | 触发条件 |
|---|---|---|
PENDING | 依赖尚未满足 | 服务缺失时 |
LOADING | 正在校验配置并运行插件入口 | 依赖全部满足时 |
ACTIVE | 插件已激活 | 插件入口执行完成 |
FAILED | 配置或插件入口失败 | 加载过程中出错 |
UNLOADING | 正在执行清理 | 依赖消失或被禁用 |
DISPOSED | 已永久销毁,不再重启 | Entry 被删除 |
Fiber 状态变化不是简单直线:
依赖满足: PENDING → LOADING → ACTIVE
依赖消失: ACTIVE → UNLOADING → PENDING
再次满足: PENDING → LOADING → ACTIVE
最终销毁: 任意可销毁状态 → UNLOADING → DISPOSED
Epoch:依赖实现的指纹
epoch 不是简单的执行次数,而是当前依赖实现组合的指纹:只要有一项依赖找不到可用实现,结果就是 INACTIVE;依赖全部满足时,则将各服务提供者的 Fiber uid 拼成一个字符串。它既能表示"依赖是否齐全",也能识别"服务实现是否已经被替换"。
Service / Event / Effect
理解 DSH 的插件模型,需要先认识 Cordis 提供的三种基础机制:
Service(能力)
提供可直接调用、可替换的服务接口。通过 ctx.provide() 注册,通过 ctx.<key> 消费。
例子:ctx.llm、ctx.tools、ctx.sessions
Event(流程)
提供可供插件监听、拦截或参与处理的流程扩展点。支持 emit、bail、waterfall、parallel、serial 多种模式。
例子:agent/pre-step、tools/pre-execute
Effect(生命周期)
管理随插件创建、并在插件卸载时撤销的资源。setup 返回 disposer,卸载时逆序执行。
例子:服务注册、监听器、定时器、子Fiber
三者通常会在同一个插件中配合使用:通过 Service 暴露能力,通过 Event 接入流程,再由 Effect 保证相关注册和资源随插件卸载一并撤销。
Effect 的核心价值
ctx.effect(setup) 立即运行 setup,并收集它返回或 yield 的 disposer:
ctx.effect(() => {
const resource = acquire()
return () => resource.release()
})
这种局部性非常重要:创建资源和释放资源在同一段代码里,而不是分别散落在 start() 和 shutdown() 中。Fiber 卸载时,所有 Effect 按逆序清理。
Events 的分发模式
| 模式 | 用途 | DSH 中的例子 |
|---|---|---|
emit | 按顺序同步通知,不汇总结果 | session/event |
bail | 第一个给出有效结果的监听器终止查找 | — |
waterfall | 环绕中间件,可短路可包装 | agent/request、tools/execute |
parallel | 并行执行并等待 | tools/result |
serial | 按顺序执行并等待 | — |
Inject / Isolate / Intercept
这三个概念经常混在一起,可以用三个问题区分:
Inject
插件需要哪些服务才能运行?
声明依赖,服务出现/离开/替换时,epoch 改变,驱动插件卸载和重新加载。
Isolate
当前 Context 应该使用哪一个同名服务实现?
为某个服务名建立独立解析域,不同域中可有不同实现而不冲突。
Intercept
当前插件希望怎样消费选中的服务?
挂消费侧配置到子 Context,供服务解析配置时使用,不替换提供者。
一句话区分
isolate:决定使用哪一个服务实现 | intercept:决定怎样使用选中的服务
四、Loader 配置树
Cordis Core 管理单个插件实例的生命周期,Loader 则管理配置中的一组插件:它把 EntryOptions[] 变成 Entry 和 Fiber,并在配置发生变化后,让运行中的插件树收敛到新的配置。
Loader 的三重身份
配置树
Loader extends EntryTree,持有根 EntryGroup 和按 ID 索引的 Entry Store
Cordis 插件
启动时通过 ctx.plugin(Loader) 挂载,事件监听和内部资源受 Fiber 管理
Service
将自身提供为 ctx.loader,其他插件可以用 Inject 等待或使用它
三个运行时对象
| 对象 | 职责 | 关键字段 |
|---|---|---|
| EntryTree | 一棵配置树的边界,按 ID 查找节点、修改树结构、等待任务结束 | ctx, root, store |
| EntryGroup | 持有一组有序的 EntryOptions,批量创建、更新和删除节点 | ctx, tree, data |
| Entry | 单个配置条目的运行时表示,导入插件、构造 Context、持有 Fiber | options, parent, fiber, subgroup, subtree |
Group 与 Include:两种嵌套方式
| 维度 | Group | Include |
|---|---|---|
| 继承 | EntryGroup | EntryTree |
| 配置来源 | 当前 Entry 内联的 config | 外部 YAML 或 JSON 文件 |
| 创建的结构 | 当前树中的子分组 | 具有独立根 Group 和 Store 的子树 |
| 关联字段 | subgroup | subtree |
| Store | 共享父树的 store | 有独立 store |
简单理解:Group 在当前树里分组(子 Entry 仍进入同一个 tree.store);Include 把另一棵配置树挂进来(有独立的根 Group 和 Store)。Include 仍使用已有的 ctx.loader,不会再创建一个 Loader Service。
事务式更新
配置文件变化时,更新单位不是一个 Entry,而是整个 EntryOptions[]。EntryGroup.update() 遵循事务语义:
- 为缺少 ID 的条目生成 ID,拒绝重复 ID
- 并行创建或更新新配置中的所有 Entry,等待全部结果
- 只有全部成功后,才删除不存在的旧 Entry,提交新的 group.data
- 任意一项失败时,反向移除新增 Entry,按旧配置恢复
- 回滚本身也出错时,合并原始错误和回滚错误
这样尽量保证一次配置更新要么形成完整的新树,要么回到更新前的可用树,不让只更新了一半的配置成为新的稳定状态。
五、Agent 核心运行时
packages/core 包含九个子包,它们共同构成了 Agent 的运行核心。Scope 和 Session 是底座,Agent 把它们组合成一个运行主体,Agent Loop 再使用 Prompt、LLM 和 Tools 驱动这个主体完成工作。
作用域化注册] --> AGENT[Agent
运行主体] SESSION[Session
事件溯源日志] --> AGENT MODEL[Default Model
默认模型选择] --> AGENT AGENT --> LOOP[Agent Loop
Turn/Step 驱动] PROMPT[System Prompt
动态组装提示] --> LOOP TOOLS[Tools
注册与执行流水线] --> LOOP PRESENT[Tool Presentation
Native/Code/Both] --> TOOLS LOOP --> SESSION LOOP --> LLM[LLM Service] style SCOPE fill:#4f46e5,stroke:#4338ca,color:#fff style SESSION fill:#7c3aed,stroke:#6d28d9,color:#fff style AGENT fill:#06b6d4,stroke:#0891b2,color:#fff style LOOP fill:#10b981,stroke:#059669,color:#fff style PROMPT fill:#f59e0b,stroke:#d97706,color:#fff style TOOLS fill:#ef4444,stroke:#dc2626,color:#fff style PRESENT fill:#8b5cf6,stroke:#7c3aed,color:#fff style MODEL fill:#14b8a6,stroke:#0d9488,color:#fff style LLM fill:#64748b,stroke:#475569,color:#fff
Scope:按 Agent 划分注册可见性
为什么全局 Context 还不够?
一个 DSH 进程可以同时运行多个 Agent。它们可能共享模型 Provider、文件系统后端和持久化服务,但拥有不同的 Persona、工具集合、工具呈现模式、Prompt Section 和策略限制。如果所有注册都进入同一张全局表,一个 Agent 的工具很容易泄漏给另一个 Agent。
createScope(ctx, key) 创建一个由空插件 Fiber 支撑的子 Context,并给它写入 Scope key。通过这个 Context 完成的注册同时获得两种属性:
可见性
只在对应 Scope 或其规则允许的子 Scope 中可见
所有权
Scope Fiber 销毁时,这些注册一起撤销
全局 Tool / Prompt 贡献"] A["Agent Scope Context
(key = sessionId)"] T["Agent-local Tool"] P["Agent-local Persona"] M["Agent-local Presentation Mode"] R --> A A --> T A --> P A --> M style R fill:#4f46e5,stroke:#4338ca,color:#fff style A fill:#06b6d4,stroke:#0891b2,color:#fff style T fill:#10b981,stroke:#059669,color:#fff style P fill:#f59e0b,stroke:#d97706,color:#fff style M fill:#8b5cf6,stroke:#7c3aed,color:#fff
Scope 不是权限边界:Scope 面向受信任的同进程插件,解决注册路由和生命周期所有权。它不是沙箱,也不能阻止恶意插件直接访问进程中的其他对象。真正的权限和执行隔离由 Approval、Sandbox、FS Policy 等独立能力负责。
Session:事件日志是真相
Session 的核心原则
Session 是 Agent 交互历史的仅追加事实源。模型历史、UI、Transcript、持久化、Fork 和恢复都从这份日志派生。
仅追加,永久保留"] B["Session Surface(投影)
模型可见的有序视图"] C["deriveMessages()"] D["LLM Message History"] A --> B B --> C C --> D style A fill:#4f46e5,stroke:#4338ca,color:#fff style B fill:#7c3aed,stroke:#6d28d9,color:#fff style D fill:#10b981,stroke:#059669,color:#fff
Event Log 与 Surface 的分离
这与"直接维护一个 messages 数组"有本质区别。日志里可以保存 turn/start、step/start、assistant/chunk、tool/call、tool/result 等多种事件,其中只有一部分进入模型历史。
原始日志: A B C D E F (永久保留)
Surface: A [summary] E F (模型当前看到)
新的替换事件可以遮蔽旧 Surface 节点,而不删除原日志。这既保留了审计和回放事实,也允许压缩旧上下文——Compaction 不再意味着删除历史。
重要不变量:发送给模型的会话内容,必须能够从 Session Log 重建。如果插件要给模型增加持久上下文,不能只在请求发送前临时修改数组;它应产生能够记录来源的 Session Event。否则 Resume、Fork、遥测和重放都会看到不同的世界。
SessionStore ≠ 持久化后端
Core 中的 SessionStore 创建并持有内存 Session,提供 create()、get()、list()、fork() 和 flush() 等能力,但它故意不直接负责磁盘持久化。持久化插件订阅 session/event 并在 session/flush 上建立写入屏障——JSONL、本地数据库或其他存储实现可以替换,而 Session 的事件语义保持不变。
Agent Loop
Turn 与 Step
Step
一次模型请求,加上该响应产生的工具调用
Turn
从领取用户工作开始,到没有任何待处理工作为止,可包含零个或多个 Step
为什么 Turn 可能没有 Step?因为输入可以在 agent/pre-step 被拒绝或改写为空。系统仍记录一次打开并关闭的 Turn,用来表达"这批工作被领取和处理过",但不会发起模型请求。
一次 Step 的完整过程
- 追加
step/start - 把获准消息追加为
user/message - 从 Session Surface 派生历史
- 组装 System Prompt 与 Tool Schema
- 解析 Provider、Model 和 Adapter 默认值
- 通过
agent/request和llm/stream请求模型 - 持续记录
assistant/chunk - 请求成功后提交完整
assistant/message - 调度模型给出的 Tool Calls
- 按模型顺序提交
tool/call和tool/result - 追加
step/end - 判断是否还欠下一次请求
为什么既记录 Chunk 又记录 Message
流式 Chunk 用于忠实回放、UI 增量展示和 Usage 统计;完整 Message 是成功 Provider 调用的完成锚点,也是后续模型历史的稳定来源。如果请求失败,不会伪造成功消息。若用户取消时已经看到了非空文本前缀,循环会提交带 interrupted: true 的消息,使后续请求知道用户实际看见过什么。
工具并发
工具是否可并发由工具定义的 isConcurrencySafe(args) 分类:
- 独占调用形成屏障
- 声明安全的调用进入有界滚动池
- 启动前会重新分类,避免定义在排队期间变化
- 执行主体可以重叠,但策略、持久结果和结果上下文仍按模型顺序提交
默认独占(fail-closed):没有声明安全,或分类异常时,默认独占。这个默认值避免未知副作用被乐观并发。
Agent Loop 刻意不负责什么
默认循环只负责"请求模型、执行工具、判断是否继续"。其他行为通过插件接入:
| 能力 | 接入方式 |
|---|---|
| Compaction(上下文压缩) | 监听 agent/pre-step 和请求错误 |
| Retry(重试) | 监听 agent/request-error |
| Approval/Sandbox/Plan | 使用工具执行事件和 Guard |
| Persistence(持久化) | 监听 session/event 和 session/flush |
| UI | 观察 Session 事件与 Agent 状态 |
| Subagent(子 Agent) | 通过 ctx.subagents、ctx.agents 和 Jobs 组合 |
工具流水线
一次工具调用不是直接执行 definition.execute(),而是经过一条可插入策略的流水线:
前置观察/准备"] B --> C["单调 Guard
审批/权限/只读限制"] C --> D["tools/execute waterfall
沙箱/远端执行器"] D --> E["definition.execute
工具业务实现"] E --> F["校验规范化输出"] F --> G["tools/post-execute
审计/指标/后处理"] G --> H["finalizeContent
生成模型可见内容"] H --> I["tools/result observer
只读观察"] I --> J["Session tool/result"] style A fill:#4f46e5,stroke:#4338ca,color:#fff style C fill:#ef4444,stroke:#dc2626,color:#fff style E fill:#10b981,stroke:#059669,color:#fff style H fill:#f59e0b,stroke:#d97706,color:#fff style J fill:#7c3aed,stroke:#6d28d9,color:#fff
各阶段的职责
| 阶段 | 适合做什么 | 特性 |
|---|---|---|
pre-execute | 记录上下文、准备策略输入、做前置观察 | 观察为主 |
| Guard | 审批、权限、只读限制等"只能收紧"的决策 | 单调收紧,不可反转 |
execute waterfall | 沙箱、远端执行器等替换执行路径 | 可替换实际执行 |
| definition.execute | 工具自己的业务实现 | 核心逻辑 |
| 输出校验 | 保证返回值满足声明的规范形式 | 机器可验证 |
post-execute | 审计、指标、后处理 | 可替换结果 |
finalizeContent | 生成写入会话并返回模型的最终内容 | 仅工具拥有者 |
result | 只读观察最终结果 | 仅观察 |
工具的三种呈现模式
native
每个工具的原生 Function Calling Schema
code
保留的 run_code 工具和生成的 SDK
both
原生工具与 Code Mode 同时可用
通告面 = 可调用面:Code Mode 不只是隐藏 Schema。执行器也会拒绝模型绕过 run_code 直接调用其他工具,从而保证"告诉模型能调用的"和"实际能调用的"一致。
六、完整调用链
前面分别拆开了配置、Cordis、Loader 和 Agent Core。现在把它们重新接起来,区分两条链:
- 组装主线:进程里最终有哪些能力?
- 运行主线:这些能力如何共同完成一次任务?
组装主线:dsh 如何变成一棵运行中的插件树
apps/cli/src/bin.ts"] --> B["runProfile()"] B --> C["prepareProfile()
加载 Bundle + Profile Patch"] C --> D["composeProfile()
合成所有 Patch 层"] D --> E["boot()
创建根 Context + Loader"] E --> F["mountRootInclude()
创建根 Include Entry"] F --> G["空 cordis.yml + allPatches"] G --> H["applyEntryPatches()
生成最终 Entry 列表"] H --> I["Loader 协调 EntryTree"] I --> J["插件 Fiber 完成激活"] style A fill:#4f46e5,stroke:#4338ca,color:#fff style E fill:#06b6d4,stroke:#0891b2,color:#fff style J fill:#10b981,stroke:#059669,color:#fff
启动是一次事务:根树挂载后,boot() 会等待 Loader 的活动收敛,并执行激活审计。如果任何准备或装载步骤抛错,boot() 会销毁已创建的根 Context,避免半启动资源遗留。DSH 的启动不是"把一批模块 import 完就结束",而是一次带所有权、依赖检查和失败清理的运行时事务。
运行主线:一次 Agent 请求如何完成
这张图省略了插件事件,但保留了三条关键不变量:
- 用户、Assistant 和工具的可见结果先成为 Session 事实,再用于后续模型请求
- 每个 Step 至多对应一次模型请求,工具调用可能触发下一个 Step
- Agent Loop 控制流程,策略插件通过事件和 Service seam 介入,不需要接管整个循环
两条主线在哪里汇合
ctx.agents / ctx.tools / ctx.llm"] end subgraph Request["运行主线"] direction TB U["User Input"] --> A[Agent] A --> AL["Agent Loop"] AL --> SP["Prompt / LLM / Tools"] SP --> SS["Session Events"] end SV --> A SV --> AL SV --> SP style Configuration fill:#eef2ff,stroke:#4f46e5,color:#0f172a style Request fill:#ecfeff,stroke:#06b6d4,color:#0f172a
组装主线最终产出 ctx.agents、ctx.systemPrompt、ctx.tools、模型 Provider 等服务;运行主线消费这些服务完成任务。插件树一旦更新,服务供给随之变化,运行中的任务则通过 Scope、依赖跟踪和 Step 边界感知这些变化。
七、设计原则与取舍
生命周期优先
资源注册与插件生命周期绑定。监听器、定时器、进程都必须明确归属于某个 Fiber
依赖是响应式关系
inject 声明的是"有效性依赖",不是一次性检查。服务替换时,消费者自动重新组合
配置描述结构
Patch 描述差异。Bundle 提供可叠加层,Profile 只表达相对于低层的差异
日志保存事实
Surface 服务模型。Event Log 仅追加,Surface 可替换,Compaction 不删历史
安全保守默认
fail-closed 思路:工具未声明并发安全则独占;Guard 只能收紧;失败不伪造完成消息
核心定义机制
插件决定策略。核心定义 Turn/Step 和执行流水线语义,重试/审批/沙箱由插件实现
动态能力的代价
| 收益 | 相应代价 |
|---|---|
| 插件可热插拔、服务可替换 | 控制流更分散,需要追踪 Event 和 Service seam |
| 多层 Patch 高度复用 | 最终配置不等于任一源文件 |
| Prompt 可动态贡献 | 频繁变化会降低模型 KV Cache 命中率 |
| Session 可审计、可投影 | 需要理解 Event Log 与 Surface 两套视图 |
| 同进程插件组合高效 | Scope 不是恶意代码隔离,仍需 Sandbox/Approval |
DSH 更像一套为大型 Agent 产品准备的可组合运行时,而不是追求最短示例代码的轻量循环库。
五个常见误解
八、调试与阅读指南
推荐源码阅读顺序
按源码目录从上到下读,容易先掉进大量具体插件。更高效的路径对应三个逐层深入的问题:
应用是怎么被装起来的?
CLI bin.ts → profile-boot.ts → App Boot → Loader
组件为什么能安全地出现和消失?
Cordis Context → Reflect → Registry → Fiber → Effect
装好以后一次任务怎么跑?
Scope → Session → Agent → System Prompt → Tools → Agent Loop
阅读一个插件时问四个问题
面对任意插件,不必一开始追完全部代码,先回答:
- 它通过
inject依赖哪些 Service? - 它通过
provide、事件或注册表贡献什么? - 它创建的副作用由哪个 Fiber/Scope 所有?
- 卸载、失败和依赖消失时如何清理?
这四个问题通常比"它 export 了哪些函数"更接近 DSH 的真实架构。
按现象定位层次
| 现象 | 优先检查 | 原因 |
|---|---|---|
| 插件完全没出现 | Profile、Bundle、Patch、--dump-config |
很可能 Entry 根本没有进入最终树 |
| Entry 存在但插件不工作 | Loader 状态、模块解析、Fiber inject | 可能是装载失败或依赖未满足 |
| 改配置后不能恢复默认值 | Patch 优先级、对象是否重新克隆 | 可能把覆盖写入了复用对象 |
| 一个 Agent 看到了另一个 Agent 的工具 | Scope key 和注册时使用的 Context | 注册可能落到了全局 Scope |
| Resume 后模型上下文不同 | Session Event 与 Surface 投影 | 可能绕过日志临时修改了消息 |
| 工具似乎绕过审批/沙箱 | pre-execute、Guard、execute waterfall | 策略挂载点或执行替换链可能缺失 |
| 工具结果顺序异常 | 并发分类与提交顺序 | 执行并发不应改变模型顺序 |
| 热更新后监听器重复触发 | Effect disposer 与 Fiber 所有权 | 旧副作用可能没有被撤销 |
| UI 有半段回复,模型却不知道 | assistant/chunk / interrupted commit | 取消路径可能没有提交可见前缀 |
三类问题的排查路径
配置问题
先看最终结果:dsh --profile web --dump-config,再沿 Entry 的 id 反查它来自哪个 Bundle 或用户 Patch。
生命周期问题
沿所有权反向查:资源是谁创建的 → disposer 是否注册为 Effect → Effect 属于哪个 Fiber → 父子关系是否正确。
请求问题
按 Session 事件切段:turn/start → step/start → assistant → tool → step/end,逐层定位卡在哪个阶段。
避免静态错觉:看到 Service 类型声明 ≠ 运行时一定存在;看到默认 Bundle ≠ 高优先级 Patch 不能替换它;看到注册代码 ≠ 注册在 Fiber 存活期间始终有效;看到 Assistant Chunk ≠ 已完成消息;看到 Scope ≠ 进程级安全隔离。更可靠的思路是同时追踪结构、状态、生命周期、可见范围四个维度。
九、术语速查
| 术语 | 含义 |
|---|---|
| Harness | 把模型变成可运行 Agent 的上下文、工具、状态、策略与控制环境 |
| Context | Cordis 暴露 Service、Event 和当前 Fiber 所有权的代理对象(Proxy) |
| Service | 通过 ctx.<key> 消费、可被插件动态提供或替换的能力 |
| Fiber | 一个插件实例的运行与资源所有权单元,拥有自己的 Effect 列表和生命周期 |
| Effect | 随 Fiber 生命周期自动撤销的副作用(setup + disposer) |
| Epoch | 当前依赖实现组合的指纹,用于识别依赖是否齐全及服务实现是否被替换 |
| Entry | Loader 配置树中的一个插件实例描述(id、name、config 等) |
| Patch | 对 Entry 树的插入或字段级覆盖操作 |
| Group | 同一 EntryTree 内的结构分组,子 Entry 共享父树的 store |
| Include | 挂载配置文件并形成独立子树边界的 Entry,有独立的根 Group 和 Store |
| Profile | 一种部署形态,由 Bundle 列表和用户 Patch 组成(如 web、headless) |
| Bundle | 可复用的一组 Entry Patch,是插件配置的分发单位 |
| Scope | Agent 维度的注册可见性和生命周期域(不是安全边界) |
| Session Log | 仅追加的会话事实源,所有审计、恢复、回放都从这里派生 |
| Surface | 当前向模型暴露的 Session 投影,可压缩替换但不删除原日志 |
| Turn | Agent 领取一批输入直到本轮停止的过程,可包含零个或多个 Step |
| Step | 一次模型请求及其紧随的工具执行 |
| Guard | 只能把执行决策进一步收紧的策略检查(单调收紧) |
| Capability Seam | 一项可替换能力的完整接口:Service Definition + Provider + Consumer |