命令、权限与用户交互

这里收敛四条渠道中立 seam:direct Commands、permission presets、一次性审批,以及向人提问。 CLI、Web/SDK 都消费同一 command descriptor 与 permission projection;审批和提问都 失败关闭——没有应答者时默认不放行。 对应实现:src/Tether.Interaction/,审批契约在 src/Tether.Core.Contracts/IApprovalService.cs,提问契约在 src/Tether.Interaction.Abstractions/。

授权是一次性的

public enum ApprovalOutcome
{
    AllowedOnce,   // "allowed-once":只授权这一次这个确切动作
    Rejected,      // "rejected":明确拒绝
    Cancelled,     // "cancelled":调用方取消了这一请求
    Unavailable,   // "unavailable":没有可用应答者,调用方失败关闭
}

词汇表里没有"记住"和"总是允许"

这是刻意的设计决定,契约注释里就写着 There is no remember/always vocabulary。 每次动作各自授权,不存在一次点头换来长期通行证。想要长期放宽应当改策略, 而不是让一次批准无限延期。

策略先于应答者

public enum ApprovalPolicy
{
    Ask,     // 委托给组合起来的应答者;一个都没有则 Unavailable
    Never,   // 不询问,拒绝每一个已经解析为 Ask 的动作
}

策略在任何交互式应答者看到请求之前生效,但只解释工具策略已经给出的 Ask。 普通 Allow 在 ask 与 never 下都经过最终 guard 检查,普通 Deny 在两者下都直接拒绝; Ask + ask 才进入 answerer 链,Ask + never 直接返回 Rejected。因此 never 不是“拒绝全部工具”,permission preset 也不会把所有工具无条件改写成 Ask。

策略是按会话覆盖的,而且覆盖本身写进日志:

var policy = approval.EffectivePolicy(session);          // 最后一次记录的覆盖,否则默认值
await approval.SetPolicyAsync(session, ApprovalPolicy.Never);

EffectivePolicy 从日志尾部往前扫,读取最新的 approval/policy;缺席时才使用 composition 默认值。 permission/preset 只记录选择意图,不能覆盖实际 approval/sandbox 规则。初始化会把缺失的规则 写成独立事实,后续冷重放据此恢复已固定的策略。

direct Commands:不打开模型 turn

ICommandRegistry 面向明确的 IAgent 列出、解析并执行命令。命中的 direct command 只写一对 log-only 生命周期事件:

command/run { commandId: CommandId, source: { kind: "user" } }
        → handler
command/done { commandId: CommandId, kind: "success" | "error" }

它不会写 user/message,不会进入 Agent inbox,也不会创建 turn/start。命令自身若要 改变领域状态,必须由 handler 追加所属领域的 SessionEvent,不能把 command log 当成领域事实。 CommandId 是强类型关联 id;live precommit 与 cold replay 都拒绝重复 run、orphan/duplicate done、 未知 kind/source,以及不是非负 JSON safe integer、没有指向更早真实非 command 事件的 sourceEventSeq。

CommandDefinition.Input 是冻结的发现契约:Images 默认 false,并原样进入 descriptor、 list 与 resolve surface。Host executor 收到 EncodedImageAttachment[] 后会再次检查这项能力; 声明可消费图片的命令才会把完整有序批次交给同一个 IAttachmentStore.AdmitAsync。任一图片的 格式、数量、总字节、像素、边长或取消校验失败,handler 都看不到半批引用,已开始的 command 以确定的 command/done error 关闭。wire 中的 base64 必须非空且 canonical:解码后重新编码 必须与输入逐字相同;整个批次在调用 store 前完成这项校验,所以一个成员失败时 store 不会 看到部分批次。

