Wire the permission ask port to HITL and adopt the Policy object (N1 WP2/WP3) #69

Merged
weichen merged 3 commits from feature/agent-stack-n1 into main 2026-10-07 04:38:53 +00:00
7 changed files with 168 additions and 20 deletions
+53
View File
@@ -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)
}
}
+61 -19
View File
@@ -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()}
}
+1
View File
@@ -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)
+35
View File
@@ -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}
}
+16
View File
@@ -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
}
+1
View File
@@ -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 {