Add a per-user lead agent and make the right pane task-focused
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/claude"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
const leadGuidance = `You are the human's lead agent in byre, shown in the left pane of byre's web UI. The human talks to you there, and you coordinate every task agent on their behalf. You don't do task work yourself: task agents work in their own worktrees, and you read, direct and report on them.
|
||||
- Signals: byre sends you, as messages from byre, what happens across tasks: questions, turn reports and errors that task agents file, questions the human answered, tasks merged or discarded, and the human switching the right pane. Several may arrive in one message. Messages from the human arrive separately.
|
||||
- Direct the human's attention. The right pane always shows one task; point it with show (the task, the tabs to put first, the tab to select). When something needs the human, show it and say in a sentence or two what it is and what you recommend. Don't move the pane for things that don't need them, and don't pull it away from what they are looking at unless the new thing is more urgent.
|
||||
- Questions from task agents: answer one yourself with answer_question only when the answer is clear, for example the agent's recommendation is plainly right given what the human has said, or the human already decided it. Otherwise show it to the human with your recommendation and wait. When the human tells you an answer, give it with answer_question. Say which questions you answered and how.
|
||||
- Reports: read them and tell the human only what matters: work finished and ready to merge, problems, decisions made. Stay quiet about routine progress.
|
||||
- Dig into context with list_tasks, read_transcript and read_inbox, and with your own Read, Grep and Bash on a task's worktree path (from list_tasks). Don't edit files in task worktrees; ask the task's agent with send_message.
|
||||
- You can create tasks, start TODO items, message, interrupt and compact task agents, and merge or discard tasks. Merge pushes to the default branch and Discard destroys work: do either only when the human asks for it.
|
||||
- Keep messages short; the human reads them in half the screen. Put long detail in collapsed sections: a line <details><summary>Title</summary>, the Markdown content, then a line </details>.
|
||||
- You are long-running. At logical points (a piece of work wrapped up, a topic closed, before turning to something unrelated) call compact_self; when the turn ends, byre replaces you with a fresh session that starts from your handoff.
|
||||
- Don't commit, push, or touch git branches yourself.`
|
||||
|
||||
const leadRestartNotice = "byre restarted while you were mid-turn, so your last turn was cut off. Pick up where you left off."
|
||||
|
||||
func leadActor(login string) string {
|
||||
return login + " (lead)"
|
||||
}
|
||||
|
||||
func (m *Manager) LeadSessions(ctx context.Context, owner string) ([]api.AgentSession, error) {
|
||||
return m.store.LeadSessions(ctx, owner)
|
||||
}
|
||||
|
||||
func (m *Manager) activeLead(ctx context.Context, owner string) (api.AgentSession, bool, error) {
|
||||
sessions, err := m.store.LeadSessions(ctx, owner)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, false, err
|
||||
}
|
||||
for i := len(sessions) - 1; i >= 0; i-- {
|
||||
if sessions[i].State != api.AgentSessionStateCompleted {
|
||||
return sessions[i], true, nil
|
||||
}
|
||||
}
|
||||
return api.AgentSession{}, false, nil
|
||||
}
|
||||
|
||||
func (m *Manager) createLead(ctx context.Context, owner api.User, prompt, model, actor string, images []string) (api.AgentSession, error) {
|
||||
_, found, err := m.activeLead(ctx, owner.Login)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
if found {
|
||||
return api.AgentSession{}, fmt.Errorf("%w: %s already has a lead session", ErrConflict, owner.Login)
|
||||
}
|
||||
|
||||
id := newUUID()
|
||||
sess := api.AgentSession{
|
||||
Id: id,
|
||||
Owner: owner.Login,
|
||||
Vendor: api.Claude,
|
||||
Model: model,
|
||||
VendorSessionId: id,
|
||||
State: api.AgentSessionStateIdle,
|
||||
Role: api.Lead,
|
||||
CreatedBy: owner.Login,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
err = m.store.CreateSession(ctx, sess, "")
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
return m.turn(ctx, sess, api.Task{}, actor, prompt, images)
|
||||
}
|
||||
|
||||
func (m *Manager) MessageLead(ctx context.Context, user api.User, text, model string, images []string) (api.AgentSession, error) {
|
||||
m.leadMu.Lock()
|
||||
defer m.leadMu.Unlock()
|
||||
|
||||
sess, found, err := m.activeLead(ctx, user.Login)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
if !found {
|
||||
return m.createLead(ctx, user, text, model, user.Login, images)
|
||||
}
|
||||
|
||||
if sess.State == api.AgentSessionStateIdle {
|
||||
s, err := m.Send(ctx, sess.Id, user.Login, text, model, images...)
|
||||
if !errors.Is(err, ErrConflict) {
|
||||
return s, err
|
||||
}
|
||||
}
|
||||
|
||||
if model != "" && model != sess.Model {
|
||||
err = m.store.SetSessionModel(ctx, sess.Id, model, user.Login)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
sess.Model = model
|
||||
}
|
||||
err = m.store.AddLeadInput(ctx, store.LeadInput{Owner: user.Login, Kind: store.LeadMessage, Actor: user.Login, Text: text, Images: images})
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (m *Manager) DeliverLead(ctx context.Context, owner string) error {
|
||||
m.leadMu.Lock()
|
||||
defer m.leadMu.Unlock()
|
||||
|
||||
sess, found, err := m.activeLead(ctx, owner)
|
||||
if err != nil || !found || sess.State != api.AgentSessionStateIdle {
|
||||
return err
|
||||
}
|
||||
|
||||
inputs, err := m.store.LeadInputs(ctx, owner)
|
||||
if err != nil || len(inputs) == 0 {
|
||||
return err
|
||||
}
|
||||
|
||||
batch, actor, text, images := nextLeadTurn(inputs)
|
||||
_, err = m.Send(ctx, sess.Id, actor, text, "", images...)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ids := []int64{}
|
||||
for _, in := range batch {
|
||||
ids = append(ids, in.ID)
|
||||
}
|
||||
return m.store.DeleteLeadInputs(ctx, ids)
|
||||
}
|
||||
|
||||
func nextLeadTurn(inputs []store.LeadInput) ([]store.LeadInput, string, string, []string) {
|
||||
for _, in := range inputs {
|
||||
if in.Kind == store.LeadMessage {
|
||||
return []store.LeadInput{in}, in.Actor, in.Text, in.Images
|
||||
}
|
||||
}
|
||||
texts := []string{}
|
||||
for _, in := range inputs {
|
||||
texts = append(texts, in.Text)
|
||||
}
|
||||
return inputs, "byre", fmt.Sprintf("Signals since your last turn (%d):\n\n%s", len(texts), strings.Join(texts, "\n\n")), nil
|
||||
}
|
||||
|
||||
func (m *Manager) deliverLead(owner string) {
|
||||
err := m.DeliverLead(context.Background(), owner)
|
||||
if err != nil {
|
||||
slog.Error("deliver to lead", "owner", owner, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) deliverAllLeads() {
|
||||
owners, err := m.store.LeadOwners(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("list lead owners", "err", err)
|
||||
return
|
||||
}
|
||||
for _, owner := range owners {
|
||||
m.deliverLead(owner)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) parkLeads(ctx context.Context) error {
|
||||
running, err := m.store.SessionsInState(ctx, api.AgentSessionStateRunning)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, sess := range running {
|
||||
if sess.Owner == "" {
|
||||
continue
|
||||
}
|
||||
err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, "byre", map[string]any{"error": "byre stopped mid-turn"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.store.AddLeadInput(ctx, store.LeadInput{Owner: sess.Owner, Kind: store.LeadSignal, Actor: "byre", Text: leadRestartNotice})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) leadTurnEnded(ctx context.Context, l *live, res claude.Result) error {
|
||||
m.mu.Lock()
|
||||
interrupted := l.interrupted
|
||||
compacting := l.compacting
|
||||
wantCompact := l.wantCompact
|
||||
m.mu.Unlock()
|
||||
|
||||
finished := !res.IsError && !interrupted && strings.TrimSpace(res.Result) != ""
|
||||
handoff := compacting && finished
|
||||
|
||||
payload := store.ToPayload(res)
|
||||
if handoff {
|
||||
payload["handoff"] = true
|
||||
}
|
||||
err := m.emit(ctx, l, agentActor, "turn.ended", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
window := res.ModelUsage[l.contextModel].ContextWindow
|
||||
if window > 0 {
|
||||
err = m.updateContext(ctx, l, l.contextUsed, window)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var state map[string]any
|
||||
if res.IsError && !interrupted {
|
||||
state = map[string]any{"error": fmt.Sprintf("the turn ended with an error (%s): %s", res.Subtype, res.Result)}
|
||||
}
|
||||
err = m.store.SetSessionState(ctx, l.id, api.AgentSessionStateIdle, agentActor, state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case handoff:
|
||||
go m.continueFromHandoff(l.id, res.Result)
|
||||
case wantCompact && finished && !compacting:
|
||||
go func() {
|
||||
_, err := m.Compact(context.Background(), l.id)
|
||||
if err != nil {
|
||||
slog.Error("compact lead", "session", l.id, "err", err)
|
||||
}
|
||||
}()
|
||||
default:
|
||||
go m.deliverLead(l.owner)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Observe(ev api.Event) {
|
||||
switch ev.Kind {
|
||||
case "inbox.created", "inbox.updated", "task.merged", "task.discarded", "focus.updated":
|
||||
default:
|
||||
return
|
||||
}
|
||||
select {
|
||||
case m.signals <- ev:
|
||||
default:
|
||||
slog.Warn("lead signal dropped", "kind", ev.Kind, "seq", ev.Seq)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) signalLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-m.watchStop:
|
||||
return
|
||||
case ev := <-m.signals:
|
||||
err := m.signal(context.Background(), ev)
|
||||
if err != nil {
|
||||
slog.Error("signal lead", "kind", ev.Kind, "seq", ev.Seq, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) signal(ctx context.Context, ev api.Event) error {
|
||||
owners := []string{}
|
||||
if ev.Owner != nil {
|
||||
owners = append(owners, *ev.Owner)
|
||||
} else {
|
||||
all, err := m.store.LeadOwners(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
owners = all
|
||||
}
|
||||
|
||||
text, err := m.signalText(ctx, ev)
|
||||
if err != nil || text == "" {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, owner := range owners {
|
||||
if ev.Actor == leadActor(owner) {
|
||||
continue
|
||||
}
|
||||
err = m.store.AddLeadInput(ctx, store.LeadInput{Owner: owner, Kind: store.LeadSignal, Actor: "byre", Text: text})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.deliverLead(owner)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) taskLabel(ctx context.Context, taskID string) string {
|
||||
task, err := m.store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("[task %s]", taskID)
|
||||
}
|
||||
project, err := m.store.GetProject(ctx, task.ProjectId)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("[%s, task %s]", task.Title, task.Id)
|
||||
}
|
||||
return fmt.Sprintf("[%s ▸ %s, task %s]", project.Name, task.Title, task.Id)
|
||||
}
|
||||
|
||||
func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error) {
|
||||
switch ev.Kind {
|
||||
case "inbox.created", "inbox.updated":
|
||||
item := api.InboxItem{}
|
||||
err := remarshal(ev.Payload, &item)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ev.Kind == "inbox.updated" {
|
||||
return m.answeredText(ctx, item), nil
|
||||
}
|
||||
return m.itemText(ctx, item), nil
|
||||
|
||||
case "task.merged":
|
||||
commit, _ := ev.Payload["commit"].(string)
|
||||
if commit == "" {
|
||||
return fmt.Sprintf("%s was merged by %s with nothing to commit.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil
|
||||
}
|
||||
return fmt.Sprintf("%s was merged by %s as %s and pushed to %v.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor, short(commit), ev.Payload["pushed"]), nil
|
||||
|
||||
case "task.discarded":
|
||||
return fmt.Sprintf("%s was discarded by %s.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil
|
||||
|
||||
case "focus.updated":
|
||||
f := api.Focus{}
|
||||
err := remarshal(ev.Payload, &f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if f.TaskId == nil {
|
||||
return "The human cleared the right pane.", nil
|
||||
}
|
||||
return fmt.Sprintf("The human switched the right pane to %s, tab %s.", m.taskLabel(ctx, *f.TaskId), f.Tab), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *Manager) itemText(ctx context.Context, item api.InboxItem) string {
|
||||
label := m.taskLabel(ctx, item.TaskId)
|
||||
p := item.Payload
|
||||
switch item.Kind {
|
||||
case api.Question:
|
||||
b := &strings.Builder{}
|
||||
fmt.Fprintf(b, "%s asked question %s (%s): %v\n", label, item.Id, item.Priority, p["question"])
|
||||
recommended := map[string]bool{}
|
||||
for _, r := range anyStrings(p["recommended"]) {
|
||||
recommended[r] = true
|
||||
}
|
||||
kind := "pick one"
|
||||
if p["multiple"] == true {
|
||||
kind = "pick any"
|
||||
}
|
||||
fmt.Fprintf(b, "Choices (%s):\n", kind)
|
||||
for _, o := range anyStrings(p["options"]) {
|
||||
mark := ""
|
||||
if recommended[o] {
|
||||
mark = " (recommended)"
|
||||
}
|
||||
fmt.Fprintf(b, "- %s%s\n", clip(o, 300), mark)
|
||||
}
|
||||
fmt.Fprintf(b, "Blocked on: %v\nThe full context is in read_inbox.", p["why_blocked"])
|
||||
return b.String()
|
||||
case api.Report:
|
||||
return fmt.Sprintf("%s finished a turn (report %s):\n%s", label, item.Id, clip(fmt.Sprint(p["text"]), 1500))
|
||||
case api.SessionError:
|
||||
return fmt.Sprintf("%s hit an error (item %s): %v", label, item.Id, p["error"])
|
||||
case api.RateLimited:
|
||||
return fmt.Sprintf("%s was rate limited (item %s): %v", label, item.Id, p["error"])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Manager) answeredText(ctx context.Context, item api.InboxItem) string {
|
||||
m.mu.Lock()
|
||||
byLead := m.leadAnswered[item.Id]
|
||||
delete(m.leadAnswered, item.Id)
|
||||
m.mu.Unlock()
|
||||
|
||||
if byLead || item.State != api.Answered || len(item.Responses) == 0 {
|
||||
return ""
|
||||
}
|
||||
r := item.Responses[len(item.Responses)-1]
|
||||
answer := ""
|
||||
if r.Options != nil {
|
||||
answer = strings.Join(*r.Options, "; ")
|
||||
}
|
||||
if r.Text != nil && strings.TrimSpace(*r.Text) != "" {
|
||||
answer = strings.TrimSpace(answer + " " + *r.Text)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s answered %s %s: %s", m.taskLabel(ctx, item.TaskId), r.User, item.Kind, item.Id, clip(answer, 600))
|
||||
}
|
||||
|
||||
func remarshal(from any, to any) error {
|
||||
b, err := json.Marshal(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, to)
|
||||
}
|
||||
|
||||
func anyStrings(v any) []string {
|
||||
out := []string{}
|
||||
list, _ := v.([]any)
|
||||
for _, x := range list {
|
||||
out = append(out, fmt.Sprint(x))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
return string(runes[:n]) + "…"
|
||||
}
|
||||
|
||||
func short(sha string) string {
|
||||
if len(sha) > 7 {
|
||||
return sha[:7]
|
||||
}
|
||||
return sha
|
||||
}
|
||||
|
||||
func deref(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
Reference in New Issue
Block a user