From 9758a8d8d00b8d72a82d6899649164c0575f0b67 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 7 Oct 2026 03:01:17 +0000 Subject: [PATCH] Add permission ask loop, lifecycle hook emissions, and Policy type Why: The N1 harness phase needs the kernel to resolve ask decisions through the host (paired HITL), emit the full hook lifecycle the plan promises, and carry mode/rules/session-allow in one named object. Evidence: go vet clean; go test ./... 24 packages ok, including new loop_hooks_lifecycle_test.go (lifecycle order, prompt text, ask allow/deny/legacy paths, stop continuation counts) and three new policy tests (struct-form parity, session-allow store, nil store). Impact: hooks adds PostToolBatch and SessionEnd events; loop emits SessionStart/UserPromptSubmit/PostToolBatch/SessionEnd and PreCompact lands in AgentSession.Compact; gateTool consults PermissionAsk on ask and keeps ask=reject when the port is unset; policy gains Policy, SessionAllows, and MemoryAllows. SubagentStart/Stop emissions are deferred to the adapters assembly increment (factory site has no bus access yet). Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP2/WP3 --- runtime/hooks/hooks.go | 2 + runtime/loop.go | 45 ++++- runtime/loop_hooks_lifecycle_test.go | 253 +++++++++++++++++++++++++++ runtime/policy/policy.go | 56 +++++- runtime/policy/policy_test.go | 49 ++++++ runtime/session_agent.go | 2 + 6 files changed, 399 insertions(+), 8 deletions(-) create mode 100644 runtime/loop_hooks_lifecycle_test.go diff --git a/runtime/hooks/hooks.go b/runtime/hooks/hooks.go index 7393f55..b113de1 100644 --- a/runtime/hooks/hooks.go +++ b/runtime/hooks/hooks.go @@ -13,10 +13,12 @@ const ( PermissionRequest Event = "PermissionRequest" PostToolUse Event = "PostToolUse" PostToolUseFailure Event = "PostToolUseFailure" + PostToolBatch Event = "PostToolBatch" Stop Event = "Stop" SubagentStart Event = "SubagentStart" SubagentStop Event = "SubagentStop" PreCompact Event = "PreCompact" + SessionEnd Event = "SessionEnd" ) type Decision string diff --git a/runtime/loop.go b/runtime/loop.go index e6f8882..aa43ce2 100644 --- a/runtime/loop.go +++ b/runtime/loop.go @@ -110,6 +110,17 @@ type Config struct { KeepToolResults int // Hooks 是进程内钩子。空总线不改变停止、提醒和拒绝次数。 Hooks *hooks.Bus + // PermissionAsk 在钩子对一次工具调用表态 ask 时向宿主请求裁决。 + // nil 时保持旧行为:ask 与 deny 一样按拒绝处理。 + // 返回 Allow 时工具照常执行;宿主需要「本会话始终允许」时自行记录, + // 内核不替宿主保存放行状态。 + PermissionAsk func(ctx context.Context, call llm.ToolCall) AskVerdict +} + +// AskVerdict 是宿主对一次 ask 决策的裁决。 +type AskVerdict struct { + Allow bool + Reason string // 拒绝时写进配对 tool 结果的理由;放行时忽略 } // StopInfo 是 Stop 钩子看到的本 run 进度。 @@ -192,6 +203,8 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { } messages = append(messages, user) emit(cfg, Event{Type: "agent_start"}) + cfg.Hooks.Emit(hooks.Call{Event: hooks.SessionStart}) + cfg.Hooks.Emit(hooks.Call{Event: hooks.UserPromptSubmit, LastText: user.Content}) var last llm.Message var usage llm.Usage @@ -200,6 +213,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { lengthContinues := 0 lengthNudge := false var repeats repeatState + defer func() { + cfg.Hooks.Emit(hooks.Call{Event: hooks.SessionEnd, LastText: last.Content, ToolCalls: toolCalls}) + }() for turn := 0; turn < cfg.MaxTurns; turn++ { if ctx.Err() != nil { cfg.logger().Error("runtime turn aborted", ctx.Err()) @@ -266,6 +282,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { messages = append(messages, m) } } + cfg.Hooks.Emit(hooks.Call{Event: hooks.PostToolBatch, Turn: turn, ToolCalls: len(last.ToolCalls)}) emit(cfg, Event{Type: "turn_end", Message: last}) continue } @@ -525,19 +542,26 @@ func stopDecision(cfg Config, last llm.Message, continuations, toolCalls int) (S return StopDecision{}, false } -func gateTool(cfg Config, call llm.ToolCall) (llm.ToolCall, llm.Message, bool) { +func gateTool(ctx context.Context, cfg Config, call llm.ToolCall) (llm.ToolCall, llm.Message, bool) { if cfg.Hooks == nil { return call, llm.Message{}, false } out := cfg.Hooks.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: call.Name, Args: call.Arguments}) switch out.Decision { - case hooks.Deny, hooks.Block, hooks.Ask: + case hooks.Ask: + // ask 先问宿主;没有裁决端口(或宿主拒绝)时保持旧行为:按拒绝处理。 + cfg.Hooks.Emit(hooks.Call{Event: hooks.PermissionRequest, Tool: call.Name, Args: call.Arguments}) why := out.Reason - if why == "" { - why = "工具调用被拒绝" + if cfg.PermissionAsk != nil { + if verdict := cfg.PermissionAsk(ctx, call); verdict.Allow { + return call, llm.Message{}, false + } else if verdict.Reason != "" { + why = verdict.Reason + } } - msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true} - return call, msg, true + return call, rejectToolMessage(call, why), true + case hooks.Deny, hooks.Block: + return call, rejectToolMessage(call, out.Reason), true case hooks.Modify: if out.Args != "" { call.Arguments = out.Args @@ -546,6 +570,13 @@ func gateTool(cfg Config, call llm.ToolCall) (llm.ToolCall, llm.Message, bool) { return call, llm.Message{}, false } +func rejectToolMessage(call llm.ToolCall, why string) llm.Message { + if why == "" { + why = "工具调用被拒绝" + } + return llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true} +} + func noteToolHook(cfg Config, call llm.ToolCall, msg llm.Message) { if cfg.Hooks == nil { return @@ -571,7 +602,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message { return msg } emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, Detail: call.Arguments}) - if gated, msg, blocked := gateTool(cfg, call); blocked { + if gated, msg, blocked := gateTool(ctx, cfg, call); blocked { emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content}) noteToolHook(cfg, call, msg) if cfg.AfterTool != nil { diff --git a/runtime/loop_hooks_lifecycle_test.go b/runtime/loop_hooks_lifecycle_test.go new file mode 100644 index 0000000..03fce49 --- /dev/null +++ b/runtime/loop_hooks_lifecycle_test.go @@ -0,0 +1,253 @@ +package runtime + +import ( + "context" + "strings" + "testing" + + "agent-native/runtime/hooks" + "agent-native/runtime/llm" + "agent-native/runtime/tools" +) + +type eventRecorder struct { + seen []hooks.Event +} + +func (r *eventRecorder) handler(c hooks.Call) hooks.Outcome { + r.seen = append(r.seen, c.Event) + return hooks.Outcome{} +} + +func (r *eventRecorder) has(ev hooks.Event) bool { + for _, e := range r.seen { + if e == ev { + return true + } + } + return false +} + +// TestHookLifecycleOrder 断言一次带工具调用的 run 的事件顺序为 +// SessionStart → UserPromptSubmit → PreToolUse → PostToolUse → +// PostToolBatch → Stop → SessionEnd。 +func TestHookLifecycleOrder(t *testing.T) { + rec := &eventRecorder{} + bus := hooks.New() + bus.On(rec.handler) + hits := 0 + reg := tools.NewRegistry(t.TempDir()) + reg.Register(hitTool{hits: &hits}) + _, err := Run(context.Background(), Config{ + Model: "fake", + Tools: reg, + Hooks: bus, + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c1", Name: "echo", Arguments: `{}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + ), + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + want := []hooks.Event{hooks.SessionStart, hooks.UserPromptSubmit, hooks.PreToolUse, + hooks.PostToolUse, hooks.PostToolBatch, hooks.Stop, hooks.SessionEnd} + if len(rec.seen) != len(want) { + t.Fatalf("events = %v, want %v", rec.seen, want) + } + for i := range want { + if rec.seen[i] != want[i] { + t.Fatalf("events = %v, want %v", rec.seen, want) + } + } +} + +// TestHookUserPromptSubmitCarriesText 断言 UserPromptSubmit 事件带用户输入正文。 +func TestHookUserPromptSubmitCarriesText(t *testing.T) { + var got string + bus := hooks.New() + bus.On(func(c hooks.Call) hooks.Outcome { + if c.Event == hooks.UserPromptSubmit { + got = c.LastText + } + return hooks.Outcome{} + }) + _, err := Run(context.Background(), Config{ + Model: "fake", + Hooks: bus, + Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}), + }, llm.Message{Role: llm.RoleUser, Content: "ping"}) + if err != nil { + t.Fatal(err) + } + if got != "ping" { + t.Fatalf("UserPromptSubmit text = %q", got) + } +} + +// TestAskPortAllowProceeds:ask 决策经宿主裁决放行后工具照常执行。 +func TestAskPortAllowProceeds(t *testing.T) { + hits := 0 + reg := tools.NewRegistry(t.TempDir()) + reg.Register(hitTool{hits: &hits}) + bus := hooks.New() + bus.On(func(c hooks.Call) hooks.Outcome { + if c.Event == hooks.PreToolUse { + return hooks.Outcome{Decision: hooks.Ask, Reason: "需要确认"} + } + return hooks.Outcome{} + }) + sawRequest := false + bus.On(func(c hooks.Call) hooks.Outcome { + if c.Event == hooks.PermissionRequest { + sawRequest = true + } + return hooks.Outcome{} + }) + res, err := Run(context.Background(), Config{ + Model: "fake", + Tools: reg, + Hooks: bus, + PermissionAsk: func(ctx context.Context, call llm.ToolCall) AskVerdict { + return AskVerdict{Allow: true} + }, + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c1", Name: "echo", Arguments: `{}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + ), + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + if hits != 1 { + t.Fatalf("tool executed %d times, want 1", hits) + } + if !sawRequest { + t.Fatal("PermissionRequest event missing") + } + if !strings.Contains(transcriptText(res.Messages), "ran") { + t.Fatalf("tool result missing: %s", transcriptText(res.Messages)) + } +} + +// TestAskPortDenyPairsResult:宿主拒绝时写配对错误结果,理由取宿主理由。 +func TestAskPortDenyPairsResult(t *testing.T) { + hits := 0 + reg := tools.NewRegistry(t.TempDir()) + reg.Register(hitTool{hits: &hits}) + bus := hooks.New() + bus.On(func(c hooks.Call) hooks.Outcome { + if c.Event == hooks.PreToolUse { + return hooks.Outcome{Decision: hooks.Ask, Reason: "需要确认"} + } + return hooks.Outcome{} + }) + res, err := Run(context.Background(), Config{ + Model: "fake", + Tools: reg, + Hooks: bus, + PermissionAsk: func(ctx context.Context, call llm.ToolCall) AskVerdict { + return AskVerdict{Allow: false, Reason: "用户拒绝"} + }, + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c1", Name: "echo", Arguments: `{}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + ), + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + if hits != 0 { + t.Fatalf("tool executed %d times, want 0", hits) + } + paired := false + for _, m := range res.Messages { + if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.IsError && m.Content == "用户拒绝" { + paired = true + } + } + if !paired { + t.Fatalf("missing paired deny result: %s", transcriptText(res.Messages)) + } +} + +// TestAskWithoutPortKeepsLegacyReject:没有裁决端口时 ask 仍按拒绝处理(旧行为)。 +func TestAskWithoutPortKeepsLegacyReject(t *testing.T) { + hits := 0 + reg := tools.NewRegistry(t.TempDir()) + reg.Register(hitTool{hits: &hits}) + bus := hooks.New() + bus.On(func(c hooks.Call) hooks.Outcome { + if c.Event == hooks.PreToolUse { + return hooks.Outcome{Decision: hooks.Ask, Reason: "需要确认"} + } + return hooks.Outcome{} + }) + res, err := Run(context.Background(), Config{ + Model: "fake", + Tools: reg, + Hooks: bus, + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c1", Name: "echo", Arguments: `{}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + ), + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + if hits != 0 { + t.Fatalf("tool executed %d times, want 0", hits) + } + paired := false + for _, m := range res.Messages { + if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.IsError && m.Content == "需要确认" { + paired = true + } + } + if !paired { + t.Fatalf("missing paired ask-reject result: %s", transcriptText(res.Messages)) + } +} + +// TestStopContinuationFlagVisible:续跑后 Stop 事件的 Continuations 计数对下游可见。 +func TestStopContinuationFlagVisible(t *testing.T) { + var counts []int + bus := hooks.New() + bus.On(func(c hooks.Call) hooks.Outcome { + if c.Event == hooks.Stop { + counts = append(counts, c.Continuations) + return hooks.Outcome{Decision: hooks.Block, Reason: "再试"} + } + return hooks.Outcome{} + }) + _, err := Run(context.Background(), Config{ + Model: "fake", + MaxTurns: 8, + Hooks: bus, + Stream: script( + llm.Message{Role: llm.RoleAssistant, Content: "a", StopReason: llm.StopEnd}, + llm.Message{Role: llm.RoleAssistant, Content: "a", StopReason: llm.StopEnd}, + llm.Message{Role: llm.RoleAssistant, Content: "a", StopReason: llm.StopEnd}, + llm.Message{Role: llm.RoleAssistant, Content: "a", StopReason: llm.StopEnd}, + ), + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + if len(counts) != 4 { + t.Fatalf("stop events = %v", counts) + } + for i, c := range counts { + if c != i { + t.Fatalf("stop continuations = %v, want 0,1,2,3", counts) + } + } +} diff --git a/runtime/policy/policy.go b/runtime/policy/policy.go index fb11b98..de61e80 100644 --- a/runtime/policy/policy.go +++ b/runtime/policy/policy.go @@ -2,7 +2,10 @@ // 默认模式不额外表态,调用方继续用原有画像策略。 package policy -import "strings" +import ( + "strings" + "sync" +) type Mode string @@ -92,6 +95,57 @@ func Decide(req Request, rules []Rule) Effect { return EffectNone } +// SessionAllows 记录并查询「本会话始终允许」的决定。键的口径由实现方定义; +// 宿主可以用自己的模式匹配(如参数通配)实现同一接口。 +type SessionAllows interface { + Remember(name, args string) + Allowed(name, args string) bool +} + +// MemoryAllows 是进程内的会话放行存储,按「工具名 + 原样参数串」精确匹配。 +type MemoryAllows struct { + mu sync.Mutex + allowed map[string]bool +} + +func NewMemoryAllows() *MemoryAllows { + return &MemoryAllows{allowed: make(map[string]bool)} +} + +func (m *MemoryAllows) Remember(name, args string) { + m.mu.Lock() + m.allowed[name+"\n"+args] = true + m.mu.Unlock() +} + +func (m *MemoryAllows) Allowed(name, args string) bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.allowed[name+"\n"+args] +} + +// Policy 是一次会话的权限配置:模式、规则与会话放行存在同一个对象上, +// Decide/Visible 语义与同名函数完全一致。 +type Policy struct { + Mode Mode + Rules []Rule + SessionAllows SessionAllows // 可为 nil:等价于 SessionAllow 恒为 false +} + +// Decide 按 Policy 的模式、规则与会话放行判定一次工具调用。 +func (p Policy) Decide(name, args string, traits Traits) Effect { + var allowed bool + if p.SessionAllows != nil { + allowed = p.SessionAllows.Allowed(name, args) + } + return Decide(Request{Mode: p.Mode, Name: name, Args: args, Traits: traits, SessionAllow: allowed}, p.Rules) +} + +// Visible 报告该模式下这个工具能否出现在清单里。 +func (p Policy) Visible(name string) bool { + return Visible(string(p.Mode), name, mergeTraits(name, Traits{}).ReadOnly) +} + // Visible 报告计划模式下这个工具能否出现在清单里。其他模式一律可见。 func Visible(mode string, name string, readOnly bool) bool { if Normalize(mode) != ModePlan { diff --git a/runtime/policy/policy_test.go b/runtime/policy/policy_test.go index 1395207..b17fc9a 100644 --- a/runtime/policy/policy_test.go +++ b/runtime/policy/policy_test.go @@ -39,3 +39,52 @@ func TestAutoEditAllowsWorkspaceWrites(t *testing.T) { t.Fatalf("auto edit %q", got) } } + +// TestPolicyDecideMatchesFunctionForm:Policy 结构体形态与函数形态判定一致。 +func TestPolicyDecideMatchesFunctionForm(t *testing.T) { + rules := []Rule{{Effect: EffectAllow, Tool: "write_file"}, {Effect: EffectDeny, Tool: "write_*", ArgGlob: "*secret*"}} + cases := []struct { + p Policy + name string + args string + want Effect + }{ + {Policy{Mode: ModePlan}, "write_file", `{"path":"a.md"}`, EffectDeny}, + {Policy{Mode: ModeDefault, Rules: rules}, "write_file", `{"path":"a.md"}`, EffectAllow}, + {Policy{Mode: ModeDefault, Rules: rules}, "write_file", `{"path":"secret.txt"}`, EffectDeny}, + {Policy{Mode: ModeDefault}, "run_shell", `{"command":"rm -rf /"}`, EffectAsk}, + } + for i, c := range cases { + if got := c.p.Decide(c.name, c.args, Traits{}); got != c.want { + t.Fatalf("case %d: effect %q, want %q", i, got, c.want) + } + } +} + +// TestPolicySessionAllowsStore:会话放行经存储短路 ask,且跨调用存活。 +func TestPolicySessionAllowsStore(t *testing.T) { + store := NewMemoryAllows() + p := Policy{Mode: ModeDefault, Rules: []Rule{{Effect: EffectAsk, Tool: "run_shell"}}, SessionAllows: store} + if got := p.Decide("run_shell", `{"command":"ls"}`, Traits{}); got != EffectAsk { + t.Fatalf("before remember: %q, want ask", got) + } + store.Remember("run_shell", `{"command":"ls"}`) + if got := p.Decide("run_shell", `{"command":"ls"}`, Traits{}); got != EffectAllow { + t.Fatalf("after remember: %q, want allow", got) + } + if got := p.Decide("run_shell", `{"command":"rm -rf /"}`, Traits{}); got != EffectAsk { + t.Fatalf("other args: %q, want ask(精确匹配不放行)", got) + } +} + +// TestPolicyNilStoreAndVisible:nil 存储等价于 SessionAllow 恒 false;Visible 方法可用。 +func TestPolicyNilStoreAndVisible(t *testing.T) { + p := Policy{Mode: ModePlan} + if got := p.Decide("write_file", `{}`, Traits{}); got != EffectDeny { + t.Fatalf("plan deny: %q", got) + } + if !p.Visible("update_plan") || p.Visible("write_file") { + t.Fatal("policy visible mismatch") + } +} + diff --git a/runtime/session_agent.go b/runtime/session_agent.go index 44c0a22..94f3f44 100644 --- a/runtime/session_agent.go +++ b/runtime/session_agent.go @@ -4,6 +4,7 @@ import ( "context" "sync" + "agent-native/runtime/hooks" "agent-native/runtime/llm" "agent-native/runtime/session" ) @@ -167,6 +168,7 @@ func (a *AgentSession) Compact(keep int, summarize func([]llm.Message) (string, if a.cfg.Session == nil { return false, nil } + a.cfg.Hooks.Emit(hooks.Call{Event: hooks.PreCompact}) emit(a.cfg, Event{Type: "compaction_start"}) done, err := a.cfg.Session.Compact(keep, summarize) text := "noop"