跳转至

17. 人工决策与自动化:审批、计划、定时任务、Webhook 与身份

源码固定官方主分支 da00f7f5358f2949383b35c14f548bc20187d80c。这些模块把 Agent 的行动和操作者连接起来,但彼此不是一个统一的“允许/不允许”开关。实际部署还要由 profile/preset 决定哪些模块存在、哪些 Agent 能看到工具。发布实验用 0.2.0-rc.2;其中 schedule 工具仍在 schedule 包中,主分支已提取到独立 @deepseek-ai/dsh-tool-schedule,复制配置前必须看 版本对照。

17.1 五种看起来像“问用户”的动作

动作 决策对象 记录/结果 不能推断什么
工具 approval 当前工具调用能否执行 asked/decided,一次许可或拒绝等 一次许可不是永久账户授权
user question 人类提供的信息或选择 structured answer / pending reply 没答不能当成赞成
plan review 是否按提案退出计划模式 plan 状态与问题结果 plan mode 不是内核沙箱
authorization flow 获取/更新某个 credential record 本次确实提交凭据的结果 已登录不等于全部工具可执行
feedback 对消息或会话的评价 Session feedback 事件 反馈写入不自动发到外部服务器

自己的 Agent 应把这五个对象写进产品设计。例如“用户同意计划”只允许改变计划状态,不能顺手当作审批任意 shell;“API Key 已保存”也不表示允许上传任意文件。

17.2 工具审批:审计先于许可的闭合结果

ApprovalService.request() 需要当前 Agent 的 open turn。先生成 request ID 并追加 approval/asked,执行 scoped approval/request waterfall,再追加同 ID 的 approval/decided。如果不是 open turn,先拒绝,不写孤立 asked。审计追加失败同样拒绝,不发出没有记录的一次授权。ApprovalService 完整实现

结果只有 allowed-once、rejected、cancelled、unavailable。缺少 answerer、answerer 抛错或返回乱值都会归一为 unavailable;调用工具的权限路径应把它保守处理。ask 与 never 是 approval policy,never 的意思是不向人类发审批请求并拒绝需要审批的动作,不是“全部自动允许”。这一判断在服务的 request 内部执行,不能靠 prepend answerer 绕过。审批 policy 与 answerer 归一化

sequenceDiagram participant Tool as 工具权限路径 participant Approval as ApprovalService participant Log as Session participant Human as 当前 answerer / UI Tool->>Approval: 当前 call 请求审批 Approval->>Approval: open turn / policy / signal Approval->>Log: approval/asked(id) alt policy = never Approval->>Approval: rejected else 可请求 Approval->>Human: scoped waterfall Human-->>Approval: 一次决定 / unavailable end Approval->>Log: approval/decided(id, outcome) Approval-->>Tool: 规范化 outcome Note over Tool: 只有许可路径才能继续 body

取消信号可以抢先结算为 cancelled,迟到的人类回答不再改变本次结果。审计事件提交是逻辑 Session 提交;要证明 fsync 仍要进入持久化 flush/checkpoint,而不是因为叫“审计”就跳过第 16 章的耐久边界。

setPolicy() 把 override 记录在 Session,并向 Agent 注入可见的 policy 变化信息;不是把过去的系统消息秘密重写。新的工具调用可以读取最新有效 policy,重放也能解释当时的选择。自己的应用可把企业策略放在额外的工具 gate 中,approval answerer 只回答剩下允许人类决定的那部分。

17.3 人工问题:root 所有权、超时与迟到回答

UserQuestionService 不会让任意持有旧 Session ID 的对象问操作者。有 Agent 时它验证是 registry 中精确的 live 对象,而且是 roots() 中的根 Agent;当前被父 Agent 拥有的 child 返回 DELEGATED_CALLER,应把未解问题交回父 Agent。durable lineage 与当前 live ownership 不完全相同,恢复成为根的会话不能只凭历史 parent 字段粗暴判断。live root 判断

问题必须非空并符合结构;展示 intent 指定的 approve label 必须确实存在于 options,评审详情不能缺失。answerer waterfall 没人接时产生 NO_PROVIDER,不把“无人回答”变成默认选项。ask 验证与 provider

