DeepSeek Harness 的「同会话目标」:goal 是什么、跟计划模式是什么关系
先说清楚这篇的出发点:假设你让 agent 干一件要跑很久的活,中间机器重启了,你重新恢复会话——它会不会自己接着往下跑?
这个问题在 DeepSeek Harness 里不是靠”猜它心情”回答的,而是被拆成了两个互相独立的状态。docs/subsystems/goal.md 开头那句话就是答案的全部:持久 phase 回答”目标发生了什么”,进程本地的 activation 另行回答”续跑消费方能不能开始另一个 Round”。这两件事被刻意拆开,是整个 goal 子系统里最需要先记住的一点。
必须先摆在前面的限定:该仓库 README 自述处于开发者预览阶段(Developer preview),并明写”THERE WILL BE COMPATIBILITY-BREAKING CHANGES”。下面提到的字段名、命令、默认值都随时可能变,请以仓库最新内容为准。
一个 goal 长什么样
类型定义在 packages/goal/goal/src/types.ts,一共就那么几个字段,看一遍就够:GoalSnapshot 继承 GoalRef(id + revision),带 objective、phase、maxGoalRounds,外加只在 phase 为 blocked 时出现的 blockedReason。
GoalPhase 是个闭合的四选一:active、paused、blocked、complete。注意这里没有”failed”、没有”error”——包 README 写明,提供方限制、配置预算、执行错误、需要人工输入这几类情况全都落到 blocked 这一个持久 phase 上,不再扩增生命周期状态。blocked 的原因是个两字段结构 GoalBlockReason { code, message },code 要求 lower-kebab-case,服务端在 packages/goal/goal/src/index.ts 的 resolveBlockReason() 里用正则 ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$ 卡了这个格式,message 去空后不能为空。
真正容易看漏的是 GoalActivation,值只有 'armed' | 'disarmed' 两个,注释写得很直白:process-local continuation eligibility;never persisted。它出现在 GoalView 里,但不在 GoalProjection 里——同一个文件下面的 GoalProjection 注释明确说 activation 是进程本地、从不持久化,投影只反映持久 phase。
所以开头那个问题的答案是:会话恢复之后,phase 还是 active,revision 和已用 Round 数一个不差,但 activation 不会跟着回来。GoalService 构造函数里挂了 ctx.on('agent/session-start', ...),做的事就一句:把这个 session 的 cache activation 置成 disarmed。私有的 cache() 方法首次给某个 session 建缓存时,初始值同样是 disarmed。想让它继续跑,得再来一次显式的 resume。
Round 是怎么被放行的
续跑的逻辑不在 goal 服务里,而在另一个包 packages/goal/goal-round-driver。这一点 goal 包 README 的限制清单里就写了一条 “State, not scheduling”:这个包不决定一个已 armed 的目标什么时候续跑,也不做异常重试、不取消进行中的轮次。
驱动器的核心是 apply() 里的 drive() 函数(packages/goal/goal-round-driver/src/index.ts)。它先过 readyToDrive() 这道闸:fiber 处于 ACTIVE、自己没在 stopping、ctx.agents.get(agent.id) 拿到的必须是同一个 agent 实例、agent 的 status === 'idle'、并且没有 competingQueued。任何一条不满足就直接返回。
过了闸之后是三句判断:
const goal = currentGoal(state)
if (goal === undefined || goal.phase !== 'active' || goal.activation !== 'armed') return
if (goal.roundsStarted >= goal.maxGoalRounds) {
ctx.goals.block(agent, goalRef(goal), {
code: 'round-limit',
message: `Goal reached its configured limit of ${goal.maxGoalRounds} rounds.`,
})
return
}
这段值得逐字看。phase 是 active 还不够,activation 还得是 armed;Round 用完了不是静悄悄停下,而是主动写一次 block,code 固定为 round-limit。同一个文件里 code: ' 一共只出现三次,除 round-limit 外另外两个也都是驱动器自己写的:queue-failed(agent.followup() 抛错时)、prompt-rejected(agent/pre-step 的下游决策为 reject 时)。再加上模型经工具自报时用的 model-reported(这个不在驱动器里,在 packages/goal/tool-goal/src/index.ts),一个卡住的 goal 常见的 blockedReason.code 就这四个。排查一个卡住的 goal,先看它 blockedReason.code 落在哪一个上,比读日志快。
放行之后,驱动器算出 round = goal.roundsStarted + 1,用 renderGoalRoundPrompt() 渲染一个 <goal_round> 文本块(在 packages/goal/goal-round-driver/src/prompt.ts,正文里 Objective 用 JSON.stringify 包过,Round 写成 ${round}/${goal.maxGoalRounds}),带上 source: { kind: 'goal', goalId, revision, round } 塞进 inbox。
关键在于这条消息进了 inbox 也不算数。agent/pre-step 监听器里有个 validReservation(),在下游监听器之前和之后各校验一次,条件里包含 source.round === goal.roundsStarted + 1、revision 必须对得上、attempt 的 phase 必须是 claimed 且未 stale。只有真正落成 user/message 事件的那次才推进 roundsStarted。docs/subsystems/goal.md 的说法是:回放会拒绝非正数 Round、编号缺口、陈旧修订号、已停止阶段和超出上限——对应的严格折叠在 packages/goal/goal/src/fold.ts,其中一条错误消息是 `goal round at session event ${event.seq} is not the next admitted round of the active goal`。
顺带一提,人类消息不消耗 goal 上限,这是驱动器 README 明写的;roundsStarted 只由 goal 来源且已准入的 user/message 推进。
两个默认值,管的不是一回事
packages/goal/goal 只有一项配置:
static Config: z<Config> = z.object({
defaultMaxGoalRounds: z.number().default(256),
})
构造函数里还有一次 config.defaultMaxGoalRounds ?? 256 的兜底,并走 resolveMaxGoalRounds() 校验必须是正的安全整数。这个 256 只是”创建请求没自带上限时用哪个值”,CreateGoalRequest.maxGoalRounds 传了就以传的为准。
另一个 3 在完全不同的包里:packages/goal/tool-goal/src/index.ts 的 blockedAfterConsecutiveRounds,schema 写的是 z.number().step(1).min(1).default(3)。它管的是”模型最少连续跑够几个 Goal Round 才允许自报 blocked”,低于阈值时抛 GOAL_TOOL_BLOCK_THRESHOLD。这个数字还会被拼进系统提示词段落(guidance(),注册名 tool:goal,order 114),所以改配置等于改模型看到的那句话。
包 README 特意提醒过:驱动器本身没有可调配置,maxGoalRounds 属于目标定义、阻塞阈值属于 tool-goal,在驱动器里重复任一数值都可能产生分歧策略。两个默认值别记混。
顺便按红线说清楚:256 和 3 都是配置默认值,不是”你用起来会怎样”的保证,更不能拿来推算耗时或成本。
人的入口和模型的入口,权限不一样
人这边是 /goal,在 packages/goal/command-goal/src/index.ts,用法字符串是 Usage: /goal [<objective>|clear|edit <objective>|pause|resume]。解析规则很省事:输入为空是查看,clear/pause/resume 三个控制词大小写不敏感,edit 后跟空格是改写,其余任何输入都当成新目标的 objective。
模型这边是三个工具:get_goal、create_goal、update_goal,update_goal 的 action 枚举是 edit | pause | resume | complete | blocked,一共五个。权限门在 packages/goal/tool-goal/src/authority.ts:edit、pause、resume 走 requireDirectHuman(),要求当前轮次里存在 source.kind === 'user' 的人类消息且 agent 是根 agent;complete 和 blocked 走 completionAuthority(),可以是直接人类授权,也可以是”这一轮正是当前 goal 那个已准入的 Round”(isMatchingGoalRound() 逐字比对 goalId、revision 和 round)。create_goal 的描述里也写死了一句:Execution rejects non-human and subagent authority。
还有一处细节,看服务类的装饰器就能发现:edit、pause、resume、complete、clear 和 remoteExportCreate 带 @Remote(...),而 block()、disarm()、get() 没有。disarm() 被包 README 称作”仅供生命周期使用的例外”:它移除进程本地续行权限,不写新 revision,也不发变更事件。
组合位置也别搞混。packages/bundle/base/cordis.patch.yml 里 goal、goal-round-driver、command-goal 三个是一起挂的;而 apps/cli/config/agent-presets/standard/agent.cordis.yml 的 goal 小节只挂了 tool-goal,配置文件自己的注释解释说服务、驱动器和 /goal 命令留在 host plane,preset 选择的只是”这个 agent 能不能调 goal 工具”。tool-goal README 也照应了一句:Goal Round 权限需要驱动器,只挂工具包不会凭空产生这些轮次。
跟计划模式到底什么关系
答案可能有点反直觉:在源码层面,没有关系。
我们把 packages/goal/ 全目录 grep 了一遍 planMode / plan-mode / plan mode,零命中;反过来在 packages/plan/plan-mode/src/ 里 grep goal,同样零命中。两边各自成包,互不引用。
再看语义。docs/subsystems/plan.md 对计划模式的定性是”软性指引”——激活期间往每个模型请求里加一段部署持有的 plan:policy 提示词段落,plan/mode 事件形状只是 { active: boolean },而且原文写明沙箱模式与审批策略都不读写计划状态,要分别配置。goal 这边正相反:它是一个带 revision 的持久状态机,每次变更都落一条 goal/change 会话事件,携带变更后的完整快照;clear 留一个 revision 加一的 tombstone。一个是往提示词里加话,一个是往日志里写状态。
它们唯一的表面交集在文案里:standard preset 的 plan 指引文本要求计划要 “state the goal and success criteria”。那是让模型在计划文档里写清目标,跟 ctx.goals 这个领域没有任何调用关系。
时间上的先后倒是可以从各自的注释里读到:goal 领域和同会话驱动器的 Agent Note 日期都是 2026-07-19,plan-mode 的设计说明日期是 2026-07-22。两处白纸黑字放在一起就是这样,至于作者怎么想的,源码里没写,我们不猜。
什么时候这个差异会咬到你
三种场景:
一是恢复会话后以为它会自己跑。持久 phase 还是 active,你看 /goal 也显示 active,但 Activation: disarmed——renderGoal() 会把这一行打出来。tool-goal 的策略文本里写了对应做法:session resume 或 fork 之后 active goal 是 disarmed,人类以任何措辞要求继续时,用 update_goal 的 resume 重新 arm。
二是把计划模式当成”目标的一部分”。计划模式退出与否不改变 goal 的任何字段;反过来 goal 跑到 complete,也不会把计划模式关掉。两套状态各自持久、各自恢复。
三是想同时跑两个目标。包 README 写得很硬:最多只有一个当前目标,系统有意不支持并行目标或独立目标数据库。已完成的目标可以被新目标替换,README 原文对其余情况的说法是「必须被 edited、transitioned 或 cleared」——对应到源码,create() 里只要 current !== undefined && current.phase !== 'complete' 就抛 GOAL_ALREADY_EXISTS。
最后补一句 README 自己列出的限制,别当成安全边界看:goal 包”信任进程内生产方”,能直接访问 Session 的插件可以追加伪造的 goal/change 数据,严格回放只做完整性检测、不是插件隔离。这是原文措辞,我们照抄。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。