From 0fba869ab71eef5bb78df89d45d3ec5b74421806 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Sun, 26 Apr 2026 07:19:35 +0200 Subject: [PATCH] agent: add preTool and postTool hooks preTool fires before each tool execution; exit 2 blocks the call and returns the hook's stderr as the tool error result. postTool fires after; exit 0 appends context to the result, exit 2 replaces it entirely. Co-Authored-By: Claude Sonnet 4.6 --- pkg/agent/core.go | 17 +++++++++++++++++ pkg/agent/hooks.go | 2 ++ pkg/agent/loop.go | 32 ++++++++++++++++++++++++++++++-- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/pkg/agent/core.go b/pkg/agent/core.go index 66bc8e5..a818478 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -776,6 +776,23 @@ func (s *agent) executeTurn(ctx context.Context, input string, handler EventHand } return "" } + s.cfg.PreTool = func(ctx context.Context, name string, args json.RawMessage) HookResult { + return s.hooks.Run(ctx, HookPreTool, map[string]string{ + "session_id": s.sessionID, + "cwd": s.CWD(), + "tool": name, + "args": string(args), + }, s.log) + } + s.cfg.PostTool = func(ctx context.Context, name string, args json.RawMessage, result string) HookResult { + return s.hooks.Run(ctx, HookPostTool, map[string]string{ + "session_id": s.sessionID, + "cwd": s.CWD(), + "tool": name, + "args": string(args), + "result": result, + }, s.log) + } s.cfg.AutoCompact = func(ctx context.Context) { if ctx.Err() != nil || s.session == nil { return diff --git a/pkg/agent/hooks.go b/pkg/agent/hooks.go index 67e4e87..4f7d233 100644 --- a/pkg/agent/hooks.go +++ b/pkg/agent/hooks.go @@ -19,6 +19,8 @@ const ( HookAgentSpawn = "agentSpawn" HookPreTurn = "preTurn" HookPostTurn = "postTurn" + HookPreTool = "preTool" + HookPostTool = "postTool" HookPreCompact = "preCompact" HookPostCompact = "postCompact" ) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e39529d..2a903f3 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -22,8 +22,10 @@ type agentConfig struct { Output EventHandler preamble string // compiled system+agent prompt sent as the system role GenerationParams backend.GenerationParams - PopInject func() string // returns and clears pending inject, or "" - AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place + PopInject func() string // returns and clears pending inject, or "" + AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place + PreTool func(ctx context.Context, name string, args json.RawMessage) HookResult // called before each tool; exit 2 blocks execution + PostTool func(ctx context.Context, name string, args json.RawMessage, result string) HookResult // called after each tool; exit 0 appends, exit 2 replaces result } func run(ctx context.Context, cfg agentConfig, state state) error { @@ -154,6 +156,24 @@ func run(ctx context.Context, cfg agentConfig, state state) error { emit(cfg, Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)}) + if cfg.PreTool != nil { + hr := cfg.PreTool(ctx, tc.Name, tc.Arguments) + if hr.Blocked { + msg := hr.Context + if msg == "" { + msg = fmt.Sprintf("tool %q blocked by hook", tc.Name) + } + results = append(results, toolResult{ + ToolCallID: tc.ID, + Name: tc.Name, + Content: msg, + IsError: true, + }) + emit(cfg, Event{Role: "tool", Name: tc.Name, Content: msg}) + continue + } + } + var result string var isErr bool if cfg.Exec != nil { @@ -194,6 +214,14 @@ func run(ctx context.Context, cfg agentConfig, state state) error { } if !interrupted { + if cfg.PostTool != nil { + hr := cfg.PostTool(ctx, tc.Name, tc.Arguments, result) + if hr.Blocked { + result = hr.Context + } else if hr.Context != "" { + result += "\n" + hr.Context + } + } if cfg.PopInject != nil { if injected := cfg.PopInject(); injected != "" { result += "\n\n\n" + injected + "\n"