agent.go RawHistoryFolder
1package swarm
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "sync"
8 "time"
9)
10
11// Agent is one supervised task. Public fields are immutable after
12// Spawn; mutable state (status, activity, transcript) lives behind
13// the embedded mutex.
14type Agent struct {
15 ID string
16 Task string
17 Dir string // always the host's RepoRoot; agents share its cwd.
18 Started time.Time
19
20 // Model and Provider, when non-empty, override the child
21 // subprocess's model resolution. Empty means "inherit whatever
22 // the child resolves on its own from config / env / flags" —
23 // the historical behaviour. Persisted in meta.json so Resume
24 // keeps using the same model across zot restarts.
25 Model string
26 Provider string
27
28 // SessionID, when non-empty, scopes the agent to a particular
29 // host zot session: the dashboard only surfaces agents whose
30 // SessionID matches the active session. Empty means "unscoped"
31 // (legacy meta files from before the field existed, or agents
32 // spawned without a session context such as in tests). Set at
33 // Spawn time from Swarm.activeSession and persisted in
34 // meta.json so the scope survives restarts.
35 SessionID string
36
37 // Resuming is true when this Agent struct was built by Resume
38 // rather than Spawn. The runner consults it to decide whether
39 // to pass the original Task as a positional argv to the child:
40 // on first Spawn we want the child to run the task immediately;
41 // on Resume the task was already run last time and replaying it
42 // would produce a second turn that collides ("agent busy; send
43 // 'cancel' first") with whatever the user types next. Not
44 // persisted — every Resume sets it explicitly.
45 Resuming bool
46
47 // InboxPath is the unix socket the child agent listens on for
48 // follow-up prompts and control messages. The supervisor opens
49 // an Inbox at this path; the child opens a Listener.
50 InboxPath string
51
52 // EventLogPath is the durable JSONL event log for this agent.
53 // The runner appends every well-formed event from the child
54 // (plus lifecycle events of its own) here. /swarm open in any
55 // zot process reads from this file to replay the full history.
56 EventLogPath string
57
58 // SessionPath is the child's persistent session file. Surfaced
59 // so the dashboard / /swarm open can resume the agent later.
60 SessionPath string
61
62 // inbox is the supervisor-side socket handle. Populated by
63 // Swarm.Spawn so SendInput can route messages without each
64 // caller redialing.
65 inbox *Inbox
66
67 mu sync.Mutex
68 status Status
69 activity string
70 transcript []string
71 finished time.Time
72 lastErr error
73
74 // OnTurnEnd, if set, fires once per turn_end event the runner
75 // observes from the child daemon. Used by auto-swarm watchers
76 // to detect that a sub-agent's first (or n-th) task has
77 // finished without waiting for the long-lived daemon itself to
78 // exit — sub-agents keep running on the inbox even after the
79 // initial task completes, so Wait() never unblocks for them.
80 OnTurnEnd func(step int, errMsg string)
81
82 ctx context.Context
83 cancel context.CancelFunc
84 runner Runner
85
86 // done closes when the run goroutine finalises the agent's
87 // status (done / failed / killed). Wait blocks on this so
88 // callers don't have to poll.
89 done chan struct{}
90}
91
92// Inbox exposes the supervisor-side socket handle. Returns nil for
93// agents without inboxes (e.g. tests using a custom Runner that
94// doesn't speak the daemon protocol).
95func (a *Agent) Inbox() *Inbox { return a.inbox }
96
97// Status returns the current high-level status. Cheap; safe from any goroutine.
98func (a *Agent) Status() Status {
99 a.mu.Lock()
100 defer a.mu.Unlock()
101 return a.status
102}
103
104// Activity returns the current one-line activity string.
105func (a *Agent) Activity() string {
106 a.mu.Lock()
107 defer a.mu.Unlock()
108 return a.activity
109}
110
111// Transcript returns a copy of the running transcript.
112func (a *Agent) Transcript() []string {
113 a.mu.Lock()
114 defer a.mu.Unlock()
115 out := make([]string, len(a.transcript))
116 copy(out, a.transcript)
117 return out
118}
119
120// Err returns the runner's terminal error, if any.
121func (a *Agent) Err() error {
122 a.mu.Lock()
123 defer a.mu.Unlock()
124 return a.lastErr
125}
126
127// Wait blocks until the agent reaches a terminal state. Used by tests
128// and by /swarm wait <id>.
129func (a *Agent) Wait() { <-a.done }
130
131// SetOnTurnEnd installs (or clears, with nil) the per-turn callback
132// fired from the runner when the child daemon emits a turn_end
133// event. Safe to call from any goroutine: the runner reads the
134// callback under the same mutex.
135func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {
136 a.mu.Lock()
137 a.OnTurnEnd = fn
138 a.mu.Unlock()
139}
140
141func (a *Agent) setStatus(s Status) {
142 a.mu.Lock()
143 a.status = s
144 a.mu.Unlock()
145}
146
147func (a *Agent) setActivity(msg string) {
148 a.mu.Lock()
149 a.activity = strings.TrimSpace(msg)
150 a.mu.Unlock()
151}
152
153func (a *Agent) appendTranscript(chunk string) {
154 chunk = strings.TrimRight(chunk, "\n")
155 if chunk == "" {
156 return
157 }
158 a.mu.Lock()
159 for _, line := range strings.Split(chunk, "\n") {
160 a.transcript = append(a.transcript, line)
161 }
162 // Bound the transcript so dashboards don't hold gigabytes for
163 // long-running agents. 2000 lines is plenty for inspection;
164 // the durable record lives in the agent's session file.
165 const cap = 2000
166 if len(a.transcript) > cap {
167 a.transcript = a.transcript[len(a.transcript)-cap:]
168 }
169 a.mu.Unlock()
170}
171
172// newAgentID returns a short, mostly-collision-free identifier of the
173// form "<slug>-<nano>". The slug is derived from the task text so
174// dashboards stay readable; the nano suffix guarantees uniqueness
175// even when two agents are spawned in the same millisecond.
176func newAgentID(task string, now time.Time) string {
177 slug := taskSlug(task)
178 return fmt.Sprintf("%s-%d", slug, now.UnixNano()%1_000_000)
179}
180
181func taskSlug(task string) string {
182 task = strings.ToLower(task)
183 var b strings.Builder
184 dash := false
185 for _, r := range task {
186 switch {
187 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
188 b.WriteRune(r)
189 dash = false
190 default:
191 if !dash && b.Len() > 0 {
192 b.WriteByte('-')
193 dash = true
194 }
195 }
196 if b.Len() >= 24 {
197 break
198 }
199 }
200 out := strings.Trim(b.String(), "-")
201 if out == "" {
202 out = "agent"
203 }
204 return out
205}