1053 lines
38 KiB
Go
1053 lines
38 KiB
Go
package agents
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"g.fc.run/ai/byre/internal/api"
|
|
"g.fc.run/ai/byre/internal/git"
|
|
"g.fc.run/ai/byre/internal/mcp"
|
|
"g.fc.run/ai/byre/internal/store"
|
|
"g.fc.run/ai/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 questions), "+
|
|
"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, questions asked, errors and rate limits, 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 questions as JSON, with each one's full context, choices and responses. Give task_id for one task's questions.",
|
|
`{"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",
|
|
"Answer an open question 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.",
|
|
`{"type": "object", "properties": {
|
|
"item_id": {"type": "string"},
|
|
"type": {"type": "string", "enum": ["option", "other"]},
|
|
"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. "+
|
|
"On a team task, the message is posted to the team's board instead, so every agent of the team gets it the same way; model is refused there.",
|
|
`{"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 sess.Member != "" {
|
|
if args.Model != "" {
|
|
return "", fmt.Errorf("%w: model can't be given for a team task; its agents keep their own models", ErrInvalid)
|
|
}
|
|
err = m.Post(ctx, task, leadActor(user.Login), args.Text)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("Posted to %s's board; every agent of its team gets it.", task.Id), nil
|
|
}
|
|
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. "+
|
|
"With agents, it starts a team instead of one agent: each agent works in the task's one worktree on its own model, gets prompt and then its own prompt, "+
|
|
"and talks to the others through the task's board; model and workflow can't be given with it.",
|
|
`{"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."},
|
|
"agents": {"type": "array", "description": "A team of at least two agents; the first is the team's first agent, which byre's own requests (merges, discards, review findings) go to.", "items": {"type": "object", "properties": {
|
|
"name": {"type": "string", "description": "One line, unique in the team, such as a role: architect, skeptic."},
|
|
"model": {"type": "string"},
|
|
"prompt": {"type": "string", "description": "This agent's own instructions: its role in the team."}
|
|
}, "required": ["name", "model", "prompt"]}}
|
|
}, "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"`
|
|
Agents []api.TeamMember `json:"agents"`
|
|
}{}
|
|
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 args.Agents != nil && (args.Workflow != "" || args.Model != "") {
|
|
return "", fmt.Errorf("%w: agents can't be combined with model or workflow", ErrInvalid)
|
|
}
|
|
if args.Agents != nil {
|
|
err = checkTeam(args.Agents)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
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
|
|
}
|
|
if args.Agents != nil {
|
|
err = m.startTeam(ctx, task, user, args.Prompt, args.Agents, leadActor(user.Login))
|
|
if err != nil {
|
|
return "", fmt.Errorf("task %s created, but its team did not start: %w", task.Id, err)
|
|
}
|
|
return fmt.Sprintf("Task %s %q created from %s and its team of %d agents started.", task.Id, task.Title, task.BaseRef, len(args.Agents)), nil
|
|
}
|
|
_, 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 show as errors in the task's transcript, and you get a signal for each.",
|
|
`{"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, board (a team task's board: its agents' posts, the human's and yours), changes, files, terminal (a shell in the task's worktree), questions (the task's oldest open question), 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 (focused in it, or with unsent text in it), unless it is on the project tab, or what it shows is finished (a merged or discarded task, or a questions tab with nothing left open),"+
|
|
"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", "board", "changes", "files", "terminal", "questions", "reviews", "workflow", "project"]}},
|
|
"tab": {"type": "string", "enum": ["transcript", "board", "changes", "files", "terminal", "questions", "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()
|
|
working := m.paneFocus[user.Login].Focused || m.paneFocus[user.Login].Drafting
|
|
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 working && 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 no open questions left"
|
|
|
|
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.TabQuestions {
|
|
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
|
|
}
|
|
lead := teamLead(sessions)
|
|
sess, found := memberSession(sessions, lead)
|
|
if !found {
|
|
return api.AgentSession{Member: lead}, false, nil
|
|
}
|
|
return sess, true, 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)
|
|
sessions, err := m.store.ListSessions(ctx, t.Id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sess, found := memberSession(sessions, teamLead(sessions))
|
|
team := teamSessions(sessions)
|
|
switch {
|
|
case len(team) > 0:
|
|
members := []string{}
|
|
for _, s := range team {
|
|
members = append(members, fmt.Sprintf("%q %s on %s", s.Member, s.State, s.Model))
|
|
}
|
|
agent += "; team: " + strings.Join(members, ", ")
|
|
case 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; %d open questions; 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
|
|
}
|
|
sessions, err := m.store.ListSessions(ctx, task.Id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
members := map[string]string{}
|
|
for _, s := range sessions {
|
|
members[s.Id] = s.Member
|
|
}
|
|
|
|
b := &strings.Builder{}
|
|
for _, ev := range events {
|
|
if ev.AgentSessionId != nil && reviewers[*ev.AgentSessionId] {
|
|
continue
|
|
}
|
|
line := transcriptLine(ev, task.WorktreePath)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if ev.AgentSessionId != nil && members[*ev.AgentSessionId] != "" && ev.Kind != "board.posted" {
|
|
line = fmt.Sprintf("[%s] %s", members[*ev.AgentSessionId], 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":
|
|
switch ev.Actor {
|
|
case "byre":
|
|
return "byre: " + clip(text("text"), 300)
|
|
case boardActor:
|
|
return ""
|
|
}
|
|
return ev.Actor + ": " + text("text")
|
|
case "board.posted":
|
|
return fmt.Sprintf("board post by %s: %s", postAuthor(ev), 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("asked question %s", text("id"))
|
|
case "session.error":
|
|
return "error: " + clip(text("error"), 300)
|
|
case "session.rate_limited":
|
|
return "rate limited: " + clip(text("error"), 300)
|
|
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 ""
|
|
}
|