timed ask 是给前台回答窗口一个上限,不是给用户静默投票。时间结束后工具可以报告 pending/callId,使 Agent 继续工作,并把问题留在投影中;前台 timeout 不会合成 approve。UI 可 follow 前台等待状态,脱离客户端与请求超时是各自的生命周期。timed ask

stateDiagram-v2 [*] --> Asking: 有效根 Agent 发问题 Asking --> Answered: 前台答案成功 Asking --> Continued: timed wait 到期,问题仍待答 Asking --> Failed: 无 provider / 非法问题 / 取消 Continued --> ReplyQueued: Remote answer,完整答案批次 ReplyQueued --> Answered: reply 被 Agent admit 到消息日志 ReplyQueued --> Continued: 消息丢弃 / 生命周期清理后可再处理 Answered --> [*]

迟到回答走 answer(agent, callId, answer)。它必须给原 call 每个 question 恰好一个答案,重复/缺失 ID 拒绝;已有 reply 在 inbox 等待 admission 时再回答会报 REPLY_QUEUED。回复被包装成来源为 user-question-reply 的用户 steer 消息;关闭投影的证据来自 Agent 真正接受的消息,而不是某个浏览器点击按钮。迟到 answer 的完整流程

为自己的 Agent 做问答组件时,保留 callId/questionId,展示 pending 状态,防止重复提交,重连后读 projection。简单地把文字追加到聊天末尾会失去“这段答案对应哪个问题”的结构。

17.4 Plan mode:指导何时生效,与真正权限分开

PlanModeService 使用日志投影记录 active/wanted/running 等状态,按配置加入模型可见指导。计划模式改变 Agent 如何思考/提出方案;工具允许范围仍由 tool catalog、permission preset、sandbox 和审批路径决定。本模块不能取代这些机制。计划模式源码

执行期间 UI 选择不会随意改变已经发出的模型请求。open turn 内先保存 pending intent;agent/pre-step await 下游接受这一步、确认未取消后,才提交 plan/mode 并把正确指导加入请求。append 失败保留 pending 并告警。没有 open turn 时,set 可以立即记录,因为不会再有本轮的 in-turn pre-step。计划选择提交时机

flowchart LR UI[选择计划开关] --> Check{是否 open turn} Check -->|否| Commit[立即记录 plan/mode] Check -->|是| Pending[保存 pending intent] Pending --> Accepted[下一 accepted pre-step] Accepted --> Commit Commit --> Prompt[新请求中加入对应指导] Prompt --> Gate[实际工具权限另行判断]

exit_plan_mode 保持工具 catalog 稳定,一直注册;执行时检查 active、提案格式和 userQuestions。提案需要以 # 标题开头,交给人类评审;精确批准选项才能通过,拒绝/自定义反馈不被解释成批准。批准后只是排队切换到 false,当前工具 batch 的后续动作仍处于本次请求的既有指导中,到下一 accepted step 才切换。退出计划工具

自己的 Agent 如果要“计划期间绝不写文件”,必须另设只读权限/工具集;只把“不要写文件”放进计划指导,模型误调用时没有执行器硬边界。产品可以显示 plan approval,但应同时显示具体工具批准的范围。

17.5 Todo:当前轮的进度投影

todo_write 接收完整 todo 列表,追加 todo/write 快照,last-write-wins。内容去空白后非空、不能重复,status 只能 pending/in_progress/completed;allowParallelInProgress 是必填部署选择,false 时最多一个 in_progress,true 才允许真实并行任务多个进行中。工具必须有 owning Agent Session。todo 工具

todo projection 在下一次 turn/start 重置为 null,turn/end 保留本轮最后清单,方便看结果。这不是一个跨任意轮次长期追踪用户目标的 goal 数据库,也不是 schedule task。模型把 todo 全部标 completed 只是报告,宿主仍应以测试/交付物/业务状态验收。todo projection

自己的长期任务需要独立业务 domain 保存 goal、子任务、验收、时间预算与恢复点;可以把其当前进度映射成 todo 给人看,但不要只靠 UI 清单决定是否已经完成客户请求。

17.6 Schedule:Host 的耐久任务,不是模型睡眠

