Free the right pane for the lead once its inbox empties or task merges
This commit is contained in:
+35
-3
@@ -17,12 +17,13 @@ import (
|
||||
const leadGuidance = `You are the human's lead agent in byre, shown in the left pane of byre's web UI. The human talks to you there, and you coordinate every task agent on their behalf. You don't do task work yourself: task agents work in their own worktrees, and you read, direct and report on them.
|
||||
- Signals: byre sends you, as messages from byre, what happens across tasks: questions, turn reports and errors that task agents file, questions the human answered, tasks created, merged or discarded. Several may arrive in one message. Messages from the human arrive separately.
|
||||
- Don't respond to what the human just did. A signal about their own action (a question they answered, a task they created, merged or discarded) tells them nothing new: don't report, confirm or comment on it. Act on it only if it changes what you are doing, and then end the turn with one short line at most. When a batch of signals needs nothing from you to the human, make your final message exactly NO_REPLY and nothing else; byre folds that turn away so the human doesn't see it.
|
||||
- "Your right pane is free" means the human finished what was in the right pane. Show them the next thing that needs them, most urgent first (blocked questions and errors, then reports, then tasks ready to merge), with one line saying what it is. If nothing needs them, show that project's todo tab and make your final message NO_REPLY.
|
||||
- Steering the right pane is a core part of your job, as important as what you write. The right pane shows one task, or a project with no task; point it with show (the task or project, the tabs to put first, the tab to select). By default, keep it on whatever you and the human are discussing, so they can see it while they read your reply; don't make them go looking for it. Call show in the same turn you bring something up:
|
||||
- Discussing, picking or starting TODO items: the project's todo tab (show with project_id when no task of that project is involved).
|
||||
- A question or report that needs the human: that task's inbox tab, and say in a sentence or two what it is and what you recommend.
|
||||
- Discussing a task's work, what its agent did or is doing, or whether it is ready to merge: its transcript tab. 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.
|
||||
- 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: say in chat what you would have shown, and carry on.
|
||||
- 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 what it shows is finished: say in chat what you would have shown, and carry on.
|
||||
- Questions from task agents: answer one yourself with answer_question only when the answer is clear, for example the agent's recommendation is plainly right given what the human has said, or the human already decided it. Otherwise show it to the human with your recommendation and wait. When the human tells you an answer, give it with answer_question. Say which questions you answered and how.
|
||||
- Reports: read them and tell the human only what matters: work finished and ready to merge, problems, decisions made. Stay quiet about routine progress.
|
||||
- Dig into context with list_tasks, read_transcript and read_inbox, and with your own Read, Grep and Bash on a task's worktree path (from list_tasks). Don't edit files in task worktrees; ask the task's agent with send_message.
|
||||
@@ -283,7 +284,7 @@ func (m *Manager) signal(ctx context.Context, ev api.Event) error {
|
||||
}
|
||||
|
||||
text, err := m.signalText(ctx, ev)
|
||||
if err != nil || text == "" {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -291,7 +292,15 @@ func (m *Manager) signal(ctx context.Context, ev api.Event) error {
|
||||
if ev.Actor == leadActor(owner) {
|
||||
continue
|
||||
}
|
||||
err = m.store.AddLeadInput(ctx, store.LeadInput{Owner: owner, Kind: store.LeadSignal, Actor: "byre", Text: text})
|
||||
freed, err := m.freedText(ctx, owner, ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lines := strings.TrimSpace(text + "\n" + freed)
|
||||
if lines == "" {
|
||||
continue
|
||||
}
|
||||
err = m.store.AddLeadInput(ctx, store.LeadInput{Owner: owner, Kind: store.LeadSignal, Actor: "byre", Text: lines})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -300,6 +309,29 @@ func (m *Manager) signal(ctx context.Context, ev api.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) freedText(ctx context.Context, owner string, ev api.Event) (string, error) {
|
||||
switch ev.Kind {
|
||||
case "inbox.updated", "task.merged", "task.discarded":
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
f, err := m.store.GetFocus(ctx, owner)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if f.TaskId == nil || *f.TaskId != deref(ev.TaskId) {
|
||||
return "", nil
|
||||
}
|
||||
finished, err := m.paneFinished(ctx, f)
|
||||
if err != nil || finished == "" {
|
||||
return "", err
|
||||
}
|
||||
if ev.Kind == "inbox.updated" && finished != inboxEmptied {
|
||||
return "", nil
|
||||
}
|
||||
return fmt.Sprintf("Your right pane is free: %s %s.", m.taskLabel(ctx, *f.TaskId), finished), nil
|
||||
}
|
||||
|
||||
func (m *Manager) taskLabel(ctx context.Context, taskID string) string {
|
||||
task, err := m.store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package agents
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -10,59 +11,90 @@ import (
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
func TestTaskCreatedSignalText(t *testing.T) {
|
||||
func TestPaneFreed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
events := []api.Event{}
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), func(ev api.Event) { events = append(events, ev) })
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), func(api.Event) {})
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err = st.EnsureUser(ctx, "ian@fc.run", "Ian")
|
||||
_, err = st.EnsureUser(ctx, "alice", "Alice")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure user: %v", err)
|
||||
}
|
||||
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "byre", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "ian@fc.run", CreatedAt: now})
|
||||
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
item := "Question answers given while the agent is working get lost"
|
||||
tasks := []api.Task{
|
||||
{Id: "a5ad5dbb72eb", ProjectId: "p1", Title: item, Branch: "byre/a5ad5dbb72eb", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "ian@fc.run", CreatedAt: now, TodoItem: &item},
|
||||
{Id: "b6be6ecc83fc", ProjectId: "p1", Title: "Fix the thing", Branch: "byre/b6be6ecc83fc", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "ian@fc.run", CreatedAt: now},
|
||||
}
|
||||
for _, task := range tasks {
|
||||
err = st.CreateTask(ctx, task, task.CreatedBy)
|
||||
for _, id := range []string{"t1", "t2"} {
|
||||
err = st.CreateTask(ctx, api.Task{Id: id, ProjectId: "p1", Title: "Fix " + id, Branch: id, WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
created := []api.Event{}
|
||||
for _, ev := range events {
|
||||
if ev.Kind == "task.created" {
|
||||
created = append(created, ev)
|
||||
}
|
||||
err = st.CreateSession(ctx, api.AgentSession{Id: "s1", TaskId: "t1", Vendor: api.Claude, VendorSessionId: "s1", State: api.AgentSessionStateIdle, Role: api.Implementer, CreatedBy: "alice", CreatedAt: now}, "p1")
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
if len(created) != 2 {
|
||||
t.Fatalf("task.created events %+v", created)
|
||||
err = st.CreateInboxItem(ctx, api.InboxItem{
|
||||
Id: "r1", ProjectId: "p1", TaskId: "t1", AgentSessionId: "s1", ThreadId: "r1",
|
||||
Kind: api.Report, Priority: api.Fyi, Payload: map[string]any{"text": "done"},
|
||||
State: api.Open, CreatedAt: now, Responses: []api.InboxResponse{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("file report: %v", err)
|
||||
}
|
||||
|
||||
m := New(st, DefaultLimits, t.TempDir(), t.TempDir())
|
||||
want := []string{
|
||||
`[byre ▸ Question answers given while the agent is working get lost, task a5ad5dbb72eb] was created by ian@fc.run from TODO item "Question answers given while the agent is working get lost".`,
|
||||
`[byre ▸ Fix the thing, task b6be6ecc83fc] was created by ian@fc.run.`,
|
||||
}
|
||||
for i, ev := range created {
|
||||
got, err := m.signalText(ctx, ev)
|
||||
t1, t2, p1 := "t1", "t2", "p1"
|
||||
focus := func(tab api.TaskTab) {
|
||||
t.Helper()
|
||||
err := st.SetFocus(ctx, "alice", api.Focus{TaskId: &t1, ProjectId: &p1, Tabs: []api.TaskTab{tab}, Tab: tab}, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("signal text: %v", err)
|
||||
t.Fatalf("set focus: %v", err)
|
||||
}
|
||||
if got != want[i] {
|
||||
t.Fatalf("signal text\n got %q\nwant %q", got, want[i])
|
||||
}
|
||||
freed := func(kind, taskID string) string {
|
||||
t.Helper()
|
||||
text, err := m.freedText(ctx, "alice", api.Event{Kind: kind, TaskId: &taskID})
|
||||
if err != nil {
|
||||
t.Fatalf("freed text: %v", err)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
focus(api.TabInbox)
|
||||
if text := freed("inbox.updated", t1); text != "" {
|
||||
t.Fatalf("freed with an item still open: %q", text)
|
||||
}
|
||||
|
||||
err = st.WithdrawItem(ctx, "r1")
|
||||
if err != nil {
|
||||
t.Fatalf("withdraw: %v", err)
|
||||
}
|
||||
text := freed("inbox.updated", t1)
|
||||
if !strings.HasPrefix(text, "Your right pane is free: ") || !strings.HasSuffix(text, "task t1] has nothing left in its inbox.") {
|
||||
t.Fatalf("emptied inbox: %q", text)
|
||||
}
|
||||
if text := freed("inbox.updated", t2); text != "" {
|
||||
t.Fatalf("freed by another task: %q", text)
|
||||
}
|
||||
|
||||
focus(api.TabTranscript)
|
||||
if text := freed("inbox.updated", t1); text != "" {
|
||||
t.Fatalf("freed on the transcript tab: %q", text)
|
||||
}
|
||||
|
||||
_, err = st.MergeTask(ctx, "t1", "abc", "origin/main", "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
if text := freed("task.merged", t1); !strings.HasSuffix(text, "task t1] is merged.") {
|
||||
t.Fatalf("merged: %q", text)
|
||||
}
|
||||
if text := freed("inbox.updated", t1); text != "" {
|
||||
t.Fatalf("inbox.updated on a merged task: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,18 @@ func TestLeadSignalsAndShow(t *testing.T) {
|
||||
if f.Tab != api.TabInbox {
|
||||
t.Fatalf("focus moved while the pane was focused: %+v", f)
|
||||
}
|
||||
|
||||
err = e.store.WithdrawItem(e.ctx, created[len(created)-1].Payload["id"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("withdraw report: %v", err)
|
||||
}
|
||||
_, err = m.MessageLead(e.ctx, e.user, "Call the show tool with task_id "+e.task.Id+", tabs [\"changes\"] and tab \"changes\". Then say done.", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("message lead: %v", err)
|
||||
}
|
||||
e.waitFor("show allowed on an emptied inbox", func(ev api.Event) bool {
|
||||
return ev.Kind == "focus.updated" && ev.Actor == login+" (lead)" && ev.Payload["tab"] == string(api.TabChanges)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFocusProject(t *testing.T) {
|
||||
|
||||
@@ -294,7 +294,8 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
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. "+
|
||||
"Tabs: transcript, changes, files, inbox (the task's open items), todo (the project's TODO.md); with only project_id, only todo shows anything. "+
|
||||
"Omit both task_id and project_id to clear the pane. Refused while the human is working in the right pane.",
|
||||
"Omit both task_id and project_id to clear the pane. "+
|
||||
"Refused while the human is working in the right pane, unless what it shows is finished (a merged or discarded task, or an inbox tab with nothing left).",
|
||||
`{"type": "object", "properties": {
|
||||
"task_id": {"type": "string"},
|
||||
"project_id": {"type": "string", "description": "Ignored when task_id is given."},
|
||||
@@ -311,7 +312,17 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
focused := m.paneFocused[user.Login]
|
||||
m.mu.Unlock()
|
||||
if focused {
|
||||
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)
|
||||
current, err := m.store.GetFocus(ctx, user.Login)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
finished, err := m.paneFinished(ctx, current)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if finished == "" {
|
||||
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 {
|
||||
@@ -359,6 +370,32 @@ func (m *Manager) SetFocus(ctx context.Context, login string, f api.Focus, actor
|
||||
return m.store.SetFocus(ctx, login, f, actor)
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
func (m *Manager) SetPaneFocus(login string, focused bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
Reference in New Issue
Block a user