diff --git a/docs/operations.md b/docs/operations.md index 63c5fbb..0beba22 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -150,7 +150,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie - **Focus.** `users.focus` holds what the user's right pane shows: `project_id`, `task_id`, `tabs` (the tabs put first, in order) and `tab` (the selected one). With a `task_id`, `project_id` is always that task's project, filled in by the server; with only a `project_id`, the pane shows that project with no task, which only the Project tab has anything for. `GET /api/me/focus` reads it; `PUT /api/me/focus` and the lead's `show` set it, refusing an unknown tab, a task that isn't active or an unknown project, and record a `focus.updated` event owned by the user. The page applies the focus on load and on every `focus.updated` it didn't cause itself; the human's own changes (⌘K, the right pane's task picker, clicking a tab, a task link, a notification) are sent with `PUT /api/me/focus`. The URL follows the focus: `#/task/`, `#/project/`, or `#/` for neither. - **Pane lock.** A `show` of what the pane already shows (the same task, or the same project with no task, on the same selected tab; `sameView`) tells the lead so and applies only its `tabs` order, whether or not the human is in the pane, so the human's selected tab stays selected. Otherwise, while the human is working in the right pane, the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless its selected tab is `project` (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or the pane is finished (`Manager.paneFinished`): its task is merged or discarded, or its selected tab is `inbox` and the task has no open items. When an `inbox.updated`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: has nothing left in its inbox." (or "is merged."/"is discarded."), even when the event itself makes no signal, such as a dismissed error; `inbox.updated` on a task that is no longer active adds none. The lead's guidance tells it to show the next thing that needs the human on that line, or, when nothing does, the project's Project tab with a short status in chat: one line per active task, then up to three TODO items it would start next, each with a reason, starting one only when the human picks it. It writes each item as a link to `#/todo//`; Markdown views draw that (`web/src/todolink.ts`) as the title with a **Start** button that calls `POST /api/projects/{id}/todo/tasks` with the first model, as the Project tab's does, or, once an active task was started from that item, the task's colour, dot and state, linking to it. A chat view refills them whenever its transcript grows or tasks or sessions change. When that status says nothing new since its last one, it replies `NO_REPLY` instead. The page sends `PUT /api/me/pane-focus` with `focused` whenever that changes: true while the browser window has focus and the focused element is inside the right pane. The pane itself is focusable (`tabIndex` -1), so a click anywhere in it counts, not only on inputs and buttons; a re-render that removes the focused element hands focus back to the pane rather than counting as leaving. The page sends false on `pagehide` (with `keepalive`) and resends true when its event stream reconnects. byre keeps the flag in memory per user (`Manager.paneFocused`), so it starts false after a restart until the page resends it. - **Start hold.** Start on a TODO item in the Project tab leaves the pane where it is. When the user starts one themselves (`StartTodo` with the user as actor, not the lead's `start_todo`), byre sets `Manager.startedTodo` for them, and while it is set and the pane's selected tab is `project`, every `show` that would change the view is refused, focused or not, with an error telling the lead the human is likely to start more. The user's own `PUT /api/me/focus` (switching task or tab) or a message to the lead clears it. It is in memory, so a restart clears it. -- **Compaction.** The pane's **Compact** button asks the lead for a handoff (`leadHandoffPrompt`). The lead can also call `compact_self` at a logical point; when that turn ends normally, byre sends the same request. The handoff turn ends the session, and a new lead session starts from `leadHandoffPreamble` plus the handoff; held input follows once that turn ends. +- **Compaction.** The pane's **Compact** button asks the lead for a handoff (`leadHandoffPrompt`). The lead can't see its own context size, so the first time one of its turns ends (other than a handoff or a `compact_self` turn) with the latest `session.context` `used` at half its `window` or more, byre holds a signal for it giving the percentage and asking it to call `compact_self` at the next logical point (`warnLeadContext` in `internal/agents/lead.go`); it is sent once per live process, so a byre restart can send it again once the window is known. The lead calls `compact_self`; when that turn ends normally, byre sends the same request. The handoff turn ends the session, and a new lead session starts from `leadHandoffPreamble` plus the handoff; held input follows once that turn ends. - **Restarts.** On shutdown, byre marks each running lead session idle with "byre stopped mid-turn" and holds a restart notice for it, so a lead is never left `running` for a build without lead support to find. After a crash, startup resumes a running lead like a task session. Startup then delivers any held input. The UI is two panes: the left is the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), the right shows the focused task, or project with no task: a top row holding only the task picker, centred, then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Changes, Files, Terminal, Preview, Inbox, Reviews and Project, the lead's `tabs` first. Preview shows only once the task's stream has a `preview.exposed`, Reviews only once it has a `review.*` event, and Inbox only while the task has open items; the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`web/src/panes.ts`); the selected tab never moves there, and More carries the Inbox highlight and count when Inbox is in it. ⌘P (Ctrl+P elsewhere) selects the Project tab, keeping the right pane's task, and replaces the browser's print shortcut. ⌘L (Ctrl+L elsewhere) puts the cursor in the lead's composer, and replaces the browser's address bar shortcut. ⌘S (Ctrl+S elsewhere) opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled, and replaces the browser's save shortcut. diff --git a/internal/agents/lead.go b/internal/agents/lead.go index 4e4368a..32c7ca6 100644 --- a/internal/agents/lead.go +++ b/internal/agents/lead.go @@ -33,7 +33,7 @@ const leadGuidance = `You are the human's lead agent in byre, shown in the left - Workflows: you manage the server's workflows with list_workflows, read_workflow, save_workflow and delete_workflow, and run one by giving its name to create_task or start_todo. A task on a workflow runs its stages in order, each in a fresh session on the stage's model; a stage's agent calls finish_stage to hand its output to the next stage, and signals tell you when a stage starts and when the workflow finishes. Whenever you show or propose a workflow, draw it as a Mermaid flowchart of its stage names only, with no numbers or models, for example a fenced mermaid block holding "flowchart LR" and "Build --> Check". - Whenever you name a task in a message, link it as a Markdown link to #/task/, for example [Fix login redirect](#/task/da7e487305e4). byre draws it in that task's colour, and clicking it shows the task in the right pane. To open a specific tab, append it: #/task//. When you point the human at a question to answer, link to #/task//inbox; at a finished turn, to #/task//transcript. - Keep messages short; the human reads them in half the screen. Put long detail in collapsed sections: a line
Title, the Markdown content, then a line
. -- You are long-running. Your context window is large. Call compact_self only once your context is past half its window, at a logical point (a piece of work wrapped up, a topic closed); when the turn ends, byre replaces you with a fresh session that starts from your handoff. +- You are long-running. Your context window is large. byre sends you a signal once your context passes half its window; then call compact_self at a logical point (a piece of work wrapped up, a topic closed), and not before; when the turn ends, byre replaces you with a fresh session that starts from your handoff. - Don't commit, push, or touch git branches yourself.` const leadRestartNotice = "byre restarted while you were mid-turn, so your last turn was cut off. Pick up where you left off." @@ -255,11 +255,24 @@ func (m *Manager) leadTurnEnded(ctx context.Context, l *live, res endResult) err } }() default: + err = m.warnLeadContext(ctx, l) + if err != nil { + return err + } go m.deliverLead(l.owner) } return nil } +func (m *Manager) warnLeadContext(ctx context.Context, l *live) error { + if l.contextWarned || l.contextWindow == 0 || l.contextUsed*2 < l.contextWindow { + return nil + } + l.contextWarned = true + text := fmt.Sprintf("Your context is at %d%% of its window (%d of %d tokens). Call compact_self at the next logical point, this turn if nothing is in progress.", l.contextUsed*100/l.contextWindow, l.contextUsed, l.contextWindow) + return m.store.AddLeadInput(ctx, store.LeadInput{Owner: l.owner, Kind: store.LeadSignal, Actor: "byre", Text: text}) +} + func (m *Manager) Observe(ev api.Event) { switch ev.Kind { case "inbox.created", "inbox.updated", "task.created", "task.merged", "task.discarded", "turn.ended", "task.workflow_stage", "task.workflow_finished": diff --git a/internal/agents/leadtools.go b/internal/agents/leadtools.go index df0bce3..8749124 100644 --- a/internal/agents/leadtools.go +++ b/internal/agents/leadtools.go @@ -376,7 +376,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { }), 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 once your context is past half its window, at a logical point, then end the turn with a short final message.", + "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 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() diff --git a/internal/agents/manager.go b/internal/agents/manager.go index facfe53..7e8c5a6 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -89,6 +89,7 @@ type live struct { contextModel string contextUsed int contextWindow int + contextWarned bool } func (l *live) implementer() bool {