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 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-26 07:19:35 +02:00
parent aa890502cd
commit 0fba869ab7
3 changed files with 49 additions and 2 deletions

View File

@ -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

View File

@ -19,6 +19,8 @@ const (
HookAgentSpawn = "agentSpawn"
HookPreTurn = "preTurn"
HookPostTurn = "postTurn"
HookPreTool = "preTool"
HookPostTool = "postTool"
HookPreCompact = "preCompact"
HookPostCompact = "postCompact"
)

View File

@ -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<system-user-interruption>\n" + injected + "\n</system-user-interruption>"