handler 成功看到的只有冻结的 CommandImageBlock 与 durable AttachmentRef,没有原始 path、 bytes 或 base64;command 事件、CLI transcript 与 projection 也不保存这些 wire 内容。registry 不会擅自把图片放进模型消息。普通 Agent 发送则通过完整 InboxMessage + refs-only attachment 走独立的 model-visible materializer,精确顺序与 capability fence 见附件与溢写; Plan/Goal 如何消费参考图仍由 #31 负责。

CommandResult.Error 与已分类的 AttachmentException 是正常 error result,调用方收到闭合的 CommandExecution。handler 抛异常、caller cancellation 或非 Attachment provider fault 则会先 best-effort 追加配对的 error command/done,再把原始异常或取消传播给调用方;失败路径中 done 自身追加失败不会替换 primary error,而会发到可观察的 internal/error。handler 成功后若 done 无法落盘则直接向上失败,不能把没有 durable closure 的执行报告为成功。

新会话权限初始化

permission/preset 只记录 preset id,不再包含 origin 或完整策略副本。 sandbox/mode 与 approval/policy 分别决定实际规则;预设表把用户的选择映射为这两个值:

presetsandbox policyapproval policy
workspace-writeworkspace-writeask
danger-full-accessdanger-full-accessnever

sandbox 是 provider-neutral 会话策略。受限策略若没有 enforcement provider,仍以 SANDBOX_UNAVAILABLE 失败关闭;每次 turn 检查实际 enforcement,不因用户批准而绕过。

Settings 的位置是 permission/defaultPreset。没有 seed、preset 或实际规则的新 Session, 在初始化时采样当前 Settings 默认值并固定。修改 Settings 只影响尚未初始化的新会话; command dispatch 和后续 turn 不重新选择旧会话默认值。

带 seed boundary 的 resumed empty Session,以及已有部分规则的 Session,保留实际规则, 缺失部分使用 composition 默认规则补齐。插件重挂先订阅创建事件,再扫描 exact live Sessions: 无 seed、无权限事实的空会话仍采样当前 Settings;已初始化会话保持原值。这个边界与固定上游一致, 不能把所有“既有空会话”都归为 composition 默认值。

preset 意图、sandbox 和 approval 顺序写入,使用 exact Session 的串行 gate。任何 append 失败都向上传播,保留已经写入的真实前缀,不伪装成整体原子成功。实际规则不匹配预设表时, projection 派生 custom;它不是可写预设,也没有 origin 字段。

工具入口要求 exact initiating Session、live Agent 与 active owner 同时匹配。 缺失、stale 或同 id 不同实例均失败关闭,两个 Agent 的审计不会写进彼此的 Session。 CLI :status 和 Web 使用同一 permissions projection,读取 permission、sandbox 和 approval, 不维护客户端默认值或 permissionOrigin。

scoped 审批 waterfall

using var registration = context.OnApprovalRequest(async (request, next) =>
{
    if (request.ToolName == "bash") return ApprovalOutcome.AllowedOnce;
    return await next();
});

listener 按注册顺序派发,global、exact scope 及其祖先可见,prepend 可提前插入。 只有显式调用 next() 才进入后继,外层可以包装后继结果;返回结果即接管。 RegisterAnswerer 是便利适配器,只有返回 null 时才调用后继。

  • 无人接管、provider 抛异常或返回非法枚举值 → Unavailable。
  • 已认领请求的 listener 卸载 → 立即 Unavailable,不转交给替换或后继 listener。
  • caller 取消 → Cancelled;Never 在派发前返回 Rejected。
  • 已关闭请求的迟到答案与 next() 无效,不得启动新的 listener。

Context helper 绑定注册权威与 effect 回收。preset generation 不能隐式注册成 global, 也不能向 foreign scope 注册。调用方可借用已保留的 invocation Scope;只提供 Agent 时, 服务获取真实 pipeline snapshot。在途请求保留旧 generation,HMR 后的新请求使用新 scope。 逻辑取消不等待不合作的 provider;snapshot 则等 listener 与取消回调都排空后才释放。 清理异常报告 internal/error,不能改写已决定的审批结果。

