读完 Cordis 再回到 DeepSeek Harness,会发现代码里到处都是 ctx.sessions、ctx.agents、ctx.tools 这样的调用。
它们看上去像一组放进依赖注入容器的全局对象,但真正读下去会遇到几个问题:Agent 到底是谁创建的?agent-loop 和 agent 为什么要拆成两个包?同一个工具为什么能只对某个 Agent 生效?Session 事件和 Agent 事件又有什么区别?
我后来不再从 package 名字猜职责,而是从 ctx.agents.create() 往下追。顺着这条调用链,七个核心包的关系会清楚很多。
项目地址:deepseek-ai/deepseek-harness
源码基线:0.1.0-rc.5
创建 Agent 的入口为什么在 ctx.agents
Headless、Web、ACP 和 SDK 创建 Agent 时,面对的都是 ctx.agents,而不是具体的 AgentLoop 类。
入口在 packages/core/agent/src/index.ts。AgentRegistry.create() 做的事情很克制:找到当前注册的工厂,再把创建请求交给它。
ts
async create(options: CreateAgentOptions): Promise<AgentHandle> {
const ownerCtx = this.ctx
const { target } = this.requireFactory()
const receiver = getTraceable(ownerCtx, target)
return Reflect.apply(
target.createAgent,
receiver,
[ownerCtx, options],
)
}
这里没有 new ReactLoopAgent(),也没有 Session、LLM 或工具调用。AgentRegistry 只负责两件事:保存当前进程里的活跃 Agent,以及把创建和恢复请求交给 AgentFactory。
如果 Profile 没有装载 Agent Loop,requireFactory() 会直接报错:
text
no agent factory registered (load an agent-loop plugin)
这条错误很有用。它说明 ctx.agents 服务已经存在,但还没有任何驱动器接管 Agent 的创建工作。排查时应该看有效插件树,而不是先怀疑模型配置。
AgentFactory 把公共入口和默认驱动器隔开
AgentFactory 接口也定义在 dsh-agent,而不是 dsh-agent-loop:
ts
export interface AgentFactory {
createAgent(
ownerCtx: Context,
options: CreateAgentOptions,
): Promise<AgentHandle>
resume(
ownerCtx: Context,
options: ResumeAgentOptions,
): Promise<AgentHandle>
}
消费者只依赖这两个动作:创建新 Agent,或者从持久化 Session 恢复 Agent。至于背后使用哪一种循环实现,调用方不需要知道。
当前默认实现是 packages/core/agent-loop/src/index.ts 里的 AgentLoop:
ts
export class AgentLoop extends Service implements AgentFactory {
static inject = [
'agents',
'sessions',
'llm',
'tools',
'systemPrompt',
]
constructor(ctx: Context, config: Config) {
super(ctx, 'agentLoop')
// ...
ctx.effect(
() => ctx.agents.setFactory(this),
'agentLoop.setFactory()',
)
}
}
ctx.agents.setFactory(this) 是交接点。Agent Loop 装载时成为当前工厂;插件卸载时,这项注册跟着 effect 一起撤销。
所以 agent 和 agent-loop 不是重复拆包。前者提供稳定的调用入口和活体注册表,后者提供当前默认的执行机器。以后换一种驱动方式,入口仍然可以保持 ctx.agents.create()。
Agent Loop 负责协调,不负责拥有所有能力
static inject 已经写出了 Agent Loop 的直接依赖:Agent 注册表、Session、LLM、Tools 和 System Prompt。
创建 Agent 时,它先向 Session 服务申请一份尚未发布的会话,再完成 Agent 级配置,最后才把 Agent 和 Session 一起公开:
ts
async createAgent(
ownerCtx: Context,
options: CreateAgentOptions,
): Promise<AgentHandle> {
const preparation = SessionPreparation.create(
this.runtime.ctx.sessions.prepare(options.sessionId, {
...(options.seed === undefined ? {} : { seed: options.seed }),
...(options.meta === undefined ? {} : { meta: options.meta }),
}),
)
const published = this.setupAndPublish(
ownerCtx,
options.sessionId,
preparation,
options.agentOptions ?? {},
options.setup,
options.signal,
'startup',
)
this.ownership.trackWrapper(published)
return published
}
这里有一个容易忽略的细节:ownerCtx 来自 ctx.agents.create() 的调用方。创建出来的 Agent 因而跟随调用方的生命周期,而不是永远挂在 Agent Loop 自己的全局 Context 上。
进入实际回合后,Agent Loop 会让 ctx.systemPrompt 组装提示词和工具 schema,调用 ctx.llm 获取流,再把工具请求交给 ctx.tools。过程中产生的 Turn、Step、消息和工具结果由 ctx.sessions 记录。
Agent Loop 拥有的是这段协调顺序,不是模型 Provider、工具实现或持久化后端本身。
七个核心包其实可以分成三层
如果把七个包当成七个平级模块,很容易在依赖关系里绕晕。我更习惯按运行时职责把它们分成三层。
最下面是 scope、llm 和 session。scope 提供每个 Agent 的作用域身份;llm 定义供应商无关的消息、流和适配器入口;session 保存只追加的事件日志,并从日志推导模型历史。
中间是 system-prompt、agent 和 tools。它们分别管理提示词片段与工具 schema、活跃 Agent 与实时事件、带作用域的工具注册与执行管线。
最上面只有 agent-loop。它调用下面的服务,把一次运行串起来。

