package serve import ( "fmt" "strings" "time" "codey/internal/acp" "codey/internal/agent" "codey/internal/subagent" ) // A subagent is shown as a tool call, because a tool call is the only // container the protocol has for work with a beginning, a running middle and // an end. The row opens when the child starts, fills in as it works, and // closes with its report. // // The id is prefixed so it cannot collide with a real tool call: the parent's // own spawn_agent call is a row too, and the two are different things. const agentCallPrefix = "agent-" func agentCallID(id string) string { return agentCallPrefix + id } // watchAgents subscribes this session to its children. Both callbacks arrive // on the children's own goroutines, so everything they touch is guarded. func (s *session) watchAgents() { if s.agents == nil { return } s.agents.Subscribe(s.onAgentEvent, s.onAgentsChanged) } // onAgentEvent is one child saying something. func (s *session) onAgentEvent(ev subagent.Event) { line := agentLine(ev.Agent) if line == "" { return } s.emit.agentProgress(ev, line) } // onAgentsChanged is the supervisor telling us something moved. Statuses are // read from the snapshot rather than carried on the event, so this is also // where a child that has finished gets its row closed. func (s *session) onAgentsChanged() { if s.agents == nil { return } for _, info := range s.agents.Snapshot() { if info.Status.Live() { continue } s.emit.agentFinished(info) } // Wake a turn that is waiting for children to finish. Buffered and // non-blocking: a signal nobody is waiting for is not worth keeping, and // the waiter re-reads the snapshot anyway. select { case s.agentsMoved <- struct{}{}: default: } } // agentLine renders one of a child's events as a line for its row. Only the // things worth watching from outside are rendered: what it said, and what it // ran. Its usage and its turn boundaries are the parent's business, not the // reader's. func agentLine(ev agent.Event) string { switch ev.Kind { case agent.EventText: return strings.TrimSpace(ev.Text) + "\n" case agent.EventTextDelta: return ev.Text case agent.EventToolStart: return "\nĀ· " + ev.Tool + "\n" default: return "" } } // agentProgress opens a child's row if it is new and appends to it. func (e *emitter) agentProgress(ev subagent.Event, line string) { id := agentCallID(ev.ID) e.mu.Lock() buf, known := e.running[id] if !known { buf = &strings.Builder{} e.running[id] = buf } buf.WriteString(line) out := e.dueLocked(id, buf) e.mu.Unlock() if !known { e.send(acp.Update{ SessionUpdate: acp.UpdateToolCall, ToolCallID: id, Title: agentTitle(ev), Kind: acp.KindThink, Status: acp.StatusInProgress, }) } if out == "" { return } e.send(acp.Update{ SessionUpdate: acp.UpdateToolCallUpdate, ToolCallID: id, Status: acp.StatusInProgress, Content: []acp.ToolCallContent{acp.ToolCallText(out)}, }) } // agentFinished closes a child's row with its report. It is called on every // change once the child has ended, so it has to be the one that decides it has // already been called. func (e *emitter) agentFinished(info subagent.Info) { id := agentCallID(info.ID) e.mu.Lock() if e.closed == nil { e.closed = make(map[string]bool) } if e.closed[id] { e.mu.Unlock() return } e.closed[id] = true _, opened := e.running[id] e.forgetLocked(id) e.mu.Unlock() // An agent that finished without ever saying anything — killed while // queued, or failed on its first request — has no row yet. Open one, so // what happened to it is visible rather than merely absent. if !opened { e.send(acp.Update{ SessionUpdate: acp.UpdateToolCall, ToolCallID: id, Title: agentInfoTitle(info), Kind: acp.KindThink, Status: acp.StatusInProgress, }) } status := acp.StatusCompleted if info.Status != subagent.Done { status = acp.StatusFailed } e.send(acp.Update{ SessionUpdate: acp.UpdateToolCallUpdate, ToolCallID: id, Title: agentInfoTitle(info), Status: status, Content: []acp.ToolCallContent{acp.ToolCallText(agentOutcome(info))}, }) } func agentTitle(ev subagent.Event) string { title := fmt.Sprintf("subagent %s (%s)", ev.ID, ev.Preset) if ev.Model != "" { title += " on " + ev.Model } return title } func agentInfoTitle(info subagent.Info) string { title := fmt.Sprintf("subagent %s (%s)", info.ID, info.Preset) if info.Model != "" { title += " on " + info.Model } if d := info.Elapsed(); d > 0 { title += fmt.Sprintf(" — %s", d.Round(time.Second)) } return title } // agentOutcome is what the closed row shows: the report when there is one, and // why there is not when there is not. func agentOutcome(info subagent.Info) string { var b strings.Builder fmt.Fprintf(&b, "%s (%s) %s after %d turns", info.ID, info.Preset, endedWord(info.Status), info.Turns) if cost, ok := info.Cost(); ok && cost > 0 { fmt.Fprintf(&b, ", $%.4f", cost) } b.WriteString("\n\n") switch { case info.Report != "": b.WriteString(info.Report) case info.Err != "": b.WriteString(info.Err) default: b.WriteString("It reported nothing.") } return b.String() } func endedWord(s subagent.Status) string { switch s { case subagent.Done: return "finished" case subagent.Failed: return "failed" case subagent.Killed: return "was stopped" default: return string(s) } } // reportBlocks folds the reports this session is still entitled to hand over // into one message for the parent. // // The claim is made here rather than when the report was drained: agent_wait // may have collected it in between, and putting it in again would charge the // model twice for something already in its context. func (s *session) reportBlocks() string { if s.agents == nil { return "" } var parts []string for _, r := range s.agents.TakeReports() { if !s.agents.ClaimReport(r.ID) { continue } parts = append(parts, r.Notification()) } return strings.Join(parts, "\n\n") } // agentsLive reports whether any child is still going to do something. A turn // stays open while one is: their updates belong inside the turn that started // them, and a report that arrives after the turn closed has nowhere to go. func (s *session) agentsLive() bool { if s.agents == nil { return false } for _, info := range s.agents.Snapshot() { if info.Status.Live() { return true } } return false }