diff --git a/pkg/agent/core.go b/pkg/agent/core.go index 191a902..e8f3992 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -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: " - 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: " + 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 diff --git a/pkg/agent/hooks.go b/pkg/agent/hooks.go index 39c0c2a..348175e 100644 --- a/pkg/agent/hooks.go +++ b/pkg/agent/hooks.go @@ -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) +}