Schedule 是宿主服务。主分支的模型工具单独放在 tool-schedule,注册 schedule_create/list/update/delete,并按 mounting scope/preset 让 Agent 得到它们。工具还根据 delegationDepth 拒绝 child 使用提醒,不能只靠 prompt 说“子 Agent 请勿定时”。schedule 服务关闭时挂了工具也不保证服务存在。工具 scope 与 child 拒绝

任务有全局 ID、绑定 Session、title/prompt、规则、active/inactive 及 delivery receipt/history 等宿主记录。新的宿主任务 title 必填、trim 后非空且至多 120 字;旧 Session schedule/change 事件可缺 title,这是历史兼容读取,不能据此允许新的持久任务缺标题。任务类型 标题与历史准入

支持 after、at、every、daily、weekly、cron。every 至少一分钟且固定率对齐;daily/weekly/cron 明确 IANA 时区,weekday 用 ISO 周一1到周日7;cron 是五字段,不能把六字段带秒的 cron 默认视为支持。墙钟缺口跳过那一天,重叠选择较早 instant,是类型和解析路径约定,不是服务器 OS 时区隐式控制。schedule 类型与时区语义

Timer runtime 串行调度,使用一个 timer 驱动;到期后通过 sessionController.resolveAgent 激活/恢复 Session,生成来源为 schedule 的用户消息,通过 followup 提交,明确 await sessions.flush,然后提交 task receipt、一次任务 inactive 或周期任务 nextScheduledAt。receipt 的意思是inbox 持久交付,不是模型已做完任务。runtime 完整调度

sequenceDiagram participant Timer as Host timer participant Task as Schedule domain participant Controller as SessionController participant Agent as Agent inbox participant Log as Session persistence Timer->>Task: 读 active 且 due 的任务 Timer->>Controller: resolveAgent(session) Controller-->>Timer: 活跃/恢复 Agent Timer->>Agent: followup(schedule message) Timer->>Log: sessions.flush Log-->>Timer: 交付前缀持久化 Timer->>Task: receipt + inactive / next occurrence Note over Log,Task: 两份介质之间存在崩溃窗口 Agent->>Agent: 执行任务,独立的完成判定

周期任务的 occurrence 采用 latest-only 决策,避免恢复后枚举无限积压;同一 Session 的到期周期任务可汇到同一 message。调度采用 due scan 时间及接受时的实际时间复核,防止异步 resume 期间时钟回拨误投。周期 occurrence 契约 runtime 时间检查

这里有真实的跨介质窗口:Session flush 完成、task receipt 还未提交时崩溃,恢复可能再次投递。不能承诺 exactly-once 外部动作。插件卸载会等已接受的 drive 排空,但 timer 不会让已关闭 dsh 在后台永久活着;运行调度需要宿主进程持续运行或由服务管理器恢复。自己的任务消费者应按 task/occurrence ID 建幂等规则,并区分 delivered 与 succeeded。

rc.2 与主分支工具包位置不同,使用课程的 rc.2 composition 时不要未经比对插入主分支新增包名。更改 schedule 规则还要测试编辑时间、夏令时、停机和恢复,不能只测试“10 秒之后出现提示”。

17.7 Webhook:校验事件来源,再由可信规则产生任务

GitHub adapter 的 handler 只接受 POST 和合规 JSON content type,限制 body,要求不歧义的签名、delivery ID、event name;读取动态 credential secret,以原始 body 验证签名,再 parse object/lossless JSON。secret 缺失或 runtime 不可用 503,坏签名 401,错误方法/内容类型相应拒绝。GitHub handler

签名通过说明 payload 来自已配置来源,不能让 issue body、PR 描述等用户内容自动变成最高优先级指令。rule 是可信宿主代码,收到 detached/deep-frozen verified delivery,决定返回一个 Session request 或 null,再由 runtime 创建 workspace-backed Session。Webhook runtime

flowchart LR GH[GitHub HTTP delivery] --> Validate[方法 / 大小 / headers / 签名] Validate --> Verified[Verified delivery:外部数据] Verified --> Rule[受信任 rule.run] Rule -->|null| Ignore[忽略] Rule -->|Session request| Create[创建 Workspace / Session] Create --> Agent[受配置权限的 Agent]

