v0.1.80 63e28ad

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close