要求有开启的 turn 否则抛异常 写 approval/asked 取消已触发? 是 策略 = Never? 是 逐个询问 answerer null 表示弃权 AllowedOnce 首个非 null 结果胜出 Cancelled Rejected Unavailable 全弃权 / unload / provider fault 写 approval/decided 五种结果都会写
无应答者、应答者异常和非法结果统一为 Unavailable,均不执行工具。

最终审批词汇

审批只有 AllowedOnce、Rejected、Cancelled 和 Unavailable 四种结果。 旧 provider-failure 不再是可写或可重放结果;会话词汇版本为 12,旧格式明确拒绝。

取消、provider unload 与应答是竞速的:第一个提交的终局决定胜出。late、duplicate 或旧 generation 的 answer 都不能改变已经提交的结果;两个 Agent 的并发请求没有共享完成态。

请求与审计事件对

var outcome = await approval.RequestAsync(new ApprovalRequest("bash", session)
{
    Agent = agent,
    CallId = call.CallId,
    Reason = "命令会写入工作区之外的路径",
    Cancellation = ct,
});

一次请求会在会话日志里留下成对的两条事件,靠同一个 id 关联:

事件载荷
approval/askedid、toolName、callId?、reason?
approval/decidedid、outcome(四种闭合结果)
approval/policypolicy

顺序是确定的:先写 approval/asked,再去问应答者,最后写一次 approval/decided。 任一审计 append 失败都会使请求失败;asked 失败也释放 open 请求,不运行工具或伪造 decided。 Tether.Interaction invariant companion 在 live precommit 与 cold replay 都拒绝 orphan/duplicate decision、重复 id、未知 outcome/policy,以及 turn 结束或日志尾部仍有 unmatched ask。

审计事件不进模型上下文

这三个事件是只记录的——它们不投影成模型可见消息,因此不会进入模型 transcript。 按会话与事件溯源里的规则,它们没有 projectMessage, IsSurfaceEligible 自然为假。审批过程对人可见、对模型不可见。

必须在 turn 内

RequestAsync 要求当前有一个打开的 turn,否则抛 InvalidOperationException,消息里说明了原因:审计事件对必须被 turn 封闭。

判定方式同样是从日志尾部往前扫:先遇到 turn/start 说明有开启的 turn,先遇到 turn/end 说明没有,什么都没遇到也算没有。

这条约束的意义是让审批记录能被归属到某一次具体的 turn,而不是漂在日志里无从对应。turn 与 step 的括号结构见 Agent 生命周期。

另外,审计对关闭之后到来的迟到应答不会改变已决定的结果——决定一旦落盘就是终局。

接进工具管线

工具管线的 pre-execute 阶段可以返回 ToolPreDecision.Ask,语义是”请求批准,没有批准能力时失败关闭”。这条 seam正是那个批准能力的提供方:

tools.RegisterPreExecuteListener(null, async (execution, next) =>
{
    if (execution.Name is "bash" or "fs_write")
    {
        return ToolPreDecision.Ask("这个工具会改动工作区");
    }

    return await next();
});

AllowedOnce 后仍执行 global/parent/exact monotonic guard;明确 deny 时不执行 body。 审批等待期间的取消先于 guard 和 dispatch。 Rejected 对应工具侧 DENIED,Unavailable 对应 UNAVAILABLE, Cancelled 对应 ABORTED_BEFORE_DISPATCH。 模型得到明确、可分类的失败,而不是超时挂住。

装配

catalog.Register("approval", () => new ApprovalPlugin(ApprovalPolicy.Ask));
catalog.Register("user-questions", () => new UserQuestionsPlugin());
catalog.Register("ask-user-tool", () => new AskUserToolPlugin());

ApprovalPlugin 声明 Inject = [typeof(SessionEventCatalog)],因为它要把三个审计事件类型注册进目录才能往会话里写。默认策略是 Ask。

向人提问

