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