agent: emit hook timeout/start-failure warnings as info events

- Add Warning field to HookResult
- runHookCmd sets Warning on timeout and start failure
- Hooks.Run collects warnings across commands
- All hook call sites emit warnings via infoEvent
- Test verifies timeout warning is surfaced
This commit is contained in:
Levi Neely 2026-04-21 21:20:55 +02:00
parent 53442e193b
commit dee0fda330
3 changed files with 44 additions and 6 deletions

View File

@ -500,6 +500,9 @@ func (s *agentCore) fireAgentSpawn(ctx context.Context, handler EventHandler) {
if result.Ran {
handler(infoEvent(hooksRan(1)))
}
if result.Warning != "" {
handler(infoEvent(result.Warning))
}
if result.Context != "" {
s.loopcfg.systemPrompt += "\n\n---\n\n" + result.Context
}
@ -689,6 +692,9 @@ func (s *agentCore) executeTurn(ctx context.Context, input string, handler Event
handler(infoEvent("hook blocked prompt"))
return ""
}
if hookResult.Warning != "" {
handler(infoEvent(hookResult.Warning))
}
if hookResult.Context != "" {
input += "\n" + hookResult.Context
}
@ -744,6 +750,9 @@ func (s *agentCore) executeTurn(ctx context.Context, input string, handler Event
if pre.Ran {
handler(infoEvent(hooksRan(1)))
}
if pre.Warning != "" {
handler(infoEvent(pre.Warning))
}
if !pre.Blocked {
if pre.Context != "" {
s.session.appendUserMessage(pre.Context)
@ -758,6 +767,9 @@ func (s *agentCore) executeTurn(ctx context.Context, input string, handler Event
if post.Ran {
handler(infoEvent(hooksRan(1)))
}
if post.Warning != "" {
handler(infoEvent(post.Warning))
}
if post.Context != "" {
s.session.appendUserMessage(post.Context)
}
@ -783,6 +795,9 @@ func (s *agentCore) executeTurn(ctx context.Context, input string, handler Event
"session_id": s.sessionID,
"cwd": s.CWD(),
}, s.log)
if stopResult.Warning != "" {
handler(infoEvent(stopResult.Warning))
}
s.saveSession()
@ -1007,6 +1022,9 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve
if pre.Ran {
handler(infoEvent(hooksRan(1)))
}
if pre.Warning != "" {
handler(infoEvent(pre.Warning))
}
if pre.Blocked {
handler(infoEvent("compact cancelled by hook"))
return
@ -1022,6 +1040,9 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve
if post.Ran {
handler(infoEvent(hooksRan(1)))
}
if post.Warning != "" {
handler(infoEvent(post.Warning))
}
if post.Context != "" {
s.session.appendUserMessage(post.Context)
}

View File

@ -1893,8 +1893,7 @@ func TestHookTimeout_Branch(t *testing.T) {
t.Cleanup(func() { hookTimeout = old })
// A hook that sleeps longer than the timeout. The timeout branch kills the
// process and returns HookResult{} (Ran=false), so the turn is NOT blocked
// and the backend runs normally.
// process and returns a warning, but does not block the turn.
hooks := Hooks{HookPreTurn: []string{"sleep 10"}}
c := newCore(t, nil, hooks)
evs := collectEvents(context.Background(), c, "hello")
@ -1906,6 +1905,17 @@ func TestHookTimeout_Branch(t *testing.T) {
if c.State() != "idle" {
t.Errorf("State() = %q after timeout hook; want idle", c.State())
}
// A timeout warning should have been emitted.
infos := byRole(evs, "info")
found := false
for _, s := range infos {
if strings.Contains(s, "timed out") {
found = true
}
}
if !found {
t.Errorf("expected 'timed out' warning in info events; got %v", infos)
}
}
// --- /backend with injected newBackend ---

View File

@ -39,6 +39,9 @@ type HookResult struct {
Blocked bool
// Context is stdout from the hook, injected into the conversation.
Context string
// Warning, if non-empty, is a message about hook execution problems
// (e.g. timeout, start failure) that should be surfaced to the user.
Warning string
}
// Run executes all commands for the named hook in order, sending payload as
@ -61,20 +64,24 @@ func (h Hooks) Run(ctx context.Context, name string, payload any, log *olog.Logg
}
var contextParts []string
var warnings []string
for _, cmdStr := range cmds {
log.Debug("hook %s: cmd=%q", name, cmdStr)
result := runHookCmd(ctx, name, cmdStr, payloadJSON, cwd, log)
if result.Warning != "" {
warnings = append(warnings, result.Warning)
}
if !result.Ran {
continue
}
if result.Blocked {
return HookResult{Ran: true, Blocked: true, Context: result.Context}
return HookResult{Ran: true, Blocked: true, Context: result.Context, Warning: strings.Join(warnings, "; ")}
}
if result.Context != "" {
contextParts = append(contextParts, result.Context)
}
}
return HookResult{Ran: true, Context: strings.Join(contextParts, "\n")}
return HookResult{Ran: true, Context: strings.Join(contextParts, "\n"), Warning: strings.Join(warnings, "; ")}
}
func runHookCmd(ctx context.Context, name, cmdStr string, payloadJSON []byte, cwd string, log *olog.Logger) HookResult {
@ -91,7 +98,7 @@ func runHookCmd(ctx context.Context, name, cmdStr string, payloadJSON []byte, cw
done := make(chan error, 1)
if err := cmd.Start(); err != nil {
log.Debug("hook %s: start error: %v", name, err)
return HookResult{}
return HookResult{Ran: true, Warning: fmt.Sprintf("hook %s: failed to start: %v", name, err)}
}
go func() { done <- cmd.Wait() }()
@ -130,7 +137,7 @@ func runHookCmd(ctx context.Context, name, cmdStr string, payloadJSON []byte, cw
cmd.Process.Kill() //nolint:errcheck
<-done
log.Debug("hook %s: timed out after %ds", name, hookTimeout)
return HookResult{}
return HookResult{Ran: true, Warning: fmt.Sprintf("hook %s: timed out after %ds", name, hookTimeout)}
}
}