Why: Plan mode has to block writes, confirmations dropped the argument preview, and direct chat did not share its stop gate with squad members. Evidence: GOWORK=off go test ./internal/store/ ./internal/tools/ ./internal/runmgr/ ./internal/runtime/ ./internal/server/ passed. Default mode still runs write_artifact; plan mode rejects it. Impact: Empty permission mode keeps the profile allow, deny, and confirm lists. High-risk shell commands now ask. There is no bypass-all mode. Refs: feature/permission-hooks Co-authored-by: Cursor <cursoragent@cursor.com>
110 lines
3.3 KiB
Go
110 lines
3.3 KiB
Go
package tools
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
core "agent-system-core"
|
|
)
|
|
|
|
type userAsker interface {
|
|
AskUser(card core.HITLCard) (core.HITLResponse, error)
|
|
}
|
|
|
|
type planExiter interface {
|
|
ExitPlanMode() error
|
|
}
|
|
|
|
func registerAskTools(r *Registry) error {
|
|
if err := r.Register(core.ToolDesc{
|
|
Name: "ask_user", Description: "向用户提一个问题。可以给选项,用户也可以自己输入。", Source: core.SourceBuiltin, ReadOnly: true,
|
|
Parameters: json.RawMessage(`{"type":"object","properties":{"question":{"type":"string"},"options":{"type":"array","items":{"type":"string"}}},"required":["question"]}`),
|
|
}, execAskUser); err != nil {
|
|
return err
|
|
}
|
|
return r.Register(core.ToolDesc{
|
|
Name: "exit_plan_mode", Description: "结束计划模式。用户批准后,本会话余下的执行改回默认权限。", Source: core.SourceBuiltin, ReadOnly: true,
|
|
Parameters: json.RawMessage(`{"type":"object","properties":{"summary":{"type":"string"}}}`),
|
|
}, execExitPlan)
|
|
}
|
|
|
|
func execAskUser(rc ToolExecCtx, args json.RawMessage) (string, error) {
|
|
ask, ok := rc.(userAsker)
|
|
if !ok {
|
|
return "", fmt.Errorf("ask_user 未接入")
|
|
}
|
|
var a struct {
|
|
Question string `json:"question"`
|
|
Options []string `json:"options"`
|
|
}
|
|
if err := json.Unmarshal(args, &a); err != nil || strings.TrimSpace(a.Question) == "" {
|
|
return "", fmt.Errorf("ask_user: question required")
|
|
}
|
|
card := core.HITLCard{
|
|
Question: a.Question, Kind: core.HITLKindChoice, AllowFreeInput: true,
|
|
FreeInputHint: "也可以直接输入",
|
|
}
|
|
for i, opt := range a.Options {
|
|
opt = strings.TrimSpace(opt)
|
|
if opt == "" {
|
|
continue
|
|
}
|
|
card.Choices = append(card.Choices, core.HITLChoice{
|
|
ID: fmt.Sprintf("opt%d", i+1), Label: opt, Description: opt, Impact: "模型会按这个选择继续",
|
|
})
|
|
}
|
|
if len(card.Choices) == 0 {
|
|
card.Kind = core.HITLKindInput
|
|
}
|
|
resp, err := ask.AskUser(card)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if text := strings.TrimSpace(resp.FreeText); text != "" {
|
|
return "用户回答:" + text, nil
|
|
}
|
|
for _, ch := range card.Choices {
|
|
if ch.ID == resp.ChoiceID {
|
|
return "用户选择:" + ch.Label, nil
|
|
}
|
|
}
|
|
if resp.ChoiceID != "" {
|
|
return "用户选择:" + resp.ChoiceID, nil
|
|
}
|
|
return "用户没有给出回答", nil
|
|
}
|
|
|
|
func execExitPlan(rc ToolExecCtx, args json.RawMessage) (string, error) {
|
|
ask, ok := rc.(userAsker)
|
|
exit, okExit := rc.(planExiter)
|
|
if !ok || !okExit {
|
|
return "", fmt.Errorf("exit_plan_mode 未接入")
|
|
}
|
|
var a struct {
|
|
Summary string `json:"summary"`
|
|
}
|
|
_ = json.Unmarshal(args, &a)
|
|
q := "批准计划并继续执行?"
|
|
if s := strings.TrimSpace(a.Summary); s != "" {
|
|
q = "批准计划并继续执行?\n" + s
|
|
}
|
|
resp, err := ask.AskUser(core.HITLCard{
|
|
Question: q, Kind: core.HITLKindConfirm, Detail: strings.TrimSpace(a.Summary),
|
|
Choices: []core.HITLChoice{
|
|
{ID: "yes", Label: "批准", Description: "结束计划模式,按默认权限继续", Impact: "之后可以改文件", Recommended: true},
|
|
{ID: "no", Label: "继续计划", Description: "留在计划模式", Impact: "写文件仍然被拒绝"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if resp.ChoiceID != "yes" {
|
|
return "用户还没有批准计划", nil
|
|
}
|
|
if err := exit.ExitPlanMode(); err != nil {
|
|
return "", err
|
|
}
|
|
return "已切回默认权限", nil
|
|
}
|