Skip to content

压缩 / 上下文注入 / 子代理:装配式的长会话扩展

本文基于 dsh-v0.1.0-rc.7。项目处于 developer preview,迭代很快,文中机制以该基线为准。

前面六篇我们把 dsh 从插件森林、Cordis、agent-loop、会话日志、工具管线一路看到 capability 缝。第七篇把三件"装配另一层"的事放在一起讲,因为它们共享同一个根基——capability 缝 + 会话日志

  1. 压缩(compaction):长会话超了上下文怎么办?——把"压缩"做成一个可换的缝。
  2. 上下文注入(request context):怎么在模型开口之外给它塞进"该知道但没问"的上下文?——agent.inject() + request-context 插件。
  3. 子代理(subagent):怎么让一个 agent 把活交给一个 child agent、甚至交给另一个产品里的 agent?——provider registry + 从主会话 fork。

这三者放在一篇文章里,是因为它们都不是 agent-loop 脊柱的一部分,而是像 bash 一样"可选的扩展"——通过缝、注入、provider 这些第 6 篇的老朋友,松松地挂在 session log 周围。

先放这一篇的核心判断:

dsh 里,压缩、上下文注入、子代理都不是"内建于主循环"的魔法,而是三个独立扩展点:压缩是可换的缝(CompactionEngine),注入是 agent.inject() 排队,子代理是可换的 provider 注册表。 它们的共同根基是:一切演变都写在 session log 上、一切实现都可换 Provider。

一、压缩:把"长会话活下来"做成一个可换的缝

为什么压缩必须是一个缝

先看 dsh-compaction-basic 是 Service Provider 的角色,docs/subsystems/compaction.md 开头就说:

The compaction seam — a capability seam split like bash: Service Definition (ctx.compaction), Service Provider (a backend such as dsh-compaction-basic), and human Consumer (dsh-command-compact). Compaction is one optional capability, not part of the agent-loop spine.

所以**"压缩"与 bash 是完全同构的一个缝**:接口(ctx.compaction)+ Provider(basic 实现)+ 人为消费(/compact 命令)。这也回答了"为什么把压缩做成缝":因为不同产品对"怎么压"的策略差异极大——有人要 token 预算、有人要模板摘要、有人接远程 summarizer。与其在 loop 里写死,不如像 bash 一样做成可换的缝:默认给 basic,需要别的就换 Provider。

语义:把一段表面折叠成一个摘要

压缩在 session 模型里到底做了什么?答案是用一个新的 user/message(摘要)去"替换"一段旧 surface——这正是第 4 篇 SurfaceOp 里那个 { op: 'replace', start, end } 的具体用量。docs/subsystems/compaction.md 把它说得很清楚:

the summary itself rides on a separate user/message with surfaceOp: { op: 'replace', start, end } — the only surface mutation performed by summary compaction.

也即:压缩 = 把 [start, end] 这段旧的模型历史,整个折叠成一个摘要节点。start/end 是 surface 位置(不是数值区间),所以经过一次 replace 之后,新摘要节点落在旧位置,start 甚至可以大于 end —— 权威的被遮蔽节点集合是 shadowedSeqs

触发与策略

ctx.compaction 的三个入口对应了三种时机:

  • compactIfNeeded(agent, trigger, signal)自动策略,按 triggerpressure 上下文压力 / context-overflow 容量溢出);
  • compactNow(agent, signal, sourceCommandId?)手动 /compact,在 idle 期做一次"低于阈值也有用"的压缩;
  • compactRegion(start, end, agent, signal?):显式压缩一段指定范围。

自动压缩跑在串行的 agent/pre-step 里(在推导请求之前检查压力),或进入 agent/request-error 做溢出恢复。BasicCompactionEngine 负责把 ctx.tokenMeter 压力量、token 预算保留下来、并用一次 ctx.llm.stream() 做摘要——关键设计是"重放对话前缀以复用 provider 的 KV cache",而不是给模型发一个"全新"的上下文。

