Skip to content

后台任务与子代理

本章目标

本章讲两件让插件「跑得久、跑得多」的事。前半部分介绍统一后台任务注册表 ctx.jobs:bash 后台进程、subagent 委派、PTY 后台发送共享的长期工作运行时,以及面向模型的 job_* 工具;后半部分介绍 subagent 能力 seam:一个 agent 如何把工作委派给子 agent,可继续子 agent 如何工作,以及必须遵守的权限纪律。

学完本章你会:

  • 说出 ctx.jobs 是什么,以及 job 从启动到结算的生命周期;
  • 在自定义工具里把长时间工作注册为后台任务,返回类型化的 job id 句柄;
  • 理解 subagent 的命名提供方注册表与可继续 subagent 的「持久 Session + Activation」模型;
  • 记住两条纪律:能力不符响亮拒绝,权限只授直接 parent。

统一后台任务注册表:ctx.jobs

ctx.jobsJobRegistry)是进程内每套组合共享一个实例的抽象服务(Service Definition 为 @deepseek-ai/dsh-jobs,进程局部实现为 dsh-jobs-local)。它是所有长时间运行工作的统一身份层:bash 的后台进程、subagent 的委派、PTY 的后台发送,都通过 ctx.jobs.start() 注册成一条 job,由同一个 job_* 工具族收集或停止。生产方拥有执行资源;运行时拥有身份、访问权限与生命周期状态。

job 的身份是 <kind>-N 形式的品牌化 id。kind 来自可合并扩展的 JobKindMap(内置 bashsubagent),插件通过声明合并扩展它,注册表把每个 kind 视为不透明的 id 命名空间。id 可预测,所以访问控制依赖 owner 授权而不是 id 保密:注册时带 owner: Agent 的任务,读取、杀死、等待都被该 agent 的 session id 围栏;不带 owner 的任务是无主任务,任何调用方可见。

job 生命周期:start → 运行 → 结算

启动走 ctx.jobs.start(spec)specJobStartkind、一行模型可见的 label、可选 outputLimitBytes、可选 owner,以及只调用一次的同步 run(): JobHooks。运行时先完成预检(访问、owner 清理、准入),再调用 run()run() 抛异常则什么都不注册。JobHooks 是运行时的控制手柄:

ts
interface JobHooks {
  cancel(reason?: string): void            // 同步、幂等,最终 settle done
  done: Promise<JobOutcome>                // 生产方释放资源后 resolve,绝不 reject
  readOutput?(): string                    // 消费式增量;缺省 = 仅最终输出任务
}

doneJobOutcome{ status: 'completed' | 'killed' | 'failed', detail?, output? }。注意两点:done 在资源释放后 resolve,不是工作完成时;它不得 reject,运行时把 rejection 转成 failed

结算语义是 first-wins:最早到达的终止结果只记录一次,随后释放等待方、只通知监听器一轮——即使迟到一份生产方结果也如此。完成通知在记录提交、所有其他观察者都看到之后最后宣布,因为报告方可能同步开启一个模型轮次。

准入有界:LocalJobRegistrymaxConcurrentJobsPerOwner(默认 10)按确切 owner 统计 running 与 stopping 任务,无主任务共享一个服务级桶;满容量在 id 分配之前就失败(错误会提示 job_kill 或等待停稳),不排队、不抢占。还有一道 controller 门槛:start() 拒绝任何没有附加任务控制器服务的 owner——错误消息会提示在该 agent 的组合里加载 @deepseek-ai/dsh-tool-jobs

面向模型的 job_* 工具

dsh-tool-jobsctx.jobs 的面向模型控制器:加载它即附加 start() 所需的 controller,并注册三个 kind 无关工具:

  • job_output(job_id, wait?, timeout_ms?):默认非阻塞;流式任务返回下一段增量,仅最终输出的任务在终止后返回结果;每个响应都以 [status: ...] 结尾。wait: true 最多等到配置上限(默认 30 秒,模型可给的最大值是 10 分钟)。
  • job_list():以 <id> [<kind>] <status> — <label> 列出调用方可见的任务。
  • job_kill(job_id, reason?):立即请求取消并转发已记录的原因。

