Lock the right pane while focused, add project focus, quiet lead
This commit is contained in:
+12
-23
@@ -15,8 +15,14 @@ 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 merged or discarded, and the human switching the right pane. Several may arrive in one message. Messages from the human arrive separately.
|
||||
- Direct the human's attention. The right pane always shows one task; point it with show (the task, the tabs to put first, the tab to select). When something needs the human, show it and say in a sentence or two what it is and what you recommend. Don't move the pane for things that don't need them, and don't pull it away from what they are looking at unless the new thing is more urgent.
|
||||
- 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 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 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -245,7 +251,7 @@ func (m *Manager) leadTurnEnded(ctx context.Context, l *live, res claude.Result)
|
||||
|
||||
func (m *Manager) Observe(ev api.Event) {
|
||||
switch ev.Kind {
|
||||
case "inbox.created", "inbox.updated", "task.merged", "task.discarded", "focus.updated":
|
||||
case "inbox.created", "inbox.updated", "task.merged", "task.discarded":
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -271,15 +277,9 @@ func (m *Manager) signalLoop() {
|
||||
}
|
||||
|
||||
func (m *Manager) signal(ctx context.Context, ev api.Event) error {
|
||||
owners := []string{}
|
||||
if ev.Owner != nil {
|
||||
owners = append(owners, *ev.Owner)
|
||||
} else {
|
||||
all, err := m.store.LeadOwners(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
owners = all
|
||||
owners, err := m.store.LeadOwners(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
text, err := m.signalText(ctx, ev)
|
||||
@@ -334,17 +334,6 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error)
|
||||
|
||||
case "task.discarded":
|
||||
return fmt.Sprintf("%s was discarded by %s.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil
|
||||
|
||||
case "focus.updated":
|
||||
f := api.Focus{}
|
||||
err := remarshal(ev.Payload, &f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if f.TaskId == nil {
|
||||
return "The human cleared the right pane.", nil
|
||||
}
|
||||
return fmt.Sprintf("The human switched the right pane to %s, tab %s.", m.taskLabel(ctx, *f.TaskId), f.Tab), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agents_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -66,8 +67,61 @@ func TestLeadSignalsAndShow(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("get focus: %v", err)
|
||||
}
|
||||
if f.TaskId == nil || *f.TaskId != e.task.Id || f.Tab != api.TabInbox {
|
||||
if f.TaskId == nil || *f.TaskId != e.task.Id || f.ProjectId == nil || *f.ProjectId != e.project.Id || f.Tab != api.TabInbox {
|
||||
t.Fatalf("focus %+v", f)
|
||||
}
|
||||
e.waitFor("lead idle after show", leadIdle)
|
||||
|
||||
m.SetPaneFocus(login, true)
|
||||
_, 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 refused", func(ev api.Event) bool {
|
||||
return ev.Kind == "agent.tool_result" && ev.Owner != nil && strings.Contains(fmt.Sprint(ev.Payload), "working in the right pane")
|
||||
})
|
||||
e.waitFor("lead idle after refused show", leadIdle)
|
||||
f, err = e.store.GetFocus(e.ctx, login)
|
||||
if err != nil {
|
||||
t.Fatalf("get focus: %v", err)
|
||||
}
|
||||
if f.Tab != api.TabInbox {
|
||||
t.Fatalf("focus moved while the pane was focused: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusProject(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
err := m.SetFocus(e.ctx, login, api.Focus{ProjectId: &e.project.Id, Tabs: []api.TaskTab{api.TabTodo}, Tab: api.TabTodo}, login)
|
||||
if err != nil {
|
||||
t.Fatalf("focus project: %v", err)
|
||||
}
|
||||
f, err := e.store.GetFocus(e.ctx, login)
|
||||
if err != nil {
|
||||
t.Fatalf("get focus: %v", err)
|
||||
}
|
||||
if f.TaskId != nil || f.ProjectId == nil || *f.ProjectId != e.project.Id || f.Tab != api.TabTodo {
|
||||
t.Fatalf("focus %+v", f)
|
||||
}
|
||||
|
||||
other := "nope"
|
||||
err = m.SetFocus(e.ctx, login, api.Focus{TaskId: &e.task.Id, ProjectId: &other, Tabs: []api.TaskTab{}, Tab: api.TabTranscript}, login)
|
||||
if err != nil {
|
||||
t.Fatalf("focus task: %v", err)
|
||||
}
|
||||
f, err = e.store.GetFocus(e.ctx, login)
|
||||
if err != nil {
|
||||
t.Fatalf("get focus: %v", err)
|
||||
}
|
||||
if f.TaskId == nil || *f.TaskId != e.task.Id || f.ProjectId == nil || *f.ProjectId != e.project.Id {
|
||||
t.Fatalf("focus %+v", f)
|
||||
}
|
||||
|
||||
err = m.SetFocus(e.ctx, login, api.Focus{ProjectId: &other, Tabs: []api.TaskTab{}, Tab: api.TabTodo}, login)
|
||||
if err == nil {
|
||||
t.Fatal("focus on an unknown project was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,10 +292,12 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
}),
|
||||
|
||||
tool("show",
|
||||
"Point the human's right pane at a 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). Omit task_id to clear the pane.",
|
||||
"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.",
|
||||
`{"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", "inbox", "todo"]}},
|
||||
"tab": {"type": "string", "enum": ["transcript", "changes", "files", "inbox", "todo"]}
|
||||
}, "required": ["tabs", "tab"]}`,
|
||||
@@ -305,6 +307,12 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m.mu.Lock()
|
||||
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)
|
||||
}
|
||||
err = m.SetFocus(ctx, user.Login, f, leadActor(user.Login))
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -333,15 +341,30 @@ func (m *Manager) SetFocus(ctx context.Context, login string, f api.Focus, actor
|
||||
if f.TaskId != nil && *f.TaskId == "" {
|
||||
f.TaskId = nil
|
||||
}
|
||||
if f.ProjectId != nil && *f.ProjectId == "" {
|
||||
f.ProjectId = nil
|
||||
}
|
||||
if f.TaskId != nil {
|
||||
_, _, err := m.activeTask(ctx, *f.TaskId)
|
||||
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)
|
||||
}
|
||||
}
|
||||
return m.store.SetFocus(ctx, login, f, actor)
|
||||
}
|
||||
|
||||
func (m *Manager) SetPaneFocus(login string, focused bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.paneFocused[login] = focused
|
||||
}
|
||||
|
||||
func (m *Manager) modelOr(model string, l *live) string {
|
||||
if model != "" {
|
||||
return model
|
||||
|
||||
@@ -53,6 +53,7 @@ type Manager struct {
|
||||
rateLimits map[api.AgentSessionVendor]map[string]any
|
||||
merges map[string]string
|
||||
leadAnswered map[string]bool
|
||||
paneFocused map[string]bool
|
||||
|
||||
deliverMu sync.Mutex
|
||||
capMu sync.Mutex
|
||||
@@ -100,6 +101,7 @@ func New(st *store.Store, limits Limits, worktreeRoot, leadRoot string) *Manager
|
||||
rateLimits: map[api.AgentSessionVendor]map[string]any{},
|
||||
merges: map[string]string{},
|
||||
leadAnswered: map[string]bool{},
|
||||
paneFocused: map[string]bool{},
|
||||
signals: make(chan api.Event, 1024),
|
||||
snaps: map[string]*snapState{},
|
||||
bad: newBadLines(),
|
||||
|
||||
Reference in New Issue
Block a user