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>
This commit is contained in:
2026-10-03 17:01:46 +00:00
co-authored by Cursor
parent 8f946e3094
commit 6b0ff49f34
16 changed files with 363 additions and 54 deletions
+16
View File
@@ -25,6 +25,7 @@ import (
agentllm "agent-native/runtime/llm"
agentmcp "agent-native/runtime/mcp"
"agent-native/runtime/policy"
agenttools "agent-native/runtime/tools"
nativeadp "agent-squad/adapters/native"
"agent-system-backend/internal/bus"
@@ -149,12 +150,27 @@ func Run(opt Options) error {
if route.Model == "" || route.Base == "" {
return nativeadp.Config{}, false
}
mode := "default"
if src, ok := rc.(interface{ PermissionMode() string }); ok {
mode = src.PermissionMode()
}
cfg := nativeadp.Config{
Client: &agentllm.Client{BaseURL: route.Base, APIKey: route.Key, HTTPClient: &http.Client{}},
DefaultModel: route.Model,
MaxTurns: 0,
Timeout: 0,
Subagent: squadadapter.SubagentFactory(rc),
RepeatGuard: true,
BeforeTool: func(call agentllm.ToolCall) error {
effect := policy.Decide(policy.Request{
Mode: policy.Normalize(mode), Name: call.Name, Args: call.Arguments,
Traits: policy.TraitsFor(call.Name),
}, nil)
if effect == policy.EffectDeny {
return fmt.Errorf("计划模式拒绝了工具 %s", call.Name)
}
return nil
},
}
if keys := splitKeyPool(effNow.GetString("tools.tavily.apiKeys", "")); len(keys) > 0 {
cfg.Search = agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys)
+38
View File
@@ -0,0 +1,38 @@
package runmgr
import (
"testing"
core "agent-system-core"
)
func TestPlanModeRejectsWriteAndKeepsRead(t *testing.T) {
c, st := newExecEnv(t)
if err := st.SetPermissionMode("alice", c.run.ConversationID, "plan"); err != nil {
t.Fatal(err)
}
c.permissionMode = "plan"
res := c.ExecuteTool(core.ToolCallRequest{CallID: "c1", Name: "write_artifact", Arguments: []byte(`{"name":"a.md","content":"x"}`)})
if res.Status != "error" || res.Output == "" {
t.Fatalf("write in plan mode: %+v", res)
}
ok := c.ExecuteTool(core.ToolCallRequest{CallID: "c2", Name: "time_now", Arguments: []byte(`{}`)})
if ok.Status != "ok" {
t.Fatalf("read in plan mode: %+v", ok)
}
names := map[string]bool{}
for _, tool := range c.Tools() {
names[tool.Name] = true
}
if names["write_artifact"] || !names["time_now"] || !names["ask_user"] || !names["exit_plan_mode"] {
t.Fatalf("plan tool list: %v", names)
}
}
func TestDefaultModeStillRunsWrite(t *testing.T) {
c, _ := newExecEnv(t)
res := c.ExecuteTool(core.ToolCallRequest{CallID: "c1", Name: "write_artifact", Arguments: []byte(`{"name":"a.md","content":"x"}`)})
if res.Status != "ok" {
t.Fatalf("default write: %+v", res)
}
}
+93 -25
View File
@@ -16,6 +16,7 @@ import (
"unicode/utf8"
"agent-native/runtime/filestate"
agentpolicy "agent-native/runtime/policy"
"agent-native/runtime/subagent"
"agent-system-backend/internal/bus"
@@ -68,6 +69,9 @@ type Manager struct {
notices map[string][]bgNotice
bgInFlight map[string]int
delivering map[string]bool
allowMu sync.Mutex
sessionAllow map[string]map[string]bool
}
const maxBackgroundChildren = 3
@@ -150,20 +154,21 @@ func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir }
// ---- runCtx 实现(runtime.RunCtx)----
type runCtx struct {
mgr *Manager
run core.Run
prof core.AgentProfile
eff core.EffectiveSettings
ctx context.Context
h *runHandle
userMessage core.Message
attachments []core.ParsedDocument
inputPaths []string
subTask string
subHint string
subAgent string
launch *workflow.Launch
files *filestate.Tracker
mgr *Manager
run core.Run
prof core.AgentProfile
eff core.EffectiveSettings
ctx context.Context
h *runHandle
userMessage core.Message
attachments []core.ParsedDocument
inputPaths []string
subTask string
subHint string
subAgent string
launch *workflow.Launch
files *filestate.Tracker
permissionMode string
}
// Workflow 返回这次 run 启动时拷贝的工作流。普通回合没有。
@@ -236,16 +241,64 @@ func (c *runCtx) Subtask() string { return c.subTask }
// Tools 模型可见工具清单:Hidden 工具(fs 预留能力)在 runtime.fsTools 未开时过滤
// (与 /api/tools 列表同门禁;registry.Execute 另有执行侧 fail-closed)。
func conversationMode(m *Manager, owner, id string) string {
if m == nil || m.st == nil || id == "" {
return ""
}
conv, err := m.st.GetConversation(owner, id)
if err != nil {
return ""
}
return conv.PermissionMode
}
func (c *runCtx) PermissionMode() string { return c.permissionMode }
func (c *runCtx) ExitPlanMode() error {
c.permissionMode = ""
if c.mgr == nil || c.mgr.st == nil {
return nil
}
return c.mgr.st.SetPermissionMode(c.run.Owner, c.run.ConversationID, "default")
}
func (m *Manager) rememberSessionAllow(conv, tool string) {
if m == nil || conv == "" || tool == "" {
return
}
m.allowMu.Lock()
defer m.allowMu.Unlock()
if m.sessionAllow == nil {
m.sessionAllow = map[string]map[string]bool{}
}
if m.sessionAllow[conv] == nil {
m.sessionAllow[conv] = map[string]bool{}
}
m.sessionAllow[conv][tool] = true
}
func (m *Manager) sessionAllows(conv, tool string) bool {
if m == nil {
return false
}
m.allowMu.Lock()
defer m.allowMu.Unlock()
return m.sessionAllow[conv][tool]
}
func (c *runCtx) Tools() []core.ToolDesc {
list := c.mgr.registry.List()
if c.eff.GetBool("runtime.fsTools", false) {
return list
}
out := make([]core.ToolDesc, 0, len(list))
fs := c.eff.GetBool("runtime.fsTools", false)
for _, t := range list {
if !t.Hidden {
out = append(out, t)
if t.Hidden && !fs {
continue
}
readOnly := t.ReadOnly || agentpolicy.TraitsFor(t.Name).ReadOnly
if !agentpolicy.Visible(c.permissionMode, t.Name, readOnly) {
continue
}
out = append(out, t)
}
return out
}
@@ -582,25 +635,36 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
if !policy.ToolAllowed(call.Name) {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", call.Name)}
}
if policy.NeedsConfirm(call.Name) {
desc, _, _ := c.mgr.registry.Get(call.Name)
effect := agentpolicy.Decide(agentpolicy.Request{
Mode: agentpolicy.Normalize(c.permissionMode), Name: call.Name, Args: string(call.Arguments),
Traits: agentpolicy.Traits{ReadOnly: desc.ReadOnly, Edits: desc.Edits, Risk: desc.Risk},
SessionAllow: c.mgr.sessionAllows(c.run.ConversationID, call.Name),
}, nil)
if effect == agentpolicy.EffectDeny {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("计划模式拒绝了工具 %s", call.Name)}
}
if effect != agentpolicy.EffectAllow && (effect == agentpolicy.EffectAsk || policy.NeedsConfirm(call.Name)) {
var argsPreview string
if len(call.Arguments) > 0 {
argsPreview = truncate(string(call.Arguments), 600)
}
card := core.HITLCard{
Question: fmt.Sprintf("执行工具 %s?", call.Name), Kind: core.HITLKindConfirm,
Question: fmt.Sprintf("执行工具 %s?", call.Name), Kind: core.HITLKindConfirm, Detail: argsPreview,
Choices: []core.HITLChoice{
{ID: "yes", Label: "执行", Description: "按上述参数调用", Impact: "该工具将访问外部资源并返回结果", Recommended: true},
{ID: "session", Label: "本会话始终允许", Description: "这次以及本会话后续的同名调用不再询问", Impact: "拒绝规则和计划模式仍然有效"},
{ID: "no", Label: "取消", Description: "跳过本次调用", Impact: "模型会收到调用被拒绝的说明"},
},
AllowFreeInput: false,
}
_ = argsPreview
resp, err := c.AskHITL(card)
if err != nil {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()}
}
if resp.ChoiceID != "yes" {
if resp.ChoiceID == "session" {
c.mgr.rememberSessionAllow(c.run.ConversationID, call.Name)
} else if resp.ChoiceID != "yes" {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"}
}
}
@@ -655,6 +719,10 @@ func (t *toolCtx) Context() context.Context { return t.rc.Context()
func (t *toolCtx) OwnerID() string { return t.rc.run.Owner }
func (t *toolCtx) WorkspaceRoot() string { return t.rc.mgr.workspaceRoot }
func (t *toolCtx) SettingBool(key string, def bool) bool { return t.rc.eff.GetBool(key, def) }
func (t *toolCtx) AskUser(card core.HITLCard) (core.HITLResponse, error) {
return t.rc.AskHITL(card)
}
func (t *toolCtx) ExitPlanMode() error { return t.rc.ExitPlanMode() }
// EmitToolOutput 把解释器执行中的输出增量发到会话事件流。
func (t *toolCtx) EmitToolOutput(stream, text string) {
@@ -875,7 +943,7 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa
runner := selectConversationRunner(m, eff, launch)
inputPaths, _ := m.stageInputs(conv, eff, inputs)
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New()}
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)}
go m.execute(rc, runner)
return run, nil
}
@@ -1203,7 +1271,7 @@ func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID
prof, _ := m.profiles.Get(parent.Owner, parent.ProfileID)
eff := m.settings.Effective(parent.Owner, parent.ProjectID)
rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, files: filestate.New()}
rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, files: filestate.New(), permissionMode: conversationMode(m, child.Owner, child.ConversationID)}
m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing)
runner := m.subRunner
+22
View File
@@ -11,6 +11,7 @@ import (
nruntime "agent-native/runtime"
agentllm "agent-native/runtime/llm"
"agent-native/runtime/policy"
agenttools "agent-native/runtime/tools"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/prompts"
@@ -283,6 +284,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
decls = append(decls, agentllm.ToolDecl{Name: td.Name, Description: td.Description, Parameters: td.Parameters})
}
}
decls = filterPlanDecls(decls, permissionModeOf(rc))
decls = applyToolSelection(decls, eff.GetString("runtime.toolSelection", ""))
maxTurns, maxTools := loopCaps(prof.Budget)
@@ -290,6 +292,26 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools)
}
func permissionModeOf(rc RunCtx) string {
if m, ok := rc.(interface{ PermissionMode() string }); ok {
return m.PermissionMode()
}
return ""
}
func filterPlanDecls(decls []agentllm.ToolDecl, mode string) []agentllm.ToolDecl {
if policy.Normalize(mode) != policy.ModePlan {
return decls
}
out := make([]agentllm.ToolDecl, 0, len(decls))
for _, d := range decls {
if policy.Visible(mode, d.Name, policy.TraitsFor(d.Name).ReadOnly) {
out = append(out, d)
}
}
return out
}
func applyToolSelection(decls []agentllm.ToolDecl, spec string) []agentllm.ToolDecl {
spec = strings.TrimSpace(spec)
if spec == "" {
+13 -1
View File
@@ -8,6 +8,7 @@ import (
"time"
nruntime "agent-native/runtime"
"agent-native/runtime/hooks"
agentllm "agent-native/runtime/llm"
"agent-native/runtime/reminders"
agenttools "agent-native/runtime/tools"
@@ -145,6 +146,17 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
}
window, reserve, buffer := contextWindow(st.eff)
bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event != hooks.Stop {
return hooks.Outcome{}
}
d := st.stop(agentllm.Message{Role: agentllm.RoleAssistant, Content: c.LastText}, nruntime.StopInfo{Continuations: c.Continuations, ToolCalls: c.ToolCalls})
if d.Continue {
return hooks.Outcome{Decision: hooks.Block, Reason: d.Reason, Replacement: d.Replacement, Notice: d.Notice}
}
return hooks.Outcome{Decision: hooks.Allow, Notice: d.Notice}
})
res, err := nruntime.Run(rc.Context(), nruntime.Config{
Model: model,
History: history,
@@ -159,7 +171,7 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
OutputReserve: reserve,
ContextBuffer: buffer,
OnEvent: st.onEvent,
Stop: st.stop,
Hooks: bus,
Reminders: st.reminders,
UnknownTool: func(call agentllm.ToolCall) agenttools.Result {
return st.runTool(call.Name, call.Arguments)
+19 -9
View File
@@ -513,17 +513,27 @@ func (s *Server) conversationDetail(w http.ResponseWriter, r *http.Request) {
func (s *Server) patchConversation(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req struct {
Title string `json:"title"`
ProfileID string `json:"profileId"`
Pinned *bool `json:"pinned"`
Archived *bool `json:"archived"`
AllowCrossRead *bool `json:"allowCrossRead"`
CrossPrivate *bool `json:"crossPrivate"`
Title string `json:"title"`
ProfileID string `json:"profileId"`
Pinned *bool `json:"pinned"`
Archived *bool `json:"archived"`
AllowCrossRead *bool `json:"allowCrossRead"`
CrossPrivate *bool `json:"crossPrivate"`
PermissionMode *string `json:"permissionMode"`
}
json.NewDecoder(r.Body).Decode(&req)
if err := s.st.UpdateConversation(s.owner(r), id, req.Title, req.ProfileID); err != nil {
writeStoreErr(w, err)
return
onlyMode := req.PermissionMode != nil && req.Title == "" && req.ProfileID == "" && req.Pinned == nil && req.Archived == nil && req.AllowCrossRead == nil && req.CrossPrivate == nil
if !onlyMode {
if err := s.st.UpdateConversation(s.owner(r), id, req.Title, req.ProfileID); err != nil {
writeStoreErr(w, err)
return
}
}
if req.PermissionMode != nil {
if err := s.st.SetPermissionMode(s.owner(r), id, *req.PermissionMode); err != nil {
writeStoreErr(w, err)
return
}
}
writeOKOr(w, s.st.SetConversationFlags(s.owner(r), id, req.Pinned, req.Archived, req.AllowCrossRead, req.CrossPrivate))
}
+11
View File
@@ -390,6 +390,17 @@ func init() {
`ALTER TABLE messages DROP COLUMN request_id`,
`ALTER TABLE runs DROP COLUMN instance_id`,
}}},
migration{version: 15, name: "conversation_permission_mode",
up: map[string][]string{"sqlite": {
`ALTER TABLE conversations ADD COLUMN permission_mode TEXT NOT NULL DEFAULT ''`,
}, "postgres": {
`ALTER TABLE conversations ADD COLUMN permission_mode TEXT NOT NULL DEFAULT ''`,
}},
down: map[string][]string{"sqlite": {
`ALTER TABLE conversations DROP COLUMN permission_mode`,
}, "postgres": {
`ALTER TABLE conversations DROP COLUMN permission_mode`,
}}},
)
}
+25 -5
View File
@@ -3,6 +3,7 @@ package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
@@ -137,7 +138,7 @@ func (s *Store) CreateConversation(owner, projectID, title, profileID string) (c
}
func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation, error) {
rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner)
rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner)
if err != nil {
return nil, err
}
@@ -156,7 +157,7 @@ func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation,
// ListTasks 跨项目任务列表(方案 A 侧栏数据源):默认仅未归档;includeArchived 含归档。
func (s *Store) ListTasks(owner string, includeArchived bool) ([]core.Conversation, error) {
q := `SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,created_at,updated_at FROM conversations WHERE owner=?`
q := `SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,created_at,updated_at FROM conversations WHERE owner=?`
if !includeArchived {
q += ` AND archived=0`
}
@@ -181,7 +182,7 @@ func (s *Store) ListTasks(owner string, includeArchived bool) ([]core.Conversati
func scanConversation(rows interface{ Scan(dest ...any) error }, c *core.Conversation) error {
var ca, ua string
var pinned, archived, allowCrossRead, crossPrivate int
if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &ca, &ua); err != nil {
if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &c.PermissionMode, &ca, &ua); err != nil {
return err
}
c.Pinned = pinned == 1
@@ -197,8 +198,8 @@ func (s *Store) GetConversation(owner, id string) (core.Conversation, error) {
var c core.Conversation
var ca, ua string
var pinned, archived, allowCrossRead, crossPrivate int
err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner).
Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &ca, &ua)
err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner).
Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &c.PermissionMode, &ca, &ua)
if errors.Is(err, sql.ErrNoRows) {
return c, ErrNotFound
}
@@ -274,6 +275,25 @@ func (s *Store) SetConversationFlags(owner, id string, pinned, archived, allowCr
return nil
}
func (s *Store) SetPermissionMode(owner, id, mode string) error {
switch mode {
case "", "default", "plan", "auto_edit":
default:
return fmt.Errorf("store: unknown permission mode %q", mode)
}
if mode == "default" {
mode = ""
}
res, err := s.db.Exec(s.q(`UPDATE conversations SET permission_mode=?, updated_at=? WHERE id=? AND owner=?`), mode, nowISO(), id, owner)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) DeleteConversation(owner, id string) error {
return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner)
}
+2 -2
View File
@@ -56,8 +56,8 @@ func TestMigrationUpgradesExistingDatabase(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(got) != 14 {
t.Fatalf("expected versions [1..14], got %v", got)
if len(got) != 15 {
t.Fatalf("expected versions [1..15], got %v", got)
}
// 老库形态(v2 前):events 全局自增、settings 单列主键。
if err := st.MigrateDown(1); err != nil {
+109
View File
@@ -0,0 +1,109 @@
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
}
+2 -2
View File
@@ -30,7 +30,7 @@ import (
// RegisterInterpreterTools 注册解释器执行原语(run_shell / run_python)。
func RegisterInterpreterTools(r *Registry) error {
if err := r.Register(core.ToolDesc{
Name: "run_shell", Source: core.SourceBuiltin,
Name: "run_shell", Source: core.SourceBuiltin, Risk: "medium",
Description: "在会话工作区目录内执行 bash 脚本:检查环境、安装依赖(pip install --user …)、处理文件等。脚本原样落盘执行;返回退出码与合并输出(stdout+stderr,超限头尾截断)。以当前用户身份运行。",
Parameters: json.RawMessage(`{"type":"object","properties":{` +
`"script":{"type":"string","description":"bash 脚本(可多行)"},` +
@@ -40,7 +40,7 @@ func RegisterInterpreterTools(r *Registry) error {
return err
}
return r.Register(core.ToolDesc{
Name: "run_python", Source: core.SourceBuiltin,
Name: "run_python", Source: core.SourceBuiltin, Risk: "medium",
Description: "执行 Python 脚本(python3,工作目录为会话工作区):生成文档/表格/演示稿/数据处理的首选路径。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。python-docx/openpyxl/python-pptx 缺失时可先 pip install --user 安装。交付物用 import housestyle(技能目录在环境变量 AGENT_SKILLS_DIR,house-style 已在 PYTHONPATH)。",
Parameters: json.RawMessage(`{"type":"object","properties":{` +
`"script":{"type":"string","description":"完整 Python 脚本(utf-8)"},` +
+3 -3
View File
@@ -24,19 +24,19 @@ const (
// RegisterFSTools 注册隐藏 fs 工具(read/write/list)。root 由宿主注入(空=未配置,执行时报错)。
func RegisterFSTools(r *Registry) error {
if err := r.Register(core.ToolDesc{
Name: "fs_read_file", Description: "读取会话工作区内的文本文件(≤1MB)", Source: core.SourceBuiltin, Hidden: true,
Name: "fs_read_file", Description: "读取会话工作区内的文本文件(≤1MB)", Source: core.SourceBuiltin, Hidden: true, ReadOnly: true,
Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"工作区内相对路径"}},"required":["path"]}`),
}, execFSRead); err != nil {
return err
}
if err := r.Register(core.ToolDesc{
Name: "fs_write_file", Description: "把文本写入会话工作区文件(≤2MB),并同步登记为可下载工件", Source: core.SourceBuiltin, Hidden: true,
Name: "fs_write_file", Description: "把文本写入会话工作区文件(≤2MB),并同步登记为可下载工件", Source: core.SourceBuiltin, Hidden: true, Edits: true, Risk: "medium",
Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}`),
}, execFSWrite); err != nil {
return err
}
return r.Register(core.ToolDesc{
Name: "fs_list_dir", Description: "列出会话工作区目录(一层,≤200 条)", Source: core.SourceBuiltin, Hidden: true,
Name: "fs_list_dir", Description: "列出会话工作区目录(一层,≤200 条)", Source: core.SourceBuiltin, Hidden: true, ReadOnly: true,
Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"工作区内相对路径,空=根"}}}`),
}, execFSList)
}
+7 -4
View File
@@ -111,7 +111,7 @@ func (r *Registry) Execute(rc ToolExecCtx, call core.ToolCallRequest) core.ToolC
// RegisterBuiltin 注册骨架期内置工具:时间、会话历史检索、知识检索、读文件、写工件。
func (r *Registry) RegisterBuiltin() error {
if err := r.Register(core.ToolDesc{
Name: "time_now", Description: "获取当前时间(RFC3339)与时区", Source: core.SourceBuiltin,
Name: "time_now", Description: "获取当前时间(RFC3339)与时区", Source: core.SourceBuiltin, ReadOnly: true,
Parameters: json.RawMessage(`{"type":"object","properties":{}}`),
}, func(rc ToolExecCtx, args json.RawMessage) (string, error) {
return time.Now().Format(time.RFC3339), nil
@@ -119,7 +119,7 @@ func (r *Registry) RegisterBuiltin() error {
return err
}
if err := r.Register(core.ToolDesc{
Name: "knowledge_search", Description: "在项目知识与插件知识库中做全文检索,返回命中片段", Source: core.SourceBuiltin,
Name: "knowledge_search", Description: "在项目知识与插件知识库中做全文检索,返回命中片段", Source: core.SourceBuiltin, ReadOnly: true,
Parameters: json.RawMessage(`{"type":"object","properties":{"query":{"type":"string","description":"检索词"}},"required":["query"]}`),
}, func(rc ToolExecCtx, args json.RawMessage) (string, error) {
var a struct {
@@ -150,7 +150,7 @@ func (r *Registry) RegisterBuiltin() error {
return err
}
if err := r.Register(core.ToolDesc{
Name: "write_artifact", Description: "把生成内容写入工件(文档/数据),返回工件 ID 与名称", Source: core.SourceBuiltin,
Name: "write_artifact", Description: "把生成内容写入工件(文档/数据),返回工件 ID 与名称", Source: core.SourceBuiltin, Edits: true, Risk: "medium",
Parameters: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"},"kind":{"type":"string","enum":["document","data"]},"content":{"type":"string"}},"required":["name","content"]}`),
}, func(rc ToolExecCtx, args json.RawMessage) (string, error) {
var a struct {
@@ -186,5 +186,8 @@ func (r *Registry) RegisterBuiltin() error {
if err := registerQualityReview(r); err != nil {
return err
}
return registerPeerTools(r)
if err := registerPeerTools(r); err != nil {
return err
}
return registerAskTools(r)
}