交互缝:审批、权限预设与命令
一句话版:
packages/interaction把"人怎么介入运行中的 agent"拆成几条彼此独立的缝——ctx.approval问一次"这个动作能过吗"(ApprovalOutcome四值闭集,缺 answerer 就 fail closed)、ctx.permissionPresets把沙箱模式与审批策略打包成一个用户可见的预设选择器、ctx.commands让插件注册人类斜杠命令(不经模型轮次);第四个缝ctx.userQuestions(模型向人提问)已在 专页 讲过,本页只画地图并交叉链接。
审计基线 0.1.5-alpha.1 @ 5dda764ed3:包名、ctx key、事件名、方法、类型与配置键均与官方源码逐点核对。
本页是"交互面"的核心课。读完你能回答:一次审批从工具请求到放行到底经过哪些环节、为什么答案只有四个值、answerer 怎么写和怎么组合、预设切换是怎么"写穿"到审批策略的、以及一个斜杠命令的注册、寻址、生命周期事件与结果去向。
一、四条缝的分工(地图)
| 缝 | ctx key | 包 | 谁发起 | 答案 / 结果形态 | 本页 |
|---|---|---|---|---|---|
| 审批 | ctx.approval | packages/interaction/user-approval | 工具流水线、沙箱升级重试 | ApprovalOutcome 四值闭集 | 二~五节 |
| 权限预设 | ctx.permissionPresets | packages/interaction/permission-presets | 用户(/permission) | 预设名 + 两个旋钮事件 | 六节 |
| 人类命令 | ctx.commands | packages/interaction/commands | 用户(UI 里敲 /xxx) | CommandResult,直接渲染 | 七节 |
| 向人提问 | ctx.userQuestions | packages/interaction/user-questions | 模型(ask_user_question) | AskUserQuestionAnswer | 专页 |
三条关系要点:
- 预设写穿审批,审批不依赖预设。
permissionPresets的set()调用user-approval导出的setApprovalPolicy();反过来ApprovalService不知道预设存在,它只折叠approval/policy事件。所以移除预设包后,最后一次旋钮值仍然生效。 - 命令注册表不属于模型面。命令的元数据、输入、直接输出都不进模型请求;只有命令产出者显式经
Agent调度的工作才计 token。 - 审批与提问是两条不同的路:审批是"要动作,请授权"(由工具/沙箱发起,人给一次性许可);提问是"我需要信息"(由模型发起,人给结构化答案)。两者都走 Agent 作用域 waterfall,但服务、事件名与答案类型完全不同。
用法层面的配置、环境变量与 /permission 操作见 权限;执行边界本身见 沙箱与安全。
二、ctx.approval:API 与决策词汇
ApprovalService(Service 名 'approval')是这条缝的服务定义(user-approval/src/index.ts:142)。公开面:
| 成员 | 签名 | 语义 |
|---|---|---|
request | request(req: ApprovalRequest): Promise<ApprovalOutcome> | 问一次;要求会话处于 open turn,审计成对落日志 |
setPolicy | setPolicy(agent: Agent, policy: ApprovalPolicy): void | 切换 live agent 的策略,并给模型注入一条"策略已变更"的 user message |
overrideOf | overrideOf(session: Session): ApprovalPolicy | undefined | 读日志里最后一条 approval/policy;没有 override 时返回 undefined |
setApprovalPolicy | 模块级 setApprovalPolicy(session, policy): void | 唯一 durable 写路径(会话初始化直接用它);非法值先抛错再写 |
APPROVAL_POLICIES | readonly ApprovalPolicy[] | ['ask', 'never'],用于选项广播与运行时校验 |
有效策略由私有折叠函数给出:overrideOf(session) ?? config.policy ?? 'ask'(user-approval/src/index.ts:235)。配置只有一项:
- name: '@deepseek-ai/dsh-user-approval'
config:
policy: ask # ask | never,缺省 ask
决策词汇是闭集(user-approval/src/types.ts:32):
| 返回值 | 含义 | 调用方应当 |
|---|---|---|
allowed-once | 唯一的授权值:只对本次请求动作有效,不形成记忆规则 | 放行这一次 |
rejected | 明确拒绝 | 拒绝 |
cancelled | 请求被中止(signal abort) | 拒绝 |
unavailable | 没有 answerer、answerer 抛错、或返回了闭集外的值 | fail closed:按拒绝处理 |
ApprovalRequest(user-approval/src/index.ts:103)刻意不带工具参数:answerer 通过 callId 把提示挂到已经流式呈现的 tool call 上,而不是再渲染一份可能漂移的参数副本。
| 字段 | 必填 | 说明 |
|---|---|---|
agent | 是 | 替谁问;决定作用域路由与审计落在哪个会话 |
toolName | 是 | 提问针对的工具(展示 + 审计) |
callId | 否 | 确切的工具调用 id,让 UI 把提示贴到它已经流式的调用上 |
reason | 否 | 提问方给出的人类可读理由 |
signal | 否 | abort 即撤题:请求立刻 settle 成 cancelled,晚到的答案被丢弃 |
三、request() 的时序与 fail-closed 规则
request()(user-approval/src/index.ts:207)的顺序是固定的:
- open turn 前置检查。会话日志必须处于一个
turn/start尚未被turn/end关闭的区间;不满足直接抛错,不写任何事件。原因是审计对必须被 turn 包住——turn 是持久日志的提交/重放边界,夹在两个 turn 之间的裸事件在重载时与崩溃残尾无法区分。 - 生成一个全新的
ApprovalRequestId(randomUUID()),appendapproval/asked。 decide()求一个 outcome:signal已 abort →cancelled;- 有效策略是
never→rejected,在任何 answerer 分发之前; - 否则进入
ctx.waterfall(scopeTarget(req.agent, req.agent), 'approval/request', req, () => 'unavailable'),返回值不在闭集内 →unavailable,抛错 →unavailable(同步抛错也在同一条 containment 里); - 有
signal时与 abort 赛跑,abort 先到就返回cancelled,之后 answerer 的晚到答案被丢弃。
- append
approval/decided,返回 outcome。
三条 fail-closed 规则(decide(),user-approval/src/index.ts:258):没有 answerer 认领 → unavailable;answerer 抛错 → unavailable;返回非闭集值 → unavailable。另外,若审计 append 在提交点前失败,请求reject——返回一个没被记录的决策会破坏审计对。
消费方举例:工具流水线在 packages/core/tools/src/index.ts:1696 用 ctx.get('approval') 机会性取服务,把 ask 决策映射成 allow/deny;沙箱升级重试共用同一缝(approveEscalation,packages/sandbox/sandbox/src/escalation.ts,细节见 沙箱与安全)。
四、answerer 的组合:approval/request 瀑布
服务本身没有 provider 注册表,接入点是 Cordis waterfall approval/request(user-approval/src/types.ts:85):
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* Ask composed answerers for one decision. Return an outcome to claim the
* request or call `next()` to delegate. Scope-filtered dispatch
* (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'approval/request'(
this: Scoped<Agent>,
req: ApprovalRequestEvent,
next: () => Promise<ApprovalOutcome>,
): Promise<ApprovalOutcome>
}
}
- 返回 outcome 即认领,第一个返回值结束瀑布;
return next()表示"我不处理"。 - 作用域过滤:请求经
scopeTarget(req.agent, req.agent)派发,只有该 agent 作用域的 listener 收到;scope 层从args[0].agent取键(packages/core/scope/src/scoped-events.generated.ts:24)。 - 终点默认是
unavailable,所以一个部署应当组合恰好一个终点 answerer;兄弟 listener 的先后顺序不是策略优先级。 - listener 是普通
ctx.on(...),随插件 fiber 卸载自动注销。
官方组合里的两个真实 answerer:
| 实现 | 位置 | 行为 |
|---|---|---|
| 浏览器 UI | packages/client/ui-approval/src/client/index.ts | ctx.remote.$on('approval/request', …):把请求渲染成待决交互面板;Host 侧由 packages/api/remotes/src/remote-events.ts:18 以 { event: 'approval/request', mode: 'waterfall' } 转发到浏览器 |
| ACP 机器决策 | packages/acp/acp/src/index.ts:155 | 只回答它拥有的会话,且请求必须带 callId,否则 next();向客户端只提供 allow-once / reject-once 两个一次性选项,绝不从陌生响应推断持久授权 |
never 绕不过:策略在服务自身的 request() 路径上判断,早于任何瀑布分发,所以一个后注册的 prepend: true listener 也无法让 never 会话发出提问。
五、策略与审计事件
user-approval 声明四个事件:
| 事件 | 模式 | 载荷 | 用途 |
|---|---|---|---|
approval/request | waterfall | ApprovalRequestEvent | answerer 接入点 |
approval/asked | 仅日志 | { id, toolName, callId?, reason? } | 审计对前半,id 配对后半 |
approval/decided | 仅日志 | { id, outcome } | 审计对后半,每次 ask 恰好一条 |
approval/policy | 仅日志 | { policy, source?: 'delegation' } | 会话策略 override,最后一条生效;source: 'delegation' 标记播种进子代理的 override |
两条审计事件不进模型 transcript:模型看到的是消费方最终的 tool 结果,加上运行上下文快照里的策略句子。
策略语义(ApprovalPolicy,user-approval/src/index.ts:63):
ask(默认)——委派给已组合的 answerer;一个都没有则链条落到 fail-closed 的unavailable。never——不询问任何人,每次 ask 确定性地rejected;这是 CI / 无人值守的严格立场。模型侧会收到一句"审批提示已禁用,不要请求沙箱升权(不要设sandbox_permissions)"。
模型可见面由 systemPrompt 上下文贡献 approval:policy 提供(user-approval/src/index.ts:155):两种策略各贡献一句完整当前语义,追加在保留历史之后,切换策略不会重写稳定的系统提示前缀。setPolicy() 额外注入一条带来源的 user message 通告变更。
审计不变式(user-approval/src/invariant.ts)在加载与新增事件两条路径上校验:approval/asked / approval/decided 必须在 open turn 内、按 id 一一配对、outcome 与 policy 必须落在闭集内。失败消息形如:
approval/asked appended outside any open turn
approval/asked toolName must be non-empty
approval/asked repeated open id "<id>"
approval/decided has no matching approval/asked for id "<id>"
approval/decided carries unknown outcome "<value>"
approval/policy carries unknown policy "<value>"
错误形态:这条缝没有错误码枚举(对比 user-questions 的 UserQuestionError + 稳定错误码)。它的"失败分类"就是闭集 outcome;只有编程错误抛异常:
| 触发 | 抛出 |
|---|---|
无 open turn 时调用 request() | Error: approval.request() outside an open turn: … |
给 setApprovalPolicy() 传非法策略 | TypeError: approval policy must be one of "ask" or "never" |
六、ctx.permissionPresets:预设如何写穿到审批
PermissionPresetService(Service 名 'permissionPresets',permission-presets/src/index.ts:162)把两个独立的强制旋钮——沙箱模式 sandbox/mode 与审批策略 approval/policy——打包成用户可见的命名预设。它自己不做强制:执行、提示与重放仍然各读各的旋钮折叠值。
一个预设就是一条表项(PresetSpec,permission-presets/src/index.ts:58):
| 字段 | 含义 |
|---|---|
sandbox | 该预设写入的 sandbox/mode 值 |
approval | 该预设写入的 approval/policy 值 |
name | 客户端展示标签;省略时用表键 |
description | 一句用户可读说明;未配置则省略 |
插件源码里的默认表只有两项:workspace-write(workspace-write + ask)与 danger-full-access(danger-full-access + never)。官方 base bundle 在 packages/bundle/base/cordis.patch.yml:229 覆盖成三项,多出 read-only(read-only + ask)——你在 dsh web --dump-config 里看到的就是这张表。custom 是保留名:表项命名 custom 会在插件加载时抛错,因为它只用于派生的"拼不出任何预设"状态。
写穿路径(set() → apply(),permission-presets/src/index.ts:379、:384):
resolve(name)解析表项,未知名字抛错;- 若当前有效预设不等于目标名,append
permission/preset(仅日志的用户意图); - 逐个比较旋钮的有效值,只对确实变化的那一个调用它自己的 canonical setter——
setSandboxMode(来自dsh-sandbox-policy)或setApprovalPolicy(来自dsh-user-approval)。净零选择不写任何事件。
current(session) 的推导顺序(permission-presets/src/index.ts:308):仍与旋钮匹配的上次选择优先(这样两个预设共享同一 bundle 时也能保住用户意图)→ 表内声明顺序的第一个匹配项 → 否则返回派生的 CUSTOM_PRESET('custom',只可显示、不可选中)。
读侧是 permissions 会话投影(permission-presets/src/index.ts:237):key 为 permissions、stateVersion: 2,折叠 permission/preset / sandbox/mode / approval/policy / session/end-seed 四个事件,wire 视图是 PermissionSelect(表内全部可切换预设 + 恰好当前时追加的 custom + currentValue)。投影 key 不存在 = 没有组合权限服务,客户端隐藏该控件。
默认值与会话钉入:设置命名空间 permission 的 defaultPreset 只影响未来会话;session/created 与已存在会话都会 pinInitialPermission()——全新会话写入默认预设与两个旋钮事实,种子/半初始化会话保留既有旋钮、只补缺失的持久事实。
/permission 命令是 web 客户端使用的唯一写入路径(permission-presets/src/index.ts:256;服务级写路径是 set()):裸命令报告当前值与可用表,带参数则 apply(..., policy => ctx.approval.setPolicy(agent, policy))。注意这个差异——直接 setApprovalPolicy() 只写日志,而 /permission 走 setPolicy(),因此还会给模型注入策略变更通知。
预设如何解析成审批行为:预设的 approval 旋钮最终落成一条 approval/policy 事件;ApprovalService 折叠它得到有效策略;never 在 answerer 分发之前短路,ask 才进入瀑布。预设与审批之间没有第二条通道。
配置错误在加载期就失败(permission-presets/src/index.ts:192):
permission: "custom" is reserved for the derived not-a-preset state and cannot name a table entry
permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration
permission: composed sandbox and approval defaults match no preset; configure defaultPreset explicitly
permission: unknown preset "<name>" (known: <names>)
permission: permissions session projection is not registered
服务声明 static inject = ['shell', 'approval', 'sessions', 'sessionProjections'](permission-presets/src/index.ts:183):没有会限制的 ctx.shell 执行器就没有 sandboxMode 事实,预设无从打包,因此组合失败而不是静默降级。
七、ctx.commands:插件命令注册表契约
CommandRuntime(Service 名 'commands',commands/src/index.ts:258)是插件拥有的人类命令注册表,由交互式 UI 适配器消费。用法级教程见 自定义命令与用户交互;本节只讲注册表契约。
注册契约
export interface CommandDefinition {
readonly name: string // 小写,^[a-z][a-z0-9_-]*$
readonly description: string // 发现 UI 里的说明,非空
readonly input?: CommandInputDescriptor // { hint: string; attachments?: boolean }
readonly recordInput?: boolean // 默认 true
readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
}
register(definition)(commands/src/index.ts:280)会先校验再冻结一份脱离调用方的副本(normalizeDefinition,:176):名字必须匹配 /^[a-z][a-z0-9_-]*$/u,description 必须是非空字符串,handler 必须是函数,input.hint 必须是非空字符串,input.attachments 给了就必须是布尔。返回精确的 disposer 用于注销;同一层内重名在注册时抛错。
所有权与作用域
- 普通上下文里的注册是全局;挂在某个 agent 上下文下、声明了
commands注入的子插件注册的是该 agent 作用域命令,对那个 agent 遮蔽同名全局命令。 - 层由
ScopedLayers合并:list(agent)返回按名字排序的、无 handler 的CommandDescriptor;find(agent, name)返回作用域遮蔽后的 effective definition。 - 注册/注销会 emit
commands/change(:87,emit 模式)。观察者失败被逐条 containment,既不能否决注册变更,也不会饿死后面的观察者。
寻址与参数
CommandId是 brand 类型(commands/src/brand.ts:20);每次执行 mint 一个cmd-<instanceToken>-<seq>,实例 token 前缀保证同一条被恢复的日志上重启后不会重复。parseCommand(line)(commands/src/index.ts:122)用/^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u切分:名字之后的全部字节(含分隔空白)原样作为rawInput,命令自己定义语法。- 附件:只有声明了
input.attachments: true的命令才接收;admission 在execute()内完成——图片经admitEncodedImages提交,文件经唯一的registerFileReceiptResolver提供的 resolver 解析 receipt,并按用户选择顺序还原混合顺序。不合规(未声明、无附件存储、未知 receipt、超限)在 handler 之前就 settle 成 error。
调用路径与生命周期
适配器调用 Host 侧 execute(agent, line, attachments, signal)(commands/src/index.ts:356):
parseCommand或名字解析失败 → 返回undefined,不写任何日志(它从未进入 handler);- mint
commandId,在 handler 之前 appendcommand/run(recordInput: false时省略args); - admission 附件;
- 调 handler,
normalizeResult校验返回值形状; - append
command/done,返回{ commandId, result }。
两条生命周期事件都是独立、仅日志的 append:没有 turn 包裹它们,持久化在普通 checkpoint 落盘;handler 抛错或被 abort 时 settle 成 kind: 'error'。command/run append 失败让执行响亮失败;handler 失败路径上的 command/done append 失败被 containment,以保住 handler 自己的错误。
结果 CommandResult 只有两种形状——{ kind: 'success', text?, sourceEventSeq? } 与 { kind: 'error', text }——由派发它的 UI 直接渲染,不进模型历史;sourceEventSeq(仅 success)指向更早的权威域事件,让客户端把命令生命周期与该域投影拼起来,而不必解析 text。
浏览器半部的 Remote 门面以 sessionId 寻址(packages/client/ui-commands/src/client/service.ts:365),Host 侧方法的第一参数则是 agent。
注册/结果校验的错误全是 TypeError(commands/src/index.ts:176、:223):
command name "<name>" must match /^[a-z][a-z0-9_-]*$/u
command "<name>" description must be a string
command "<name>" description must not be empty
command "<name>" handler must be a function
command "<name>" input hint must be a string
command "<name>" input hint must not be empty
command "<name>" input attachments flag must be a boolean
command "<name>" handler must return a CommandResult
command "<name>" success text must be a string when supplied
command "<name>" success sourceEventSeq must be a non-negative safe integer when supplied
command "<name>" error text must be a non-empty string
command "<name>" returned unknown result kind "<kind>"
另外:第二个 registerFileReceiptResolver 抛 Error: commands: a file receipt resolver is already registered;未知的文件 receipt 抛 AttachmentError('File upload receipt is unknown for this session.', 'ATTACHMENT_NOT_FOUND')。命令不变式(commands/src/invariant.ts)校验 command/done 必须配对同一会话日志里先前的 command/run,且 sourceEventSeq 必须指向更早的、非 command 事件。
源码里可见的注册方:/permission(permission-presets/src/index.ts:256)、/plan(packages/plan/plan-mode/src/index.ts:225)、/compact(packages/compaction/command-compact/src/index.ts:100)、/feedback(packages/feedback/command-feedback/src/index.ts:61)、/goal(packages/goal/command-goal/src/index.ts:190)、/export(packages/session-query/session-log-export/src/index.ts:78)。
源码佐证
| 文件 | 符号 / 行 |
|---|---|
packages/interaction/user-approval/src/index.ts | ApprovalService :142;request() :207;setPolicy() :176;overrideOf() :244;setApprovalPolicy() :92;effectivePolicy() 折叠 :235;decide() :258;never 短路 :266;OUTCOMES :48;APPROVAL_POLICIES :63;approval/policy 事件声明 :33;approval:policy 上下文贡献 :155 |
packages/interaction/user-approval/src/types.ts | ApprovalRequestId :17;ApprovalOutcome :32;approval/asked :44;approval/decided :55;ApprovalRequestEvent :63;approval/request waterfall :85 |
packages/interaction/user-approval/src/invariant.ts | validateApprovalEvent() :28(turn 内、id 配对、闭集校验) |
packages/interaction/permission-presets/src/index.ts | PresetSpec :58;CUSTOM_PRESET :73;PERMISSION_SETTINGS_NAMESPACE :76;Config :143;PermissionPresetService :162;static inject :183;permissions 投影注册 :237;/permission 命令 :256;current() :308;selectFor() :333;resolve() :350;optionOf() :365;set() :379;apply() :384;pinInitialPermission() :404 |
packages/interaction/permission-presets/src/types.ts | PresetOption :13;PermissionSelect :27;SessionProjectionMap.permissions 声明 |
packages/interaction/commands/src/index.ts | COMMAND_NAME :31;CommandInvocation :40;CommandDefinition :60;parseCommand() :122;normalizeDefinition() :176;normalizeResult() :223;CommandRuntime :258;register() :280;registerFileReceiptResolver() :294;list() :310;find() :323;execute() :356;command/run append :368;command/done append :375;mintCommandId() :441;notifyChange() :470 |
packages/interaction/commands/src/types.ts | CommandResult :34;CommandExecution :49;CommandDescriptor :57;commands/change :87;command/run :103;command/done :110 |
packages/interaction/commands/src/brand.ts | CommandId :20 |
packages/interaction/commands/src/invariant.ts | run/done 配对与 sourceEventSeq 校验 |
packages/bundle/base/cordis.patch.yml | 审批配置 :225;三项预设表 :229 |
packages/core/tools/src/index.ts | serviceAsk() 消费 ctx.approval :1696 |
packages/sandbox/sandbox/src/escalation.ts | EscalationOutcome / approveEscalation:升权重试共用同一缝 |
packages/acp/acp/src/index.ts | 机器 answerer :155 |
packages/client/ui-approval/src/client/index.ts | 浏览器 answerer(ctx.remote.$on('approval/request', …)) |
packages/api/remotes/src/remote-events.ts | approval/request 远程转发 :18 |
packages/core/scope/src/scoped-events.generated.ts | 作用域键取 args[0].agent :24 |
验证
SRC=~/.dsh/source/official
# 1) 组合树:三个服务都在 web profile 里(含 base 覆盖后的预设表)
dsh web --dump-config | grep -nE "dsh-user-approval|dsh-permission-presets|dsh-commands"
# 2) 审批:闭集 outcome、瀑布声明、策略写路径
grep -n "allowed-once\|approval/request\|setApprovalPolicy" \
$SRC/packages/interaction/user-approval/src/types.ts \
$SRC/packages/interaction/user-approval/src/index.ts
# 3) 预设:保留名 custom、permission/preset 事件、写穿的两个 setter
grep -n "CUSTOM_PRESET\|permission/preset\|setSandboxMode\|setApprovalPolicy" \
$SRC/packages/interaction/permission-presets/src/index.ts
# 4) 命令:名字正则、解析函数、生命周期事件
grep -n "COMMAND_NAME\|parseCommand\|command/run\|command/done" \
$SRC/packages/interaction/commands/src/index.ts \
$SRC/packages/interaction/commands/src/types.ts
# 5) 运行时:审计对、命令生命周期与预设切换都写进会话日志(只读)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd 2>/dev/null \
| grep -E '"approval/(asked|decided|policy)"|"command/(run|done)"|"permission/preset"' | head
第 1 条在本机 0.1.5-alpha.1 上会打印出 dsh-user-approval、dsh-permission-presets、dsh-commands 三个插件条目,以及 read-only / workspace-write / danger-full-access 三项预设。
下一步
- user-questions 服务缝与身份边界:模型向人提问的那条缝,与审批的对比
- 权限:预设、
/permission、环境变量与配置落盘 - 沙箱与安全:
ctx.sandbox.confine与升权重试 - 自定义命令与用户交互:命令与
ask_user_question的用法 - 写服务:自己提供一条能力缝