任务完成时,如果尚未被任何读取报告,会向确切 owner 投递一条通知:background job <id> (<kind>: <label>) finished [status: ...]. Read its output with job_output. 通道选择取决于 owner 当时的忙闲:繁忙的 owner 走注入(通知进 next-step inbox),空闲的 owner 被 follow-up 唤醒;completionDelivery: quiet 可强制前者,maxConsecutiveWakes(默认 3)限制连续唤醒轮数,超出后降级为注入。

dsh-tool-jobs 还贡献一段固定的「后台任务指引」系统提示词:跟踪每个启动的 job id、留意会话内完成通知、不要忙轮询或 sleep、收尾前用 job_output 收集、用 job_kill 停掉不再需要的任务。这解释了模型为什么总是先记住 id、最后统一收集——你的工具返回的 job id 就是要被这样消费的。

在自定义工具里跑后台任务

把长时间工作注册成后台任务的模式:producer 配置控制 runInBackground;执行时先预检 signal;ctx.jobs.start 返回 job id;工具返回类型化规范句柄 { kind: 'background', jobId }(Code Mode 等消费方按 schema 解析,绝不解析自然语言文本取 id)。jobId 发布之后,取消必须用任务自有的取消信号,而不是 exec.signal

ts
// scan-tool —— 一个支持后台运行的自定义工具
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { JobHooks, JobOutcome } from '@deepseek-ai/dsh-jobs'

// 声明合并扩展 JobKindMap:'scan' 成为合法 kind(也是 job id 前缀)
declare module '@deepseek-ai/dsh-jobs' {
  interface JobKindMap { scan: 'scan' }
}

export const name = 'scan-tool'
export const inject = ['tools', 'jobs']   // 同时依赖工具注册表与后台任务注册表

export interface Config { runInBackground: boolean }
export const Config: Schema<Config> = Schema.object({
  runInBackground: Schema.boolean().default(true),
})

export function apply(ctx: Context, config: Config) {
  ctx.tools.register(defineTool({
    name: 'code_scan',
    description: 'Scan a directory for TODO comments.',
    parameters: {
      path: { type: 'string', required: true, description: 'Directory to scan' },
    },
    output: {
      schema: {
        type: 'object', additionalProperties: false,
        properties: { kind: { type: 'const', const: 'background' }, jobId: { type: 'string' } },
        required: ['kind', 'jobId'],
      },
      render: (_args, value) => [{ type: 'text', text: `started job ${value.jobId}` }],
    },
    async execute(args, exec) {
      // 预中止:signal 已触发时没有可启动的任务,直接判失败
      if (exec.signal.aborted) throw new Error('aborted before dispatch')

      const jobId = ctx.jobs.start({
        kind: 'scan',
        label: `scan ${args.path}`,        // 一行模型可见标签
        owner: exec.agent,                 // 归属当前 agent:访问被其 session id 围栏
        run() {
          // 任务自有取消信号:jobId 发布之后不再用 exec.signal
          const controller = new AbortController()
          exec.signal.addEventListener('abort', () => controller.abort())

          const done = (async (): Promise<JobOutcome> => {
            const hits: string[] = []
            try {
              for await (const line of scanForTodos(args.path, controller.signal)) hits.push(line)
              return { status: 'completed', detail: `${hits.length} matches`, output: hits.join('\n') }
            } catch (err) {
              if (controller.signal.aborted) return { status: 'killed' }
              return { status: 'failed', detail: String(err) }
            }
          })()

          // 同步返回 hooks:运行时据此控制与观察这份工作
          return { cancel(reason) { controller.abort(reason) }, done } satisfies JobHooks
        },
      })

      return { kind: 'background', jobId }   // 类型化规范句柄
    },
  }))
}

一点纪律提醒:cancel 必须同步且幂等;done 必须最终 settle,否则 owner 销毁可能停滞、并发容量一直被占用。

subagent seam:命名提供方注册表

