mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
fix(agent): apply configured tool hooks in subagents
This commit is contained in:
1 parent
7aff0b79ab
commit
896faf36dd
7 files changed
+138
-12
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"}`}}}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user