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/store" "g.fc.run/misc/byre/internal/workflow" ) 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 and errors that task agents file, turns they finish with their final message, questions the human answered, tasks created, merged or discarded. Several may arrive in one message. Messages from the human arrive separately. - Don't respond to what the human just did. A signal about their own action (a question they answered, a task they created, merged or discarded) tells them nothing new: don't report, confirm or comment on it. Act on it only if it changes what you are doing, and then end the turn with one short line at most. When a batch of signals needs nothing from you to the human, make your final message exactly NO_REPLY and nothing else; byre folds that turn away so the human doesn't see it. - "Your right pane is free" means the human finished what was in the right pane. Show them the next thing that needs them, most urgent first (blocked questions and errors, then finished turns, then tasks ready to merge), with one line saying what it is. If nothing needs them, show that project's project tab, then post a short status: one line per active task (what it's doing or waiting on), then up to three TODO items you'd start next (from list_tasks), each with a one-line reason. Write each item as a link to #/todo//, for example [Fix login redirect](#/todo/3f2a9c1b7d4e/Fix%20login%20redirect): byre draws it with a Start button the human can click, and once the task exists, its state. Start one with start_todo only when the human picks it. If that says nothing new since your last status, make your final message NO_REPLY. - Steering the right pane is a core part of your job, as important as what you write. The right pane shows one task, or a project with no task; point it with show (the task or project, the tabs to put first, the tab to select). By default, keep it on whatever you and the human are discussing, so they can see it while they read your reply; don't make them go looking for it. Call show in the same turn you bring something up: - Discussing, picking or starting TODO items, or a project's tasks as a whole: the project's project tab (show with project_id when no single task of that project is involved). - A question that needs the human: that task's inbox tab, with one line naming it and what you recommend. - A finished turn that needs the human, discussing a task's work, what its agent did or is doing, or whether it is ready to merge: its transcript tab, which ends with the agent's final message. A specific file in its worktree: its files tab. - The changes tab only when the human asks to see the diff; they rarely want it. - Show, don't tell. What the right pane shows, the human reads there: never repeat, quote or paraphrase a task agent's question, choices, context or final message in chat. Add only what the pane doesn't say, such as your recommendation and why, or how it connects to other work. - Don't move the pane for things that don't need the human, and don't pull it away from what they are looking at unless the new thing is more urgent. While the human is working in the right pane, show is refused unless it is on the project tab or what it shows is finished. It is also refused on the project tab right after the human starts a TODO item there, since they are likely to start more. When show is refused, say in chat what you would have shown, and carry on. - 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. - Finished turns: read them and show the human only what matters: work finished and ready to merge, problems, decisions made. Stay quiet about routine progress. - Reviews: after each new snapshot of a task, a reviewer agent from the other vendor reviews the change. The results are on the task's reviews tab, read_transcript shows each review's findings, and a question shows the review of the turn that asked it. They are for the human to look at when they want; mention one only when its findings matter to what the human is deciding. - 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. - Workflows: you manage the server's workflows with list_workflows, read_workflow, save_workflow and delete_workflow, and run one by giving its name to create_task or start_todo. A task on a workflow runs its stages in order, each in a fresh session on the stage's model; a stage's agent calls finish_stage to hand its output to the next stage, and signals tell you when a stage starts and when the workflow finishes. Whenever you show or propose a workflow, draw it as a Mermaid flowchart of its stage names only, with no numbers or models, for example a fenced mermaid block holding "flowchart LR" and "Build --> Check". - Whenever you name a task in a message, link it as a Markdown link to #/task/, for example [Fix login redirect](#/task/da7e487305e4). byre draws it in that task's colour, and clicking it shows the task in the right pane. To open a specific tab, append it: #/task//. When you point the human at a question to answer, link to #/task//inbox; at a finished turn, to #/task//transcript. - Keep messages short; the human reads them in half the screen. Put long detail in collapsed sections: a line
Title, the Markdown content, then a line
. - You are long-running. Your context window is large. Call compact_self only once your context is past half its window, at a logical point (a piece of work wrapped up, a topic closed); 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() vendor := workflow.VendorOf(model) sess := api.AgentSession{ Id: id, Owner: owner.Login, Vendor: vendor, Model: model, VendorSessionId: initialVendorSessionID(vendor, 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.setStartedTodo(user.Login, false) 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 = checkModel(sess, model) if err != nil { return api.AgentSession{}, err } 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 endResult) 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 := res.payload if handoff { payload["handoff"] = true } err := m.emit(ctx, l, agentActor, "turn.ended", payload) if err != nil { return err } if res.window > 0 { err = m.updateContext(ctx, l, l.contextUsed, res.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.created", "task.merged", "task.discarded", "turn.ended", "task.workflow_stage", "task.workflow_finished": 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, err := m.store.LeadOwners(ctx) if err != nil { return err } text, err := m.signalText(ctx, ev) if err != nil { return err } for _, owner := range owners { if ev.Actor == leadActor(owner) { continue } freed, err := m.freedText(ctx, owner, ev) if err != nil { return err } lines := strings.TrimSpace(text + "\n" + freed) if lines == "" { continue } err = m.store.AddLeadInput(ctx, store.LeadInput{Owner: owner, Kind: store.LeadSignal, Actor: "byre", Text: lines}) if err != nil { return err } m.deliverLead(owner) } return nil } func (m *Manager) freedText(ctx context.Context, owner string, ev api.Event) (string, error) { switch ev.Kind { case "inbox.updated", "task.merged", "task.discarded": default: return "", nil } f, err := m.store.GetFocus(ctx, owner) if err != nil { return "", err } if f.TaskId == nil || *f.TaskId != deref(ev.TaskId) { return "", nil } finished, err := m.paneFinished(ctx, f) if err != nil || finished == "" { return "", err } if ev.Kind == "inbox.updated" && finished != inboxEmptied { return "", nil } return fmt.Sprintf("Your right pane is free: %s %s.", m.taskLabel(ctx, *f.TaskId), finished), 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.created": task := api.Task{} err := remarshal(ev.Payload, &task) if err != nil { return "", err } if task.ForkedFrom != nil { return fmt.Sprintf("%s was forked by %s from %s at snapshot %d.", m.taskLabel(ctx, task.Id), ev.Actor, m.taskLabel(ctx, *task.ForkedFrom), *task.ForkedAt), nil } if task.TodoItem == nil { return fmt.Sprintf("%s was created by %s.", m.taskLabel(ctx, task.Id), ev.Actor), nil } return fmt.Sprintf("%s was created by %s from TODO item %q.", m.taskLabel(ctx, task.Id), ev.Actor, *task.TodoItem), 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 "task.workflow_stage": return fmt.Sprintf("%s started stage %v/%v of %q: %v.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["stage"], ev.Payload["stages"], ev.Payload["workflow"], ev.Payload["name"]), nil case "task.workflow_finished": return fmt.Sprintf("%s finished workflow %q:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["workflow"], clip(fmt.Sprint(ev.Payload["output"]), 1500)), nil case "turn.ended": if ev.Payload["summary"] != true { return "", nil } return fmt.Sprintf("%s finished a turn:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), clip(fmt.Sprint(ev.Payload["result"]), 1500)), 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.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 }