fix(agent): apply configured tool hooks in subagents

This commit is contained in:
xiexian committed 2026-09-26 14:51:30 +08:00
1 parent 7aff0b79ab
commit 896faf36dd
7 files changed
+138 -12

No files matched your search

+1
View File
@@ -290,6 +290,7 @@ type TaskTool struct {
mutationObserver *checkpoint.MutationObserver
writeRoots *sandbox.WritableRootSet
imageResolver ImageRequestResolver
hooksForSession func(string) ToolHooks
// capabilityRuntime is the session-shared MCP Host/specs substrate. Each
// sub-agent gets its own use_capability frontend so ledger state stays
// isolated while connections reuse the parent Host.
+5
View File
@@ -40,6 +40,7 @@ func NewTaskToolWithOptions(opts TaskToolOptions) *TaskTool {
resolveProvider: opts.ResolveProvider,
maxSubagentDepth: DefaultMaxSubagentDepth,
imageResolver: opts.ImageRequestResolver,
hooksForSession: opts.HooksForSession,
}
}
@@ -74,6 +75,9 @@ func (t *TaskTool) subagentOptions(ctx context.Context, maxSteps int, pricing *p
WriteWorkspaceRoot: t.workspaceRoot,
ImageRequestResolver: t.imageResolver,
}
if t.hooksForSession != nil {
opts.Hooks = t.hooksForSession(recoveryTaskID)
}
// Writer children inherit the parent turn's frozen risk and closure floors.
// The parent publishes its policy into the run context; a child that never
// received it (direct unit construction) keeps its own derived policy.
@@ -100,6 +104,7 @@ func (t *TaskTool) WithImageRequestResolver(resolver ImageRequestResolver) *Task
type TaskToolOptions struct {
ImageInput *imageinput.Config
ImageRequestResolver ImageRequestResolver
HooksForSession func(string) ToolHooks
Provider provider.Provider
Pricing *provider.Pricing
QuoteContext *event.QuoteContext
+3 -9
View File
@@ -1013,14 +1013,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
} else {
resolvedHooks = hook.Load(hook.LoadOptions{ProjectRoot: root})
}
hookRuntime := hook.RuntimeOptions{}
if shell.Kind == sandbox.ShellBash {
hookRuntime.BashPath = shell.Path
}
hookRunner := hook.NewRunner(
resolvedHooks, root, hook.NewDefaultSpawner(hookRuntime),
func(msg string) { sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: msg}) },
)
hookRunner := newBootHookRunner(resolvedHooks, root, shell, sink)
// The `task` tool spawns sub-agents that reuse the parent's provider and
// tool registry. Wired here after the built-ins / plugins are loaded so
// sub-agents inherit the full tool set (minus `task` itself, to keep
@@ -1145,6 +1138,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
Provider: execProv,
Pricing: entry.Price,
QuoteContext: quoteCtx,
HooksForSession: func(id string) agent.ToolHooks { return hookRunner.ForSession(id) },
ParentRegistry: reg,
MaxSteps: maxSteps,
ContextWindow: entry.ContextWindow,
@@ -1276,7 +1270,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
// read_only_task, so they cannot write, install, mutate memory, resume/fork
// transcripts, or delegate further.
//
subagentSkillOptions := newSubagentSkillOptionsFactory(cfg.Agent, quoteCtx, headlessGate, keepPolicy, maxSubagentDepth, opts.Ablation, workspaceLease, writeRootSet, childImageRouting{ctrlRef.Load, imageConfig})
subagentSkillOptions := newSubagentSkillOptionsFactory(cfg.Agent, quoteCtx, headlessGate, keepPolicy, maxSubagentDepth, opts.Ablation, workspaceLease, writeRootSet, hookRunner, childImageRouting{ctrlRef.Load, imageConfig})
readOnlySkillRunner := func(sctx context.Context, sk skill.Skill, task string, runOpts skill.SubagentRunOptions) (string, error) {
if strings.TrimSpace(runOpts.ContinueFrom) != "" || strings.TrimSpace(runOpts.ForkFrom) != "" {
return "", fmt.Errorf("read_only_skill does not support continue_from/fork_from")
+104 -2
View File
@@ -1009,6 +1009,76 @@ model = "x"
}
}
func TestBuildRunsPreToolUseInsideTaskSubagent(t *testing.T) {
for _, delegationTool := range []string{"task", "read_only_task", "run_skill", "read_only_skill"} {
t.Run(delegationTool, func(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerHeadlessTaskTestProvider()
prov := &headlessTaskTestProvider{hookProbe: true, delegationTool: delegationTool}
setHeadlessTaskTestProvider(t, prov)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[[providers]]
name = "test-model"
kind = "boot-headless-test"
model = "x"
`)
writeFile(t, dir, "marker.txt", "dummy hook probe")
if delegationTool == "run_skill" || delegationTool == "read_only_skill" {
writeFile(t, dir, ".reasonix/skills/hook-probe.md", "---\ndescription: inspect a marker\nrunAs: subagent\nallowed-tools: read_file\n---\nRead the requested file.")
}
logPath := filepath.Join(dir, "hook.log")
script := filepath.Join(dir, "deny-read.sh")
writeFile(t, dir, "deny-read.sh", "#!/bin/sh\ncat >> "+shellQuoteForTest(logPath)+"\nexit 2\n")
if err := os.Chmod(script, 0o755); err != nil {
t.Fatal(err)
}
settings, err := json.Marshal(map[string]any{"hooks": map[string]any{"PreToolUse": []any{map[string]string{"match": "read_file", "command": script}}}})
if err != nil {
t.Fatal(err)
}
writeFile(t, dir, ".reasonix/settings.json", string(settings))
ctrl, err := Build(context.Background(), withTestSession(t, Options{Sink: event.Discard}))
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if err := ctrl.Run(context.Background(), "read marker.txt, then delegate reading it to a task subagent"); err != nil {
t.Fatalf("Run: %v", err)
}
log, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read hook log: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(log)), "\n")
if len(lines) != 2 {
t.Fatalf("PreToolUse calls = %d, want parent and subagent read_file calls; log=%q", len(lines), log)
}
var sessions [2]string
for i, line := range lines {
var payload struct{ ToolName, SessionID string }
if err := json.Unmarshal([]byte(line), &payload); err != nil {
t.Fatalf("decode hook payload: %v", err)
}
if payload.ToolName != "read_file" || payload.SessionID == "" {
t.Fatalf("hook payload = %+v, want read_file and session ID", payload)
}
sessions[i] = payload.SessionID
}
if sessions[0] == sessions[1] {
t.Fatalf("parent and child hook payloads share session ID %q", sessions[0])
}
if !prov.childReadBlocked {
t.Fatal("subagent's read_file tool result was not blocked by PreToolUse")
}
})
}
}
const headlessTaskTestProviderKind = "boot-headless-test"
var (
@@ -1045,8 +1115,11 @@ func setHeadlessTaskTestProvider(t *testing.T, p *headlessTaskTestProvider) {
}
type headlessTaskTestProvider struct {
mu sync.Mutex
calls int
mu sync.Mutex
calls int
hookProbe bool
delegationTool string
childReadBlocked bool
}
func (p *headlessTaskTestProvider) Name() string { return "boot-headless-test" }
@@ -1058,6 +1131,35 @@ func (p *headlessTaskTestProvider) Stream(_ context.Context, req provider.Reques
p.mu.Unlock()
var chunks []provider.Chunk
if p.hookProbe {
switch call {
case 0:
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "parent-read", Name: "read_file", Arguments: `{"path":"marker.txt"}`}}}
case 1:
args := `{"prompt":"read marker.txt"}`
if p.delegationTool == "run_skill" || p.delegationTool == "read_only_skill" {
args = `{"name":"hook-probe","arguments":"read marker.txt"}`
}
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "task-1", Name: p.delegationTool, Arguments: args}}}
case 2:
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "child-read", Name: "read_file", Arguments: `{"path":"marker.txt"}`}}}
case 3:
for _, msg := range req.Messages {
if msg.Role == provider.RoleTool && msg.Name == "read_file" && strings.Contains(msg.Content, "blocked:") {
p.childReadBlocked = true
}
}
chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "child done"}, {Type: provider.ChunkDone}}
default:
chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "parent done"}, {Type: provider.ChunkDone}}
}
ch := make(chan provider.Chunk, len(chunks))
for _, chunk := range chunks {
ch <- chunk
}
close(ch)
return ch, nil
}
switch call {
case 0:
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "task-1", Name: "task", Arguments: `{"prompt":"find callers"}`}}}
+1 -1
View File
@@ -164,7 +164,7 @@ func TestSubagentSkillOptionsPreserveScheduledQuoteContext(t *testing.T) {
}
},
}
factory := newSubagentSkillOptionsFactory(config.AgentConfig{}, quoteCtx, nil, 0, 1, ablation.Set{}, nil, nil)
factory := newSubagentSkillOptionsFactory(config.AgentConfig{}, quoteCtx, nil, 0, 1, ablation.Set{}, nil, nil, nil)
opts := factory(context.Background(), 5, &provider.Pricing{CacheHit: 0.30, Input: 9, Output: 27, Currency: "CNY"}, 1_000_000, 1)
if opts.QuoteContext != quoteCtx {
t.Fatal("skill subagent did not retain the host QuoteContext")
+13
View File
@@ -9,6 +9,7 @@ import (
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/hook"
"reasonix/internal/provider"
"reasonix/internal/sandbox"
"reasonix/internal/skill"
@@ -25,6 +26,7 @@ func newSubagentSkillOptionsFactory(
ablationSet ablation.Set,
lease *workspacelease.Owner,
writeRoots *sandbox.WritableRootSet,
hookRunner *hook.Runner,
imageRoutes ...childImageRouting,
) func(context.Context, int, *provider.Pricing, int, int) agent.Options {
home, stateRoot := userHomeDir(), config.MemoryUserDir()
@@ -44,10 +46,21 @@ func newSubagentSkillOptionsFactory(
opts.ImageRequestResolver = imageRoutes[0].controller()
opts.ImageInput = imageRoutes[0].config
}
callID, _, _, _ := agent.CallContext(ctx)
opts.Hooks = hookRunner.ForSession("subagent:" + callID)
return opts
}
}
func newBootHookRunner(resolved []hook.ResolvedHook, root string, shell sandbox.Shell, sink event.Sink) *hook.Runner {
runtime := hook.RuntimeOptions{}
if shell.Kind == sandbox.ShellBash {
runtime.BashPath = shell.Path
}
return hook.NewRunner(resolved, root, hook.NewDefaultSpawner(runtime),
func(msg string) { sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: msg}) })
}
func reviewSubagentSkillOptions(
ctx context.Context,
profile, task string,
+11
View File
@@ -47,6 +47,17 @@ func NewRunner(hooks []ResolvedHook, cwd string, spawner Spawner, notify func(st
return &Runner{hooks: hooks, cwd: cwd, spawner: spawner, notify: notify}
}
// ForSession gives a child session the same resolved rules and execution
// environment without sharing mutable session identity with its parent.
func (r *Runner) ForSession(id string) *Runner {
if r == nil {
return nil
}
child := NewRunner(r.hooks, r.cwd, r.spawner, r.notify)
child.SetSessionID(id)
return child
}
// Hooks returns the resolved hooks (for `/hooks` listing).
func (r *Runner) Hooks() []ResolvedHook {
if r == nil {