agent: add hook observability; log state changes only on transition
- Debug-log all hook firings unconditionally (name, cmd, exit, context_len) - Emit "N hook(s) run" to chat for lifecycle hooks (agentSpawn, pre/postCompact, pre/postClear); suppress per-turn hooks (userPromptSubmit, stop) from chat - Store env.Messages (MCP connections) on agentCore; emit to chat on first turn, debug-log unconditionally - Replace inline s.state assignments with setState() so debug logs emit only on state transitions, not on every State() poll Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d66b8f473f
commit
031dc349e2
|
|
@ -258,25 +258,26 @@ type AgentCoreConfig struct {
|
|||
// agentCore is the Core implementation. It owns all agent and session state
|
||||
// but has no knowledge of how output is rendered.
|
||||
type agentCore struct {
|
||||
session *Session
|
||||
loopcfg loopConfig
|
||||
hooks Hooks
|
||||
agentName string
|
||||
agentsDir string
|
||||
sessionsDir string
|
||||
sessionID string
|
||||
dispatcher tools.Dispatcher
|
||||
newDispatcher func() tools.Dispatcher
|
||||
cwd string
|
||||
agentPrompt string // agent-specific prompt suffix; kept for system prompt rebuilds
|
||||
currentAction atomic.Pointer[actionHandle]
|
||||
fifo PromptFIFO
|
||||
pendingInject atomic.Pointer[string]
|
||||
mu sync.RWMutex
|
||||
state string // "idle", "thinking", "calling: <tool>"
|
||||
reply string // assistant text from the most recently completed turn
|
||||
envMu sync.RWMutex
|
||||
env map[string]string // session-scoped env vars
|
||||
session *Session
|
||||
loopcfg loopConfig
|
||||
hooks Hooks
|
||||
agentName string
|
||||
agentsDir string
|
||||
sessionsDir string
|
||||
sessionID string
|
||||
dispatcher tools.Dispatcher
|
||||
newDispatcher func() tools.Dispatcher
|
||||
cwd string
|
||||
agentPrompt string // agent-specific prompt suffix; kept for system prompt rebuilds
|
||||
startupMessages []string
|
||||
currentAction atomic.Pointer[actionHandle]
|
||||
fifo PromptFIFO
|
||||
pendingInject atomic.Pointer[string]
|
||||
mu sync.RWMutex
|
||||
state string // "idle", "thinking", "calling: <tool>"
|
||||
reply string // assistant text from the most recently completed turn
|
||||
envMu sync.RWMutex
|
||||
env map[string]string // session-scoped env vars
|
||||
}
|
||||
|
||||
// SetEnv stores a session-scoped variable and propagates it to the execute server.
|
||||
|
|
@ -331,18 +332,19 @@ func NewAgentCore(cfg AgentCoreConfig) Core {
|
|||
}
|
||||
|
||||
a := &agentCore{
|
||||
session: cfg.Session,
|
||||
loopcfg: loopcfg,
|
||||
hooks: cfg.Env.Hooks,
|
||||
agentName: cfg.AgentName,
|
||||
agentsDir: cfg.AgentsDir,
|
||||
sessionsDir: cfg.SessionsDir,
|
||||
sessionID: cfg.SessionID,
|
||||
cwd: cfg.CWD,
|
||||
agentPrompt: cfg.Env.agentPrompt,
|
||||
dispatcher: cfg.Env.dispatcher,
|
||||
newDispatcher: cfg.NewDispatcher,
|
||||
state: "idle",
|
||||
session: cfg.Session,
|
||||
loopcfg: loopcfg,
|
||||
hooks: cfg.Env.Hooks,
|
||||
agentName: cfg.AgentName,
|
||||
agentsDir: cfg.AgentsDir,
|
||||
sessionsDir: cfg.SessionsDir,
|
||||
sessionID: cfg.SessionID,
|
||||
cwd: cfg.CWD,
|
||||
agentPrompt: cfg.Env.agentPrompt,
|
||||
startupMessages: cfg.Env.Messages,
|
||||
dispatcher: cfg.Env.dispatcher,
|
||||
newDispatcher: cfg.NewDispatcher,
|
||||
state: "idle",
|
||||
}
|
||||
a.pushSessionEnv()
|
||||
return a
|
||||
|
|
@ -382,10 +384,16 @@ func (s *agentCore) ModelName() string {
|
|||
func (s *agentCore) State() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
clog.Debug("State() = %q", s.state)
|
||||
return s.state
|
||||
}
|
||||
|
||||
func (s *agentCore) setState(state string) {
|
||||
s.mu.Lock()
|
||||
s.state = state
|
||||
s.mu.Unlock()
|
||||
clog.Debug("state -> %q", state)
|
||||
}
|
||||
|
||||
func (s *agentCore) Reply() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
|
@ -689,12 +697,21 @@ func (s *agentCore) Submit(ctx context.Context, input string, handler EventHandl
|
|||
}
|
||||
|
||||
if s.session == nil {
|
||||
// First turn — fire agentSpawn and inject any context it provides into
|
||||
// the opening message so the agent sees it from the start.
|
||||
if spawnResult := s.hooks.Run(ctx, HookAgentSpawn, map[string]string{
|
||||
// First turn — emit startup messages (e.g. MCP connections) then fire
|
||||
// agentSpawn, injecting any context it returns into the opening message.
|
||||
for _, msg := range s.startupMessages {
|
||||
clog.Debug("startup: %s", msg)
|
||||
handler(infoEvent(msg))
|
||||
}
|
||||
s.startupMessages = nil
|
||||
spawnResult := s.hooks.Run(ctx, HookAgentSpawn, map[string]string{
|
||||
"session_id": s.sessionID,
|
||||
"agent": s.agentName,
|
||||
}); spawnResult.Context != "" {
|
||||
})
|
||||
if spawnResult.Ran {
|
||||
handler(infoEvent(hooksRan(1)))
|
||||
}
|
||||
if spawnResult.Context != "" {
|
||||
input += "\n" + spawnResult.Context
|
||||
}
|
||||
s.session = newSession(input)
|
||||
|
|
@ -702,9 +719,7 @@ func (s *agentCore) Submit(ctx context.Context, input string, handler EventHandl
|
|||
s.session.appendUserMessage(input)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.state = "thinking"
|
||||
s.mu.Unlock()
|
||||
s.setState("thinking")
|
||||
|
||||
actCtx, actCancel := context.WithCancelCause(ctx)
|
||||
handle := &actionHandle{cancel: actCancel}
|
||||
|
|
@ -716,13 +731,9 @@ func (s *agentCore) Submit(ctx context.Context, input string, handler EventHandl
|
|||
case "assistant":
|
||||
replyBuf.WriteString(ev.Content)
|
||||
case "call":
|
||||
s.mu.Lock()
|
||||
s.state = "calling: " + ev.Name
|
||||
s.mu.Unlock()
|
||||
s.setState("calling: " + ev.Name)
|
||||
case "tool":
|
||||
s.mu.Lock()
|
||||
s.state = "thinking"
|
||||
s.mu.Unlock()
|
||||
s.setState("thinking")
|
||||
}
|
||||
if ev.Role == "usage" && s.session != nil {
|
||||
var in, out, est int
|
||||
|
|
@ -742,10 +753,17 @@ func (s *agentCore) Submit(ctx context.Context, input string, handler EventHandl
|
|||
if s.session != nil {
|
||||
if limit := s.autoCompactLimit(ctx); limit > 0 && s.session.estimateTokens() >= limit {
|
||||
payload := map[string]string{"session_id": s.sessionID, "trigger": "auto"}
|
||||
if pre := s.hooks.Run(ctx, HookPreCompact, payload); !pre.Blocked {
|
||||
pre := s.hooks.Run(ctx, HookPreCompact, payload)
|
||||
if pre.Ran {
|
||||
handler(infoEvent(hooksRan(1)))
|
||||
}
|
||||
if !pre.Blocked {
|
||||
emit(s.loopcfg, Event{Role: "info", Content: "auto-compacting context...\n"})
|
||||
s.session.compact(ctx, s.loopcfg.Backend) //nolint:errcheck
|
||||
s.hooks.Run(ctx, HookPostCompact, payload)
|
||||
post := s.hooks.Run(ctx, HookPostCompact, payload)
|
||||
if post.Ran {
|
||||
handler(infoEvent(hooksRan(1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -756,9 +774,9 @@ func (s *agentCore) Submit(ctx context.Context, input string, handler EventHandl
|
|||
|
||||
s.mu.Lock()
|
||||
s.reply = replyBuf.String()
|
||||
s.state = "idle"
|
||||
s.mu.Unlock()
|
||||
replyBuf.Reset()
|
||||
s.setState("idle")
|
||||
|
||||
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, ErrInterrupted) {
|
||||
handler(Event{Role: "error", Content: err.Error()})
|
||||
|
|
@ -965,13 +983,20 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve
|
|||
}
|
||||
// Snapshot history before compaction for persistence.
|
||||
payload := map[string]string{"session_id": s.sessionID, "trigger": "manual"}
|
||||
if pre := s.hooks.Run(ctx, HookPreCompact, payload); pre.Blocked {
|
||||
pre := s.hooks.Run(ctx, HookPreCompact, payload)
|
||||
if pre.Ran {
|
||||
handler(infoEvent(hooksRan(1)))
|
||||
}
|
||||
if pre.Blocked {
|
||||
handler(infoEvent("compact cancelled by hook"))
|
||||
return true
|
||||
}
|
||||
snapshot := s.session.PreCompactionSnapshot()
|
||||
n, _, err := s.session.compact(ctx, s.loopcfg.Backend)
|
||||
s.hooks.Run(ctx, HookPostCompact, payload)
|
||||
post := s.hooks.Run(ctx, HookPostCompact, payload)
|
||||
if post.Ran {
|
||||
handler(infoEvent(hooksRan(1)))
|
||||
}
|
||||
if err != nil {
|
||||
handler(infoEvent("compact error: " + err.Error()))
|
||||
} else if n == 0 {
|
||||
|
|
@ -1048,13 +1073,20 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve
|
|||
handler(infoEvent("error: cannot clear while agent is running"))
|
||||
return true
|
||||
}
|
||||
if pre := s.hooks.Run(ctx, HookPreClear, map[string]string{"session_id": s.sessionID}); pre.Blocked {
|
||||
pre := s.hooks.Run(ctx, HookPreClear, map[string]string{"session_id": s.sessionID})
|
||||
if pre.Ran {
|
||||
handler(infoEvent(hooksRan(1)))
|
||||
}
|
||||
if pre.Blocked {
|
||||
handler(infoEvent("clear cancelled by hook"))
|
||||
return true
|
||||
}
|
||||
s.session = nil
|
||||
s.sessionID = NewSessionID()
|
||||
s.hooks.Run(ctx, HookPostClear, map[string]string{"session_id": s.sessionID})
|
||||
post := s.hooks.Run(ctx, HookPostClear, map[string]string{"session_id": s.sessionID})
|
||||
if post.Ran {
|
||||
handler(infoEvent(hooksRan(1)))
|
||||
}
|
||||
handler(infoEvent("cleared"))
|
||||
return true
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -28,6 +29,8 @@ type Hooks map[string]string
|
|||
|
||||
// HookResult holds the outcome of running a hook.
|
||||
type HookResult struct {
|
||||
// Ran is true when a hook command was configured and executed.
|
||||
Ran bool
|
||||
// Blocked is true when the hook wants to prevent the action (exit 2).
|
||||
// For Stop hooks, Blocked means "don't stop, continue".
|
||||
Blocked bool
|
||||
|
|
@ -36,7 +39,7 @@ type HookResult struct {
|
|||
}
|
||||
|
||||
// Run executes the hook for the named event, sending payload as JSON on stdin.
|
||||
// Returns a HookResult describing whether the hook blocked and any context.
|
||||
// Returns a HookResult describing whether the hook ran, blocked, and any context.
|
||||
//
|
||||
// Exit codes:
|
||||
// - 0: success. Stdout is added as conversation context.
|
||||
|
|
@ -45,6 +48,7 @@ type HookResult struct {
|
|||
// - other: non-blocking warning (stderr logged, execution continues).
|
||||
func (h Hooks) Run(ctx context.Context, name string, payload any) HookResult {
|
||||
cmdStr := h[name]
|
||||
clog.Debug("hook %s: cmd=%q", name, cmdStr)
|
||||
if cmdStr == "" {
|
||||
return HookResult{}
|
||||
}
|
||||
|
|
@ -60,6 +64,7 @@ func (h Hooks) Run(ctx context.Context, name string, payload any) HookResult {
|
|||
|
||||
done := make(chan error, 1)
|
||||
if err := cmd.Start(); err != nil {
|
||||
clog.Debug("hook %s: start error: %v", name, err)
|
||||
return HookResult{}
|
||||
}
|
||||
go func() { done <- cmd.Wait() }()
|
||||
|
|
@ -73,31 +78,43 @@ func (h Hooks) Run(ctx context.Context, name string, payload any) HookResult {
|
|||
if errors.As(err, &exitErr) {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
clog.Debug("hook %s: wait error: %v", name, err)
|
||||
return HookResult{}
|
||||
}
|
||||
}
|
||||
|
||||
switch exitCode {
|
||||
case 0:
|
||||
return HookResult{Context: strings.TrimSpace(stdout.String())}
|
||||
out := strings.TrimSpace(stdout.String())
|
||||
clog.Debug("hook %s: exit=0 context_len=%d", name, len(out))
|
||||
return HookResult{Ran: true, Context: out}
|
||||
case 2:
|
||||
return HookResult{
|
||||
Blocked: true,
|
||||
Context: strings.TrimSpace(stderr.String()),
|
||||
}
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
clog.Debug("hook %s: exit=2 (blocked) msg=%q", name, msg)
|
||||
return HookResult{Ran: true, Blocked: true, Context: msg}
|
||||
default:
|
||||
// Non-blocking error — stderr is just a warning, ignore.
|
||||
return HookResult{}
|
||||
clog.Debug("hook %s: exit=%d (non-blocking error) stderr=%q", name, exitCode, stderr.String())
|
||||
return HookResult{Ran: true}
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
cmd.Process.Kill() //nolint:errcheck
|
||||
<-done
|
||||
clog.Debug("hook %s: cancelled (context done)", name)
|
||||
return HookResult{}
|
||||
|
||||
case <-timeout:
|
||||
cmd.Process.Kill() //nolint:errcheck
|
||||
<-done
|
||||
clog.Debug("hook %s: timed out after %ds", name, defaultHookTimeout)
|
||||
return HookResult{}
|
||||
}
|
||||
}
|
||||
|
||||
// hooksRan returns a display string for N hooks having run, e.g. "1 hook run".
|
||||
func hooksRan(n int) string {
|
||||
if n == 1 {
|
||||
return "1 hook run"
|
||||
}
|
||||
return fmt.Sprintf("%d hooks run", n)
|
||||
}
|
||||
|
|
|
|||
Reference in New Issue