dispatch() 把当前 matching rules 放到 Promise 上启动后立即返回;handler 返回 202。rule 失败在 runtime 中记录 warn,不倒回已经发出的 HTTP 接收响应;规则卸载 hide/abort/drain 当前 callbacks。这一实现是 fire-and-forget 内存分派,不是持久消息队列,也没有内建根据 deliveryId 的持久 exactly-once 去重。dispatch 与卸载

自己的生产 webhook 需要首先保存接收记录与 deliveryId,限制 repo/event/installation,按 idempotency key 去重,再进入工作队列;202 可以只承诺已保存。如果要重试、重放和 dead-letter,应建立独立 domain/队列与业务状态。课程没有把已验签事件当成任意 workspace 命令执行权限。

17.8 反馈:会话 remark 与消息评价不一样

/feedback <text> 验证非空后追加 feedback/record;对应 SessionFeedback Remote 只处理当前 live Session。反馈命令设置 recordInput:false,再用自己的 canonical feedback 事件存内容,避免自动重复记录命令文本。成功文案中的 anonymous ID 也只是本地身份标识。这个模块的源码没有在记录时直接把对话上传到某个外部反馈端点。会话反馈

MessageFeedbackService 提供更强的持久写语义:评价某个已完成 assistant message,验证目标、正负类别和 UTF-8 note 上限,按 ifVersion 做并发控制;相同值可保持版本不变,变化生成新版本,delete 也核对观察版本。消息反馈服务

live 路径 append 后要求 flush listener 参加,并打开 read handle 核对 header 和最后事件前缀确实耐久。cold 路径持有 write handle,读/比较/append/flush 后通知 observers,finally close;不会为了评价历史消息而启动模型。observer 失败不能回滚已提交反馈。冷/热反馈持久边界

自己的 Agent 可以把 feedback 作为离线评估材料,但应记录谁给的评价、作用范围、是否得到上传授权。想自动发送到远端是另一项插件能力和数据策略,不能把“日志有 feedback”当成外传承诺。

17.9 凭据:环境、记录与授权流程

credentials-local 的优先级是继承进程环境 > $DSH_HOME/.credentials.yaml 管理记录 > invocation cwd 的 .env > home .env。继承环境是显式启动意图,read-only;UI 写入被同名环境遮住时应拒绝,不能显示“保存成功”却继续使用旧环境 key。凭据 provider 与优先级

管理文件只存 credentials,不在运行时把整份记录 materialize 进 process.env。写入在跨进程锁下重新读文档,只更新自己的 key,atomic write 后通知;POSIX 检查 group/other permission,文件创建/替换用 0600,目录按 owner-only 路径处理。Windows ACL 另有平台边界,不伪造 POSIX mode 保证。watcher 外部更新要验证,坏 reload 保留最后有效状态并告警,不能让部分 malformed 文档污染当前凭据。凭据读取/写入实现

credential ref 与 credential record 是 seam 的两种用途:简单秘密引用与插件拥有的结构化记录(例如授权结果)。describe/list 给产品显示状态,不能把内部 secret 直接作为 Remote 结果泄漏。自己的模型 provider 应通过 credentials seam 取 key,避免把 key 混入模型可见 prompt、Session event、工具日志或公开错误消息。

AuthorizationService 登记“为一个 key 获得凭据”的 flow,每个 key 同时只允许一次 attempt;第二个调用者拒绝,不加入第一个人的交互。request 自带 interaction,提示到发起请求的那个人;headless 应提供会拒绝的交互,不能靠无人值守自动答 OAuth/密钥问题。授权 registry 与交互

begin() 验证 flow/method 和并发槽位,观察本次 credentials/record-updated;run 返回但没有本次 commit 会报 NOT_COMMITTED,不能因为先前已经存在记录就宣布新授权成功。commit 开始后不能再将这次提交半途取消;取消/卸载也需防旧 attempt 使用新槽位提交。结束事件的观察器失败只记录,不改变已提交结果。授权提交与取消

sequenceDiagram participant Surface as 当前产品页面/调用方 participant Auth as AuthorizationService participant Flow as 插件 flow participant Cred as Credentials Surface->>Auth: begin(key, method, interaction) Auth->>Auth: 独占该 key 的 attempt Auth->>Flow: run(signal, prompt, notify, commit) Flow->>Surface: 问题 / 提示 Surface-->>Flow: 明确答案 Flow->>Cred: commit(record) Cred-->>Auth: 本次 record-updated Auth-->>Surface: authorized / cancelled / failure

