目标、作业与待办
DSH 把"要做的事"分成三个层次,各自服务于不同粒度的推进:
| 层次 | 服务 | 面向 | 状态存哪 |
|---|---|---|---|
| 目标 | ctx.goals | 跨多轮/多 step 的长期目标 | 会话日志(goal/change 事件) |
| 作业 | ctx.jobs | 长时运行的后台操作(共享 id、owner 隔离) | 进程内注册表 |
| 待办 | tool-todo 的 todo_write | 一次会话内的行动清单 | 事件溯源会话日志 |
1. 目标(goal)
当前完成目标,保存在 agent 的既有会话里,同一时刻至多一个。
const ref = await ctx.goals.create({
objective: '审计整个仓库的文档一致性',
maxGoalRounds, // 可覆盖部署默认 defaultMaxGoalRounds: 256
})
// 状态机:edit / pause / resume / complete / block / clear
await ctx.goals.edit(ref, {...}) // 所有变更都带 GoalRef{id,revision} 的 compare-and-set 栅栏
await ctx.goals.complete(ref)
关键点(源码 README):
- create 产生修订号 1 的 active 目标并 armed;每个变更追加一个持久的
goal/change事件(带完整后置快照),clear 用 revisioned tombstone - block 是统一阻塞态:provider 限制、配置预算、执行错误、请求人类输入都用这一个 durable phase,记录 policy-owned 的 kebab-code + 归一化解释
resume只在轮次上限还有剩余时接受(且清掉旧的 blocker reason)disarm()是生命周期例外:移除进程内继续权限但不写 revision/不发事件
目标不是简单字符串:它承载完成条件、轮次上限、阻塞状态,是"驱动一个长任务持续向前"的机制。
/goal 人类命令
command-goal 注册全局 /goal 命令,人可直接控制目标(不走模型轮次):
| 输入 | 结果 |
|---|---|
/goal | 显示当前目标、durable phase、轮次计数/上限、有效后续命令(无目标时显示用法) |
/goal <目标> | 创建并 arm 一个新目标;未完成的目标不会被替换,除非先 clear |
/goal edit <目标> | 改目标文字,不改 phase/激活态 |
/goal pause / /goal resume | 暂停并 disarm / 恢复并 rearm(受剩余轮次上限约束) |
goal-round-driver:同会话续推进
goal-round-driver 是同会话续推驱动:把 active + armed 的目标转成连续的 goal round,经公共 Agent 与会话服务推进:
- id: goal
name: '@deepseek-ai/dsh-goal'
- id: tool-goal
name: '@deepseek-ai/dsh-tool-goal'
- id: goal-round-driver
name: '@deepseek-ai/dsh-goal-round-driver'
目标推进 → goal round → 模型工作 → 下个 round,直到 complete / block / 轮次上限。
2. 作业(jobs)
后台作业注册表:给长生命周期生产者的共享 id、owner 隔离、读取、取消、等待、通知、清理,统一在一个 ctx.jobs 契约下。
const id = await ctx.jobs.start({ owner, controller, spec })
await ctx.jobs.get(id) // 非消费快照
await ctx.jobs.read(id) // 流式作业消费游标;终态幂等读
await ctx.jobs.wait(id, 30_000) // 等终态,超时返回当前快照
await ctx.jobs.kill(id, caller, '不再需要')
ctx.jobs.onJobDone(...) // 观察每个终态记录(只含精确 owner)
ctx.jobs.onJobsChanged(...) // 观察可见集合变化(owner 粒度)
- owner 是边界:
bash-1这类 id 可预测,get/list/read/kill/wait都对比 caller 的 SessionId - unowned 作业对任意 caller 开放,存活到服务卸载
- 生产者为
dsh-jobs-local,通过TaskKindMap扩展不透明 id 命名空间 - 模型面向
tool-jobs:job_output/job_list/job_kill
完成通知与自动唤醒
tool-jobs 会 via ctx.jobs.onJobDone(...) 监听每个作业的终态,替未报告的完成投递一条通知,即使模型从未读过输出也不会漏(源码 packages/jobs/tool-jobs/src/index.ts):
background job <id> (<kind>: <label>) finished [status: ...]. Read its output with job_output.
按 owner 忙闲分流:
| owner 状态 | 投递方式 | 说明 |
|---|---|---|
| busy(正在跑 step) | 注入 owner.inject(message) | 通知进 owner 的 next-step inbox,本轮内就能读到;同刻结清的多个作业只花一步 |
| idle(空闲) | completionDelivery: 'wakeup' 时 owner.followup(message) | 给空闲 owner 开一个新回合(自动 followup);quiet 时则留在 pending,等别的东西唤醒 |
# tool-jobs 配置(组合示例)
- id: tool-jobs
name: '@deepseek-ai/dsh-tool-jobs'
# completionDelivery: 'wakeup' 默认;改 'quiet' 则空闲 owner 不再被开回合
# maxConsecutiveWakes: 3 默认,限制"被唤醒的回合又启动会再唤醒它的作业"这条自激链
maxConsecutiveWakes默认 3:限制一个 owner 在再次消费用户输入前,被完成唤醒打开的回合数——封顶"被唤醒的回合又启动了会再唤醒它的作业"这条自激链。每次agent/inbox/claimed且消息来自用户源(message.source.kind === 'user')时重置预算;quiet投递不花预算(因为没有回合被打开)。completionDelivery: 'quiet':空闲 owner 不被开回合,通知留在 pending,直到别的输入唤醒它才送达。同一 owner 同步被替代(会话替换)会拿到满预算。- 该系统提示引导模型(源码
packages/jobs/tool-jobs/src/index.ts的ctx.systemPrompt.section):
Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.
job_output(wait:true)是阻塞读,但归tool-jobs管:waitTimeoutMs默认 30s(单次默认等待),maxWaitTimeoutMs默认600_000(10 分钟)是任何单次等待的硬上限——模型给的timeout_ms会被钳到它。超时返回的是活的作业快照([status: running])而不是 TOOL_TIMEOUT 错误,作业保持存活,之后还能再读。onJobDone也会把仍有等待者的终态标记为reported(源码packages/jobs/jobs-local/src/index.ts的settle)。
并发上限
dsh-jobs-local 按 exact owner 限活跃作业数(源码 packages/jobs/jobs-local/src/index.ts):
maxConcurrentJobsPerOwner默认 10;计入running+stopping两种状态(已终态的 completed/killed/failed 不占位)。- unowned 作业是独立桶:
activeTaskCount对owner === undefined单算,不受任何 owner 的额度约束。 - 到顶时
start()抛错并给出模型的正确动作:"use job_kill to stop an unneeded job, wait for it to finish, then retry"——即job_kill停掉不需要的、等它走完停稳、再重试启动。
- id: jobs-local
name: '@deepseek-ai/dsh-jobs-local'
maxConcurrentJobsPerOwner: 10 # 默认;running+stopping 计容,unowned 独立
// packages/jobs/jobs-local/src/index.ts
if (active >= this.maxConcurrentJobsPerOwner) {
throw new Error(
`background job limit reached for this owner (limit: ${this.maxConcurrentJobsPerOwner}); use job_kill to stop an unneeded job, wait for it to finish, then retry`,
)
}
3. 待办(todo)
tool-todo 提供模型可调的 todo_write:把行动清单写进事件溯源会话日志。它适合"这一轮我要按顺序做什么"的短期清单,状态随会话持久化。
todo_write 部署策略
tool-todo 的 Config.allowParallelInProgress 是必配项(z.boolean().required(),源码 packages/todo/tool-todo/src/index.ts),部署必须显式声明"能否多条并进":
allowParallelInProgress | 模型指引 | 适用 |
|---|---|---|
true | 允许多条 in_progress,要求标记每一个正在推进的任务 | fan-out:并发的子 agent、后台命令、workflow 扇出 |
false | 至多一条 in_progress;调用把它标成多条会被拒绝(抛错) | 单活动纪律的一次一步推进 |
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
allowParallelInProgress: true # 必配;fan-out 用 true,单活动纪律用 false
// packages/todo/tool-todo/src/index.ts
if (!allowParallel && active > 1) {
throw new Error(`invalid todos: at most one task may be in_progress (got ${active})`)
}
- 单 owner 边界:
todo_write需要 owning agent session,execute里exec.agent不存在(非 agent 调用方)直接抛错,不做 no-op。 - 整表替换是唯一操作:
send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits);每次调用 append 一条todo/write快照,replay 是 last-write-wins,无读回。投影(todoskey)持有最新整表,turn/start清零到null。
三者怎么配合
一个典型长任务的推进:
创建一个 goal「完成 X」 → 长期方向
用 todo_write 拆本轮待办 → 短期步骤
较慢/可后台的操作进 jobs → 不阻塞主循环,随时回来收结果
goal 完成时 complete() → 收尾
三者的共同点:都以事件溯源/注册表持有状态,谁也不会丢进度。
验证
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -E '"goal/|"todo/' | head
# jobs 是进程内的,用 dsh web --dump-config | grep jobs 看是否装载
下一步
- 内置工具:
tool-goal/tool-jobs/tool-todo的模型面 - 子 Agent 与并行:把活并行派出去