Files
Agent-System-Backend/internal/tools/ask.go
T
weichenandCursor 6b0ff49f34 Gate tools by session mode and show confirmation details
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>
2026-10-03 17:01:46 +00:00

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
}