17.10 身份:匿名 ID 不是登录,更不是租户主键

anonymous-user-id 在 home 的 .anonymous-user-id 保存随机 UUID,memo 按 home 路径;首次用独占写处理竞争,读写失败可以退到本进程生成 ID。删除文件可能下次得到新 ID,它不是设备指纹、认证凭证、账户 ID,也不保证全球“一个真人永远一个 ID”。匿名 ID 实现

自己的 Agent 多用户产品需要独立账户认证和 tenant/workspace 所属关系;不要把 anonymous UUID 用作授权依据。浏览器 grant、模型 key、平台账户 token、匿名 telemetry ID 和业务客户 ID 是不同身份面。SessionId 也不能代替用户身份。

17.11 experimental Auto review:额外模型判断有明确边界

Auto review 在 Auto permission preset 下 prepend tools/pre-execute gate,冻结当前待执行动作/上下文,直接调用 llm.stream 分类风险;它的 review prompt 不进入普通 Session 日志。输出要求严格 JSON risk/decision,重复成员、非法结构、非 stop 终止、额外文本块等都失败,不能从“包含 allow 字样”就授予动作。分类与结果解析

allow 仍 await downstream permission,不能越过已有 deny/ask;deny 且 approval override 为 never 时明确 deny;deny 且下游 allow 时返回 askUser;review 失败返回 deny,工具 body 不执行。顶层 run_code 包装器跳过,内部实际动作仍进入相应工具 gate。模型评估属于附加策略,不是操作系统隔离。Auto gate

还有需要部署者知道的生命周期行为:卸载 Auto integration 时,源码把当前使用 Auto 的 Sessions 切成 danger-full-access,再中止并 drain 活跃 reviews。不能宣称“卸载自动评审仍保持同样限制”。若产品依赖此 gate,应另外维持独立 sandbox/企业 tool policy,并明确插件卸载行为。卸载策略

本课程没有调用真实 review 模型;只有源码边界分析。要上线使用应评估分类模型成本、延迟、误拒绝/误允许、输入是否含秘密,以及明确审批 override。

17.12 experimental 语音:输入草稿,而非隐式启动 Agent

voice-input-bundle 组合 speech-to-text service、SenseVoice local provider、speech API 和 UI voice-input。provider 的 dataRoot 放在 home 的 speech-to-text/sensevoice;有专门准备任务,不是每次点录音都无条件下载模型。语音 bundle

SpeechController 的 catalog/follow 分别读取选择/就绪状态,不因浏览目录就 prepare recognizer;configure 保存偏好,prepare/cancelPreparation 明确管理资源任务。transcribe 默认限制 decoded WAV 4 MiB 和 PCM 120 秒,验证 canonical base64、字节大小和 WAV,再解析 provider selection 并带取消 signal 调用。speech Remote 控制器

关键界限是:这些 speech calls 不激活 Agent,不添加 Session event,不自动发送 prompt。识别文本先给输入界面,用户决定是否提交,才进入普通 prompt 路径。音频输入也不等于当前 ACP 的 audio prompt 支持;ACP 目前声明 audio:false。不能把语音转写功能说成“Agent 会一直监听并自主执行”。

语音模型下载来源、资源签名、平台 native runtime、麦克风权限、识别准确率和实时取消需要自己的专项测试;本次没有下载识别模型、访问麦克风或执行真实转写。关闭 optional/experimental 插件应在 composition 层进行,不要只隐藏按钮还保留公开 Remote 方法。

17.13 自己的 Agent 组合建议

先给后台 Agent 最小工具与独立时间/token/步骤预算;不提供人工 UI 时,对 questions/approval 采用明确 unavailable/deny 行为。再给操作员产品加结构化问题和一次工具审批;计划/清单只负责指导和进度显示。引入 schedule/webhook 时,单独保存业务任务状态、幂等键、交付与成功结果。凭据和账户管理走专用 seam,避免进入模型上下文。

对应课程实物可从 自己的插件、自己的 Agent composition、SDK 离线实验 开始,再逐项增加这些控制。每增加能力都应测试无人回答、取消、热卸载、进程重启、消息重复和耐久提交失败;模型正确回答一次并不能证明这些边界成立。