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:
Vendored
+1
-1
Submodule deps/agent-native updated: 6fd1d441a9...99d623a8bb
Vendored
+1
-1
Submodule deps/agent-squad updated: 4d534508a9...21a372eb84
Vendored
+1
-1
Submodule deps/agent-system-core updated: 7c66075ee9...5cbdaad2a2
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
}}},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)"},` +
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user