这张图不是编译依赖图。它表达的是运行时调用方向:入口通过 ctx.agents 找到工厂,默认工厂再协调 Session、Prompt、LLM 和 Tools。持久化、UI 与 SDK 从 Session 事实流中得到可恢复的数据,策略插件则挂在实时事件和能力事件上。
每个 Agent 都有自己的作用域
如果所有服务都挂在 ctx 上,一个自然的问题是:两个 Agent 同时运行时,怎样让某个工具、提示词片段或审批规则只属于其中一个?
答案在 packages/core/agent-loop/src/agent.ts 的构造函数里:
ts
this.scope = createScope(loopCtx, this)
this.ctx = this.scope.ctx.extend({ agent: this })
Agent 实例本身就是这层作用域的 key。agent.ctx 继承全局服务,但通过它注册的内容会带上当前 Agent 的 scope。
同一个 ctx.tools 因而可以同时看到全局工具和 Agent 私有工具;同一个 tools/pre-execute 事件,也可以有全局监听器和只对某个 Agent 生效的监听器。服务名没有变,Cordis Context 上的 scope 标签决定当前能看到哪一层注册。
事件传播方向也经过 scope 过滤:全局监听器能观察各个 Agent,挂在 agent.ctx 上的监听器只接收这个 Agent 的事件。Agent 被释放时,它的 scope Fiber 一起销毁,私有工具、提示词片段和监听器不会残留到下一次运行。
这比给所有注册项手工拼接 agentId 更可靠。身份、可见性和清理归同一套生命周期管理,不需要每个服务各写一份 Map 过滤逻辑。
三类事件解决的是三种不同问题
DeepSeek Harness 的事件很多,但先分清事件域,阅读难度会下降一大截。
text
session/event 已经写入日志的持久事实
agent/* 当前 Agent 的实时状态与控制点
tools/*、fs/* 等 某项能力自己的策略接入点
session/event 在事件追加成功后广播。JSONL 持久化协调器就是直接消费这条流:
ts
ctx.on('session/event', (session, event) => {
const live = this.initFor(session)
live.writes.enqueue(event)
})
Turn、Step、用户消息、模型输出和工具结果需要在进程重启后重建,因此属于 Session 事实。SDK 想绘制可恢复的对话轨迹,也应该读这条事件流。
agent/* 处理的是正在发生的运行。agent/status 告诉界面当前是 idle 还是 running;agent/pre-step 可以在模型请求前改写或拒绝本轮消息;agent/request-error 给重试、压缩或终止策略一个介入点;agent/turn-stopping 则是 Turn 真正关闭前的检查点。
这些事件可以改变当前执行,但它们本身不是重放日志。页面刷新后还想恢复的信息,不能只发一个 agent/* 事件就算完成。
能力事件更靠近具体服务。工具审批使用 tools/pre-execute,工具结果修整使用 tools/post-execute,文件策略监听 fs/write-intent 或 fs/edit-intent。它们不需要导入 Agent Loop,因为事件由能力所有者自己发出。
新功能放哪里,要看谁拥有这件事实
这套结构真正实用的地方,是改功能时不必先碰主循环。
接一个新模型后端,应该实现 LLM Provider,让 Agent Loop 继续调用 ctx.llm。给工具增加审批或审计,应该扩展 ctx.tools 的执行事件。为某个 Agent 增加专属提示词或工具,应当通过它的 agent.ctx 注册。需要在重启后恢复的新信息,则必须进入 Session 日志,而不是只发实时通知。
只有 Turn、Step、消息进入时机或工具结果回流方式发生变化,才真正涉及 Agent Loop。那是在改变执行模型,不是"再加一个插件"。
我现在排查运行问题也按同样的方向走:
ctx.agents.create()报没有工厂,检查 Profile 是否装载agent-loop;- 某个 Agent 看不到工具,检查注册时使用的是全局
ctx还是agent.ctx; - 页面实时显示正常,但刷新后丢失,检查是否把持久事实误放进
agent/*; - 模型请求内容不对,依次看
system-prompt/assemble、agent/pre-step、agent/request和ctx.llm; - 工具被拒绝,沿
tools/pre-execute、审批服务和作用域监听器排查。
这样读代码时,七个包就不再是七组零散 API。ctx.agents 提供稳定入口,AgentFactory 决定谁来驱动,Agent Loop 只负责编排,scope 隔离每个 Agent 的私有注册,Session 与实时事件分别承载可恢复事实和运行控制。
下一篇进入 packages/core/agent-loop/src/agent.ts,沿一次真实 Turn 看 Step 怎样开始、模型流怎样写入 Session,以及工具调用为什么会让同一个 Turn 继续跑下去。