Wire the permission ask port to HITL and adopt the Policy object (N1 WP2/WP3) #69
Vendored
+1
-1
Submodule deps/agent-native updated: 3a110a728c...c2befaf17e
@@ -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
@@ -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()}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user