subagent 是可选能力,类型定义在子系统层而非 core。与 bash 不同,同一上下文可以共存多个提供方实现,按名称注册在 ctx.subagentsSubagentRuntime)上——注册表形态镜像 LLM 适配器注册表。六个兄弟 Service Provider 是 dsh-subagent-spawn-in-process(进程内全新子 agent)、-fork(用父日志平衡前缀做种子)、-acp-codex-claude-code(把一轮委派给其他产品进程)和 -dsh-sdk。面向模型的 Consumer 是 dsh-tool-subagent提供方选择是配置,不面向模型——模型只提供 { description, prompt },工具绑定一个配置选定的提供方。

单次委派是 start(name, request) → SubagentRun:发布前失败拒绝 start() 本身;发布后经 run.result 结算,stopReasoncompleted 时输出可能不完整,消费方映射为 isError 的工具结果;run.dispose() 取消剩余工作并等待完全停稳。启动时能力(outputSchemadepthLimittoolFilterpersona)由提供方静态 capabilities 声明,服务在 run 存在前检查,不支持的请求用 SubagentError('UNSUPPORTED_CAPABILITY') 响亮拒绝,绝不接受后静默忽略

可继续 subagent:持久 Session + 至多一个在线 Activation

除了单次委派,还有可继续 subagent:一份持久化子 agent 会话(Session),至多关联一个进程内的 Activation——被重建的子 Agent 处于驻留状态的时段。Activation 不是请求、结果或 Task:它可以执行多个 FIFO 轮次,并在它创建的后代仍在运行期间保持驻留:

text
persisted Session
  -> optional live Activation
       -> one retained AgentHandle
       -> Agent inbox as the only turn FIFO
       -> zero or more owned child Activations

startContinuable() 预留稳定的 child id、创建子 agent 并提交初始提示词,inbox 准入产出消息 id 时以 { childId, messageId } resolve。之后 followup() 是唯一的继续消息操作,路由只取决于 Activation 的驻留状态:running 在同一 Activation 入队,waiting 唤醒同一 Activation,无 Activation 则冷恢复一个新的。Agent inbox 是唯一队列,所以每条已接受的继续消息共享同一个可观测顺序。interrupt(targetSessionId, authority) 是唯一的公开停止操作,fire-and-return,不等待完全停稳。

权限纪律

继续执行路径的每条消息都要鉴权:已认证 Agent 必须是持久化 SessionHeader.parentSession 中记录的直接父级——祖先、兄弟、任何其他 agent 一律以 UNAUTHORIZED 拒绝;MessageSourcesenderSessionId 只记录谁提供了消息,不授予任何权限。调用方 signal 只在 inbox 接受之前掌管查找、物化与准入,此后管理器独立掌管该 Activation:调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent。

把两条纪律放在一起记:能力不符响亮拒绝,权限只授直接 parent。前者保证没有静默降级,后者保证谱系边界是硬边界。

workflow 引擎

最后给 workflow 一句话定位:ctx.workflowEngine 运行模型编写的、会启动 subagent 的编排脚本(Service Provider 是 worker-thread 引擎,每个 run 一个 worker),脚本里的 agent() 调用走 subagent seam。它和 bash 一样每个上下文只允许一个引擎,适合把跨文件审计、多角度调研这类「扇出很多独立子任务」的工作交给脚本编排,收集与日志化则与后台 bash 共用同一套机制。多 agent 编排的细节留到后续章节。

小结

  • ctx.jobs 是统一后台任务注册表:bash、subagent、PTY 共享身份与生命周期;id 可预测,owner 授权才是安全边界。
  • 生命周期:start 预检后调 run() 一次 → 运行 → first-wins 结算 → 完成通知最后宣布;准入有界(默认每 owner 10 个并发)。
  • 模型侧用 job_output / job_list / job_kill 消费;自定义工具返回 { kind: 'background', jobId } 类型化句柄,取消走任务自有信号。
  • subagent 是命名提供方注册表;可继续 subagent = 持久 Session + 至多一个在线 Activation + inbox FIFO。
  • 权限只授直接 parent;能力不符响亮拒绝。

下一步

  • 尝试在自定义工具里跑一个后台任务,用 job_list / job_output 观察它的生命周期;
  • 下一章《打包与发布插件》:把插件变成可安装的组合包(bundle)与 profile,分发到 GitHub 或 npm。