第二条 seam 解决的是另一个问题:模型缺信息,需要人给一个答案。

public interface IUserQuestions
{
    IDisposable RegisterProvider(IUserQuestionProvider provider);
    IDisposable RegisterListener(AgentPipelineScope? scope, UserQuestionListener listener, bool prepend = false);
    ValueTask<AskUserQuestionAnswer> AskAsync(AskUserQuestionRequest request);
}

它是渠道中立的:CLI、Web、编辑器可组合 scoped listener,或通过 IUserQuestionProvider 注册 global terminal listener。只有显式 next() 才委托后继;context.OnUserQuestionRequest 绑定作用域与回收,preset 注册同样不能提升为 global。

一次提问是一批问题:

类型字段
AskUserQuestionItemId(会在答案里回显)、Question、Detail?、Header?、Options?、MultiSelect、Intent?
AskUserQuestionOptionLabel、Description?
AskUserQuestionAnswerItemId、Selected(选中的标签)、Custom?(自由文本)

失败码同样是稳定的:

码含义
NO_PROVIDER没有活跃 Provider,或已 claim 该请求的 Provider 卸载
EMPTY_QUESTIONS一个问题都没给
ASK_ABORTED用户回答之前就被取消
AGENT_UNAVAILABLE工具调用没有携带 exact live Agent/session/owner authority
CALLER_NOT_LIVEsupplied Agent 不属于当前 registry 的 exact announced runtime publication
DELEGATED_CALLER调用方是由另一个 live Agent 持有的 runtime child
BAD_INTENTplan-review 缺席 detail,或 approve label 不属于该题选项

Intent 的当前形状是 {kind: "plan-review", approve: "Approve"}。批准 label 必须精确匹配 本题选项,detail 必须存在(空字符串合法);服务在调用 listener 前校验。 exit_plan_mode 传入完整计划 Markdown、exact Agent 和保留中的 Scope,批准退出,拒绝继续计划。

普通 provider 异常与 UserQuestionException 保留原实例传播。跨 transport 仅在 name 为 UserQuestionError 且 message/code 都是字符串时恢复领域错误,空 code 保留;不完整或普通错误 继续按普通异常传播,不伪造领域失败码。

每个 ask 都有独立的 completion source、provider lifetime 与 cancellation registration。caller cancel 只把该 request 关闭为 ASK_ABORTED,Provider unload 则先把这一 generation 的全部 pending request 关闭为 NO_PROVIDER,再取消 provider lifetime。 迟到的 answer、异常或旧 registration generation 都不能改写已提交结果;replacement Provider 也不会继承上一代的完成态。UserQuestionsPlugin 自身卸载同样立即关闭 pending request。

ask_user_question 工具

AskUserToolPlugin 把这条 seam暴露成模型可见的工具,声明 Inject = [typeof(IUserQuestions), typeof(IToolRegistry), typeof(IAgentRegistry)]。工具描述告诉模型的使用场合是:需要确认、需要在选项间选择、或缺少信息无法继续时。

每个问题都要带一个稳定 id,答案里会原样回显——这样模型能把答案对应回自己提的哪个问题,批量提问也不会错位。

模型工具必须绑定 exact initiating Agent

工具从 invocation 的 AgentPipelineScope 与 owner token 解析发起者,并要求 IAgentRegistry 中仍是同一个 live Agent、同一个 Session 实例、同一个 active owner。 缺 scope、stale same-id Session、foreign/inactive owner 都在调用 Provider 前以 AGENT_UNAVAILABLE 失败关闭;root ambient IAgent 不能替代发起者。 渠道若要在 Agent 外直接提问,可以调用 IUserQuestions seam。 只要提供 Agent,服务就验证当前 registry 的 exact announced runtime root;显式借用 Scope 也不能绕过。 历史 durable parent lineage 不等于运行时 delegated,不妨碍恢复成新的 runtime root。

下一步

在 GitHub 上编辑此页