DeepSeek Harness(DSH)不只是一套 Agent Framework,更是一套承载模型、工具、会话、权限、沙箱与 UI 的完整运行底座。本文以“组装主线”和“运行主线”为线索,拆解它如何从一组配置 Patch 形成插件树,又如何驱动一次真实的 Agent 请求。
版本说明:本文基于
dsh-v0.1.0-rc.8(Commit:141eb6f)整理;项目处于 Developer Preview,后续版本可能出现破坏兼容性的变化。一句话内核:Agent ≈ Model + Harness。Model 负责推理和生成;Harness 负责上下文、工具、状态、执行环境和控制循环。
一、全景概览
5 架构层级 · 9+ Core 子包 · 6 配置优先级层 · 7 工具执行阶段
什么是 Agent Harness
LLM 本身只是一个接收消息、生成消息的函数。真正让它成为能完成真实任务的 Agent 的,是模型外面那整套运行系统——系统提示词、工具、工作目录、权限、沙箱、会话、重试、子 Agent、UI,以及把它们组织起来的生命周期。这套运行系统就是 Agent Harness。
Harness 需要回答的 8 个问题
上下文组装
本轮应把哪些规则、文件、历史发给模型?
模型路由
使用哪个 Provider、哪个 Model,什么参数?
工具系统
模型能调用什么?输入输出如何校验?
执行环境
Shell、FS、PTY、浏览器在哪里运行?
会话状态
消息、工具调用如何记录、恢复和分叉?
权限与安全
哪些操作允许、拒绝或需人工批准?
生命周期
组件重载时,监听器和资源如何清理?
可观测性
失败发生在哪一层?能否重试或回放?
Harness vs Framework:Agent Framework 更偏向编程框架(流程、节点、状态编排);Agent Harness 更偏向完整运行环境(接住模型调用、工具执行、会话持久化、权限、沙箱、UI 和扩展生态)。DSH 同时提供两者,但命名强调的是"承载多种 Agent 组合的运行底座"。
两条理解主线
理解 DSH 可以沿两条主线展开,它们分别回答不同的问题:
🔧 组装主线
Profile → Bundle → Patch → Loader → Entry → Fiber
回答:这个应用是怎样组装出来的?
- Profile 决定叠哪些 Bundle
- Bundle 提供基础 Patch 层
- Patch 描述 Entry 树的差异
- Loader 协调 Entry 与 Fiber
- Fiber 是插件的运行实例
⚡ 运行主线
User → Agent → Session → System Prompt → LLM → Tools → Session
回答:组装完成后一次任务怎样运行?
- 用户输入进入 Agent Inbox
- Agent Loop 驱动 Turn/Step
- Session 记录所有事件事实
- System Prompt 动态组装提示
- 工具经流水线执行并回写
五层架构
DSH 可以粗略分成五层,从入口到能力插件逐层深入:
flowchart TB
A["入口层<br/>CLI / Web / ACP / Python SDK"]
B["组装层<br/>Profile / Bundle / Patch / App Boot"]
C["运行内核<br/>Cordis / Loader / Include / HMR"]
D["Agent Core<br/>Scope / Session / Agent / Prompt / Tools / Loop"]
E["能力插件<br/>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
图 1:DSH 五层架构(逻辑分层,非严格单向依赖)
- 入口层:用户与系统交互的入口点(
apps/cli, apps/web) - 组装层:Profile、Bundle、Patch 配置组合与启动(
packages/boot, packages/bundle) - 运行内核:Cordis 插件系统、Loader、HMR 热更新(
vendor/cordis, vendor/loader) - Agent Core:Session、Agent、Prompt、Tools、Loop 核心抽象(
packages/core/*) - 能力插件:具体的模型、工具、沙箱、审批等实现(
packages/fs, shell, sandbox...)
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
flowchart LR
P[Profile<br/>运行配置形态] --> B1[Bundle 1<br/>dsh-base]
P --> B2[Bundle 2<br/>dsh-web-app]
P --> PP[Profile Patch<br/>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
图 2:Profile 由 Bundle 叠加用户 Patch 组成
📁 Profile(配置形态)
~/.dsh/profiles/<name>/
一套可以按名称选择的运行配置
- package.json:Bundle 清单(dsh.profile.bundles)
- cordis.yml:Loader 的空根(内容固定为 [])
- cordis.patch.yml:Profile 自定义 Patch
- pnpm-workspace.yaml:外置插件解析环境
📦 Bundle(组合包)
@deepseek-ai/dsh-xxx
插件配置的分发单位,可复用的一组 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 优先级越高:
- Bundle Patch:按 dsh.profile.bundles 顺序
- Profile Patch:profiles/name/cordis.patch.yml
- Home Patch:$DSH_HOME/cordis.patch.yml
- 命令行 Overlay:--patch,按参数顺序
- Agent 预设目录 Patch:启动器根据实际安装目录写入
agent-presets.roots - Telemetry 强制关闭 Patch:在设置
DSH_TELEMETRY_DISABLED时由启动器最后追加
查看最终配置:使用
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 与 Fiber
flowchart TD
subgraph Root [Root Context]
R["rootCtx"]
end
subgraph Plugin [Plugin Context]
P["pluginCtx"]
end
subgraph Scoped [Scoped Context]
S["scopedCtx"]
end
F["Fiber - 插件运行实例"]
R -- "ctx.plugin()" --> F
F -- "创建" --> P
P -- "ctx.extend()" --> S
P -. "ctx.fiber 回指" .-> F
F -. "fiber.ctx" .-> P
style F fill:#f59e0b,stroke:#d97706,color:#fff
style R fill:#4f46e5,stroke:#4338ca,color:#fff
style P fill:#7c3aed,stroke:#6d28d9,color:#fff
style S fill:#06b6d4,stroke:#0891b2,color:#fff
图 3: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 驱动这个主体完成工作。
flowchart LR
SCOPE[Scope<br/>作用域化注册] --> AGENT[Agent<br/>运行主体]
SESSION[Session<br/>事件溯源日志] --> AGENT
MODEL[Default Model<br/>默认模型选择] --> AGENT
AGENT --> LOOP[Agent Loop<br/>Turn/Step 驱动]
PROMPT[System Prompt<br/>动态组装提示] --> LOOP
TOOLS[Tools<br/>注册与执行流水线] --> LOOP
PRESENT[Tool Presentation<br/>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
图 4:Agent Core 各包关系图
Scope:按 Agent 划分注册可见性
为什么全局 Context 还不够?
一个 DSH 进程可以同时运行多个 Agent。它们可能共享模型 Provider、文件系统后端和持久化服务,但拥有不同的 Persona、工具集合、工具呈现模式、Prompt Section 和策略限制。如果所有注册都进入同一张全局表,一个 Agent 的工具很容易泄漏给另一个 Agent。
createScope(ctx, key) 创建一个由空插件 Fiber 支撑的子 Context,并给它写入 Scope key。通过这个 Context 完成的注册同时获得两种属性:
可见性
只在对应 Scope 或其规则允许的子 Scope 中可见
所有权
Scope Fiber 销毁时,这些注册一起撤销
flowchart TD
R["Root Context<br/>全局 Tool / Prompt 贡献"]
A["Agent Scope Context<br/>(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
图 5:Scope 层级与可见性
Scope 不是权限边界:Scope 面向受信任的同进程插件,解决注册路由和生命周期所有权。它不是沙箱,也不能阻止恶意插件直接访问进程中的其他对象。真正的权限和执行隔离由 Approval、Sandbox、FS Policy 等独立能力负责。
Session:事件日志是真相
Session 的核心原则:Session 是 Agent 交互历史的仅追加事实源。模型历史、UI、Transcript、持久化、Fork 和恢复都从这份日志派生。
flowchart TD
A["Session Event Log(事实)<br/>仅追加,永久保留"]
B["Session Surface(投影)<br/>模型可见的有序视图"]
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
图 6:从 Event Log 到模型消息的派生链
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(),而是经过一条可插入策略的流水线:
flowchart LR
A["按名称解析 Tool"] --> B["tools/pre-execute<br/>前置观察/准备"]
B --> C["单调 Guard<br/>审批/权限/只读限制"]
C --> D["tools/execute waterfall<br/>沙箱/远端执行器"]
D --> E["definition.execute<br/>工具业务实现"]
E --> F["校验规范化输出"]
F --> G["tools/post-execute<br/>审计/指标/后处理"]
G --> H["finalizeContent<br/>生成模型可见内容"]
H --> I["tools/result observer<br/>只读观察"]
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
图 7:工具执行流水线的 7 个阶段
各阶段的职责
| 阶段 | 适合做什么 | 特性 |
|---|---|---|
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 如何变成一棵运行中的插件树
flowchart TD
A["CLI 入口<br/>apps/cli/src/bin.ts"] --> B["runProfile()"]
B --> C["prepareProfile()<br/>加载 Bundle + Profile Patch"]
C --> D["composeProfile()<br/>合成所有 Patch 层"]
D --> E["boot()<br/>创建根 Context + Loader"]
E --> F["mountRootInclude()<br/>创建根 Include Entry"]
F --> G["空 cordis.yml + allPatches"]
G --> H["applyEntryPatches()<br/>生成最终 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
图 8:从 CLI 命令到插件树激活的完整组装链
启动是一次事务:根树挂载后,
boot()会等待 Loader 的活动收敛,并执行激活审计。如果任何准备或装载步骤抛错,boot()会销毁已创建的根 Context,避免半启动资源遗留。DSH 的启动不是"把一批模块 import 完就结束",而是一次带所有权、依赖检查和失败清理的运行时事务。
运行主线:一次 Agent 请求如何完成
sequenceDiagram
participant U as User / Client
participant A as Agent
participant S as Session
participant L as Agent Loop
participant P as System Prompt
participant M as LLM
participant T as Tool Runtime
U->>A: send / followup / steer
A->>A: enqueue input
A->>L: wake and run
L->>S: turn start
loop 每个 Step
L->>S: step start
L->>S: user message
L->>P: assemble(agent scope)
P-->>L: system prompt + tool schemas
L->>S: deriveMessages(surface)
L->>M: stream(request)
M-->>L: assistant chunks
L->>S: assistant chunk (流式)
L->>S: assistant message
alt Assistant 发起工具调用
L->>T: execute(tool calls)
T-->>L: canonical results
L->>S: tool result
end
L->>S: step end
end
L->>S: turn end
A-->>U: idle / final state
图 9:一次 Agent 请求的运行主线时序
这张图省略了插件事件,但保留了三条关键不变量:
- 用户、Assistant 和工具的可见结果先成为 Session 事实,再用于后续模型请求
- 每个 Step 至多对应一次模型请求,工具调用可能触发下一个 Step
- Agent Loop 控制流程,策略插件通过事件和 Service seam 介入,不需要接管整个循环
两条主线在哪里汇合
flowchart TB
subgraph Configuration["组装主线"]
direction TB
P["Profile / Bundle / Patch"] --> LD[Loader]
LD --> F[Fibers]
F --> SV["Context Services<br/>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
图 10:组装主线产出服务,运行主线消费服务
组装主线最终产出 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 产品准备的可组合运行时,而不是追求最短示例代码的轻量循环库。
五个常见误解
- 误解:
cordis.yml是空的,所以没有配置。实际:空文件是根 Include 锚点,真正配置来自多层 Patch。 - 误解:Context 是普通依赖注入容器。实际:它还通过 Proxy、Fiber 和响应式依赖管理能力生命周期。
- 误解:Group 与 Include 都只是嵌套数组。实际:Group 留在同一棵树;Include 创建有独立寻址边界的子树。
- 误解:SessionStore 就是持久化数据库。实际:Core Store 管内存对象,持久化由事件插件实现。
- 误解:Scope 提供权限隔离。实际:Scope 只管可见性和所有权,不是安全边界。
八、调试与阅读指南
推荐源码阅读顺序
按源码目录从上到下读,容易先掉进大量具体插件。更高效的路径对应三个逐层深入的问题:
应用是怎么被装起来的?
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 |