compaction/* 事件:锁 + 结果都落日志

压缩也有自己的会话事件:compaction/start(拿锁)→ 摘要 → compaction/summary(记录摘要、shadowed range、token 数、模型调用)→ compaction/end(释放)。docs/subsystems/compaction.md 强调锁是 compaction/start 先落、end 最后释放

The lock brackets the whole operation: compaction/start is appended first … then compaction/end. Releasing the lock last turns a crash mid-operation into a detectable orphaned lock (a start with no matching end).

把锁放在日志里,就能复用第 4 篇"events是事实"的机制:崩溃造成的孤儿锁(有 start 无 end)可以从日志看出来,而不用额外一套状态机。

二、上下文注入:模型"该知道的事"从哪排队进

"长会话"还有一个姊妹问题:除了用户已经说的,agent 需要主动往模型那边塞一些东西——文件变更提醒、子目录指令、cron 通知……这些在 dsh 里都属于"request-context"。

agent.inject():排队但先不唤醒

第 3 篇讲 inbox 时已经见过 agent.inject() 的关键语义——它给 inbox 排队一条 next-stepwakeup=false 的消息:

ts
// packages/core/agent-loop/src/agent.ts
inject(input: UserMessage): void {
  this.send(input, 'next-step', false)  // 排队上下文,不唤醒
}

这对应 docs/subsystems/agent 文档里的:

inject — queue non-waking next-step context. A running driver claims it at the nearest later pre-step boundary; an idle driver leaves it pending until followup() or steer() wakes the driver.

精髓:注入的上下文不会自己打断 loop。一个 idle 的 agent 收到 agent.inject() 只是把它放 inbox 里排队,等某个 followup()steer() 真正把它唤醒时才被认领。这样"给 model 塞背景"和"让 model 必须回话"被严格分开——被注入的内容在被唤醒的那个 step 里自然出现在 pre-step 的 batch 中。

谁负责注入:context 包族

packages/context/README.md 开头就说,"context" 组是 "Product plugins that add model-visible request context without defining a tool"——即一系列加模型可见上下文、但不定工具的插件:

注入什么
agent-instructions/工作区指令(类似 dojo 的那个 AGENTS.md 内容),随创建按 agent/session 隔离
time-context/当前时间 / 经过时长
tmux-context/tmux 位置上下文
session-reference/其他会话的有界快照

这些插件是"上下文注入"的标准形态:它们不在模型面前定义一个工具,而是又在 agent.inject() / request 前把该说的东西排队进 inbox(agent-instructions 是默认 bundle 的一部分、可关闭;其余 opt-in)。

对比记忆(来自 OpenCode/Codex 那三栏):OpenCode 有"指令文件 + Skill 系统"、Codex 有"上下文注入",而 dsh 通过 agent.inject() + context 包族把"塞谁、塞什么、塞多少"也做成插件化装配。

三、子代理:从"子 agent"到"跨产品委托"

一个 provider 的注册表,而不是"一个子过程"

子代理(subagent)是第七篇最值得展开的一点,因为它和 bash 有个关键不同——多个 Provider 同时存在docs/subsystems/subagent.md 开篇点破:

Like bash, it is one optional capability …, not part of the agent loop. It differs ... because multiple provider implementations coexist in one context, registered by name (ctx.subagents), while bash allows only one executor.

bash 那类只允许一个执行器;子代理则是一个 named provider 注册表ctx.subagents)——可以同时注册若干个 spawnforkacpcodexclaude-code……每个 provider 是一种"child transport"。这就是 dsh 里"从 fresh child 到跨产品委托"都能用一个接口实现的根因。

一次 start:one-shot 委托

ctx.subagents.start(name, request) 选择某个 provider,起一个一次性 child:

  • requestpromptparent(父 agent)、signal、以及可选 outputSchema/maxDepth/toolFilter/persona
  • 每个可选能力都要求 provider 声明的 capabilitiestrue,否则 fail loudUNSUPPORTED_CAPABILITY)拒绝,而不是接受后忽略;
  • 子代理通过 parent 的所有权/scope,用一个 SubagentRunresult + dispose)承载整个一次新 run。

从主会话 fork 到提交

"子代理"还有一种很优雅的来源:从主 agent 的会话 fork 出来docs/subsystems/subagent.md 的"Fork seeding"一节:

The fork backend passes a balanced completed-turn prefix of the parent's log — the parent's events up to and including its last turn/end — so the seed is contiguous-from-0 and the invariants replay accepts it.

也就是 fork 这个 provider 拿父会话一段"平衡的已完成回合前缀"(到最后一个 turn/end 为止),把它作为 seed 给子代理创建一个新会话——本质上就是在会话日志上做了一个子会话分支,非常符合第 4 篇"fork 就是拷贝日志前缀"的思想。

给模型委托、给产品委托

一个 subagent 的 provider 不止"spawn/fork 两个本地后端":ctx.subagents 允许你把一个 child 委托给另一个产品里的 agent——subagent-acp(ACP 协议)、subagent-codexsubagent-claude-code 这些 provider 直接通过 wire protocol 把委托发给 Codex 或 Claude Code。所以"子代理"的 provider 会连在一个私行的进程中,或直接跨产品,全看你怎么注册。

tool-subagent 是 model-facing 的 Consumer:把"请一个子代理做事"这个东西暴露给模型,让模型决定什么时候 delegate、委托给谁。

小结(子代理)

子代理在 dsh 就是一个 provider 注册表(ctx.subagents),由单个 Consumer 定义了对外暴露;一次委托可以从"同进程的新 agent(spawn/fork)"到"跨产品的协议搬运(acp/codex/claude-code)",都只是一次 start(),接口一样,Provider 换。

四、三件事共享的内核:都是"session 上的扩展"

把第七篇三个主题并起来看,它们其实是"同一根骨头的三种":

扩展落在 session 上做什么换成 Provider 吗
压缩compaction/* 事件 + surface replace 折叠旧段成摘要dsh-compactiondsh-compaction-basic / 别的前端
上下文注入agent.inject() / agent.followup() 往 inbox 排队;request-context 插件构建一段加入插件(agent-instructions/time-context/...)可开关可增
子代理subagent/start/subagent/end 事件 + 从 parent fork 的 child session✅ 一个 named provider 注册表,可有多 provider 共存

关键线索:三件事都复用了我们在会话日志(第 4 篇)里建立的"一切写进 session / surface"的观念

  • 压缩 fold 成 surface 节点,token 数、shadowed seqs、锁等都记录在 compaction/* 事件;
  • 注入经 inbox 排队,最终成为 user/message > 会话历史上的一条;
  • 子代理的结果都以 subagent/* 事件记在日志里,child 就是 fork 出来的 another Session。

这就是 dsh 的"装配式扩展"逻辑——外出的扩展都回写到 session 日志、都做成缝/provider,让"长会话怎么活"成为一个可配置的装配问题,而不是写死的一个算法。

五、与三栏对比:把"长会话扩展"做成缝

对比三栏(同能不同构):

维度OpenCodeCodexClaude CodeDeepSeek Harness
压缩2 级(Prune+Compact)3 种5 级做成可换缝ctx.compaction + 可换 provider)
上下文注入指令+Skill(5 层)context diffingclaude.md+sessionMemoryagent.inject() 排队 + request-context 插件族
子代理SubAgent 广播多 AgentAgentToolctx.subagents provider 注册表(spawn/fork/acp/codex/claude-code)

核心差异一句话:

三栏把压缩 / 上下文 / 子代理当作"产品自己的"机制各写各的(压缩有 2/3/5 种策略、上下文有 5 层);dsh 把这三件都统一成"可换的缝 / provider",让"长会话怎么活"变成装那个产品时"换谁做、怎么配",而非"改核心算法"。

这跟第六篇的 capability 缝的哲学一脉相承,也是 dsh 作为"通用引擎"最后一以贯之的表现——从模型适配、到上下文、到压缩、到子代理,全部是可缝/可配的轮子,而引擎骨架只有"缝一致性 + 会话日志"两根柱子。

结语 / 下一篇

第七篇把三件"长会话怎么活下来"的话题拆开了:压缩是可换缝、注入是 agent.inject() 排队、子代理是可换 provider 注册表。它们无一不是"会话日志上的扩展"。

dsh 的野心还没完——最"魔法"的一层我们留到最后一篇:agent 能不能改自己? 它能不能检查自己当前 mount 了哪些插件、主动 mount/unmount 新的插件、再配合主流 CLI(Claude Code / Codex)的 hook 协议握手?那就是全专栏最后一篇:08 自改 / hooks 桥 / 生态

章节小测

本章 Quiz — 核心机制自检

1 压缩(compaction)在 session 的模型历史上到底做了什么?

2 为什么把压缩做成一个可换的缝(Service Definition + Provider)?

3 压缩事务的锁是怎么释放的?

4 `agent.inject()` 与 `agent.followup()` 的关键区别是?

5 子代理缝和 bash 缝最大的不同是?

Agent Src — AI Agent 源码精读