ScuttleBot

scuttlebot / internal / bots / shepherd / shepherd.go
Blame History Raw 455 lines
1
// Package shepherd implements a goal-directed agent coordination bot.
2
//
3
// Shepherd monitors channels, tracks agent activity, assigns work from
4
// configured goal sources, checks in on progress, and reports status.
5
// It uses an LLM to reason about priorities, detect blockers, and
6
// generate summaries.
7
//
8
// Commands (via DM or channel mention):
9
//
10
// GOAL <text> — set a goal for the current channel
11
// STATUS — report progress on current goals
12
// ASSIGN <nick> <task> — manually assign a task to an agent
13
// CHECKIN — trigger a check-in round
14
// PLAN — generate a work plan from current goals
15
package shepherd
16
17
import (
18
"context"
19
"fmt"
20
"log/slog"
21
"net"
22
"strconv"
23
"strings"
24
"sync"
25
"time"
26
27
"github.com/lrstanley/girc"
28
29
"github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
30
)
31
32
const defaultNick = "shepherd"
33
34
// LLMProvider calls a language model for reasoning.
35
type LLMProvider interface {
36
Summarize(ctx context.Context, prompt string) (string, error)
37
}
38
39
// Config controls shepherd's behaviour.
40
type Config struct {
41
IRCAddr string
42
Nick string
43
Password string
44
45
// Channels to join and monitor.
46
Channels []string
47
48
// ReportChannel is where status reports go (e.g. "#ops").
49
ReportChannel string
50
51
// CheckinInterval is how often to check in on agents. 0 = disabled.
52
CheckinInterval time.Duration
53
54
// GoalSource is an optional URL for seeding goals (e.g. GitHub milestone).
55
GoalSource string
56
}
57
58
// Goal is a tracked objective.
59
type Goal struct {
60
ID string `json:"id"`
61
Channel string `json:"channel"`
62
Description string `json:"description"`
63
CreatedAt time.Time `json:"created_at"`
64
CreatedBy string `json:"created_by"`
65
Status string `json:"status"` // "active", "done", "blocked"
66
}
67
68
// Assignment tracks which agent is working on what.
69
type Assignment struct {
70
Nick string `json:"nick"`
71
Task string `json:"task"`
72
Channel string `json:"channel"`
73
AssignedAt time.Time `json:"assigned_at"`
74
LastUpdate time.Time `json:"last_update"`
75
}
76
77
// Bot is the shepherd bot.
78
type Bot struct {
79
cfg Config
80
llm LLMProvider
81
log *slog.Logger
82
client *girc.Client
83
84
mu sync.Mutex
85
goals map[string][]Goal // channel → goals
86
assignments map[string]*Assignment // nick → assignment
87
activity map[string]time.Time // nick → last message time
88
history map[string][]string // channel → recent messages for LLM context
89
}
90
91
// New creates a shepherd bot.
92
func New(cfg Config, llm LLMProvider, log *slog.Logger) *Bot {
93
if cfg.Nick == "" {
94
cfg.Nick = defaultNick
95
}
96
return &Bot{
97
cfg: cfg,
98
llm: llm,
99
log: log,
100
goals: make(map[string][]Goal),
101
assignments: make(map[string]*Assignment),
102
activity: make(map[string]time.Time),
103
history: make(map[string][]string),
104
}
105
}
106
107
// Name returns the bot's IRC nick.
108
func (b *Bot) Name() string { return b.cfg.Nick }
109
110
// Start connects to IRC and begins shepherding. Blocks until ctx is cancelled.
111
func (b *Bot) Start(ctx context.Context) error {
112
host, port, err := splitHostPort(b.cfg.IRCAddr)
113
if err != nil {
114
return fmt.Errorf("shepherd: %w", err)
115
}
116
117
c := girc.New(girc.Config{
118
Server: host,
119
Port: port,
120
Nick: b.cfg.Nick,
121
User: b.cfg.Nick,
122
Name: "scuttlebot shepherd",
123
SASL: &girc.SASLPlain{User: b.cfg.Nick, Pass: b.cfg.Password},
124
PingDelay: 30 * time.Second,
125
PingTimeout: 30 * time.Second,
126
})
127
128
router := cmdparse.NewRouter(b.cfg.Nick)
129
router.Register(cmdparse.Command{
130
Name: "goal",
131
Usage: "GOAL <description>",
132
Description: "set a goal for the current channel",
133
Handler: func(cmdCtx *cmdparse.Context, args string) string {
134
return b.handleGoal(cmdCtx.Channel, cmdCtx.Nick, args)
135
},
136
})
137
router.Register(cmdparse.Command{
138
Name: "status",
139
Usage: "STATUS",
140
Description: "report progress on current goals",
141
Handler: func(cmdCtx *cmdparse.Context, args string) string {
142
return b.handleStatus(ctx, cmdCtx.Channel)
143
},
144
})
145
router.Register(cmdparse.Command{
146
Name: "assign",
147
Usage: "ASSIGN <nick> <task>",
148
Description: "manually assign a task to an agent",
149
Handler: func(cmdCtx *cmdparse.Context, args string) string {
150
return b.handleAssign(cmdCtx.Channel, args)
151
},
152
})
153
router.Register(cmdparse.Command{
154
Name: "checkin",
155
Usage: "CHECKIN",
156
Description: "trigger a check-in round with all assigned agents",
157
Handler: func(cmdCtx *cmdparse.Context, args string) string {
158
b.runCheckin(c)
159
return "check-in round started"
160
},
161
})
162
router.Register(cmdparse.Command{
163
Name: "plan",
164
Usage: "PLAN",
165
Description: "generate a work plan from current goals using LLM",
166
Handler: func(cmdCtx *cmdparse.Context, args string) string {
167
return b.handlePlan(ctx, cmdCtx.Channel)
168
},
169
})
170
router.Register(cmdparse.Command{
171
Name: "goals",
172
Usage: "GOALS",
173
Description: "list all active goals for this channel",
174
Handler: func(cmdCtx *cmdparse.Context, args string) string {
175
return b.handleListGoals(cmdCtx.Channel)
176
},
177
})
178
router.Register(cmdparse.Command{
179
Name: "done",
180
Usage: "DONE <goal-id>",
181
Description: "mark a goal as completed",
182
Handler: func(cmdCtx *cmdparse.Context, args string) string {
183
return b.handleDone(cmdCtx.Channel, args)
184
},
185
})
186
187
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
188
cl.Cmd.Mode(cl.GetNick(), "+B")
189
for _, ch := range b.cfg.Channels {
190
cl.Cmd.Join(ch)
191
}
192
// Request voice on all channels after a short delay (let JOINs complete).
193
go func() {
194
time.Sleep(3 * time.Second)
195
for _, ch := range b.cfg.Channels {
196
cl.Cmd.Message("ChanServ", "VOICE "+ch)
197
}
198
if b.cfg.ReportChannel != "" {
199
cl.Cmd.Message("ChanServ", "VOICE "+b.cfg.ReportChannel)
200
}
201
}()
202
if b.cfg.ReportChannel != "" {
203
cl.Cmd.Join(b.cfg.ReportChannel)
204
}
205
if b.log != nil {
206
b.log.Info("shepherd connected", "channels", b.cfg.Channels)
207
}
208
})
209
210
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
211
if ch := e.Last(); strings.HasPrefix(ch, "#") {
212
cl.Cmd.Join(ch)
213
}
214
})
215
216
c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
217
if len(e.Params) < 1 || e.Source == nil {
218
return
219
}
220
nick := e.Source.Name
221
target := e.Params[0]
222
text := strings.TrimSpace(e.Last())
223
224
// Track activity.
225
b.mu.Lock()
226
b.activity[nick] = time.Now()
227
if strings.HasPrefix(target, "#") {
228
hist := b.history[target]
229
hist = append(hist, fmt.Sprintf("[%s] %s", nick, text))
230
if len(hist) > 100 {
231
hist = hist[len(hist)-100:]
232
}
233
b.history[target] = hist
234
}
235
b.mu.Unlock()
236
237
// Dispatch commands.
238
if reply := router.Dispatch(nick, target, text); reply != nil {
239
cl.Cmd.Message(reply.Target, reply.Text)
240
}
241
})
242
243
b.client = c
244
245
// Start periodic check-in if configured.
246
if b.cfg.CheckinInterval > 0 {
247
go b.checkinLoop(ctx, c)
248
}
249
250
errCh := make(chan error, 1)
251
go func() {
252
if err := c.Connect(); err != nil && ctx.Err() == nil {
253
errCh <- err
254
}
255
}()
256
257
select {
258
case <-ctx.Done():
259
c.Close()
260
return nil
261
case err := <-errCh:
262
return fmt.Errorf("shepherd: irc: %w", err)
263
}
264
}
265
266
// Stop disconnects the bot.
267
func (b *Bot) Stop() {
268
if b.client != nil {
269
b.client.Close()
270
}
271
}
272
273
// --- Command handlers ---
274
275
func (b *Bot) handleGoal(channel, nick, desc string) string {
276
desc = strings.TrimSpace(desc)
277
if desc == "" {
278
return "usage: GOAL <description>"
279
}
280
b.mu.Lock()
281
defer b.mu.Unlock()
282
id := fmt.Sprintf("G%d", len(b.goals[channel])+1)
283
b.goals[channel] = append(b.goals[channel], Goal{
284
ID: id,
285
Channel: channel,
286
Description: desc,
287
CreatedAt: time.Now(),
288
CreatedBy: nick,
289
Status: "active",
290
})
291
return fmt.Sprintf("goal %s set: %s", id, desc)
292
}
293
294
func (b *Bot) handleListGoals(channel string) string {
295
b.mu.Lock()
296
defer b.mu.Unlock()
297
goals := b.goals[channel]
298
if len(goals) == 0 {
299
return "no goals set for " + channel
300
}
301
var lines []string
302
for _, g := range goals {
303
lines = append(lines, fmt.Sprintf("[%s] %s (%s) — %s", g.ID, g.Description, g.Status, g.CreatedBy))
304
}
305
return strings.Join(lines, " | ")
306
}
307
308
func (b *Bot) handleDone(channel, goalID string) string {
309
goalID = strings.TrimSpace(goalID)
310
b.mu.Lock()
311
defer b.mu.Unlock()
312
for i, g := range b.goals[channel] {
313
if strings.EqualFold(g.ID, goalID) {
314
b.goals[channel][i].Status = "done"
315
return fmt.Sprintf("goal %s marked done: %s", g.ID, g.Description)
316
}
317
}
318
return fmt.Sprintf("goal %q not found in %s", goalID, channel)
319
}
320
321
func (b *Bot) handleAssign(channel, args string) string {
322
parts := strings.SplitN(strings.TrimSpace(args), " ", 2)
323
if len(parts) < 2 {
324
return "usage: ASSIGN <nick> <task>"
325
}
326
nick, task := parts[0], parts[1]
327
b.mu.Lock()
328
b.assignments[nick] = &Assignment{
329
Nick: nick,
330
Task: task,
331
Channel: channel,
332
AssignedAt: time.Now(),
333
LastUpdate: time.Now(),
334
}
335
b.mu.Unlock()
336
return fmt.Sprintf("assigned %s to %s", nick, task)
337
}
338
339
func (b *Bot) handleStatus(ctx context.Context, channel string) string {
340
b.mu.Lock()
341
goals := b.goals[channel]
342
var active, done int
343
for _, g := range goals {
344
if g.Status == "done" {
345
done++
346
} else {
347
active++
348
}
349
}
350
hist := b.history[channel]
351
var assignments []string
352
for _, a := range b.assignments {
353
if a.Channel == channel {
354
assignments = append(assignments, fmt.Sprintf("%s: %s", a.Nick, a.Task))
355
}
356
}
357
b.mu.Unlock()
358
359
summary := fmt.Sprintf("goals: %d active, %d done", active, done)
360
if len(assignments) > 0 {
361
summary += " | assignments: " + strings.Join(assignments, ", ")
362
}
363
364
// Use LLM for richer summary if available and there's context.
365
if b.llm != nil && len(hist) > 5 {
366
prompt := fmt.Sprintf("Summarize the current status of work in %s. "+
367
"Goals: %d active, %d done. Assignments: %s. "+
368
"Recent conversation:\n%s\n\n"+
369
"Give a brief status report (2-3 sentences).",
370
channel, active, done, strings.Join(assignments, ", "),
371
strings.Join(hist[max(0, len(hist)-30):], "\n"))
372
if llmSummary, err := b.llm.Summarize(ctx, prompt); err == nil {
373
return llmSummary
374
}
375
}
376
377
return summary
378
}
379
380
func (b *Bot) handlePlan(ctx context.Context, channel string) string {
381
b.mu.Lock()
382
goals := b.goals[channel]
383
var goalDescs []string
384
for _, g := range goals {
385
if g.Status == "active" {
386
goalDescs = append(goalDescs, g.Description)
387
}
388
}
389
hist := b.history[channel]
390
b.mu.Unlock()
391
392
if len(goalDescs) == 0 {
393
return "no active goals to plan from. Use GOAL <description> to set one."
394
}
395
396
if b.llm == nil {
397
return "LLM not configured — cannot generate plan"
398
}
399
400
prompt := fmt.Sprintf("You are a project coordinator. Generate a brief work plan for these goals:\n\n"+
401
"%s\n\nRecent context:\n%s\n\n"+
402
"Output a numbered action list (max 5 items). Each item should be concrete and assignable.",
403
strings.Join(goalDescs, "\n- "),
404
strings.Join(hist[max(0, len(hist)-20):], "\n"))
405
406
plan, err := b.llm.Summarize(ctx, prompt)
407
if err != nil {
408
return "plan generation failed: " + err.Error()
409
}
410
return plan
411
}
412
413
// --- Check-in loop ---
414
415
func (b *Bot) checkinLoop(ctx context.Context, c *girc.Client) {
416
ticker := time.NewTicker(b.cfg.CheckinInterval)
417
defer ticker.Stop()
418
for {
419
select {
420
case <-ctx.Done():
421
return
422
case <-ticker.C:
423
b.runCheckin(c)
424
}
425
}
426
}
427
428
func (b *Bot) runCheckin(c *girc.Client) {
429
b.mu.Lock()
430
defer b.mu.Unlock()
431
432
now := time.Now()
433
for nick, a := range b.assignments {
434
// Check if agent has been active recently.
435
lastActive, ok := b.activity[nick]
436
if !ok || now.Sub(lastActive) > 10*time.Minute {
437
// Agent appears idle — nudge them.
438
msg := fmt.Sprintf("[shepherd] %s — checking in: how's progress on %q?", nick, a.Task)
439
c.Cmd.Message(a.Channel, msg)
440
}
441
}
442
}
443
444
func splitHostPort(addr string) (string, int, error) {
445
host, portStr, err := net.SplitHostPort(addr)
446
if err != nil {
447
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
448
}
449
port, err := strconv.Atoi(portStr)
450
if err != nil {
451
return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
452
}
453
return host, port, nil
454
}
455

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button