Files
byre/internal/agents/leadtools.go
T

988 lines
35 KiB
Go

package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
"g.fc.run/misc/byre/internal/todo"
)
func (m *Manager) leadTools(l *live) []mcp.Tool {
tool := func(name, description, schema string, run func(ctx context.Context, user api.User, raw json.RawMessage) (string, error)) mcp.Tool {
return mcp.Tool{
Name: name,
Description: description,
InputSchema: json.RawMessage(schema),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
user, err := m.store.GetUser(ctx, l.owner)
if err != nil {
return "", err
}
return run(ctx, user, raw)
},
}
}
taskOnly := `{"type": "object", "properties": {"task_id": {"type": "string"}}, "required": ["task_id"]}`
return append([]mcp.Tool{
tool("list_tasks",
"List every project and its active tasks (id, title, worktree path, the agent's state and model, and open inbox items), "+
"and the titles of its TODO.md items on the default branch that no active task was started from.",
`{"type": "object", "properties": {}}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
return m.listTasksText(ctx)
}),
tool("read_transcript",
"Read a task's transcript: messages, tool calls (briefly), tool results (cut short), turn ends and inbox filings, each with its event seq. "+
"Without after_seq it returns the last limit events; with it, the events after that seq.",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"after_seq": {"type": "integer"},
"limit": {"type": "integer", "description": "At most this many events, default 100, at most 500."}
}, "required": ["task_id"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
TaskID string `json:"task_id"`
AfterSeq *int64 `json:"after_seq"`
Limit int `json:"limit"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
return m.transcriptText(ctx, args.TaskID, args.AfterSeq, args.Limit)
}),
tool("search_closed_tasks",
searchClosedDescription+" Give project_id for one project's tasks; without it, every project's. read_transcript reads a closed task's transcript.",
`{"type": "object", "properties": {"project_id": {"type": "string"},`+searchClosedProperties+`}}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := searchClosedArgs{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
return m.searchClosedText(ctx, args.ProjectID, args.Query, args.Limit)
}),
tool("read_inbox",
"Read open inbox items as JSON, with each question's full context, choices and responses. Give task_id for one task's items.",
`{"type": "object", "properties": {"task_id": {"type": "string"}}}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
TaskID string `json:"task_id"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
items, err := m.store.ListInbox(ctx, store.InboxFilter{State: api.Open, TaskID: args.TaskID})
if err != nil {
return "", err
}
b, err := json.MarshalIndent(items, "", " ")
return string(b), err
}),
tool("read_status",
"Read byre's health as JSON, the same as the Status tab: rate_limits (the latest each vendor reported: how much of its window is used, when it resets, and whether it is allowed or allowed_warning), "+
"agent_processes with each one's health, zombies, protocol_errors and running_turns. "+
"Check it before starting a batch of work or when agents misbehave: warn the human when a vendor nears its limit, and put new work on the vendor with headroom.",
`{"type": "object", "properties": {}}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
status, err := m.status(ctx)
if err != nil {
return "", err
}
b, err := json.MarshalIndent(status, "", " ")
return string(b), err
}),
tool("answer_question",
"Respond to an open inbox item on the human's behalf. Only when the answer is clear or the human told you it. "+
"type option: options holds the chosen choices' exact text (one for pick-one; pick-any may add text). "+
"type other: free text to a question or an error. type dismiss: clears an error.",
`{"type": "object", "properties": {
"item_id": {"type": "string"},
"type": {"type": "string", "enum": ["option", "other", "dismiss"]},
"options": {"type": "array", "items": {"type": "string"}},
"text": {"type": "string"}
}, "required": ["item_id", "type"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
c := api.InboxResponseCreate{}
err := json.Unmarshal(raw, &c)
if err != nil {
return "", err
}
m.mu.Lock()
m.leadAnswered[c.ItemId] = true
m.mu.Unlock()
_, err = m.Respond(ctx, user.Login, []api.InboxResponseCreate{c})
if err != nil {
m.mu.Lock()
delete(m.leadAnswered, c.ItemId)
m.mu.Unlock()
return "", err
}
return fmt.Sprintf("Responded to %s. The agent gets it once it is idle and has no other question waiting.", c.ItemId), nil
}),
tool("send_message",
"Send a message to a task's agent; it starts a turn. Starts the agent when the task has none. "+
"If the agent is mid-turn, the message is queued like the human's: the agent reads it with its next tool call's result, "+
"or as its next turn if it makes no further tool call, and gets it only once.",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"text": {"type": "string"},
"model": {"type": "string", "description": "Switch the agent to this model from this turn on."}
}, "required": ["task_id", "text"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
TaskID string `json:"task_id"`
Text string `json:"text"`
Model string `json:"model"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
if strings.TrimSpace(args.Text) == "" {
return "", errors.New("text is empty")
}
task, _, err := m.activeTask(ctx, args.TaskID)
if err != nil {
return "", err
}
sess, found, err := m.liveSession(ctx, task.Id)
if err != nil {
return "", err
}
if !found {
_, err = m.create(ctx, task, user, args.Text, args.Model, leadActor(user.Login), nil)
if err != nil {
return "", err
}
return fmt.Sprintf("Started an agent on %s with your message.", task.Id), nil
}
_, queued, err := m.Message(ctx, sess.Id, leadActor(user.Login), args.Text, args.Model)
if err != nil {
return "", err
}
if queued {
return fmt.Sprintf("Queued for %s's agent, which is mid-turn: it reads the message with its next tool call's result, or as its next turn.", task.Id), nil
}
return fmt.Sprintf("Sent to %s's agent.", task.Id), nil
}),
tool("create_task",
"Create a task with its own branch and worktree, and start an agent on it with prompt as its first message. "+
"The new agent sees nothing else, so the prompt must be a complete brief. It starts from the project's default branch, "+
"or from from_task_id's committed HEAD. With workflow, it runs that workflow instead: each stage becomes its own task, titled \"<title>: <stage>\", on the stage's model, "+
"and this call creates the tasks of the stages that start from the brief (the others follow as the stages they come after finish); "+
"prompt is then optional and is added to every stage's first message as its brief, and model can't be given.",
`{"type": "object", "properties": {
"project_id": {"type": "string"},
"title": {"type": "string"},
"prompt": {"type": "string"},
"from_task_id": {"type": "string"},
"model": {"type": "string"},
"workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of and run."}
}, "required": ["project_id", "title"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
ProjectID string `json:"project_id"`
Title string `json:"title"`
Prompt string `json:"prompt"`
FromTaskID string `json:"from_task_id"`
Model string `json:"model"`
Workflow string `json:"workflow"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
if strings.TrimSpace(args.Title) == "" {
return "", errors.New("title is required")
}
if args.Workflow == "" && strings.TrimSpace(args.Prompt) == "" {
return "", errors.New("prompt is required without a workflow")
}
if args.Workflow != "" && args.Model != "" {
return "", errModelWithWorkflow
}
if user.GitName == "" || user.GitEmail == "" {
return "", ErrNoGitIdentity
}
project, err := m.store.GetProject(ctx, args.ProjectID)
if err != nil {
return "", err
}
base := project.DefaultBranch
if args.FromTaskID != "" {
from, _, err := m.activeTask(ctx, args.FromTaskID)
if err != nil {
return "", err
}
base, err = git.Head(from.WorktreePath)
if err != nil {
return "", err
}
}
if args.Workflow != "" {
tasks, err := m.StartWorkflow(ctx, project, user, args.Title, base, leadActor(user.Login), args.Workflow, args.Prompt, nil)
if err != nil {
return "", err
}
return fmt.Sprintf("Workflow %q started: %s.", args.Workflow, describeTasks(tasks)), nil
}
task, err := m.CreateTask(ctx, project, args.Title, user.Login, base, leadActor(user.Login))
if err != nil {
return "", err
}
_, err = m.create(ctx, task, user, args.Prompt, m.modelOr(args.Model, l), leadActor(user.Login), nil)
if err != nil {
return "", fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
}
return fmt.Sprintf("Task %s %q created from %s and its agent started.", task.Id, task.Title, task.BaseRef), nil
}),
tool("create_project",
"Add a project to byre for the human: name it, and give the path of a git repository on this machine. "+
"byre records the repository's root and default branch. Returns the project as JSON.",
`{"type": "object", "properties": {
"name": {"type": "string"},
"repo_path": {"type": "string"}
}, "required": ["name", "repo_path"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := api.ProjectCreate{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
p, err := m.addProject(ctx, args.Name, args.RepoPath, user.Login)
if err != nil {
return "", err
}
b, err := json.MarshalIndent(p, "", " ")
return string(b), err
}),
tool("start_todo",
"Start a task on an item of a project's TODO.md (its title, exactly as list_tasks' project or the file on the default branch has it). "+
"With workflow, it runs that workflow with the item as its brief, each stage as its own task, and model can't be given.",
`{"type": "object", "properties": {
"project_id": {"type": "string"},
"title": {"type": "string"},
"model": {"type": "string"},
"workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of and run."}
}, "required": ["project_id", "title"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
ProjectID string `json:"project_id"`
Title string `json:"title"`
Model string `json:"model"`
Workflow string `json:"workflow"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
if args.Workflow != "" && args.Model != "" {
return "", errModelWithWorkflow
}
project, err := m.store.GetProject(ctx, args.ProjectID)
if err != nil {
return "", err
}
tasks, err := m.StartTodo(ctx, project, user, args.Title, m.modelOr(args.Model, l), leadActor(user.Login), args.Workflow, false)
if err != nil {
return "", err
}
return fmt.Sprintf("Started %s.", describeTasks(tasks)), nil
}),
tool("interrupt", "Interrupt the turn a task's agent is running.", taskOnly,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
sess, err := m.taskSessionArg(ctx, raw)
if err != nil {
return "", err
}
_, err = m.Interrupt(ctx, sess.Id)
if err != nil {
return "", err
}
return "Interrupted.", nil
}),
tool("compact_task", "Have a task's idle agent write a handoff and continue in a fresh session.",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"model": {"type": "string", "description": "Start the fresh session on this model, on either vendor; the current agent still writes the handoff. Defaults to the current model."}
}, "required": ["task_id"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
Model string `json:"model"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
sess, err := m.taskSessionArg(ctx, raw)
if err != nil {
return "", err
}
_, err = m.Compact(ctx, sess.Id, args.Model)
if err != nil {
return "", err
}
return "Compaction started.", nil
}),
tool("merge_task",
"Merge a task as the human: its agent merges the default branch in, then byre squashes it into one commit, pushes it to origin and closes the task. "+
"While another task in the project is merging, byre holds the merge and starts it when that one is done. "+
"For a subtask, target parent instead asks its parent task's agent to merge it into the parent's branch, and the subtask closes once the parent has. "+
"Refused for a task with active subtasks. Only when the human asks. You get a signal when it is merged; failures are filed in the task's inbox.",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"target": {"type": "string", "enum": ["main", "parent"], "description": "Default main."}
}, "required": ["task_id"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
Target string `json:"target"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
task, project, err := m.taskArg(ctx, raw)
if err != nil {
return "", err
}
switch args.Target {
case "", "main":
case "parent":
_, err = m.MergeIntoParent(ctx, task, user)
if err != nil {
return "", err
}
return fmt.Sprintf("Merge of %s into its parent %s requested.", task.Id, *task.ParentTaskId), nil
default:
return "", fmt.Errorf("%w: target must be main or parent, not %q", ErrInvalid, args.Target)
}
task, err = m.Merge(ctx, task, project, user, task.MergeTitle)
if err != nil {
return "", err
}
if task.MergeQueuedAt != nil {
return fmt.Sprintf("Merge of %s held until the merge in progress in its project is done.", task.Id), nil
}
return fmt.Sprintf("Merge of %s started.", task.Id), nil
}),
tool("take_snapshot", takeSnapshotDescription+" Refused while the task is merging or being discarded.", taskOnly,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
task, _, err := m.taskArg(ctx, raw)
if err != nil {
return "", err
}
err = m.RequestSnapshot(ctx, task.Id)
if err != nil {
return "", err
}
return fmt.Sprintf("Snapshot of %s requested; its review shows in the task's transcript and Reviews tab, and findings go to its agent.", task.Id), nil
}),
tool("discard_task",
"Discard a task: its agent gets one turn to write the task summary, then byre stops it and deletes its worktree and branch without merging. This destroys its unmerged work. Refused for a task with active subtasks. Only when the human asks. "+
"If the agent is mid-turn, the summary turn starts when that turn ends; no other turn runs in between.",
taskOnly,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
task, _, err := m.taskArg(ctx, raw)
if err != nil {
return "", err
}
_, err = m.Discard(ctx, task, user.Login)
if err != nil {
return "", err
}
return fmt.Sprintf("Discarding %s: its agent gets a turn to write the task summary, and byre discards the task when that turn ends.", task.Id), nil
}),
tool("show",
"Point the human's right pane at a task, or at a project with no task: tabs are the tabs to put first, in order (the rest follow), and tab is the one to select. "+
"Whenever the human asks you to show something (\"show me that when you can\") or you want to show something, call show right away; never hold back because you expect a refusal, since only a refused show arranges byre's signal to try again. "+
"Tabs: transcript, changes, files, terminal (a shell in the task's worktree), inbox (the task's open items), reviews (the task's reviews by reviewer agents), workflow (a workflow stage task's run: its graph and each stage's state, attempts and outputs), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+
"Omit both task_id and project_id to clear the pane. "+
"If the pane already shows that task (or project) on that tab, only the tab order changes and you are told so, even while the human is working in the pane. "+
"Otherwise refused for 10 seconds after the human last moved the pane themselves (switching task or tab), whatever else holds; if a show was refused for it, byre signals you \"The move hold is over\" when those 10 seconds pass without another move, and sends no signal otherwise. "+
"Otherwise refused while the human is working in the right pane, unless it is on the project tab, or what it shows is finished (a merged or discarded task, or an inbox tab with nothing left), "+
"or it is on the transcript tab of a task whose agent is mid-turn, the human has no unsent text there, and either the human sent the message that started that turn since the pane last moved or the pane moved there 10 seconds ago or more; byre signals you \"Your right pane is free\" when either happens. "+
"Also refused while the pane is on the project tab and the human started a TODO item there in the last 10 seconds, unless they switched task or tab or messaged you since; if a show was refused for it, byre signals you \"The start hold is over\" when those 10 seconds pass with the pane still on the project tab, and sends no signal otherwise.",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"project_id": {"type": "string", "description": "Ignored when task_id is given."},
"tabs": {"type": "array", "items": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"]}},
"tab": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"]}
}, "required": ["tabs", "tab"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
f := api.Focus{}
err := json.Unmarshal(raw, &f)
if err != nil {
return "", err
}
current, err := m.store.GetFocus(ctx, user.Login)
if err != nil {
return "", err
}
if sameView(current, f) {
if slices.Equal(current.Tabs, f.Tabs) {
return "Already shown: the right pane shows that now, so nothing changed.", nil
}
err = m.SetFocus(ctx, user.Login, f, leadActor(user.Login))
if err != nil {
return "", err
}
return "Already shown on that tab; the tab order was updated.", nil
}
m.mu.Lock()
focused := m.paneFocus[user.Login].Focused
humanMoved := m.humanMoved[user.Login]
since := time.Since(humanMoved)
moveHeld := since < moveHold
startHeld := !moveHeld && time.Since(m.startedTodo[user.Login]) < startHold && current.Tab == api.TabProject
if moveHeld {
m.moveRefused[user.Login] = true
}
if startHeld {
m.startRefused[user.Login] = true
}
m.mu.Unlock()
if moveHeld {
return "", fmt.Errorf("%w: the human moved the right pane %s ago, so it was left as it is; tell them in chat instead. The hold ends %s after their last move; byre signals you \"The move hold is over\" when it ends, because this show was refused", ErrConflict, since.Round(time.Second), moveHold)
}
if startHeld {
return "", fmt.Errorf("%w: the human just started a TODO item from the project tab and is likely to start more; tell them in chat instead. The hold ends %s after that start, unless they move the pane or message you first; byre signals you \"The start hold is over\" when it ends, because this show was refused", ErrConflict, startHold)
}
if focused && current.Tab != api.TabProject {
finished, err := m.paneFinished(ctx, current)
if err != nil {
return "", err
}
waiting := false
if finished == "" {
waiting, err = m.paneWaiting(ctx, user.Login, current)
if err != nil {
return "", err
}
}
if finished == "" && !waiting {
return "", fmt.Errorf("%w: the human is working in the right pane, so it was left as it is; tell them in chat instead", ErrConflict)
}
}
err = m.SetFocus(ctx, user.Login, f, leadActor(user.Login))
if err != nil {
return "", err
}
return "Shown.", nil
}),
tool("compact_self",
"Ask byre to compact you when this turn ends: you write a handoff, and a fresh lead session continues from it. Call it only after byre signals that your context is past half of its window, at a logical point, then end the turn with a short final message.",
`{"type": "object", "properties": {}}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
m.mu.Lock()
l.wantCompact = true
m.mu.Unlock()
return "byre will ask for your handoff once this turn ends.", nil
}),
}, append(m.workflowTools(tool), m.noteTools(tool)...)...)
}
func (m *Manager) SetFocus(ctx context.Context, login string, f api.Focus, actor string) error {
for _, t := range append(slices.Clone(f.Tabs), f.Tab) {
if !t.Valid() {
return fmt.Errorf("%w: unknown tab %q", ErrInvalid, t)
}
}
if f.TaskId != nil && *f.TaskId == "" {
f.TaskId = nil
}
if f.ProjectId != nil && *f.ProjectId == "" {
f.ProjectId = nil
}
if f.TaskId != nil {
task, _, err := m.activeTask(ctx, *f.TaskId)
if err != nil {
return err
}
f.ProjectId = &task.ProjectId
} else if f.ProjectId != nil {
_, err := m.store.GetProject(ctx, *f.ProjectId)
if err != nil {
return fmt.Errorf("project %s: %w", *f.ProjectId, err)
}
}
if actor == login {
m.setStartedTodo(login, false)
}
at := time.Now()
m.mu.Lock()
m.paneMoved[login] = at
if actor == login {
m.humanMoved[login] = at
delete(m.moveRefused, login)
}
m.mu.Unlock()
if actor == login {
time.AfterFunc(moveHold, func() {
err := m.endMoveHold(context.Background(), login, at)
if err != nil {
slog.Error("end move hold", "login", login, "err", err)
}
})
time.AfterFunc(watchHold, func() {
err := m.endWatchHold(context.Background(), login, at)
if err != nil {
slog.Error("end watch hold", "login", login, "err", err)
}
})
}
return m.store.SetFocus(ctx, login, f, actor)
}
func (m *Manager) endWatchHold(ctx context.Context, login string, at time.Time) error {
m.mu.Lock()
moved := m.paneMoved[login]
focused := m.paneFocus[login].Focused
m.mu.Unlock()
if !moved.Equal(at) || !focused {
return nil
}
f, err := m.store.GetFocus(ctx, login)
if err != nil {
return err
}
if f.TaskId == nil {
return nil
}
turn, running, err := m.store.RunningTurn(ctx, *f.TaskId)
if err != nil || !running || (turn.Actor == login && turn.At.After(at)) {
return err
}
waiting, err := m.paneWaiting(ctx, login, f)
if err != nil || !waiting {
return err
}
text := fmt.Sprintf("Your right pane is free: the human has watched %s's agent work for 10 seconds without typing.", m.taskLabel(ctx, *f.TaskId))
err = m.store.AddLeadInput(ctx, store.LeadInput{Owner: login, Kind: store.LeadSignal, Actor: "byre", Text: text})
if err != nil {
return err
}
m.deliverLead(login)
return nil
}
func sameView(a, b api.Focus) bool {
if a.Tab != b.Tab {
return false
}
at, bt := deref(a.TaskId), deref(b.TaskId)
if at != "" || bt != "" {
return at == bt
}
return deref(a.ProjectId) == deref(b.ProjectId)
}
const inboxEmptied = "has nothing left in its inbox"
func (m *Manager) paneFinished(ctx context.Context, f api.Focus) (string, error) {
if f.TaskId == nil {
return "", nil
}
task, err := m.store.GetTask(ctx, *f.TaskId)
if err != nil {
return "", err
}
if task.Status != api.TaskStatusActive {
return "is " + string(task.Status), nil
}
if task.Merging {
return "is merging", nil
}
if f.Tab != api.TabInbox {
return "", nil
}
items, err := m.store.ListInbox(ctx, store.InboxFilter{State: api.Open, TaskID: task.Id})
if err != nil {
return "", err
}
if len(items) > 0 {
return "", nil
}
return inboxEmptied, nil
}
const watchHold = 10 * time.Second
func (m *Manager) paneWaiting(ctx context.Context, login string, f api.Focus) (bool, error) {
if f.TaskId == nil || f.Tab != api.TabTranscript {
return false, nil
}
m.mu.Lock()
drafting := m.paneFocus[login].Drafting
reported := m.paneReported[login]
moved := m.paneMoved[login]
m.mu.Unlock()
turn, running, err := m.store.RunningTurn(ctx, *f.TaskId)
if err != nil || !running {
return false, err
}
if turn.Actor == login && turn.At.After(moved) {
// The report of the composer emptying on send can arrive after the turn starts.
return !drafting || reported.Before(turn.At), nil
}
return !drafting && time.Since(moved) >= watchHold, nil
}
func (m *Manager) SetPaneFocus(login string, p api.PaneFocus) {
m.mu.Lock()
defer m.mu.Unlock()
m.paneFocus[login] = p
m.paneReported[login] = time.Now()
}
const moveHold = 10 * time.Second
const moveHoldEnded = "The move hold is over: the human moved the right pane 10 seconds ago and hasn't moved it since, and a show of yours was refused in that time, so show works again under its other rules."
func (m *Manager) endMoveHold(ctx context.Context, login string, at time.Time) error {
m.mu.Lock()
current, ok := m.humanMoved[login]
if !ok || !current.Equal(at) {
m.mu.Unlock()
return nil
}
delete(m.humanMoved, login)
refused := m.moveRefused[login]
delete(m.moveRefused, login)
m.mu.Unlock()
if !refused {
return nil
}
err := m.store.AddLeadInput(ctx, store.LeadInput{Owner: login, Kind: store.LeadSignal, Actor: "byre", Text: moveHoldEnded})
if err != nil {
return err
}
m.deliverLead(login)
return nil
}
const startHold = 10 * time.Second
const startHoldEnded = "The start hold is over: the human started a TODO item from the project tab 10 seconds ago and the pane is still there, and a show of yours was refused in that time, so show works again."
func (m *Manager) setStartedTodo(login string, started bool) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.startRefused, login)
if !started {
delete(m.startedTodo, login)
return
}
at := time.Now()
m.startedTodo[login] = at
time.AfterFunc(startHold, func() {
err := m.endStartHold(context.Background(), login, at)
if err != nil {
slog.Error("end start hold", "login", login, "err", err)
}
})
}
func (m *Manager) endStartHold(ctx context.Context, login string, at time.Time) error {
m.mu.Lock()
current, ok := m.startedTodo[login]
if !ok || !current.Equal(at) {
m.mu.Unlock()
return nil
}
delete(m.startedTodo, login)
refused := m.startRefused[login]
delete(m.startRefused, login)
m.mu.Unlock()
if !refused {
return nil
}
f, err := m.store.GetFocus(ctx, login)
if err != nil {
return err
}
if f.Tab != api.TabProject {
return nil
}
err = m.store.AddLeadInput(ctx, store.LeadInput{Owner: login, Kind: store.LeadSignal, Actor: "byre", Text: startHoldEnded})
if err != nil {
return err
}
m.deliverLead(login)
return nil
}
func (m *Manager) modelOr(model string, l *live) string {
if model != "" {
return model
}
return l.model
}
func (m *Manager) activeTask(ctx context.Context, taskID string) (api.Task, api.Project, error) {
task, err := m.store.GetTask(ctx, taskID)
if err != nil {
return api.Task{}, api.Project{}, fmt.Errorf("task %s: %w", taskID, err)
}
if task.Status != api.TaskStatusActive {
return api.Task{}, api.Project{}, fmt.Errorf("%w: task %s is %s", ErrConflict, task.Id, task.Status)
}
project, err := m.store.GetProject(ctx, task.ProjectId)
return task, project, err
}
func (m *Manager) liveSession(ctx context.Context, taskID string) (api.AgentSession, bool, error) {
sessions, err := m.store.ListSessions(ctx, taskID)
if err != nil {
return api.AgentSession{}, false, err
}
for _, s := range sessions {
if s.State != api.AgentSessionStateCompleted {
return s, true, nil
}
}
return api.AgentSession{}, false, nil
}
func (m *Manager) taskArg(ctx context.Context, raw json.RawMessage) (api.Task, api.Project, error) {
args := struct {
TaskID string `json:"task_id"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return api.Task{}, api.Project{}, err
}
return m.activeTask(ctx, args.TaskID)
}
func (m *Manager) taskSessionArg(ctx context.Context, raw json.RawMessage) (api.AgentSession, error) {
task, _, err := m.taskArg(ctx, raw)
if err != nil {
return api.AgentSession{}, err
}
sess, found, err := m.liveSession(ctx, task.Id)
if err != nil {
return api.AgentSession{}, err
}
if !found {
return api.AgentSession{}, fmt.Errorf("task %s has no agent", task.Id)
}
return sess, nil
}
func (m *Manager) listTasksText(ctx context.Context) (string, error) {
projects, err := m.store.ListProjects(ctx)
if err != nil {
return "", err
}
items, err := m.store.ListInbox(ctx, store.InboxFilter{State: api.Open})
if err != nil {
return "", err
}
b := &strings.Builder{}
for _, p := range projects {
fmt.Fprintf(b, "## %s (project %s, repo %s, default branch %s)\n", p.Name, p.Id, p.RepoPath, p.DefaultBranch)
tasks, err := m.store.ListTasks(ctx, p.Id)
if err != nil {
return "", err
}
listed := false
started := map[string]bool{}
for _, t := range tasks {
if t.Status != api.TaskStatusActive {
continue
}
listed = true
if t.TodoItem != nil {
started[*t.TodoItem] = true
}
agent := string(t.State)
sess, found, err := m.liveSession(ctx, t.Id)
if err != nil {
return "", err
}
if found && sess.Model != "" {
agent += "; agent on " + sess.Model
}
open := 0
for _, i := range items {
if i.TaskId == t.Id {
open++
}
}
fmt.Fprintf(b, "- %s %q: %s; inbox %d open; worktree %s", t.Id, t.Title, agent, open, t.WorktreePath)
if t.TodoItem != nil {
fmt.Fprintf(b, "; from TODO item %q", *t.TodoItem)
}
if t.ForkedFrom != nil {
fmt.Fprintf(b, "; forked from %s at snapshot %d", *t.ForkedFrom, *t.ForkedAt)
}
if t.ParentTaskId != nil {
fmt.Fprintf(b, "; subtask of %s", *t.ParentTaskId)
if t.MergingIntoParent {
b.WriteString(", merging into it")
}
}
if t.Workflow != nil {
stage, err := m.workflowStatus(ctx, t)
if err != nil {
return "", err
}
fmt.Fprintf(b, "; workflow %q, %s", *t.Workflow, stage)
}
b.WriteString("\n")
}
if !listed {
b.WriteString("- no active tasks\n")
}
src, found, err := git.ReadFile(p.RepoPath, p.DefaultBranch, todo.File)
if err != nil {
return "", err
}
if !found {
continue
}
titles := []string{}
for _, item := range todo.Parse(src) {
if !started[item.Title] {
titles = append(titles, item.Title)
}
}
if len(titles) == 0 {
fmt.Fprintf(b, "%s items not started: none\n", todo.File)
continue
}
fmt.Fprintf(b, "%s items not started: %s\n", todo.File, strings.Join(titles, "; "))
}
return b.String(), nil
}
func (m *Manager) transcriptText(ctx context.Context, taskID string, after *int64, limit int) (string, error) {
task, err := m.store.GetTask(ctx, taskID)
if err != nil {
return "", fmt.Errorf("task %s: %w", taskID, err)
}
if limit <= 0 {
limit = 100
}
limit = min(limit, 500)
var events []api.Event
if after == nil {
events, err = m.store.TaskEventsTail(ctx, task.Id, limit)
} else {
events, err = m.store.TaskEvents(ctx, task.Id, *after, limit)
}
if err != nil {
return "", err
}
reviewers, err := m.store.ReviewerSessionIDs(ctx, task.Id)
if err != nil {
return "", err
}
b := &strings.Builder{}
for _, ev := range events {
if ev.AgentSessionId != nil && reviewers[*ev.AgentSessionId] {
continue
}
line := transcriptLine(ev, task.WorktreePath)
if line != "" {
fmt.Fprintf(b, "#%d %s\n", ev.Seq, line)
}
}
if len(events) == limit && limit > 0 {
fmt.Fprintf(b, "(more may follow: call again with after_seq %d)\n", events[len(events)-1].Seq)
}
if b.Len() == 0 {
return "No events.", nil
}
return b.String(), nil
}
func transcriptLine(ev api.Event, root string) string {
p := ev.Payload
text := func(k string) string { return fmt.Sprint(p[k]) }
rel := func(s string) string { return strings.ReplaceAll(s, root+"/", "") }
if p["parent_tool_use_id"] != nil {
switch ev.Kind {
case "agent.message":
return "subagent: " + text("text")
case "agent.tool_call":
return "subagent tool " + text("name")
}
return ""
}
switch ev.Kind {
case "user.message":
if ev.Actor == "byre" {
return "byre: " + clip(text("text"), 300)
}
return ev.Actor + ": " + text("text")
case "agent.message":
return "agent: " + text("text")
case "agent.tool_call":
input, _ := json.Marshal(p["input"])
return fmt.Sprintf("tool %s %s", text("name"), clip(rel(string(input)), 200))
case "agent.tool_result":
content, _ := json.Marshal(p["content"])
return "result: " + clip(rel(string(content)), 300)
case "turn.ended":
if p["is_error"] == true {
return fmt.Sprintf("turn ended with an error (%s)", text("subtype"))
}
return "turn ended"
case "inbox.created":
return fmt.Sprintf("filed %s %s", text("kind"), text("id"))
case "session.created":
return fmt.Sprintf("session started by %s", ev.Actor)
case "session.state":
if p["error"] != nil {
return fmt.Sprintf("session %s: %s", text("state"), text("error"))
}
case "task.merged", "task.discarded":
return fmt.Sprintf("%s by %s", ev.Kind, ev.Actor)
case "task.workflow_stage":
return fmt.Sprintf("started stage %q of workflow %q", fmt.Sprint(p["stage"]), text("workflow"))
case "task.workflow_finished":
return fmt.Sprintf("finished workflow %q: %s", text("workflow"), clip(text("output"), 300))
case "review.finished":
r := api.Review{}
err := remarshal(p, &r)
if err != nil {
return "review finished: " + err.Error()
}
return reviewLine(r)
}
return ""
}