diff --git a/deps/agent-native b/deps/agent-native index 3a110a7..9758a8d 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit 3a110a728cf520cfbbbafc0c4c75f14bf78bec3f +Subproject commit 9758a8d8d00b8d72a82d6899649164c0575f0b67 diff --git a/internal/runmgr/perm_test.go b/internal/runmgr/perm_test.go index 57b1349..bc94d72 100644 --- a/internal/runmgr/perm_test.go +++ b/internal/runmgr/perm_test.go @@ -1,8 +1,12 @@ package runmgr import ( + "context" "testing" + agentllm "agent-native/runtime/llm" + agentpolicy "agent-native/runtime/policy" + core "agent-system-core" ) @@ -36,3 +40,52 @@ func TestDefaultModeStillRunsWrite(t *testing.T) { t.Fatalf("default write: %+v", res) } } + +// TestConvAllowStoreKeysByName:SessionAllows 端口的键控口径保持「会话 × 工具名」, +// 不区分参数——与旧 sessionAllows 布尔参数的行为一致。 +func TestConvAllowStoreKeysByName(t *testing.T) { + c, _ := newExecEnv(t) + conv := c.run.ConversationID + store := convAllowStore{mgr: c.mgr, conv: conv} + pol := agentpolicy.Policy{ + Rules: []agentpolicy.Rule{{Effect: agentpolicy.EffectAsk, Tool: "run_shell"}}, + SessionAllows: store, + } + if got := pol.Decide("run_shell", `{"command":"ls"}`, agentpolicy.Traits{}); got != agentpolicy.EffectAsk { + t.Fatalf("before remember: %q, want ask", got) + } + store.Remember("run_shell", `{"command":"ls"}`) + if got := pol.Decide("run_shell", `{"command":"rm -rf /tmp/x"}`, agentpolicy.Traits{}); got != agentpolicy.EffectAllow { + t.Fatalf("after remember, other args: %q, want allow(按名放行)", got) + } + if !c.mgr.sessionAllows(conv, "run_shell") { + t.Fatal("remember did not reach the manager table") + } +} + +// TestPermissionAskRoutesToHITL:内核 ask 决策经 HITL 确认卡裁决; +// session 选项落会话放行,取消给出可读理由。 +func TestPermissionAskRoutesToHITL(t *testing.T) { + c, _ := newExecEnv(t) + c.h = &runHandle{hitlCh: make(chan hitlAnswer, 1)} + call := agentllm.ToolCall{ID: "c1", Name: "run_shell", Arguments: `{"command":"ls"}`} + + c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: "yes"}} + if v := c.PermissionAsk(context.Background(), call); !v.Allow { + t.Fatalf("yes verdict = %+v", v) + } + + c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: "session"}} + if v := c.PermissionAsk(context.Background(), call); !v.Allow { + t.Fatalf("session verdict = %+v", v) + } + if !c.mgr.sessionAllows(c.run.ConversationID, "run_shell") { + t.Fatal("session choice did not remember the allow") + } + + c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: "no"}} + if v := c.PermissionAsk(context.Background(), call); v.Allow || v.Reason == "" { + t.Fatalf("no verdict = %+v", v) + } +} + diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 42a0dec..7d35b1a 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -15,7 +15,9 @@ import ( "time" "unicode/utf8" + nruntime "agent-native/runtime" "agent-native/runtime/filestate" + agentllm "agent-native/runtime/llm" agentpolicy "agent-native/runtime/policy" "agent-native/runtime/subagent" @@ -286,6 +288,57 @@ func (m *Manager) sessionAllows(conv, tool string) bool { return m.sessionAllow[conv][tool] } +// convAllowStore 把 Manager 的会话放行表呈现为 agentpolicy.SessionAllows 端口。 +// 键控保持既有口径:会话 × 工具名(同名调用不再询问),不区分参数。 +type convAllowStore struct { + mgr *Manager + conv string +} + +func (s convAllowStore) Remember(name, args string) { + s.mgr.rememberSessionAllow(s.conv, name) +} + +func (s convAllowStore) Allowed(name, args string) bool { + return s.mgr.sessionAllows(s.conv, name) +} + +// toolConfirmCard 是工具确认 HITL 卡的统一形态:ExecuteTool 的画像确认与 +// 内核 PermissionAsk 端口共用,两条路径的选项与口径保持一致。 +func toolConfirmCard(name, args string) core.HITLCard { + var argsPreview string + if len(args) > 0 { + argsPreview = truncate(args, 600) + } + return core.HITLCard{ + Question: fmt.Sprintf("执行工具 %s?", 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, + } +} + +// PermissionAsk 把内核钩子的 ask 决策路由到 HITL 确认卡(N1 WP2 宿主侧)。 +// 「本会话始终允许」沿用 ExecuteTool 的会话放行口径(会话 × 工具名)。 +func (c *runCtx) PermissionAsk(ctx context.Context, call agentllm.ToolCall) nruntime.AskVerdict { + resp, err := c.AskHITL(toolConfirmCard(call.Name, call.Arguments)) + if err != nil { + return nruntime.AskVerdict{Reason: "hitl aborted: " + err.Error()} + } + switch resp.ChoiceID { + case "session": + c.mgr.rememberSessionAllow(c.run.ConversationID, call.Name) + return nruntime.AskVerdict{Allow: true} + case "yes": + return nruntime.AskVerdict{Allow: true} + default: + return nruntime.AskVerdict{Reason: "用户取消了本次工具调用"} + } +} + func (c *runCtx) Tools() []core.ToolDesc { list := c.mgr.registry.List() out := make([]core.ToolDesc, 0, len(list)) @@ -678,29 +731,18 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", 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) + // N1 WP3:判定走 Policy 对象,会话放行经 SessionAllows 端口(键控口径不变:会话 × 工具名)。 + pol := agentpolicy.Policy{ + Mode: agentpolicy.Normalize(c.permissionMode), + SessionAllows: convAllowStore{mgr: c.mgr, conv: c.run.ConversationID}, + } + effect := pol.Decide(call.Name, string(call.Arguments), + agentpolicy.Traits{ReadOnly: desc.ReadOnly, Edits: desc.Edits, Risk: desc.Risk}) 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, Detail: argsPreview, - Choices: []core.HITLChoice{ - {ID: "yes", Label: "执行", Description: "按上述参数调用", Impact: "该工具将访问外部资源并返回结果", Recommended: true}, - {ID: "session", Label: "本会话始终允许", Description: "这次以及本会话后续的同名调用不再询问", Impact: "拒绝规则和计划模式仍然有效"}, - {ID: "no", Label: "取消", Description: "跳过本次调用", Impact: "模型会收到调用被拒绝的说明"}, - }, - AllowFreeInput: false, - } - resp, err := c.AskHITL(card) + resp, err := c.AskHITL(toolConfirmCard(call.Name, string(call.Arguments))) if err != nil { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()} } diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index 5532a75..c219a5a 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -186,6 +186,7 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin OnEvent: st.onEvent, Hooks: bus, Reminders: st.reminders, + PermissionAsk: permissionAskOf(rc), Trace: l.Trace, UnknownTool: func(call agentllm.ToolCall) agenttools.Result { return st.runTool(call.Name, call.Arguments) diff --git a/internal/runtime/permission_ask_test.go b/internal/runtime/permission_ask_test.go new file mode 100644 index 0000000..021f940 --- /dev/null +++ b/internal/runtime/permission_ask_test.go @@ -0,0 +1,35 @@ +package runtime + +import ( + "context" + "testing" + + nruntime "agent-native/runtime" + agentllm "agent-native/runtime/llm" +) + +// permissionAskOf 只在宿主实现内核级询问时接线;否则返回 nil, +// 内核保持「ask 按拒绝处理」的旧行为。 +func TestPermissionAskOfOptional(t *testing.T) { + type plain struct{ RunCtx } + if got := permissionAskOf(plain{}); got != nil { + t.Fatal("plain rc returned a non-nil ask port, want nil") + } +} + +func TestPermissionAskOfWired(t *testing.T) { + type asker struct{ RunCtx } + wired := permissionAskOf(askStub{}) + if wired == nil { + t.Fatal("wired rc returned nil") + } + if v := wired(context.Background(), agentllm.ToolCall{Name: "run_shell"}); !v.Allow { + t.Fatalf("verdict = %+v", v) + } +} + +type askStub struct{ RunCtx } + +func (askStub) PermissionAsk(ctx context.Context, call agentllm.ToolCall) nruntime.AskVerdict { + return nruntime.AskVerdict{Allow: true} +} diff --git a/internal/runtime/runner.go b/internal/runtime/runner.go index d3873d2..2cc3a29 100644 --- a/internal/runtime/runner.go +++ b/internal/runtime/runner.go @@ -5,6 +5,9 @@ package runtime import ( "context" + nruntime "agent-native/runtime" + agentllm "agent-native/runtime/llm" + core "agent-system-core" ) @@ -56,3 +59,16 @@ type Runner interface { Name() string Execute(rc RunCtx) error } + +// permissionAskOf 把宿主的内核级权限询问(runmgr 的 HITL 确认卡)接成 +// nruntime.Config.PermissionAsk 端口;宿主未实现时返回 nil,内核保持 +// 「ask 按拒绝处理」的旧行为(N1 WP2 装配)。 +func permissionAskOf(rc RunCtx) func(context.Context, agentllm.ToolCall) nruntime.AskVerdict { + asker, ok := rc.(interface { + PermissionAsk(ctx context.Context, call agentllm.ToolCall) nruntime.AskVerdict + }) + if !ok { + return nil + } + return asker.PermissionAsk +} diff --git a/internal/runtime/sub_loop.go b/internal/runtime/sub_loop.go index 9206315..dde1099 100644 --- a/internal/runtime/sub_loop.go +++ b/internal/runtime/sub_loop.go @@ -108,6 +108,7 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error { OutputReserve: reserve, ContextBuffer: buffer, OnEvent: st.onEvent, + PermissionAsk: permissionAskOf(rc), Trace: l.Trace, }, user) if len(st.parts) == 0 {