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 归一化
取消信号可以抢先结算为 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
迟到回答走 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。计划选择提交时机
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 完整调度
周期任务的 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
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 使用新槽位提交。结束事件的观察器失败只记录,不改变已提交结果。授权提交与取消
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 离线实验 开始,再逐项增加这些控制。每增加能力都应测试无人回答、取消、热卸载、进程重启、消息重复和耐久提交失败;模型正确回答一次并不能证明这些边界成立。