可以。把你之前的聊天记录和我刚刚重新核对的 DeepSeek Harness 2026-08-19 当前 master 源码 合起来看,我觉得之前我们对 ctx.subagents 的理解方向是对的,但还停留在:
SubAgent = Service + 多个 Provider
这个层级。
你之前的记录已经确认了 DSH 可以把 Claude Code、Codex 等放到 ctx.subagents 后面,父 Agent 不需要绑定某个具体实现。
但当前源码已经进一步演化成了:
SubAgent = Provider Registry + Child Session + Activation Manager + Agent Inbox + Control Channel + Report Channel + Lifecycle Events
换句话说,它已经不只是"调用一个子 Agent 的工具",而是一套小型的 Multi-Agent Runtime。 官方目前明确把 ctx.subagents 定义为可同时注册多个 provider 的 named registry,而且同一个 service 还负责 continuable child 的编排。(GitHub)
一、先建立正确心智模型:DSH 的 SubAgent 到底是什么
当前源码结构可以简化成这样:
text
Parent Agent
│
│ model tool call
▼
dsh-tool-subagent
"模型可见的工具层"
│
▼
┌─────────────────────────┐
│ ctx.subagents │
│ SubagentRuntime │
│ │
│ 1. Provider Registry │
│ 2. ContinuationManager │
│ 3. Identity / Catalog │
│ 4. Lifecycle Events │
└────────────┬────────────┘
│
┌──────────────────┼─────────────────────┐
│ │ │
▼ ▼ ▼
In-process External Agent Full Runtime
Provider Provider Provider
spawn ACP dsh-sdk
fork Claude Code
Codex
│ │ │
▼ ▼ ▼
Child Agent Child Process Child DSH
Child Session Own Context Own Runtime
官方现在明确列了 6 类 provider:
| Provider | 子 Agent 在哪里跑 | 父历史 | 当前 shipped continuation |
|---|---|---|---|
spawn |
同进程 DSH Agent | 不继承 | 支持 |
fork |
同进程 DSH Agent | 继承已完成历史 | 能力实现了,但 shipped one-shot |
acp |
外部 ACP Agent | 不继承 | one-shot |
claude-code |
Claude Code + Agent SDK | 不继承 | one-shot |
codex |
Codex app-server | 不继承 | one-shot |
dsh-sdk |
新 DSH Runtime 进程 | 不继承 | one-shot |
这里最重要的不是 Provider 数量,而是:
Parent → SubAgent 的语义,与 SubAgent 究竟怎么运行,已经被拆开了。
ctx.subagents 是协议,Provider 是运输方式。(GitHub)
二、现在 DSH 其实有两套完全不同的 SubAgent 语义
这是理解当前源码最关键的一点。
以前我们很容易觉得:
text
SubAgent
=
spawn(prompt)
→ 等它跑完
→ 拿 result
当前 DSH 已经明确分成:
text
A. One-shot delegation
B. Continuable child conversation
它们甚至不是同一种运行对象。
A. One-shot:更像"把一个工作包外包出去"
典型场景:
"你去研究一下 authentication 模块,然后告诉我结论。"
父 Agent 不打算和它长期聊天。
整个调用链是:
text
Parent LLM
│
│ tool:
│ subagent({
│ description: "研究认证模块",
│ prompt: "...",
│ })
▼
dsh-tool-subagent
│
│ 构造 SubagentStartRequest
▼
ctx.subagents.start("spawn", request)
│
├─ 找到 provider
├─ capability check
├─ depth check
└─ provider.start(...)
│
▼
创建 child
│
▼
publish SubagentRun
│
├─ emit subagent/start
│
▼
child agent loop
│
▼
SubagentResult
│
├─ output
├─ structured?
└─ stopReason
│
▼
emit subagent/end
│
▼
dispose
│
▼
Parent 获得最终结果
SubagentRun 很值得注意。
它是:
ts
interface SubagentRun {
id
localAgent?
result: Promise<SubagentResult>
dispose()
}
而不是一个长期存在的 Agent handle。
官方甚至明确强调:
one-shot run has no steering and no resume
也就是:
text
创建
→ 执行
→ result
→ dispose
结束了就是结束了。(GitHub)
三、dsh-tool-subagent 和 ctx.subagents 为什么还要分两层?
这个设计很漂亮。
模型看到的是:
text
subagent(...)
但这并不是 SubAgent Runtime 本身。
实际上:
text
Model
│
▼
┌────────────────────┐
│ dsh-tool-subagent │
│ Consumer / Adapter │
└─────────┬──────────┘
│
▼
┌────────────────────┐
│ ctx.subagents │
│ Runtime Service │
└─────────┬──────────┘
│
▼
Provider
tool-subagent 负责的是:
text
模型参数
→ Runtime 参数
例如模型可能产生:
json
{
"description": "检查鉴权代码",
"prompt": "分析 src/auth 下可能存在的安全问题",
"run_in_background": true
}
而 tool plugin 自己还有部署配置:
text
provider = spawn
maxDepth = 3
persona = ...
toolFilter = ...
backgroundMode = continuable
于是最终组合成一个真正的 SubagentStartRequest。当前源码也明确规定:foreground 始终收集运行结果;background 到底变成 one-shot Job 还是 continuable child,由这个 tool plugin 的 backgroundMode 决定。(GitHub)
这意味着:
模型决定"我要委派",Harness 决定"怎么委派"。
这个分层很值得学。
四、DSH 还做了一个很重要的 Capability Negotiation
假设父 Agent 请求:
text
spawn child:
persona = "你是安全专家"
toolFilter = 禁止 Bash
maxDepth = 2
outputSchema = SecurityReport
这几个并不是所有 Provider 都能做到。
所以 Provider 会声明:
ts
SubagentCapabilities {
outputSchema
depthLimit
toolFilter
persona
}
例如本地 spawn/fork 四个都是 true。
但 Claude Code/Codex 这种外部 Provider 都是 false。
于是:
text
Parent Request
│
▼
ctx.subagents
│
├─ provider supports? ── YES → start
│
└─ NO
↓
UNSUPPORTED_CAPABILITY
不是:
text
"你要求 toolFilter,
Claude Code 不支持,
算了我偷偷忽略。"
官方叫:
fail loud, no silent degradation
这是一个非常好的 Multi-Agent 工程原则。(GitHub)
因为否则你以为:
text
这个金融子 Agent 没有 Bash 权限
实际上 provider 根本没执行这个限制,就会产生很危险的权限幻觉。
五、真正有意思的是第二种:Continuable SubAgent
这部分我认为比 one-shot 更值得研究。
例如父 Agent 说:
text
你去持续研究 DeepSeek Harness 的 SubAgent。
先研究架构。
Child 做了一轮。
过一会父 Agent 又说:
text
再重点看一下 Claude Code Provider。
再过一会:
text
刚才结论不对,继续核对 fork。
如果是 one-shot:
text
Agent A
↓ finish
Agent B
↓ finish
Agent C
三个独立 Agent。
而 continuable 是:
text
Child Session #123
│
┌─────────┼─────────┐
│ │ │
Turn1 Turn2 Turn3
│ │ │
架构研究 Claude fork校验
一直是同一个 child。
六、DSH 为此引入了一个非常关键的区分
不是:
text
Child Agent = 一个运行中的 Agent 对象
而是:
text
Durable Child Session
│
│ 可以存在很久
▼
┌───────────────────┐
│ Child #abc123 │
└─────────┬─────────┘
│
│ 需要运行时
▼
Activation
│
▼
AgentHandle
│
▼
Agent
官方定义得非常清楚:
text
persisted Session
↓
optional live Activation
↓
retained AgentHandle
↓
Agent inbox
Session 是长期身份。
Activation 是当前驻留在进程里的"活体"。
(GitHub)
这个设计我认为非常漂亮。
七、为什么 Session 和 Activation 一定要拆?
假设:
text
09:00
父 Agent 创建研究 Agent R
R Session ID = child-123
R 完成第一轮工作:
text
09:05
Activation #1
结束
为了节省资源:
text
Agent 对象释放
内存释放
但:
text
Session child-123
仍然存在
10:00 父 Agent 又发:
继续研究 memory 部分。
这时候:
text
child-123
│
│ 当前没有 Activation
▼
Cold Resume
│
▼
Activation #2
│
▼
resume child session
于是它继续原来的上下文。
所以它实际上实现了:
Agent Identity ≠ Agent Residency
这个抽象对真正的大规模 Multi-Agent 系统非常关键。
如果一个用户有:
text
100 个长期子 Agent
你显然不希望:
text
100 个 Agent 对象
永远活在内存里。
而 DSH 是:
text
100 Durable Sessions
但可能只有
3 个 Live Activations
需要的时候 cold resume。官方 continuation manager 正是负责 activation admission、cold resume、ownership 和 child-first disposal。(GitHub)
八、startContinuable() 到底发生了什么?
这段源码思想很值得拆开。
父 Agent:
text
ctx.subagents.startContinuable(...)
并不是直接:
text
provider.spawnAgent()
而是:
text
① 先预留稳定 childId
childId = session-abc
然后:
text
② 写下 subagent descriptor
provider = spawn
mode = continuable
parent = parent-session
...
然后才问 Provider:
text
③ prepareContinuable()
注意这个方法名字非常关键。
不是:
text
provider.startContinuable()
而只是:
text
provider.prepareContinuable()
之后真正的 Agent:
text
④ ContinuationManager
→ ctx.agents.create()
接着:
text
⑤ 创建 Activation
⑥ 建 ownership
⑦ 把 initial prompt 放入 Child Inbox
最后:
text
⑧ inbox 接受消息后
return {
childId,
messageId
}
此时甚至不等 Child 的 turn 开始。
官方明确规定 startContinuable() 在 inbox acceptance 后就返回,不等待模型执行,也不等待该消息进入 Session Log。(GitHub)
这实际上就是:
text
Create Durable Worker
+
Asynchronous Message Send
而不是 RPC:
text
request
→ wait
→ response
九、这意味着 Continuable SubAgent 更像 Actor
如果让我用经典计算机架构解释:
One-shot 很像:
text
RPC
而 Continuable 很像:
text
Actor
因为每个 Child 有:
text
Identity
+
Mailbox
+
State
+
Lifecycle
特别是 DSH 明确规定:
Agent Inbox 是唯一的消息 FIFO。
没有另造一个:
text
SubAgentQueue
于是:
text
Parent
│
├── message A
├── message B
└── message C
│
▼
Agent Inbox
┌───────┐
│ A │
├───────┤
│ B │
├───────┤
│ C │
└───────┘
Child 自己一个 turn 一个 turn 消费。
这避免了:
text
Agent scheduler
+
SubAgent scheduler
两套状态机互相打架。(GitHub)
十、send_message 其实只是往 Child Inbox 塞消息
现在模型侧还有:
text
send_message
interrupt_agent
list_agents
例如:
text
send_message(
childId,
"重点再看看 session 恢复逻辑"
)
背后是:
text
tool
↓
ctx.subagents.followup()
↓
authorization
↓
找到 child
↓
┌───────────────────────────┐
│ child 当前状态? │
├───────────────────────────┤
│ running → 同 Activation │
│ waiting → 唤醒 Activation │
│ cold → Cold Resume │
└───────────────────────────┘
↓
Agent.followup()
↓
Inbox
所以 parent 根本不需要知道:
text
child 现在还活着吗?
这是 Runtime 的事情。
(GitHub)
十一、有一个很细但很重要的语义:send_message 不是 steering
假设 Child 正在:
text
Turn 5:
分析数据库代码......
这时候 Parent:
text
send_message:
"别研究数据库了,改看网络层。"
DSH 不会把消息直接插入正在执行的 Turn 5。
而是:
text
当前 Turn 5
↓
继续完成/被中断
↓
Inbox
↓
新的 Turn 6
"别研究数据库了......"
官方明确说:
follow-up cannot redirect a turn already underway.
所以:
text
send_message ≠ 实时修改 Thought
它是:
text
next-turn message
(GitHub)
十二、那真想停掉当前方向怎么办?interrupt_agent
现在 DSH 又专门加了:
text
interrupt_agent
其内部是:
text
ctx.subagents.interrupt(...)
│
▼
Agent.cancel(
cause,
{ keepInbox: true }
)
注意:
text
interrupt
≠
kill child
它只是:
text
停止当前 Turn
不会:
text
销毁 Session
清空 Inbox
杀掉 descendants
删除 Activation 身份
所以可以:
text
Child:
正在做 AAAAAA......
Parent:
interrupt
↓
当前 turn cancel
Parent:
send_message("方向错了,改查 B")
↓
Child 新 turn:
开始做 B
这已经很接近真正的长期 Worker Agent 了。(GitHub)
十三、Parent 和 Child 之间默认并不是"共享脑子"
这是 DSH 一个非常明确的原则。
Child 做了:
text
读取 20 个文件
执行 8 个 Bash
思考 10k tokens
查网页
失败三次
修改代码
这些东西不会自动塞回 Parent Context。
否则 SubAgent 最大的价值:
context isolation
就没了。
现在是:
text
Child Session
Files / Tools / Thinking
Search / Intermediate
Messages / Failures
│
X
│ 不自动复制
▼
Parent
例如官方 report prompt 甚至明确告诉 Child:
父 Agent 和你共享 workspace,但不会自动收到你的 transcript、tool output 或 reasoning。
所以 Child 必须显式:
text
report({
output:
"我检查了 auth.ts 和 token.ts,
发现刷新 token 存在竞态,
关键代码在..."
})
(GitHub)
这其实是一个非常好的 Multi-Agent communication 原则:
共享世界,不共享全部认知。
十四、report 又和 one-shot 的 result 不一样
这是一个很容易混淆的地方。
One-shot:
text
Child
↓
finish
↓
SubagentResult
↓
Parent
而 Continuable:
text
Child
│
├── report("发现 A")
│
│
├── 继续工作
│
├── report("又发现 B")
│
│
├── Parent send_message(...)
│
│
└── 继续工作
report():
text
不是 finish
不是 return
不是 shutdown
官方甚至允许:
text
一个 Turn 0 次 report
一个 Turn 1 次 report
一个 Turn N 次 report
只是 prompt 建议最终应该 report 一次自包含结果。(GitHub)
所以它实际上更像:
text
Actor → Actor Message
十五、同时 Runtime 自己还有 Settlement Notice
为什么既有:
text
report
又有:
text
settlement notice
因为二者语义不同。
report 是:
text
Child 说:
"我发现 X。"
而 settlement 是:
text
Runtime 说:
"Child #123 这一轮执行结束了,
stopReason = completed,
最终 assistant output = ..."
官方刻意使用不同 provenance,防止把 Runtime 的话伪装成 Child 自己说的话。(GitHub)
这是 Session Log / provenance 思想再次出现。
十六、spawn 和 fork 到底有什么区别?
它们几乎共用所有执行机制。
真正的差别只有:
text
Child Session 初始 Seed
spawn
text
Parent history
A
B
C
D
spawn
│
▼
Child
Task
也就是说:
text
zero parent conversation
它有自己的 session、system prompt,复用同一套 DSH Agent factory,但父对话完全不带过去。(GitHub)
fork
则是:
text
Parent
Turn 1 ✓
Turn 2 ✓
Turn 3 ✓
Turn 4
assistant tool_call: subagent_fork(...)
← 当前还没结束
Child 拿到:
text
Turn 1
Turn 2
Turn 3
+
新 Task
不会拿:
text
Turn 4
原因就是我们之前研究 Session Log 时讲过的:
text
assistant tool_call
还没有:
text
tool/result
turn/end
这个 Session prefix 是不平衡的,不能直接 replay。
所以源码:
ts
const lastEnd =
events.findLast(e => e.type === 'turn/end')
return events.slice(0, lastEnd.seq + 1)
只拷贝到最后一个完整 turn/end。(GitHub)
这个地方把:
Session Event Sourcing + SubAgent Fork
真正串起来了。
十七、但 fork 并不是"继承父 Agent 一切"
这是非常容易误解的。
inheritsParentContext = true
这里的 Context 指:
conversation history
不是:
text
父 Agent 权限
父 Agent tool restriction
父 Agent Service scope
父 Agent authority
事实上 spawn 和 fork 的 Child 都创建:
fresh flat scope
所以:
text
Parent
├─ 禁止 Tool A
├─ 特殊权限 B
└─ 特殊临时 Service C
并不意味着 fork Child 自动继承这些。
官方专门强调 inheritsParentContext 只描述 conversation seeding,不代表 tool、service 或 authority inheritance。(GitHub)
这是一个非常成熟的安全设计:
继承知识和继承权限必须分开。
十八、为什么当前 shipped 的 fork 被固定成 one-shot?
这个地方很有 DeepSeek Harness 味道。
理论上 fork Provider 实现了:
text
prepareContinuable()
所以技术上能做:
text
continuable fork
但当前官方 cordis.yml 明确让:
text
spawn → continuable
fork → one-shot
原因不是功能做不到,而是 KV Cache。
fork 的价值在于:
text
Parent history
可以作为 byte-identical prefix 直接被 Child 重用。
但 continuable Child 需要额外加入:
text
report tool schema
+
report system prompt section
而这些内容出现在 inherited history 前面。
于是:
text
Parent prefix
AAAA BBBB CCCC
变成:
text
AAAA
[report prompt]
[report tool schema]
BBBB CCCC
父 prefix cache 被破坏。
所以官方当前明确选择:
fork 保持 one-shot,优先保证 prefix reuse。
(GitHub)
我觉得这个设计取舍特别值得研究:
SubAgent 架构不仅在考虑功能,还在考虑模型 Prefix Cache 的物理成本。
十九、再来看最有意思的:Claude Code Provider 到底怎么实现
我们之前说:
text
DSH
↓
ctx.subagents
↓
Claude Code
这个结论没错。
但容易产生一个误解:
DSH 是不是调用了 Claude Code 里面原生的
AgentSubAgent Tool?
不是。
当前 dsh-subagent-claude-code 做的是:
text
DSH Parent
│
▼
dsh-tool-subagent
│
▼
ctx.subagents
│
▼
ClaudeCodeProvider
│
▼
@anthropic-ai/claude-agent-sdk
│
│ query()
▼
native `claude`
CLI process
│
▼
完整 Claude Code Agent
官方在 8 月 4 日的实现说明中明确写了,它使用官方 Claude Agent SDK,并通过 query() 启动真实 Claude Code,每次给它一个 self-contained text task。每次调用都是一个fresh product process + non-resumable conversation 。(GitHub)
而 Anthropic 自己也把 Agent SDK 的 query() 定义为适合 one-shot / stateless / unidirectional interaction;需要有状态 follow-up 时应使用其他 client 形态。(GitHub)
所以:
text
DSH Claude Code Provider
更准确理解成:
把整个 Claude Code 当成一个外部 Worker Runtime。
而不是:
"调用 Claude Code 内部的某个 native subagent。"
二十、这和 Claude Code 自己的 SubAgent 是两个层级
Claude Code 原生 SubAgent 是:
text
Claude Code
│
▼
Agent Tool
│
├─ Explore
├─ Plan
├─ general-purpose
└─ custom agents
每个 subagent 有自己的 context window、system prompt、tools 和权限,只把结果带回主上下文。(Claude Platform Docs)
而 DSH 是:
text
DSH
│
ctx.subagents
│
┌────────┼────────┐
▼ ▼ ▼
spawn Codex Claude Code
│
▼
整个 Claude Code
│
▼
它内部甚至还能
再 spawn SubAgent
所以理论上会出现:
text
DSH Parent
↓
Claude Code Provider
↓
Claude Code Agent
↓
Claude native SubAgent
是两层 delegation。
这也解释了为什么 DSH 对 Claude Code Provider 配置:
text
maxDepth = provider-managed
因为一旦进入 Claude Code 世界,内部递归策略属于 Claude Code,而不是 DSH。(GitHub)
二十一、Claude Code Provider 为什么不支持 persona/toolFilter/outputSchema?
因为 DeepSeek 很克制地定义了边界。
DSH 只给 Claude Code:
text
task
cwd
cancel signal
environment
其他东西:
text
model
system prompt
tools
permissions
authentication
Claude settings
继续由:
text
native Claude Code
管理。
不是 DSH 强行覆盖它。
所以 Claude Provider 宣布:
text
outputSchema = false
depthLimit = false
toolFilter = false
persona = false
而父 Agent 如果要求这些能力,DSH 直接 fail loud。(GitHub)
我认为这个边界划分是对的。
否则就会产生:
text
DSH Permission System
×
Claude Permission System
×
Claude settings
三套 authority 互相冲突。
二十二、Codex / ACP / DSH-SDK 也是同一个抽象
所以你现在再看 ctx.subagents,就会发现它其实统一了差异极大的系统:
text
spawn
=
同进程 Child Agent
fork
=
同进程 + Parent Session Seed
ACP
=
协议级外部 Agent
Claude Code
=
外部完整 Coding Agent 产品
Codex
=
外部完整 Coding Agent 产品
DSH-SDK
=
另一套完整 DeepSeek Harness Runtime
例如 DSH SDK Provider 会真的启动一个新的 DSH runtime,它有自己的 cordis.yml、模型、工具、session 和完整 plugin tree;父 Agent 只拿最终 assistant 结果,子 runtime 的 token 和完整 transcript 都不进入 Parent Context。(GitHub)
这已经相当接近:
Agent Virtualization Layer
二十三、Session Log 在这里再次成为底座
Local SubAgent 并不是:
text
new Agent()
完事。
而是有真正的:
text
Child Session
Child header 会记录:
text
parentSession = Parent Session ID
所以形成:
text
Session P
│
├── Child A
│ │
│ ├── Child A1
│ └── Child A2
│
└── Child B
而不是临时的调用树。
listChildren() / listDescendants() 甚至可以直接从 durable Session store 和 projection 重建这棵树,不用把 Child Agent resume 到内存里 。(GitHub)
这里又出现了我们之前研究 Session 时那个核心思想:
text
Runtime Object
≠
Persistent Truth
二十四、所以 SubAgent 体系真正有四种 ID
理解源码时建议把这四个概念分清:
text
childId / SessionId
=
"这个长期 Child 是谁"
runId
=
"这次运行 epoch 是谁"
messageId
=
"塞进它 Inbox 的这条消息是谁"
jobId
=
"如果 one-shot 在后台跑,
Job Runtime 里的后台任务是谁"
例如同一个 continuable Child:
text
SessionId = child-123
上午:
text
Activation #1
runId = run-A
释放。
下午 cold resume:
text
Activation #2
runId = run-B
但是:
text
childId 仍然是 child-123
官方也明确规定,每个 Activation epoch 都有自己的 subagent/start/end lifecycle pair,cold resume 会生成新的 run epoch,但 durable child identity 不变。(GitHub)
二十五、把整个实现压缩成一张源码心智图
我建议你以后直接拿这张图去读源码:
text
Parent Model
│
subagent tool call
│
▼
┌────────────────────────┐
│ dsh-tool-subagent │
│ │
│ Model-facing Consumer │
└────────────┬───────────┘
│
provider="spawn"
│
▼
┌─────────────────────────────────────┐
│ ctx.subagents │
│ SubagentRuntime │
│ │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ Provider │ │ Continuation │ │
│ │ Registry │ │ Manager │ │
│ └──────┬──────┘ └────────┬────────┘ │
└────────┼─────────────────┼───────────┘
│ │
One-shot │ │ Continuable
│ │
┌─────────▼────────┐ ┌───▼──────────────┐
│ SubagentRun │ │ Durable Session │
│ │ │ │
│ result │ │ childId │
│ dispose │ │ descriptor │
└─────────┬────────┘ └───┬──────────────┘
│ │
│ Activation
│ │
│ AgentHandle
│ │
│ Inbox FIFO
│ │
▼ ▼
┌──────────────────────────────────────┐
│ Provider │
│ │
│ spawn / fork / ACP / Codex / Claude │
│ Code / DSH SDK │
└──────────────────┬───────────────────┘
│
▼
Child Work
│
┌────────────┴─────────────┐
│ │
one-shot result explicit report
│ │
└────────────┬─────────────┘
▼
Parent Inbox
二十六、我认为 DeepSeek SubAgent 最值得学的其实不是"多 Agent"
看完源码后,我会把值得借鉴的优先级重新排一下。
第一是 Durable Agent Identity ≠ Live Agent Instance。
这比普通 SubAgent Framework 高一个层次:
text
Session
↕ cold resume
Activation
↕
AgentHandle
它为长期 Agent、资源回收、恢复、Agent Tree 打好了基础。(GitHub)
第二是 Provider 只解决"怎么创建",Runtime 才拥有生命周期。
尤其 continuable 模式下:
text
Provider
只 prepareContinuable()
之后:
text
identity
session
activation
inbox
resume
authority
dispose
全归统一 Runtime。
这是非常重要的 ownership 设计。(GitHub)
第三是显式 Agent Communication,而不是共享 Transcript。
text
Parent → Child
send_message
Child → Parent
report
Runtime → Parent
settlement
三种通道语义完全不同。
不是把两个 Agent 的 message history 混成一锅。(GitHub)
第四才是多 Provider。
text
spawn / fork / Claude / Codex / ACP / DSH
虽然很酷,但从架构价值上反而不是最深的。
二十七、如果套到语音助手,我认为可以直接得到一个很有价值的架构
例如以后不是:
text
Main Agent
├─ TravelAgent function
├─ HealthAgent function
└─ FinanceAgent function
而是:
text
User Agent
│
ctx.subagents
│
┌───────────────┼────────────────┐
▼ ▼ ▼
Travel Agent Finance Agent Research Agent
Session T Session F Session R
│ │ │
Activation cold Activation
│ │
▼ ▼
长期维护旅行规划 持续后台调研
其中:
text
Session = Agent 长期记忆/身份
Activation = 当前是否在工作
Inbox = 用户/MainAgent 给它的新任务
Report = 它主动返回重要发现
Session Log = 它自己的完整经历
这其实和我们之前讨论的:
text
Service
Event
Session Log
真正闭环了。
而且你之前用 Claude Code Hooks 来校验 Event 的理解也仍然成立:Claude Hook 更像产品暴露出来的生命周期扩展点,而 DSH Event 是 Runtime 内部统一机制。
最后给你一句我现在对 DSH SubAgent 的定义
之前我会说:
SubAgent = 一个可以替换 Provider 的 Agent delegation Service。
看完当前源码后,我会改成:
DeepSeek Harness SubAgent 是一套以 Durable Session 为身份、以 Activation 为运行实例、以 Inbox 为通信队列、以 Provider 为执行后端、以 Event/Session Log 为生命周期事实的轻量 Agent Runtime。
而最有意思的一点是:
text
One-shot SubAgent
≈ RPC / Task
Continuable SubAgent
≈ Actor / Durable Worker
Claude Code / Codex Provider
≈ 外部 Agent Runtime Adapter
这三个模式,被 ctx.subagents 统一成了同一个能力面 。我认为这才是 DeepSeek 这套 SubAgent 设计真正厉害的地方。(GitHub)
如果下一步继续往源码最深处走,我建议直接拆 startContinuable() → ContinuationManager → Agent.followup() → Inbox → Session Log → cold resume 这一条链。我可以下一轮按函数级源码调用顺序,把每个核心 TypeScript 类、函数、关键字段以及对象生命周期逐段展开,这部分会把"为什么需要 Activation、为什么不直接 resume Session、父子 Agent 到底怎么管理 Scope"彻底讲透。
继续往下钻之后,这条链已经比较完整了。现在我认为 DeepSeek Harness 的 continuable SubAgent 最核心的实现并不是"SubAgent"本身,而是把 Agent + Session + Inbox + Activation 四个东西拆开了。
先给结论:
Session 决定"这个子 Agent 是谁、经历过什么";Agent 是执行这个 Session 的机器;Inbox 是待执行输入的持久队列;Activation 则表示"这个 Session 当前是否被装载成一个活 Agent"。
这四层一拆开,cold resume 就自然成立了。
1. 先把完整调用链画出来
父 Agent 第一次创建一个 continuable child:
text
Parent Agent
│
│ subagent(run_in_background=true)
▼
tool-subagent
│
▼
ctx.subagents.startContinuable()
│
├─ ① reserve childId
├─ ② snapshot descriptor
├─ ③ capture parent policy
├─ ④ provider.prepareContinuable()
├─ ⑤ seed child Session
│
▼
ContinuationManager.materialize()
│
├─ ctx.agents.create()
│ │
│ ├─ Session
│ ├─ Agent
│ ├─ Scope
│ └─ Inbox
│
├─ create Activation
├─ acquireOwnership()
├─ watchSettlement()
│
▼
submitAdmitted()
│
▼
Agent.followup()
│
▼
Inbox
│
│ agent/inbox/spliced
▼
Session Log
│
▼
Agent Loop
│
├─ turn/start
├─ inbox.claim()
├─ user/message
├─ LLM
├─ tools...
└─ turn/end
父 Agent 以后再发消息:
text
ctx.subagents.followup(childId)
│
▼
Activation 还在吗?
│ │
YES NO
│ │
▼ ▼
Agent.followup() coldResume()
│
▼
persistence.inspect()
│
fold descriptor
│
▼
ctx.agents.resume()
│
▼
new Activation
│
▼
Agent.followup()
当前源码对此写得非常明确:startContinuable() 只等到 Inbox acceptance ,不会等 turn 开始,更不会等消息进入 user/message。(GitHub)
这个细节非常重要。
2. startContinuable() 第一件事甚至不是创建 Agent
源码:
ts
const childId = SessionId(randomUUID())
先创建:
text
Durable Child Identity
然后才考虑 Agent。
这和传统写法是反过来的。
传统很容易:
text
Agent agent = new Agent()
childId = agent.id
DSH 的思路更像:
text
childId
↓
Session Identity
↓
需要时 materialize Agent
所以:
text
Child Identity
≠
Agent Object
startContinuable() 随后把模型、provider、persona、toolFilter 等信息快照到 descriptor;特别值得注意的是,这些值在第一个 await 之前 就确定了。(GitHub)
原因其实很讲究。
假设:
text
T0 Parent model = DeepSeek-V4
startContinuable()
│
│ await...
│
T1 Parent 被切成另一个模型
Child 应该继承哪一个?
DSH 的答案:
text
T0 时刻
因为:
delegation boundary 是调用发生的那个时刻。
而不是等 Child 真创建出来以后再读取 Parent 当前状态。(GitHub)
3. 第二步:写一个非常重要的 subagent/descriptor
它本质上就是 Child 的:
Durable Creation Manifest
大致类似:
text
subagent/descriptor
version = 2
mode = continuable
provider = spawn
label = "研究 Harness"
agentProvider = deepseek
agentModel = xxx
persona = Research Agent
toolFilter = ...
这个东西:
text
不会进入 LLM history
但是:
text
会永久留在 Child Session Log
并且 survives compaction。(GitHub)
这里非常漂亮。
因为将来 cold resume 时根本不需要:
text
"还记得这个 Agent 当时是怎么创建的吗?"
Session 自己就知道。
可以理解成:
text
Session Log
┌──────────────────────────┐
│ subagent/descriptor │
│ │
│ mode = continuable │
│ model = X │
│ persona = Y │
│ toolFilter = Z │
└──────────────────────────┘
│
▼
Cold Resume
也就是:
Agent 的构成信息本身也 Event Source 化了。
4. Provider 在 continuable 模式下其实权力很小
这是我这轮研究里觉得很值得借鉴的一点。
以前容易理解成:
text
Subagent Provider
↓
创建 Agent
运行 Agent
暂停 Agent
Resume Agent
实际上 continuable 模式不是这样。
Provider 只有:
text
prepareContinuable()
也就是说:
text
Provider
只告诉 Runtime:
"第一次创建这个 Child 时,
需要这些额外 seed 数据。"
例如:
spawn
text
seed = none
fork
text
seed =
Parent 已完成的 Session history
然后 Provider 基本退出舞台。
后面:
text
Agent 创建
Session
Activation
Resume
Inbox
Lifecycle
Dispose
全部由:
text
ContinuationManager
拥有。官方明确说明 continuable provider 只贡献 detached creation data;cold resume 完全不经过 provider。(GitHub)
这实际上是一个很好的 ownership 原则:
text
Provider
=
Factory Hint
Runtime
=
Lifecycle Owner
而不是:
text
Provider
=
Mini Agent Framework
5. 真正创建 Child 的地方:materialize()
这里是核心。
text
startContinuable
│
▼
materialize()
│
▼
materializeTracked()
如果第一次创建:
ts
ownerCtx.agents.create(...)
如果 cold resume:
ts
ownerCtx.agents.resume(...)
源码就是:
text
create !== undefined
│
├── YES → ctx.agents.create()
│
└── NO → ctx.agents.resume()
所以你可以发现:
ContinuationManager 根本没有自己实现 Agent Loop。
它依赖:
text
ctx.agents
来创建或恢复普通 Agent。
这又符合我们之前讲的 Service:
text
SubAgent Runtime
│
▼
ctx.agents
│
▼
AgentLoop implementation
SubAgent 是一个 orchestrator,而不是第二套 Agent Loop。
6. ctx.agents.create() 最终返回的不是 Agent,而是 AgentHandle
这是另一个关键抽象:
ts
interface AgentHandle {
agent: Agent
dispose(): Promise<void>
}
(GitHub)
也就是:
text
Agent
=
"我可以跟它交互"
AgentHandle
=
"我拥有它的生命周期"
这两个能力故意分开。
普通插件:
text
ctx.agents.get(id)
只能得到:
text
Agent
但负责创建它的人:
text
ctx.agents.create()
拿到:
text
AgentHandle
所以只有 owner 才有:
text
dispose()
这非常像资源系统中的:
text
reference
vs
ownership handle
ContinuationManager 会把这个 Handle 收进 Activation:
text
Activation
├─ childId
├─ parentSession
├─ provider
├─ handle
├─ ancestry
├─ ownedChildren
├─ accepted
├─ disposal
└─ observer
7. 所以 Activation 到底是什么?
现在可以给它一个非常准确的定义:
Activation = 一个 Durable Child Session 当前在这个进程中的 Residency Epoch。
例如:
text
Child Session S123
生命周期可能是:
text
Durable Session S123
─────────────────────────────────────────▶ time
Activation A1
┌───────────────┐
│ Agent object │
│ AgentHandle │
└───────────────┘
cold
Activation A2
┌──────────────┐
│ Agent object │
│ AgentHandle │
└──────────────┘
所以:
text
Session S123
可能活几个月。
但:
text
Activation A1
可能只活 30 秒。
A1 dispose 后:
text
Agent 对象没了
并不意味着:
text
Child Agent 身份没了
因为 Session 还在。
这就是:
Durable identity / ephemeral residency separation。
官方源码的定义几乎就是这个意思:一个 continuable child 始终只有一个 durable Session,同时最多存在一个 process-local Activation。
8. 为什么还需要 accepted: Set<MessageId>?
这个字段一开始看非常奇怪:
ts
accepted: Set<MessageId>
但它实际上解决一个很真实的异步竞态。
考虑:
text
Agent 当前 status = idle
Parent:
text
send_message("继续研究")
发生:
text
Agent.followup()
│
▼
Inbox 已插入消息
但是 JavaScript 调度上,可能存在一个非常短的时间窗口:
text
Inbox 已有消息
但
Agent driver 尚未真正开始运行
如果只判断:
ts
agent.status === idle
SettlementWatcher 会说:
text
"哦,它闲了,可以 dispose。"
结果刚收进去的消息还没运行,Agent 就被销毁。
所以 ContinuationManager 在真正调用:
ts
Agent.followup()
之前先:
ts
activation.accepted.add(messageId)
等 Inbox 发出:
text
agent/inbox/claimed
或者:
text
agent/inbox/discarded
再删除这个 ID。
因此判断:
text
running
不是:
ts
agent.status === "running"
而是:
text
agent.status === running
OR
accepted.size > 0
源码正是:
text
running:
Agent running
OR
accepted waking work
waiting:
Agent idle
BUT owns children
settled:
Agent idle
AND no owned children
(GitHub)
这个设计很细,但非常值得学。
9. Parent 消息究竟怎么进入 Child?
关键函数:
text
ContinuationManager.submit()
先:
text
acquireOwnership(parent, childId)
然后:
ts
const message = createUserMessage(...)
再:
ts
activation.handle.agent.followup(message)
到这里:
text
SubAgent 世界
基本结束。
之后进入:
text
普通 Agent 世界
因为:
text
Child Agent
和普通 Agent 调的是完全一样的:
text
followup()
10. Agent.followup() 实际只有两行逻辑
核心:
ts
followup(input) {
this.send(input, 'next-turn', true)
}
也就是:
text
target = next-turn
wakeup = true
(GitHub)
另外两个方法也很有意思:
text
followup
→ next-turn + wake
steer
→ next-step + wake
inject
→ next-step + no wake
所以三者语义其实很干净:
| API | 什么时候消费 | 会不会唤醒 Agent |
|---|---|---|
| followup | 下一 Turn | 是 |
| steer | 下一 Step | 是 |
| inject | 下一 Step | 否 |
(GitHub)
因此我们上一轮说:
text
send_message ≠ steering
现在从源码上完全坐实。
11. 然后消息进入 Inbox
send():
ts
this.inbox.splice(
resolvedTarget,
Infinity,
0,
[message]
)
然后:
ts
if (wakeup)
this.wakeDriver()
(GitHub)
所以:
text
send_message
↓
SubagentRuntime.followup
↓
Agent.followup
↓
Inbox.nextTurn
12. 但这里最漂亮的是:Inbox 不是普通内存数组
源码:
ts
class Inbox {
state = {
"next-turn": [],
"next-step": []
}
}
看起来很普通。
但是它构造的时候:
ts
for (const event of session.events) {
if (event.type === 'agent/inbox/spliced')
apply(event)
}
(GitHub)
也就是说:
text
Inbox
=
Session Log 的 Projection
这和之前讲:
text
Surface
=
Session Log Projection
完全同一个思想。
13. 插入 Inbox 时,先写 Session Event
真正 mutate:
ts
this.session.append(
'agent/inbox/spliced',
splice
)
然后才:
ts
inbox.splice(...)
(GitHub)
所以 Parent 发送:
再重点研究 cold resume。
Session 实际上先出现:
text
seq 500
agent/inbox/spliced
target = next-turn
inserted = [
message #M123
"再重点研究 cold resume"
]
此时:
text
这条消息还没给 LLM 看。
注意这个区别。
14. 这说明 DSH 其实有两个"消息存在状态"
状态 A:已接收但尚未消费
text
agent/inbox/spliced
代表:
Runtime 已经承诺接受这个消息。
但它还不是:
text
model history
状态 B:已经进入 Agent Turn
Agent Loop:
text
turn/start
↓
inbox.claim()
↓
agent/inbox/spliced
删除 pending
↓
user/message
↓
LLM
Agent 在 preStep() 调:
ts
this.inbox.claim(...)
真正准备执行时,随后才:
ts
session.append(
'user/message',
message,
{ surfaceOp: 'append' }
)
(GitHub)
所以完整 timeline:
text
Parent send_message
│
▼
agent/inbox/spliced
│
│ "accepted"
│
▼
等待......
│
▼
turn/start
│
▼
agent/inbox/spliced
(remove from pending)
│
▼
agent/inbox/claimed
│
▼
user/message
│
▼
LLM
15. 这比我之前理解的 Session Log 又更进了一步
我们之前一直说:
Model-visible means logged。
现在你会发现 DSH 实际已经更进一步:
text
Model-visible
→ logged
AND
Runtime-promised input
→ 也 logged
也就是说 Session Log 不只是:
text
模型经历
还开始保存:
text
Agent runtime 的待办状态。
这已经有一点:
text
WAL + Event Sourcing
的味道。
16. 这也是为什么 crash 后理论上可以恢复 Inbox
假设:
text
09:00:00
Parent:
"再研究一下 Claude Provider。"
↓
agent/inbox/spliced
此时进程 crash。
LLM 甚至还没收到消息。
重启:
text
ctx.agents.resume()
│
▼
load Session
│
▼
new Agent(...)
│
▼
new Inbox(session)
│
▼
replay agent/inbox/spliced
于是:
text
pending next-turn
可以重新构建出来。Inbox 构造函数就是通过回放 Session 后缀里的 agent/inbox/spliced 重建两个 pending list。(GitHub)
这就是我觉得 DSH Session Log 设计非常强的原因。
它开始承担:
text
Conversation State
+
Runtime State
而不只是 Chat History。
17. 现在来看 cold resume,整个逻辑就很自然了
Parent:
text
send_message(child-123)
ContinuationManager:
ts
const activation =
this.activations.get(childId)
如果没有:
text
activation === undefined
直接:
text
coldResume()
(GitHub)
18. coldResume() 第一步不是 Resume,而是 inspect()
ts
persistence.inspect(childId)
(GitHub)
注意:
text
inspect
和:
text
resume
也是分开的。
先用廉价的持久化信息确认:
text
这个 Session 存不存在?
是谁的 Child?
能不能 Resume?
descriptor 是什么?
不需要立刻创建 Agent。
这是:
text
cold data
阶段。
19. 然后先做权限检查
读取:
text
loaded.meta.parentSession
然后:
ts
authorizeLineage(
parent,
childId,
loaded.meta.parentSession
)
(GitHub)
权限非常严格:
text
只有 Durable Direct Parent
可以继续这个 Child。
不是:
text
祖先 Agent
任意同 Session Agent
workflow
team
host
目前都不行。
源码明确检查:
text
ctx.agents.get(parent.id) === parent
并且:
text
child.parentSession === parent.id
否则:
text
UNAUTHORIZED
这里又体现了一个原则:
知道 Child ID ≠ 拥有 Child。
20. 然后从 Session 自己恢复 descriptor
ts
foldSubagentDescriptor(...)
而不是找:
text
原 Provider
(GitHub)
所以:
text
第一次创建:
spawn Provider
│
▼
prepareContinuable()
但 cold resume:
text
NO spawn Provider
而是:
text
Child Session
│
├─ descriptor
├─ model
├─ persona
├─ toolFilter
└─ policies
│
▼
ctx.agents.resume()
官方甚至明确指出:
原始 provider 即使已经 unregister,continuable child 仍然可以 cold-resume;provider name 只是 descriptor provenance,不是恢复能力。(GitHub)
这个解耦很彻底。
21. ctx.agents.resume() 到底做什么?
它最终进入 AgentLoop:
text
ctx.agents.resume()
│
▼
AgentFactory.resume()
│
▼
sessionPersistence.prepare(id)
│
▼
load Session
│
▼
new ReactLoopAgent(...)
│
├─ Inbox(session)
├─ RuntimeContextProjection
└─ scope
│
▼
setup
│
▼
register Session
register Agent
│
▼
agent/session-start
│
▼
return AgentHandle
官方的 AgentLoop 文档明确说 resume 会加载 persisted Session,在同一个 SessionId 下重新注册 Agent,并继续原来的 turn numbering 和 derived history。(GitHub)
因此:
text
Cold Resume
本质上不是:
text
恢复某个 JavaScript Agent 对象。
而是:
用同一份 Session Event Log 构建一个新的 Agent runtime instance。
22. 所以"记忆"到底在哪里?
不是:
text
Activation
不是:
text
Agent object
甚至不是:
text
Provider
真正的状态锚点是:
text
Session Log
可以画成:
text
Durable State
Session Log
│
┌─────────┼──────────┐
│ │ │
▼ ▼ ▼
Conversation Inbox Descriptor
history state policy
│ │ │
└─────────┼──────────┘
│
▼
materialize
│
▼
Activation
│
▼
Agent
所以:
Agent 是 Session 的执行视图。
这句话我觉得可以作为理解 DSH Agent Runtime 的又一个核心心智模型。
23. Child 的 Scope 又是怎么恢复的?
这个问题非常关键。
因为恢复 Session 只解决:
text
历史
但是 Agent 还要知道:
text
Tools
Prompt
Persona
Permission
Model
Sandbox
DSH 的解决办法不是:
text
Parent scope 整体 clone
而是分层处理。
24. 第一层:继承 Parent 当前的 Agent Preset Composition
创建 Child 时:
ts
childCtx
.agentPresets
?.composeFrom(childCtx, parent.ctx)
(GitHub)
这很有意思。
不是:
text
重新读取 preset.yaml
而是:
join Parent 正在实际运行的那一代 composition。
官方说明原因也非常合理:
如果:
text
Parent 在 9:00 加载 Preset V1
10:00:
text
preset 文件已经更新为 V2
Parent 此刻仍运行:
text
V1
Child 应该看到:
text
V1
而不是重新解析得到 V2。
否则:
text
Parent history
是在 Tool Set V1 下形成的,
Child 却突然:
text
Tool Set V2
上下文就不一致了。(GitHub)
25. 第二层:Child 自己再 shadow
Parent Composition:
text
Standard Agent
├─ files
├─ bash
├─ web
├─ skills
└─ ...
Child:
text
composeFrom(parent)
│
▼
Standard composition
│
├─ child persona
├─ child toolFilter
└─ subagent delegation context
源码:
ts
if (persona)
systemPrompt.section(...)
if (toolFilter)
tools.restrict(...)
(GitHub)
因此实际上是:
text
Base Parent Capability
∩
Child Restriction
而不是 Child 自己随便扩大权限。
26. 这里还有一个非常重要的安全设计:approval 强制 never
Child 的系统上下文甚至明确告诉模型:
text
你是 delegated subagent,
permission scope 在创建时已经固定,
不能在 session 内自行扩大;
需要额外授权的操作会被拒绝。
(GitHub)
然后源码:
ts
approvalPolicy =
approval service exists
? 'never'
: undefined
也就是:
text
Parent:
遇到敏感行为
可以 Ask User
Child:
不允许自己 Ask User
(GitHub)
这个设计我非常认同。
否则可能出现:
text
Main Agent
委派后台 Agent
│
▼
后台 Agent 突然弹授权
│
▼
用户不知道自己在授权谁
所以 DSH 的原则更像:
SubAgent 不拥有扩大 authority 的能力。
需要更大权限时:
text
report to Parent
Parent 决定怎么办。
27. 更漂亮的是:这些权限也写入 Child 自己的 Session Log
Delegation 时:
text
captureDelegatedPolicyOverrides(parent)
然后:
text
appendDelegatedPolicyOverrides(
childSession,
policies
)
(GitHub)
于是:
text
Child Session
│
├─ sandbox/mode
│ source=delegation
│
└─ approval/policy
source=delegation
这样 cold resume:
text
不重新问 Parent:
"你现在是什么 permission?"
而是:
text
replay Child 自己的 policy log
所以:
text
Parent 今天 permission=A
Child 创建
→ 固定 A
Parent 明天 permission=B
Child cold resume
→ 仍然按照 Child durable policy
官方 8 月 10 日的实现说明明确强调:cold resume 不重新捕获 parent policy;Child log 是 policy owner 。(GitHub)
这是很值得你们记忆/权限系统借鉴的。
28. 再来看 Parent → Child → Grandchild
假设:
text
P
│
└── A
│
└── B
A 创建 B 时:
ts
A.activation.ownedChildren.add(B)
所以如果:
text
A 自己的 LLM Turn 已经结束
但是:
text
B 还在运行
A 的状态不是:
text
settled
而是:
text
waiting
因为:
ts
agent idle
AND
ownedChildren > 0
(GitHub)
于是:
text
A Activation
不能被释放。
这是非常合理的:
text
A
├─ 自己不工作了
└─ 但它还有未完成的孩子
它仍然是整个生命周期树的一部分。
29. SettlementWatcher 就是一台非常小的状态机
逻辑:
text
┌──────────────┐
│ running │
│ │
│ Agent active │
│ OR inbox work│
└──────┬───────┘
│ idle
▼
ownedChildren?
│ │
YES NO
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ waiting │ │ settled │
└────┬─────┘ └────┬─────┘
│ child done │
└──────────────┘
│
▼
dispose
Watcher 不断:
ts
await Promise.race([
agent.whenIdle(),
activation.poke
])
然后重新判断。
为什么用:
text
re-observe
而不是一次:
text
whenIdle → dispose
?
因为 waiting 状态随时可能:
text
Parent send_message
于是:
text
waiting → running
同一个 Activation 又活起来。
30. 什么时候才真的销毁 Activation?
条件:
text
Agent idle
AND
accepted.size == 0
AND
ownedChildren.size == 0
才:
text
dispose()
dispose 顺序也很讲究。
首先:
text
top-down cancellation
text
Parent
↓ cancel
Child
↓ cancel
Grandchild
但真正 release:
text
child-first
text
Grandchild dispose
↓
Child dispose
↓
Parent dispose
源码明确如此设计。
这是典型资源树析构顺序。
否则 Parent Scope 先没了:
text
Child
可能仍在访问 Parent composition / runtime dependencies。
31. 一个特别细的地方:settlement notice 必须在 releaseOwnership() 之前
源码顺序:
text
delete Activation
↓
notifySettlement()
↓
releaseOwnership()
↓
subagent/end
为什么?
假设:
text
Child C
结束。
Parent P 本身也已经:
text
idle
如果先:
text
releaseOwnership(C)
那么 Parent:
text
ownedChildren == 0
SettlementWatcher 很可能马上判断:
text
Parent settled
然后把 Parent dispose。
这时候再:
text
notify Parent:
"C 已经完成......"
消息可能刚进 Parent Inbox:
text
Parent 就被 cancel + dispose
所以必须:
text
先把 settlement notice 交给 Parent
让 Parent 变成:
text
accepted work
然后:
text
再 release ownership
这样 Parent 就不会错误 settle。官方正是为了解决这个竞态,在 manager 的 disposal transaction 内投递 settlement。(GitHub)
这个细节说明这套东西是真的做过大量并发边界推敲的。
32. 现在完整走一个实例
假设 Parent:
帮我深入研究 DeepSeek Harness 的 Session 和 SubAgent 联系。
T1 创建
text
Parent P
│
▼
startContinuable
生成:
text
childId = R123
Child Session:
text
Session R123
seq0 subagent/descriptor
seq1 sandbox/policy
seq2 approval/policy
然后:
text
Activation A1
创建。
T2 初始 Prompt 入队
text
seq3
agent/inbox/spliced
insert:
"研究 Session 和 SubAgent 联系"
此刻:
text
startContinuable()
已经可以返回:
text
{
childId: R123,
messageId: M1
}
Parent 可以继续做自己的事情。(GitHub)
T3 Agent 真正开始
text
seq4 turn/start
seq5 agent/inbox/spliced
remove M1
seq6 user/message
"研究 Session 和 SubAgent 联系"
seq7 step/start
...
seqN assistant/message
seqN+1 turn/end
Agent Loop 本身就是这么把 claimed inbox messages 转成 user/message 并继续模型循环的。(GitHub)
T4 Child 创建另一个 Child C
text
R123.ownedChildren = { C456 }
R123 自己做完:
text
status=idle
但:
text
ownedChildren.size=1
所以:
text
R123 = waiting
不会 dispose。
T5 C456 完成
text
notify R123
releaseOwnership(C456)
于是 R123 收到新的 waking message。
可能再次:
text
waiting → running
整理 Child 结果。
T6 R123 最终闲下来
text
idle
accepted = 0
ownedChildren = 0
于是:
text
settled
然后:
text
Activation A1 dispose
但:
text
Session R123
仍存在于 persistence。
T7 两小时后 Parent 又说
再重点研究下 Inbox。
Parent:
text
send_message(R123)
ContinuationManager:
text
activations.get(R123)
→ undefined
于是:
text
coldResume()
T8 恢复
text
persistence.inspect(R123)
↓
authorize parent P
↓
fold descriptor
↓
ctx.agents.resume(R123)
↓
persistence.prepare(R123)
↓
new Session projection
↓
new Agent
↓
new Inbox
↓
new Activation A2
然后:
text
"再重点研究 Inbox"
进入 next-turn。
这时候 LLM 看到的历史就是:
text
之前 R123 的完整有效 Session History
+
新的 user message
而不是:
text
一个全新的 Agent
从认知上,它还是"同一个研究 Agent"。
33. 所以这套架构实际上很像 Actor + Event Sourcing + Virtual Memory
我现在觉得可以用三个经典系统概念理解。
Actor Model
text
ChildId
+
Inbox
+
Private State
+
Messages
Event Sourcing
text
Session Log
=
durable truth
Virtual Memory / Process Residency
text
Session
=
persistent process image
Activation
=
resident process
cold resume
=
page/process reload
所以 DeepSeek Harness 的 continuable SubAgent 已经不像一般 Multi-Agent Framework 的:
text
Agent A calls Agent B
而更像:
一个非常轻量的 Agent Process Model。
34. 对你的语音助手设计,我认为这里有三个特别值得拿走
第一,Agent 身份与 Agent 实例一定要分离。
未来例如:
text
旅行规划 Agent
投资研究 Agent
健康管理 Agent
不要理解成三个:
text
长期驻留 Agent 对象
而应该:
text
Agent Identity
│
▼
Durable Agent Memory / Session
│
▼
需要时 Activation
这样资源成本才可控。
第二,Agent 的待办消息也应该是 durable memory。
DSH 的 Inbox 不是 Redis 临时 Queue,而是:
text
Event Log
↓
Inbox Projection
这对手机/语音助手尤其重要:
text
设备切换
进程被杀
端云切换
模型服务重启
都不应该导致:
text
"刚才已经交给 Agent 的任务"
悄悄丢失。
第三,权限必须在 delegation boundary 固化,而不是 resume 时重新继承。
我尤其建议记住这个原则:
text
Parent 当前权限
│
│ delegation
▼
snapshot
│
▼
Child Durable Policy
│
├── activation 1
├── activation 2
└── activation 3
而不是:
text
每次 Child resume
→ 重新复制 Parent 当前权限
后者会导致长期 Agent 的权限语义漂移。
35. 到这里可以重新定义四个核心对象
以后读 DSH SubAgent 源码,我建议就按这四句话记:
Session
text
"我是谁,我经历过什么,我被赋予了什么。"
Inbox
text
"别人已经交给我、但我还没有处理什么。"
Agent
text
"谁负责真正消费 Inbox、调用 LLM/Tool,并继续写 Session。"
Activation
text
"这个 durable Agent 此刻有没有装载进内存,以及谁拥有它的生命周期。"
合起来就是:
text
Durable
┌────────────────────────┐
│ Session │
│ │
│ History │
│ Descriptor │
│ Policy │
│ Inbox Events │
└───────────┬────────────┘
│
materialize
│
▼
┌────────────────────────┐
│ Activation │
│ │
│ AgentHandle │
│ accepted │
│ ownedChildren │
│ disposal │
└───────────┬────────────┘
│
▼
┌────────────────────────┐
│ Agent │
│ │
│ Inbox │
│ Agent Loop │
│ LLM / Tools │
└────────────────────────┘
这张图,我认为已经比单纯看 SubAgent Provider 更接近 DeepSeek Harness 当前 Multi-Agent 架构的本质了。
而且它又把我们前面研究的三个核心概念串起来了:
text
Service
│
│ ctx.subagents / ctx.agents
▼
Event
│
│ agent/status
│ inbox/claimed
│ subagent/start/end
▼
Session Log
│
│ descriptor
│ policy
│ inbox
│ conversation
▼
Durable Agent Runtime
下一层最值得继续拆的已经不是 startContinuable() 了,而是两个更深的问题:
① Child 到底继承 Parent 哪些 Service / Tool / Prompt / Memory,Cordis Scope 是怎么做到"共享 composition,但局部 shadow"的;
② spawn/fork/continuable 和 Session Surface / Compaction 怎么互动,尤其是 fork 为什么能共享历史但又不能简单共享 Parent 当前 Context。
这两个问题会直接把 Cordis Scope + Session + SubAgent 三块真正连起来,而且对你们以后设计"主 Agent + 垂域 Agent + 共享记忆/私有记忆"的架构参考价值很高。(GitHub)