// plan.go - Plan Mode 核心状态机 // // 定位:模块 17 UltraPlan.实现 EnterPlanMode / ExitPlanMode 两个内置工具, // 管理"计划模式"生命周期:进入 → 探索/写计划 → 提交审批 → 退出. // // 核心设计决策: // // 1. 文件读取,非参数传递(安全防注入) // ExitPlanMode 从 PlanStore 读计划内容,而非接受 plan 参数. // 早期实现 ExitPlanModeV2Tool 同样从磁盘读-- // 如果允许参数传入,模型可以注入任意内容绕过用户审查. // // 2. ApprovalPolicy 接口(消费层决策,引擎不假设 UI) // CLI 实现终端交互审批,SDK/API 注入函数回调,测试用 NoopApprovalPolicy. // 引擎本身不知道 "如何显示计划",只知道 "计划需要批准". // // 3. prePlanMode 保存与恢复 // 进入 plan 模式前记录当前权限模式,退出时还原. // 防止从 bypass/accept_edits 进入计划模式后无法正确恢复. // // 4. PlanStep 依赖图(P1) // Deps []string 引用其他 step 的 ID,消费方(CLI/SDK)决定调度顺序. // 引擎只暴露结构,不做调度--符合"叠加而非替换"原则. // // 对应早期方案: // - 早期方案 EnterPlanMode 工具 // - 早期方案 ExitPlanMode 工具 // - 早期方案 plans 实现 // // 升华改进(ELEVATED): 早期方案 EnterPlanMode 依赖全局单例(AppState, planSlugCache) // 和 React 状态系统(context.setAppState).Go 实现用 PlanModeManager 封装状态, // 通过构造函数注入,测试友好,无全局副作用. // 替代方案:<直接在 Engine struct 加 planMode 字段> - // 否决原因:计划模式逻辑和引擎核心混在一起,不易单独测试,也不易未来拆出. package engine import ( "context" "encoding/json" "fmt" "sync" "git.flytoex.net/yuanwei/flyto-agent/core/pkg/permission" "git.flytoex.net/yuanwei/flyto-agent/core/pkg/tools" ) // ───────────────────────────────────────────── // PlanStep - 计划步骤(P1) // ───────────────────────────────────────────── // Complexity 表示步骤的预估复杂度. type Complexity string const ( ComplexityLow Complexity = "low" ComplexityMedium Complexity = "medium" ComplexityHigh Complexity = "high" ) // PlanStep 是计划中的一个可执行步骤. // // 升华改进(ELEVATED): 早期实现 计划是纯 Markdown 文件,没有结构化步骤. // 我们增加 PlanStep 允许消费方解析依赖关系做并行调度. // 引擎只暴露结构--CLI/SDK/Coordinator 决定调度策略,不同场景可不同. // 替代方案:<计划只是 string,步骤全靠模型在 Markdown 里描述> - // 否决原因:消费方无法机器解析并行性,只能串行执行,浪费多 Agent 机会. type PlanStep struct { // ID 步骤唯一标识符(模型生成,通常是顺序编号如 "step-1"). ID string // Description 步骤的人类可读描述. Description string // Tools 该步骤预期用到的工具列表(提示性,不做强制限制). Tools []string // Complexity 预估复杂度(low/medium/high). Complexity Complexity // Deps 依赖的步骤 ID 列表.空表示无依赖(可并行执行). // 精妙之处(CLEVER): Deps 是拓扑排序的输入,而不是"顺序"-- // 消费方可以 Kahn 算法找出可并行的步骤层. // 如果用 "after: step-2" 的字符串指令,消费方要解析 Markdown,容易错. Deps []string } // ───────────────────────────────────────────── // ApprovalPolicy - 审批策略接口 // ───────────────────────────────────────────── // PlanApprovalEvent 是计划审批事件,由 ExitPlanMode 工具触发. // // 精妙之处(CLEVER): 审批事件携带 Approve/Reject 函数而不是返回 bool-- // 这让 CLI 实现可以在异步渲染审批 UI 后回调,而不需要阻塞工具执行线程. // SDK 实现可以把事件发送给外部系统(Slack 审批机器人,审批工作流)然后回调. type PlanApprovalEvent struct { // SessionID 标识是哪个会话的计划. SessionID string // Plan 计划的完整文本内容(从 PlanStore 读取). Plan string // FilePath 计划文件的逻辑路径(用于展示给用户). FilePath string // Steps 可选的结构化步骤列表(如果计划包含 YAML/JSON 步骤块则解析). Steps []PlanStep // Approve 批准计划(允许执行).editedPlan 非空时用编辑后的版本覆盖原计划. // 必须调用 Approve 或 Reject 之一,否则 ExitPlanMode 会一直等待. Approve func(editedPlan string) error // Reject 拒绝计划(保持 plan 模式,让模型修改计划). Reject func(reason string) error } // EventType lets PlanApprovalEvent satisfy engine.Event so it can flow // through the Run channel and be serialized by bridge.EventSerializer. // Consumers subscribing to Run events can receive the approval event and // invoke Approve / Reject asynchronously (push mode). // // EventType 让 PlanApprovalEvent 实现 engine.Event, 能走 Run channel 并 // 被 bridge.EventSerializer 序列化. 订阅 Run 事件的消费者可收到审批事件 // 并异步调 Approve / Reject (push 模式). func (e *PlanApprovalEvent) EventType() string { return "plan_approval" } // PlanQueuedEvent reports that an approved plan was handed off to the async plan // queue (WithPlanAutoQueue) instead of being executed inline by the model. PlanID // is the queue ticket the client polls via the plan queue (GET /plans/{id}); an // empty PlanID with a Note means nothing was queued (no structured steps, or the // queue is disabled). It is the terminal event of an auto-queue Run -- the model // does not continue, so the plan is never executed twice. // // PlanQueuedEvent 报告一个已批准计划被交给异步队列 (WithPlanAutoQueue) 而非由模型 // 内联执行. PlanID 是客户端经队列 (GET /plans/{id}) 轮询的队列票据; PlanID 空 + Note // 表示什么都没入队 (无结构化步骤, 或队列未启用). 它是 auto-queue Run 的终态事件 -- // 模型不再继续, 故计划绝不会跑两遍. type PlanQueuedEvent struct { PlanID string // 队列票据 (空 = 未入队, 见 Note) Note string // 未入队原因 (PlanID 非空时为空) } // EventType lets PlanQueuedEvent satisfy engine.Event so it flows through the Run // channel and is serialized by consumers (e.g. SSE). // // EventType 让 PlanQueuedEvent 实现 engine.Event, 能走 Run channel 并被消费者 // (如 SSE) 序列化. func (e *PlanQueuedEvent) EventType() string { return "plan_queued" } // planQueuedSignal is the in-band marker an ExitPlanMode result carries (via // tools.Result.Data) when WithPlanAutoQueue is on, telling the run loop to submit // the approved steps to the async queue and end the turn rather than letting the // model continue. Unexported: an engine-internal control signal, not a // consumer-facing payload. steps is nil when the model approved without // structured steps -- the loop then emits a "nothing queued" PlanQueuedEvent. // // planQueuedSignal 是 ExitPlanMode 结果在 WithPlanAutoQueue 开启时经 // tools.Result.Data 携带的带内标记, 告诉 run loop 把已批准步骤提交异步队列并结束 // 本轮, 而非让模型继续. 未导出: 引擎内部控制信号, 非面向消费者的载荷. steps 为 nil // 表示模型批准时未给结构化步骤 -- 循环随后发一个 "未入队" 的 PlanQueuedEvent. type planQueuedSignal struct { steps []PlanStep } // planAutoQueueKey is the private ctx key carrying the WithPlanAutoQueue opt-in // into ExitPlanMode.Execute. Mirrors eventEmitterKey (event_emitter.go): an // unexported key blocks external spoofing, and ctx scopes the flag to one Run. // // planAutoQueueKey 是把 WithPlanAutoQueue opt-in 带进 ExitPlanMode.Execute 的私有 // ctx key. 镜像 eventEmitterKey (event_emitter.go): 未导出 key 阻止外部伪造, ctx 把 // 标志锁在单次 Run. type planAutoQueueKey struct{} // withPlanAutoQueue derives a ctx flagging this Run as plan-auto-queue. // // withPlanAutoQueue 派生一个标记本次 Run 为 plan-auto-queue 的 ctx. func withPlanAutoQueue(ctx context.Context) context.Context { return context.WithValue(ctx, planAutoQueueKey{}, true) } // planAutoQueueFromContext reports whether withPlanAutoQueue tagged this ctx. // // planAutoQueueFromContext 报告 ctx 是否被 withPlanAutoQueue 标记. func planAutoQueueFromContext(ctx context.Context) bool { v, _ := ctx.Value(planAutoQueueKey{}).(bool) return v } // ApprovalPolicy 抽象计划审批机制. // // 升华改进(ELEVATED): 早期实现 ExitPlanMode 直接渲染 React 组件(PermissionRequest) // 并等待用户在 TUI 中点击.这完全无法用于 SDK 嵌入或 HTTP API. // 我们用 ApprovalPolicy 接口解耦:CLI 实现终端渲染,SDK 注入函数回调, // SaaS 实现 WebSocket/webhook 审批,测试用 NoopApprovalPolicy 自动批准. // 替代方案:<直接在 ExitPlanMode 工具内渲染 UI> - // 否决原因:工具层依赖 UI 框架,无法在 HTTP API 服务中复用. // // Shape: synchronous callback. Engine passes a PlanApprovalEvent to the // consumer and blocks until the consumer invokes Approve / Reject in the // event struct or returns from RequestApproval. // // 形态: 同步回调. 引擎把 PlanApprovalEvent 交给消费者实现, 阻塞直到消费者 // 调用 event 里的 Approve / Reject 或 RequestApproval 返回. type ApprovalPolicy interface { // RequestApproval 请求用户审批计划.阻塞直到用户批准或拒绝. // 返回: // - approved: 是否批准 // - editedPlan: 用户编辑后的计划文本(空字符串 = 未编辑,使用早期方案) // - err: 系统错误(不是用户拒绝) RequestApproval(ctx context.Context, event PlanApprovalEvent) (approved bool, editedPlan string, err error) } // NoopApprovalPolicy 自动批准所有计划,用于测试和 bypass 场景. type NoopApprovalPolicy struct{} func (NoopApprovalPolicy) RequestApproval(_ context.Context, _ PlanApprovalEvent) (bool, string, error) { return true, "", nil } // FuncApprovalPolicy 用函数实现审批策略(SDK 嵌入最常用). // // 升华改进(ELEVATED): 函数式审批策略让 SDK 用户不需要实现接口, // 只需要传一个 func 就能定制审批逻辑. // 对比:需要实现接口的方式在 Go 里更正式但更繁琐,不适合快速集成. type FuncApprovalPolicy struct { Fn func(ctx context.Context, event PlanApprovalEvent) (approved bool, editedPlan string, err error) } func (p FuncApprovalPolicy) RequestApproval(ctx context.Context, event PlanApprovalEvent) (bool, string, error) { return p.Fn(ctx, event) } // ───────────────────────────────────────────── // PlanModeManager - 状态机核心 // ───────────────────────────────────────────── // PlanModeManager 管理 plan 模式的进入/退出生命周期. // 它是 EnterPlanModeTool 和 ExitPlanModeTool 的共享状态. // // 精妙之处(CLEVER): Manager 通过构造函数注入,没有全局变量-- // 同一个进程中可以有多个 Engine 实例(多租户/测试),各自独立管理计划状态. // 早期实现 用全局 AppState 单例,在多实例场景下会互相污染. type PlanModeManager struct { mu sync.Mutex active bool // 当前是否处于 plan 模式 prePlanMode permission.Mode // 进入 plan 前的权限模式,退出时还原 perms permission.Checker store PlanStore approval ApprovalPolicy sessionID string // 用于路由到正确的计划文件(session-scoped) // progress 是审批通过后的步骤执行进度追踪器(可选). // 通过 AttachProgress 绑定,nil 时表示不追踪进度. // // 升华改进(ELEVATED): progress 生命周期独立于 active-- // active=false(计划已批准,执行中)时 progress 仍然有效. // 替代方案: - // 否决原因:执行阶段正是进度最有意义的时候,此时清空等于丢失数据. progress *PlanProgress } // NewPlanModeManager 创建计划模式管理器. // // store 控制计划文件存储位置(FilePlanStore for CLI,MemoryPlanStore for SDK/测试). // approval 控制审批机制(NoopApprovalPolicy for 测试,CLI 实现用 FuncApprovalPolicy). // perms 是权限引擎引用,进入/退出时更新模式. func NewPlanModeManager(store PlanStore, approval ApprovalPolicy, perms permission.Checker) *PlanModeManager { if store == nil { store = NewMemoryPlanStore("") } if approval == nil { approval = NoopApprovalPolicy{} } return &PlanModeManager{ store: store, approval: approval, perms: perms, } } // SetSessionID 设置当前会话 ID(Engine.Run 开始时调用). // 会话 ID 用于路由到正确的计划文件. func (m *PlanModeManager) SetSessionID(id string) { m.mu.Lock() defer m.mu.Unlock() m.sessionID = id } // AttachProgress 绑定步骤进度追踪器. // // 通常在 Exit 返回批准的计划(含 Steps)后由消费方构建 PlanProgress 并调用此方法绑定, // 之后消费方通过 PlanProgress 追踪每个步骤的执行状态. // // 精妙之处(CLEVER): 消费方主导进度追踪的创建-- // 消费方知道"要不要追踪进度"(简单 SDK 场景不需要),知道"用什么 observer", // 知道"步骤列表"(来自 PlanApprovalEvent.Steps 或自行解析计划文本). // 引擎只提供存储挂载点,不强制创建. // 替代方案: - 否决原因:自动创建需要 Exit 知道 // Observer 配置,把可观测性配置耦合到审批流程,关注点混杂. func (m *PlanModeManager) AttachProgress(p *PlanProgress) { m.mu.Lock() defer m.mu.Unlock() m.progress = p } // Progress 返回当前绑定的进度追踪器(可能为 nil). // 消费方用此方法获取追踪器后调用 StartStep / FinishStep 等方法. func (m *PlanModeManager) Progress() *PlanProgress { m.mu.Lock() defer m.mu.Unlock() return m.progress } // IsActive 返回当前是否处于 plan 模式. func (m *PlanModeManager) IsActive() bool { m.mu.Lock() defer m.mu.Unlock() return m.active } // WritePlan persists plan content into the manager's PlanStore under the // current session. It is the model's channel (via the WritePlan tool) to save // its plan: in plan mode the generic file-write tool is denied, and the default // MemoryPlanStore is not a real filesystem path, so a generic write cannot // reach it. ExitPlanMode later reads this content back from the store for // approval. // // WritePlan 把计划内容写进 manager 的 PlanStore (当前会话). 这是模型 (经 WritePlan // 工具) 保存计划的通道: plan 模式下通用文件写工具被拒, 且默认 MemoryPlanStore 不是 // 真实 FS 路径, 通用写到不了它. ExitPlanMode 之后从该 store 读回此内容送审批. func (m *PlanModeManager) WritePlan(content string) error { m.mu.Lock() store := m.store sessionID := m.sessionID m.mu.Unlock() return store.WritePlan(sessionID, content) } // Enter 进入 plan 模式.保存当前权限模式,切换到 ModePlan. // 如果已经在 plan 模式,返回错误(防止嵌套进入). func (m *PlanModeManager) Enter() error { m.mu.Lock() defer m.mu.Unlock() if m.active { return fmt.Errorf("plan_mode: already active") } // 记录当前模式,退出时恢复 if m.perms != nil { m.prePlanMode = m.perms.Mode() m.perms.SetMode(permission.ModePlan) } m.active = true return nil } // approvalResult carries a single approval decision from any resolver path // (push event.Approve / event.Reject, or pull policy.RequestApproval return). // // approvalResult 携带单条审批决策, 来源可能是任一 resolver 路径 (push 的 // event.Approve / event.Reject, 或 pull 的 policy.RequestApproval 返回值). type approvalResult struct { approved bool edited string err error } // Exit 退出 plan 模式, 读取计划, 触发审批 (push + pull 双路), 恢复权限模式. // // 审批决策有两种来源: // // pull: policy.RequestApproval 同步返回 (approved/edited/err) // push: event.Approve(edited) / event.Reject(reason) 被订阅者异步调用 // // Exit 用 resolveCh (容量 1) 收第一条决策, first-to-resolve 胜; 另一路的 // 后续写入被 tryResolve 的 select default 丢弃. 两种模式消费者选一个实现 // 即可 — pull 向后兼容 (policy 实现者照旧填返回值); push 激活 (订阅 Run // channel 的 TUI/SDK 收到 PlanApprovalEvent 后异步调 Approve/Reject). // // push 通道用上层 ctx EventEmitter (event_emitter.go) 把 event 送到父 Run // channel; emitter 由 engine 主工具派发时注入, 外部代码无法伪造 (私有 key). // // steps 从工具 input 解析的结构化步骤列表 (optional); 填入 event.Steps 供 // 消费方做并行调度. nil 表示模型没给结构化步骤 (仅 markdown 计划). // // goroutine 生命周期: 用派生 ctx + defer cancel 确保 Exit 返回时 policy 的 // goroutine 收到取消信号, policy 实现者有责任听 ctx.Done 退出 (Go ctx 标准 // 协议). 若 policy 不听, 仅影响其自身 goroutine, 不阻塞 Exit. // // 返回: // - plan: 批准后的计划文本 // - err: ErrPlanRejected 表示用户拒绝; ctx.Err 表示超时/取消; 其他为系统错误 // // Exit exits plan mode, reads the plan, triggers approval via push + pull // dual paths, and restores the permission mode. // // Approval decisions come from two sources: // // pull: policy.RequestApproval's synchronous return (approved/edited/err) // push: event.Approve(edited) / event.Reject(reason) invoked async by subscribers // // Exit uses a capacity-1 resolveCh to receive the first decision; // first-to-resolve wins, and the later writer is dropped by tryResolve's // select default. Consumers pick one mode — pull stays backward compatible // (policy implementors still return values); push is now active (TUI/SDK // subscribers receive PlanApprovalEvent on Run channel and call // Approve/Reject asynchronously). // // The push channel uses the ctx EventEmitter (event_emitter.go) to send the // event into the parent Run channel. The emitter is injected by the engine // main dispatch path and cannot be spoofed by external code (private key). // // steps are the structured step list parsed from the tool input (optional); // filled into event.Steps for downstream schedulers. nil means the model // did not supply structured steps (markdown-only plan). // // Goroutine lifecycle: a derived ctx + defer cancel ensures the policy's // goroutine gets a cancel signal when Exit returns. Policy implementors // are responsible for honoring ctx.Done (standard Go ctx protocol); if // they don't, only their own goroutine stalls, not Exit. func (m *PlanModeManager) Exit(ctx context.Context, steps []PlanStep) (plan string, err error) { m.mu.Lock() sessionID := m.sessionID store := m.store approval := m.approval perms := m.perms prePlanMode := m.prePlanMode m.mu.Unlock() if !m.IsActive() { return "", fmt.Errorf("plan_mode: not active") } // 从 PlanStore 读取计划 (不从参数读 — 防注入) planContent, err := store.ReadPlan(sessionID) if err != nil { return "", fmt.Errorf("plan_mode: read plan: %w", err) } filePath := store.PlanPath(sessionID) resolveCh := make(chan approvalResult, 1) tryResolve := func(r approvalResult) { select { case resolveCh <- r: default: // first-to-resolve wins; drop late arrivals // first-to-resolve 胜, 后到的丢弃 } } event := PlanApprovalEvent{ SessionID: sessionID, Plan: planContent, FilePath: filePath, Steps: steps, Approve: func(editedPlan string) error { tryResolve(approvalResult{approved: true, edited: editedPlan}) return nil }, Reject: func(_ string) error { tryResolve(approvalResult{approved: false}) return nil }, } // push path: emit event to parent Run channel via ctx emitter. External // subscribers receive it and may call Approve/Reject asynchronously. // push 路径: 经 ctx emitter 把 event 推到父 Run channel. 外部订阅者 // 收到后可异步调 Approve/Reject. if emit := EventEmitterFromContext(ctx); emit != nil { emit(&event) } // pull path: run policy.RequestApproval in a goroutine; its return is // also fed into resolveCh, racing against push decisions. Derived ctx // lets Exit signal policy to cancel via defer. // pull 路径: goroutine 跑 policy.RequestApproval, 返回值也喂 resolveCh, // 和 push 决策竞速. 派生 ctx 让 Exit 经 defer 给 policy 发取消信号. policyCtx, cancelPolicy := context.WithCancel(ctx) defer cancelPolicy() go func() { approved, edited, policyErr := approval.RequestApproval(policyCtx, event) if policyErr != nil { tryResolve(approvalResult{err: policyErr}) return } tryResolve(approvalResult{approved: approved, edited: edited}) }() var result approvalResult select { case result = <-resolveCh: case <-ctx.Done(): return "", ctx.Err() } if result.err != nil { return "", fmt.Errorf("plan_mode: approval: %w", result.err) } if !result.approved { // 用户拒绝: 保持 plan 模式 (让模型修改计划), 不恢复权限 return "", ErrPlanRejected } // 批准: 恢复权限模式 m.mu.Lock() m.active = false if perms != nil { perms.SetMode(prePlanMode) } m.mu.Unlock() // 如果用户编辑了计划, 写回存储 finalPlan := planContent if result.edited != "" { finalPlan = result.edited if writeErr := store.WritePlan(sessionID, result.edited); writeErr != nil { // 写回失败不阻塞审批结果, 记录但继续 _ = writeErr } } return finalPlan, nil } // ErrPlanRejected 表示用户拒绝了计划(不是系统错误). var ErrPlanRejected = fmt.Errorf("plan_mode: plan rejected by user") // ───────────────────────────────────────────── // EnterPlanModeTool // ───────────────────────────────────────────── // enterPlanModeTool 是 EnterPlanMode 内置工具的实现. // 模型调用此工具表示"我需要进入计划模式探索代码库并设计方案". // // 升华改进(ELEVATED): 早期方案 EnterPlanModeTool 通过 React context.setAppState 修改全局状态, // 并用 feature('KAIROS') 做功能开关(5 处 if 分支). // Go 实现直接操作 PlanModeManager,无全局状态,无功能开关-- // 消费方通过是否注册此工具来控制是否启用 plan 模式. // 替代方案:<用 Config.EnablePlanMode bool 开关> - // 否决原因:工具注册本身就是开关,无需额外 bool 字段. type enterPlanModeTool struct { manager *PlanModeManager } // NewEnterPlanModeTool 创建 EnterPlanMode 工具. // manager 可为 nil: registerBuiltinTools 注册时传 nil, engine.New 之后经 // setupPlanModeTools -> SetManager 注入真 manager (镜像 AgentTool 的 SetExecutor // 延迟注入). 这样工具的 PermissionClass 在注册时就登记, 而 manager 在 Engine // 装配完成后才绑定. func NewEnterPlanModeTool(manager *PlanModeManager) tools.Tool { return &enterPlanModeTool{manager: manager} } // SetManager 注入计划模式管理器 (engine.New 装配后由 setupPlanModeTools 调用). func (t *enterPlanModeTool) SetManager(m *PlanModeManager) { t.manager = m } func (t *enterPlanModeTool) Name() string { return "EnterPlanMode" } // Metadata declares PermClassPlanWrite so EnterPlanMode is allowed in every // permission mode. It only flips the plan-mode flag (safe by construction). // // Metadata 声明 PermClassPlanWrite, 使 EnterPlanMode 在各权限模式下都放行. // 它只翻转 plan 模式标志 (safe by construction). func (t *enterPlanModeTool) Metadata() tools.Metadata { return tools.Metadata{ PermissionClass: permission.PermClassPlanWrite, AuditOperation: "invoke", } } func (t *enterPlanModeTool) Description(_ context.Context) string { return "进入计划模式:开始探索代码库并设计实现方案。适用于复杂任务,在实际编码前获得用户认可。" } func (t *enterPlanModeTool) InputSchema() json.RawMessage { // EnterPlanMode 不接受任何参数(早期方案同) return json.RawMessage(`{"type":"object","properties":{},"required":[]}`) } func (t *enterPlanModeTool) Execute(_ context.Context, _ json.RawMessage, _ tools.ProgressFunc) (*tools.Result, error) { if t.manager == nil { // manager 未注入 (误配): 诚实失败, 不假成功 (镜像 Agent/Skill 病根 #1). return &tools.Result{Output: "计划模式不可用:未配置 PlanModeManager(setupPlanModeTools 未接线)。", IsError: true}, nil } if err := t.manager.Enter(); err != nil { return &tools.Result{ Output: fmt.Sprintf("无法进入计划模式:%v", err), IsError: true, }, nil } t.manager.mu.Lock() planPath := t.manager.store.PlanPath(t.manager.sessionID) t.manager.mu.Unlock() // 返回给模型的指令文本(告诉模型接下来做什么) // 精妙之处(CLEVER): 指令文本直接成为模型的 tool_result, // 不需要额外的 system message 注入--模型会把 tool_result 当成上下文读取. // 这是早期方案 mapToolResultToToolResultBlockParam 的精髓:把"什么时候该做什么" // 写进工具结果,而不是提前写死在 system prompt 里(提前写的会被遗忘/覆盖). instructions := fmt.Sprintf(`已进入计划模式。 计划文件路径:%s 在计划模式中,请: 1. 使用 Glob、Grep、Read 工具彻底探索代码库 2. 理解现有的架构模式 3. 考虑多种方案及其权衡 4. 使用 WritePlan 工具保存你的实现计划(计划模式下普通文件写入工具会被拒绝,必须用 WritePlan) 5. 完成后使用 ExitPlanMode 提交计划请求审批 重要:计划模式下只读探索工具可用,所有改写代码/文件的工具都会被拒绝。这是只读探索和规划阶段。`, planPath) return &tools.Result{Output: instructions}, nil } // ───────────────────────────────────────────── // ExitPlanModeTool // ───────────────────────────────────────────── // exitPlanModeTool 是 ExitPlanMode 内置工具的实现. // 模型调用此工具表示"我已写完计划,请用户审批". // // 精妙之处(CLEVER): 工具执行会阻塞(通过 ApprovalPolicy.RequestApproval)直到用户审批. // 这意味着模型的下一轮循环要等用户操作--用户审批期间模型不会继续输出. // 早期方案用 shouldDefer: true 标记实现类似的延迟语义. // 替代方案:<非阻塞,通过事件通道异步审批> - // 否决原因:非阻塞需要工具链路两端都处理 "等待审批" 状态,复杂度倍增. // 阻塞更简单,且审批通常 <10s,对用户体验无影响. type exitPlanModeTool struct { manager *PlanModeManager } // NewExitPlanModeTool 创建 ExitPlanMode 工具. manager 可为 nil (延迟注入, 见 // NewEnterPlanModeTool). func NewExitPlanModeTool(manager *PlanModeManager) tools.Tool { return &exitPlanModeTool{manager: manager} } // SetManager 注入计划模式管理器 (engine.New 装配后由 setupPlanModeTools 调用). func (t *exitPlanModeTool) SetManager(m *PlanModeManager) { t.manager = m } func (t *exitPlanModeTool) Name() string { return "ExitPlanMode" } // Metadata declares PermClassPlanWrite so ExitPlanMode is allowed inside plan // mode (where every mutating tool is denied). Without it the model could never // exit plan mode. Safe by construction: it only reads the plan + triggers // approval + restores the permission mode. // // It also sets RequiresReverseThinking: committing a plan for execution is the // "decide to act on plan A" moment, exactly where a consumer-registered // reverse-thinking hook (ADR-0001, e.g. platform/common/safetychain. // ReverseThinkingHook) should make the agent argue alternative B before the // plan locks in. The flag is an opt-in signal only -- it runs nothing by // itself; the real switch is whether a consumer registers a PreToolUse hook // that reads it. Low-frequency by design (a session exits plan mode once or // twice), so it does not burden the throughput-sensitive tool path the way // flagging Bash/FileWrite would. Complements the existing approval gate: the // gate asks "should a human approve this plan?", reverse-thinking asks "should // the agent challenge its own plan first?". // // Metadata 声明 PermClassPlanWrite, 使 ExitPlanMode 在 plan 模式内放行 (那里其余 // 改写工具全被拒). 没有它模型永远退不出 plan 模式. Safe by construction: 只读计划 // + 触发审批 + 恢复权限模式. // // 同时设 RequiresReverseThinking: 提交计划去执行正是 "决定按方案 A 行动" 的时刻, // 正该让消费者注册的反向思维 hook (ADR-0001, 如 platform/common/safetychain. // ReverseThinkingHook) 在计划锁定前让 agent 论证替代 B. 此 flag 仅是 opt-in 信号, // 自身不跑任何东西; 真开关是消费者有没有注册读它的 PreToolUse hook. 设计上低频 // (一个 session 退出 plan 模式一两次), 不像标 Bash/FileWrite 那样压上吞吐敏感的 // 工具路径. 与既有审批闸互补: 闸问 "要不要人批这个计划?", 反向思维问 "agent 该 // 不该先挑战自己的计划?". func (t *exitPlanModeTool) Metadata() tools.Metadata { return tools.Metadata{ PermissionClass: permission.PermClassPlanWrite, AuditOperation: "invoke", RequiresReverseThinking: true, } } func (t *exitPlanModeTool) Description(_ context.Context) string { return "退出计划模式:提交计划文件请求用户审批。仅在已将完整计划写入计划文件后调用。" } // InputSchema declares the ExitPlanMode tool's parameters. // // The plan markdown itself is NOT a parameter — it is read from PlanStore // (防注入, same as the early design). steps is an optional structured // step list the model may supply so downstream consumers (schedulers, // parallel orchestrators) can machine-parse dependencies rather than // guessing from free-form markdown. // // InputSchema 声明 ExitPlanMode 工具参数. 计划 markdown 本身不是参数 — // 从 PlanStore 读 (防注入, 对齐早期设计). steps 是模型可选提交的结构化 // 步骤列表, 让下游消费者 (调度器 / 并行协调器) 能机器解析依赖, 不再靠 // 自由 markdown 猜. func (t *exitPlanModeTool) InputSchema() json.RawMessage { return json.RawMessage(`{ "type": "object", "properties": { "steps": { "type": "array", "description": "Optional structured step list for downstream schedulers. Each item: id / description / tools / complexity (low|medium|high) / deps.", "items": { "type": "object", "properties": { "id": {"type": "string"}, "description": {"type": "string"}, "tools": {"type": "array", "items": {"type": "string"}}, "complexity": {"type": "string", "enum": ["low", "medium", "high"]}, "deps": {"type": "array", "items": {"type": "string"}} } } } }, "required": [] }`) } func (t *exitPlanModeTool) Execute(ctx context.Context, input json.RawMessage, _ tools.ProgressFunc) (*tools.Result, error) { if t.manager == nil { return &tools.Result{Output: "计划模式不可用:未配置 PlanModeManager(setupPlanModeTools 未接线)。", IsError: true}, nil } if !t.manager.IsActive() { // 防止在非 plan 模式下误调用 (早期方案有 validateInput 拦截同样情况) return &tools.Result{ Output: "你当前不在计划模式中。只有在 EnterPlanMode 后才能调用此工具。", IsError: true, }, nil } // Parse optional structured steps. A malformed or missing payload // degrades to nil — markdown-only plan still flows through normally. // 解析可选的结构化 steps. 缺失或格式不对时降级为 nil, markdown-only // 计划流程仍正常. var parsed struct { Steps []PlanStep `json:"steps"` } if len(input) > 0 { _ = json.Unmarshal(input, &parsed) } approvedPlan, err := t.manager.Exit(ctx, parsed.Steps) if err == ErrPlanRejected { // 用户拒绝:告知模型需要修改计划,保持 plan 模式 return &tools.Result{ Output: "用户拒绝了你的计划。请根据反馈修改计划文件,然后再次调用 ExitPlanMode。", }, nil } if err != nil { return &tools.Result{ Output: fmt.Sprintf("退出计划模式失败:%v", err), IsError: true, }, nil } t.manager.mu.Lock() planPath := t.manager.store.PlanPath(t.manager.sessionID) t.manager.mu.Unlock() // Auto-queue (WithPlanAutoQueue): hand the approved steps to the async plan // queue instead of letting the model execute inline. Signal the run loop via // Result.Data -- the loop submits, emits PlanQueuedEvent, and ends the turn // (so the plan is NOT run twice: model inline + queue). The tool_result here // is still appended to history by the loop before it terminates, so a resumed // session is never left with a tool_use lacking its tool_result. // // 自动入队 (WithPlanAutoQueue): 把已批准步骤交给异步队列, 而非让模型内联执行. // 经 Result.Data 给 run loop 信号 -- 循环提交, 发 PlanQueuedEvent, 结束本轮 // (故计划不会跑两遍: 模型内联 + 队列). 此处 tool_result 仍由循环在终止前 append // 进历史, 故 resume 的 session 绝不会留下缺 tool_result 的 tool_use. if planAutoQueueFromContext(ctx) { return &tools.Result{ Output: "计划已批准, 转入后台队列异步执行, 本次规划任务到此完成.", Data: &planQueuedSignal{steps: parsed.Steps}, }, nil } // 审批通过:告知模型可以开始实现 // 对应早期方案 mapToolResultToToolResultBlockParam 的 approved 分支 var content string if approvedPlan == "" { content = "用户已批准退出计划模式。你现在可以继续。" } else { content = fmt.Sprintf(`用户已批准你的计划。你现在可以开始编码实现。 计划文件已保存至:%s 如有需要,可在实现过程中随时查阅。 ## 已批准的计划: %s`, planPath, approvedPlan) } return &tools.Result{Output: content}, nil } // ───────────────────────────────────────────── // WritePlanTool // ───────────────────────────────────────────── // writePlanTool is the WritePlan built-in tool. It is the model's channel to // persist its plan while in plan mode: the generic file-write tool is denied in // plan mode, and the default MemoryPlanStore is not a real filesystem path, so // the model writes its plan via this tool (which goes through PlanStore). // ExitPlanMode later reads the plan back from the store for approval. // // writePlanTool 是 WritePlan 内置工具. 它是模型在 plan 模式下持久化计划的通道: // plan 模式下通用文件写工具被拒, 且默认 MemoryPlanStore 不是真实 FS 路径, 故模型 // 经本工具 (走 PlanStore) 写计划. ExitPlanMode 之后从 store 读回计划送审批. type writePlanTool struct { manager *PlanModeManager } // NewWritePlanTool 创建 WritePlan 工具. manager 可为 nil (延迟注入, 见 // NewEnterPlanModeTool). func NewWritePlanTool(manager *PlanModeManager) tools.Tool { return &writePlanTool{manager: manager} } // SetManager 注入计划模式管理器 (engine.New 装配后由 setupPlanModeTools 调用). func (t *writePlanTool) SetManager(m *PlanModeManager) { t.manager = m } func (t *writePlanTool) Name() string { return "WritePlan" } func (t *writePlanTool) Description(_ context.Context) string { return "将你的实现计划写入计划文件(覆盖式)。仅在计划模式中使用:用此工具保存计划,而不是普通文件写入工具(后者在计划模式被拒绝)。写完后调用 ExitPlanMode 提交审批。" } func (t *writePlanTool) InputSchema() json.RawMessage { return json.RawMessage(`{ "type": "object", "properties": { "content": { "type": "string", "description": "The full plan content (Markdown). Overwrites any previous plan content for this session." } }, "required": ["content"] }`) } // Metadata declares PermClassPlanWrite so WritePlan is allowed in plan mode // (where generic file writes are denied). Safe by construction: it writes only // to the session's plan store, never to arbitrary files or code. // // Metadata 声明 PermClassPlanWrite, 使 WritePlan 在 plan 模式放行 (那里通用文件写 // 被拒). Safe by construction: 只写会话的计划存储, 从不碰任意文件或代码. func (t *writePlanTool) Metadata() tools.Metadata { return tools.Metadata{ ConcurrencySafe: false, ReadOnly: false, PermissionClass: permission.PermClassPlanWrite, AuditOperation: "write", } } func (t *writePlanTool) Execute(_ context.Context, input json.RawMessage, _ tools.ProgressFunc) (*tools.Result, error) { if t.manager == nil { return &tools.Result{Output: "计划模式不可用:未配置 PlanModeManager(setupPlanModeTools 未接线)。", IsError: true}, nil } if !t.manager.IsActive() { return &tools.Result{ Output: "你当前不在计划模式中。只有在 EnterPlanMode 后才能写入计划。", IsError: true, }, nil } var in struct { Content string `json:"content"` } if err := json.Unmarshal(input, &in); err != nil { return &tools.Result{Output: fmt.Sprintf("WritePlan: 无效输入: %v", err), IsError: true}, nil } if in.Content == "" { return &tools.Result{Output: "WritePlan: content 不能为空", IsError: true}, nil } if err := t.manager.WritePlan(in.Content); err != nil { return &tools.Result{Output: fmt.Sprintf("WritePlan: 写入失败: %v", err), IsError: true}, nil } return &tools.Result{Output: "计划已保存。完成后调用 ExitPlanMode 提交审批。"}, nil } // ───────────────────────────────────────────── // Engine wiring // ───────────────────────────────────────────── // setupPlanModeTools injects the constructed PlanModeManager into the three // registered plan-mode tools (EnterPlanMode / ExitPlanMode / WritePlan). Mirror // of SetupAgentExecutor: the tools are registered with a nil manager (so their // PermissionClass auto-registers at Register time), and the live manager is // bound here after Engine assembly. Tool-gated: a no-op for any plan tool not // registered (a cfg.Tools allowlist excluded it, or a custom cfg.Toolset // omitted it), so it is always safe to call. // // setupPlanModeTools 把构造好的 PlanModeManager 注入三个已注册的 plan 模式工具. // 镜像 SetupAgentExecutor: 工具以 nil manager 注册 (其 PermissionClass 在 Register // 时自动登记), 真 manager 在 Engine 装配后于此绑定. tool-gated: 任一 plan 工具未 // 注册时 (cfg.Tools allowlist 排除了它, 或自定义 cfg.Toolset 不含它) no-op, 故 // 无条件调用永远安全. func setupPlanModeTools(engine *Engine) { m := engine.planMode if m == nil { return } if t, ok := engine.tools.Get("EnterPlanMode"); ok { if pt, ok := t.(*enterPlanModeTool); ok { pt.SetManager(m) } } if t, ok := engine.tools.Get("ExitPlanMode"); ok { if pt, ok := t.(*exitPlanModeTool); ok { pt.SetManager(m) } } if t, ok := engine.tools.Get("WritePlan"); ok { if pt, ok := t.(*writePlanTool); ok { pt.SetManager(m) } } } // submitAutoQueuePlan submits an auto-queue plan's approved steps to the async // queue and returns the terminal PlanQueuedEvent. Empty steps or a disabled queue // yield a PlanQueuedEvent with an empty PlanID + a Note: nothing was queued, but // the turn still ends gracefully -- opt-in (WithPlanAutoQueue) semantics never let // the model run the plan inline, even when there is nothing to queue. // // submitAutoQueuePlan 把 auto-queue 计划的已批准步骤提交异步队列, 返回终态 // PlanQueuedEvent. 空步骤或队列禁用时返回空 PlanID + Note: 未入队, 但本轮仍优雅结束 // -- opt-in (WithPlanAutoQueue) 语义下绝不让模型内联执行计划, 即便没东西可入队. func (e *Engine) submitAutoQueuePlan(sig *planQueuedSignal) *PlanQueuedEvent { if len(sig.steps) == 0 { return &PlanQueuedEvent{Note: "计划已批准但未提供结构化步骤, 未入队"} } q := e.PlanQueue() if q == nil { return &PlanQueuedEvent{Note: "计划已批准但异步队列未启用 (EnablePlanQueue), 未入队"} } planID, err := q.Submit(sig.steps, PlanSubmitOptions{}) if err != nil { return &PlanQueuedEvent{Note: fmt.Sprintf("计划入队失败: %v", err)} } return &PlanQueuedEvent{PlanID: planID} } // planModeActive reports whether plan mode is currently active. The main runLoop // permission gate consults this so plan-mode read-only enforcement runs even // when permGateEnabled is false (local default, no handler): entering plan mode // flips e.perms to ModePlan, and the gate must actually run to honor it (deny // mutating tools, allow read-only + plan tools). // // planModeActive 报告当前是否处于 plan 模式. 主 runLoop 权限闸据此运行, 使 plan // 模式只读强制即便在 permGateEnabled=false (本地默认无 handler) 时也生效: 进入 // plan 模式会把 e.perms 切到 ModePlan, 闸必须真跑才能兑现 (拒改写工具, 放行只读 // + plan 工具). func (e *Engine) planModeActive() bool { return e.planMode != nil && e.planMode.IsActive() } // PlanModeManager returns the engine's plan-mode manager (nil when plan mode is // disabled via cfg.DisablePlanMode). Consumers may call SetSessionID / // AttachProgress on it; a custom ApprovalPolicy / PlanStore is injected via // Config before New. // // PlanModeManager 返回引擎的 plan 模式管理器 (cfg.DisablePlanMode 禁用时为 nil). // 消费者可在其上调 SetSessionID / AttachProgress; 自定义 ApprovalPolicy / PlanStore // 经 Config 在 New 之前注入. func (e *Engine) PlanModeManager() *PlanModeManager { return e.planMode }