Files
byre/internal/agents/lead.go
T

676 lines
31 KiB
Go
Raw Normal View History

package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
2026-10-18 15:32:07 -07:00
"g.fc.run/ai/byre/internal/api"
"g.fc.run/ai/byre/internal/store"
"g.fc.run/ai/byre/internal/workflow"
)
const leadGuidance = `You are the human's lead agent in byre, shown in the left pane of byre's app. 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 that task agents ask, errors and rate limits their sessions hit, turns they finish with their final message, a task's agent saying the task is ready to merge (with its overview of the whole task, which byre asks for once the review of its latest work has finished and its turn has ended) or suggesting the human discard it (with its reasoning), questions the human answered, tasks created, reviews started and finished, a merge held behind another task's or set to start after the review (and that cancelled or dropped), starting to merge, merged or discarded, and every change of a task's state, as "<task> state: <before> → <now>." listed first among what happened at the same moment. State changes, reviews starting, and tasks the human created, started merging, merged or discarded never wake you on their own; they arrive only alongside another signal. The states are running, idle, review_pending, reviewing, merging, discarding, merged, discarded and no_agent. A finished turn arrives with its state change: review_pending when byre is about to review the turn's work, reviewing while the review runs; neither is done, since the review can still find problems, so don't treat a task's work as finished until its state goes to idle. Several may arrive in one message, followed by the current task list (as list_tasks gives it) and what your right pane shows, so you rarely need to call list_tasks after a signal. The human doesn't see signals. Messages from the human arrive separately.
2026-10-16 08:38:14 -07:00
- Don't respond to what the human just did. A signal about their own action (a question they answered, a task they created, merged, started merging 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 (questions, then errors on the task's transcript tab, then finished turns, then tasks ready to merge or suggested for discarding), 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/<project_id>/<title, URL-encoded with parentheses as %28 and %29>, 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.
- Whenever you suggest new work that isn't in TODO.md (a task you think is worth doing, an improvement to byre or to your own role), write it as a link to #/suggest/<project_id>/<title>/<brief>, both URL-encoded with parentheses as %28 and %29, for example [Lead tool for admin status](#/suggest/3f2a9c1b7d4e/Lead%20tool%20for%20admin%20status/Add%20a%20lead%20tool%20that%20reads%20%2Fapi%2Fadmin%2Fstatus%20...). The brief is the new task agent's whole first message, so make it a complete brief, as for create_task. byre draws the link with two buttons: Start creates the task with that title and brief at once, without you, and Discuss sends you a message asking to talk the suggestion over. Once an active task has that title, it shows the task's state instead. Don't ask whether to start a suggestion; the buttons do that.
2026-10-19 08:56:37 -07:00
- 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, and 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 transcript tab, where the question waits to be answered, 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.
2026-10-17 09:24:13 -07:00
- 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 for 10 seconds after the human starts a TODO item there, since they are likely to start more. It is always refused for 10 seconds after the human moves the pane themselves, since they just picked what to look at. Whenever the human asks you to show something (for example "show me that when you can") or you want to show something, call show right away and never hold back expecting a refusal: a refused show is the only thing that arranges byre's signal to try again, so a show you skip is never retried. When show is refused, say in chat what you would have shown, and carry on. "The start hold is over" means those 10 seconds passed with the pane still on the project tab, and "The move hold is over" means 10 seconds passed since the human's last move; byre sends either only when a show of yours was refused during that hold. Then show what you held back, if it still matters and the pane's other rules allow it, or make your final message NO_REPLY.
- 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, the human already decided it, or one of your notes settles it outright. 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, naming the note by its id when one decided it.
- Notes: you learn how the human works and keep it with remember, forget and list_notes. byre adds every note to your instructions at the start of each lead session, so they outlast compaction; the human can read and delete them in the Settings tab. Write a note when the human shows a priority, a preference or the reasoning behind a choice that will recur: an answer that departs from the agent's recommendation (the answered signal shows both), a correction or overrule of you or a task agent, or a statement of how they want things done. Record the choice and the why, in their terms, general enough to apply to the next case and specific enough to act on, for example "Prefers replacing a state label over adding a tag beside it: keeps the picker uncluttered." File each note under the project it is about with project_id when it concerns that project's code, UI, docs or process, and with no project_id only when it holds for how the human works everywhere; apply a project's notes only to that project's tasks. Task agents start with their creator's every-project notes and their project's, and suggest new ones with suggest_note, which reaches you as a signal: save a suggestion only when it holds and your notes don't already cover it, choosing its project yourself, and otherwise drop it; neither needs a word to the human. Don't note one-off facts about a single task, and don't duplicate: update an existing note with remember and its id instead. When the human contradicts a note, update it or forget it. Before recommending anything, check your notes, and when one shaped the recommendation, cite its id.
- 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 reviews the change, on the human's review model, or their second review model when the review model wrote the work. The results are in the task's transcript, where its agent answers each finding with respond_to_finding (fixing, or ignoring with a reason), and read_transcript shows each review's findings. They are for the human to look at when they want; mention one only when its findings matter to what the human is deciding.
2026-10-17 08:04:05 -07:00
- Dig into context with list_tasks, read_transcript, read_inbox and read_status (vendor rate limits and agent health), 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.
2026-10-17 08:08:05 -07:00
- Projects: when the human asks you to add a project, create it with create_project from its name and the path of its git repository on this machine.
- 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. Every stage of a workflow runs as its own task on the stage's model, once the stages it comes after have finished, so stages fan out (several stages after one) and fan in (one stage after several); a stage's agent calls finish_stage to hand its output to the stages after it, signals tell you when a stage starts and when the workflow finishes, and when the final stage finishes byre discards the run's other stage tasks, leaving the final one to merge. Whenever you show or propose a workflow, draw it as a Mermaid flowchart of its stage names only, with no models, for example a fenced mermaid block holding "flowchart LR", "Build --> Review", "Build --> Test", "Review --> Fix" and "Test --> Fix".
- Whenever you name a task in a message, link it as a Markdown link to #/task/<task_id>, 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/<task_id>/<tab>. When you point the human at a question to answer or a finished turn, link to #/task/<task_id>/transcript.
- 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. Your context window is large. byre sends you a signal once your context passes a quarter of its window; then call compact_self at a logical point (a piece of work wrapped up, a topic closed), and not before; 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)"
}
2026-10-16 09:51:32 -07:00
func humanActor(actor string) bool {
return actor != "byre" && actor != agentActor && actor != boardActor && !strings.HasSuffix(actor, leadActor(""))
2026-10-16 09:51:32 -07:00
}
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)
2026-10-19 14:08:56 -07:00
if batch[0].Kind == store.LeadSignal && m.interrupted(sess.Id) {
return nil
}
if batch[0].Kind == store.LeadSignal {
state, err := m.leadStateText(ctx, owner)
if err != nil {
slog.Error("lead state", "owner", owner, "err", err)
state = fmt.Sprintf("The task list and right pane state are unavailable: %v", err)
}
text += "\n\n" + state
}
_, 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) leadStateText(ctx context.Context, owner string) (string, error) {
tasks, err := m.listTasksText(ctx)
if err != nil {
return "", err
}
f, err := m.store.GetFocus(ctx, owner)
if err != nil {
return "", err
}
pane := "Your right pane shows nothing."
switch {
case f.TaskId != nil:
pane = fmt.Sprintf("Your right pane shows %s on its %s tab.", m.taskLabel(ctx, *f.TaskId), f.Tab)
case f.ProjectId != nil:
project, err := m.store.GetProject(ctx, *f.ProjectId)
if err != nil {
return "", err
}
pane = fmt.Sprintf("Your right pane shows project %s (%s) on its %s tab.", project.Name, project.Id, f.Tab)
}
m.mu.Lock()
2026-10-16 09:51:32 -07:00
focused := m.paneFocus[owner].Focused
m.mu.Unlock()
if focused {
pane += " The human is working in it."
}
return fmt.Sprintf("Tasks now:\n%s\n%s", tasks, pane), 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:
2026-10-15 22:18:55 -07:00
err = m.warnLeadContext(ctx, l)
if err != nil {
return err
}
go m.deliverLead(l.owner)
}
return nil
}
2026-10-15 22:18:55 -07:00
func (m *Manager) warnLeadContext(ctx context.Context, l *live) error {
if l.contextWarned || l.contextWindow == 0 || l.contextUsed*4 < l.contextWindow {
2026-10-15 22:18:55 -07:00
return nil
}
l.contextWarned = true
text := fmt.Sprintf("Your context is at %d%% of its window (%d of %d tokens). Call compact_self at the next logical point, this turn if nothing is in progress.", l.contextUsed*100/l.contextWindow, l.contextUsed, l.contextWindow)
return m.store.AddLeadInput(ctx, store.LeadInput{Owner: l.owner, Kind: store.LeadSignal, Actor: "byre", Text: text})
}
func (m *Manager) Observe(events []api.Event) {
batch := []api.Event{}
for _, ev := range events {
if ev.Kind == "task.state" && ev.Payload["to"] == string(api.TaskStateIdle) && ev.TaskId != nil {
go m.promptReady(*ev.TaskId, ev.Payload["from"])
}
switch ev.Kind {
case "inbox.created", "inbox.updated", "task.created", "task.merge_queued", "task.merge_after_review", "task.merge_after_review_cancelled", "task.merge_after_review_dropped", "task.merge_started", "task.merged", "task.discarded", "turn.ended", "task.workflow_stage", "task.workflow_finished", "review.started", "review.finished", "task.state", "task.ready", "task.discard_suggested":
case "user.message":
if ev.TaskId == nil || !humanActor(ev.Actor) {
continue
}
case "session.error", "session.rate_limited":
if ev.TaskId == nil {
continue
}
default:
continue
2026-10-16 09:51:32 -07:00
}
batch = append(batch, ev)
}
if len(batch) == 0 {
return
}
select {
case m.signals <- batch:
default:
slog.Warn("lead signal dropped", "kind", batch[0].Kind, "seq", batch[0].Seq)
}
}
func (m *Manager) signalLoop() {
for {
select {
case <-m.watchStop:
return
case batch := <-m.signals:
err := m.signal(context.Background(), batch)
if err != nil {
slog.Error("signal lead", "kind", batch[0].Kind, "seq", batch[0].Seq, "err", err)
}
}
}
}
func (m *Manager) signal(ctx context.Context, batch []api.Event) error {
owners, err := m.store.LeadOwners(ctx)
if err != nil {
return err
}
texts := []string{}
for _, ev := range batch {
text, err := m.signalText(ctx, ev)
if err != nil {
return err
}
texts = append(texts, text)
}
for _, owner := range owners {
states := []string{}
lines := []string{}
loud := false
for i, ev := range batch {
if ev.Actor == leadActor(owner) {
continue
}
freed, err := m.freedText(ctx, owner, ev)
if err != nil {
return err
}
text := strings.TrimSpace(texts[i] + "\n" + freed)
if text == "" {
continue
}
if freed != "" || !quietSignal(ev) {
loud = true
}
if ev.Kind == "task.state" {
states = append(states, text)
continue
}
lines = append(lines, text)
}
if !loud {
continue
}
text := strings.Join(append(states, lines...), "\n")
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 quietSignal(ev api.Event) bool {
switch ev.Kind {
case "task.state", "review.started":
return true
case "task.created", "task.merge_started", "task.merged", "task.discarded":
return humanActor(ev.Actor)
}
return false
}
func (m *Manager) freedText(ctx context.Context, owner string, ev api.Event) (string, error) {
2026-10-16 09:51:32 -07:00
if ev.Kind == "user.message" {
return m.waitingText(ctx, owner, ev)
}
switch ev.Kind {
case "task.merge_started", "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
}
return fmt.Sprintf("Your right pane is free: %s %s.", m.taskLabel(ctx, *f.TaskId), finished), nil
}
2026-10-16 09:51:32 -07:00
func (m *Manager) waitingText(ctx context.Context, owner string, ev api.Event) (string, error) {
if ev.Actor != owner {
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
}
waiting, err := m.paneWaiting(ctx, owner, f)
if err != nil || !waiting {
return "", err
}
return fmt.Sprintf("Your right pane is free: the human is waiting on %s's agent.", m.taskLabel(ctx, *f.TaskId)), 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" || item.State != api.Open {
return m.answeredText(ctx, item), nil
}
return m.itemText(ctx, item), nil
case "session.error":
return fmt.Sprintf("%s hit an error: %v", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["error"]), nil
case "session.rate_limited":
return fmt.Sprintf("%s was rate limited: %v", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["error"]), 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
}
2026-10-16 10:36:38 -07:00
if task.ParentTaskId != nil {
return fmt.Sprintf("%s was created as a subtask of %s by its agent.", m.taskLabel(ctx, task.Id), m.taskLabel(ctx, *task.ParentTaskId)), 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
2026-10-18 23:41:53 -07:00
case "task.merge_queued":
return fmt.Sprintf("%s's merge was requested by %s and is held until the merge in progress in its project is done.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil
case "task.merge_after_review":
return fmt.Sprintf("%s was set by %s to merge once the review of snapshot %v finishes with no findings.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor, ev.Payload["snapshot_n"]), nil
case "task.merge_after_review_cancelled":
return fmt.Sprintf("%s's merge after review was cancelled by %s.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil
case "task.merge_after_review_dropped":
return fmt.Sprintf("%s didn't merge after the review: %v", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["reason"]), nil
2026-10-16 08:38:14 -07:00
case "task.merge_started":
return fmt.Sprintf("%s is merging, started by %s.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), 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
}
2026-10-16 10:36:38 -07:00
if into, ok := ev.Payload["parent_task_id"].(string); ok {
return fmt.Sprintf("%s was merged by %s into %s, now at %s.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor, m.taskLabel(ctx, into), short(commit)), 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.ready":
return fmt.Sprintf("%s's agent says the task is ready to merge:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), clip(fmt.Sprint(ev.Payload["overview"]), 3000)), nil
case "task.discard_suggested":
return fmt.Sprintf("%s's agent suggests discarding the task:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), clip(fmt.Sprint(ev.Payload["reason"]), 3000)), nil
case "task.workflow_stage":
return fmt.Sprintf("%s started stage %q of workflow %q.", m.taskLabel(ctx, deref(ev.TaskId)), fmt.Sprint(ev.Payload["stage"]), ev.Payload["workflow"]), 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 "review.started", "review.finished":
2026-10-16 10:46:42 -07:00
r := api.Review{}
err := remarshal(ev.Payload, &r)
if err != nil {
return "", err
}
if ev.Kind == "review.started" {
return fmt.Sprintf("%s started a review of snapshot %d.", m.taskLabel(ctx, r.TaskId), r.SnapshotN), nil
}
2026-10-16 10:46:42 -07:00
return fmt.Sprintf("%s %s", m.taskLabel(ctx, r.TaskId), reviewLine(r)), nil
case "task.state":
if ev.Payload["from"] == "" {
return fmt.Sprintf("%s state: %v.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["to"]), nil
}
return fmt.Sprintf("%s state: %v → %v.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["from"], ev.Payload["to"]), nil
2026-10-16 09:51:32 -07:00
case "user.message":
return fmt.Sprintf("%s sent %s a message; its agent is working on it.", ev.Actor, m.taskLabel(ctx, deref(ev.TaskId))), nil
case "turn.ended":
if ev.Payload["summary"] != true {
return "", nil
}
if member, _ := ev.Payload["member"].(string); member != "" {
return fmt.Sprintf("%s's agent %q finished a turn:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), member, clip(fmt.Sprint(ev.Payload["result"]), 1500)), 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
b := &strings.Builder{}
fmt.Fprintf(b, "%s asked question %s: %v\n", label, item.Id, 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()
}
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)
}
recommended := strings.Join(anyStrings(item.Payload["recommended"]), "; ")
return fmt.Sprintf("%s: %s answered question %s %q: %s\nThe agent recommended: %s", m.taskLabel(ctx, item.TaskId), r.User, item.Id, clip(fmt.Sprint(item.Payload["question"]), 300), clip(answer, 600), clip(recommended, 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
}