diff --git a/docs/operations.md b/docs/operations.md index 2a5f0c1..ca99dac 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -36,7 +36,7 @@ A task ends `merged` or `discarded`; either way its sessions are stopped, its sn A task's summary is what its agent writes, before the task closes, about the whole task: what it set out to do, what was built or tried, the key decisions and why, and what was left out. The agent sets it with the `set_task_summary` MCP tool (`internal/agents/summary.go`), any time while the task is active; each call replaces `tasks.summary` and records a `task.summary` event carrying it, which the transcript shows as a note. The Merge turn's prompt and the discard turn's prompt (`summaryRequest`) both ask for it. Turn summaries don't stand in for it: each covers only its own turn, and a merged task's last one is always the Merge turn's. Tasks closed before the column existed have none. `sqlite3 byre.db "SELECT id, status, summary FROM tasks WHERE status != 'active' ORDER BY closed_at DESC LIMIT 5"` shows recent ones. -An implementer says its task is ready to merge with the `ready_for_merge` MCP tool (`internal/agents/ready.go`), whose `overview` covers the whole task for a human switching between tasks: a summary, tradeoffs and compromises, what was not done, and future TODOs. Its description tells the agent to call it only once the review of its most recent changes has come back, never in the same turn as a change or while that review is pending. Each call records a `task.ready` event carrying it, which the lead gets as a signal and the transcript draws as a bubble (`ReadyBubble` in `mac/Sources/Byre/Chat.swift`) headed "ready for merge". The latest one, while the task is active and no question, `task.ready_cleared` or snapshot other than an `answer`, `fork` or `ask_human` one (the never-reviewed kinds) has come since it (a question, a withdrawal or dismissal, or newer work the overview doesn't cover), is drawn like an open question (the warn tint and border) and carries an **Other…** field and small Dismiss, Discard, **Merge & continue** and **Merge & complete** buttons; earlier ones are drawn like an answered question (the faint warn tint), without them. **Merge & complete** is the header's Merge, dialog included. **Merge & continue** opens the same dialog without the parent and after-review choices, its button also named **Merge & continue**, and sends `keep_open` (see `merge_to_main` below). The tool's optional `merge` (`complete`, the default, or `continue`) is carried on the event and picks which of the two the card highlights with an accent border. Text sent from **Other…** (Return sends, Shift-Return is a newline) dismisses the ready call (`DELETE /api/tasks/{id}/ready`) and then goes to the agent as a chat message (`Store.messageTask`), with any images pasted or dropped into the field. While the card is current, the composer is hidden by default as it is for an open question (`ChatModel.composerHiddenByDefault`). Recording it changes nothing else. byre asks for it (`readyPrompt`, sent as a byre message) when a task's state goes to `idle`, `ready_to_merge` or `discard_suggested` (`readyState`) or its workflow finishes (`Manager.Observe` calls `promptReady`) and the latest reviewable snapshot's review is `done` or `skipped`. It checks and sends under the session's delivery lock (the one human messages and answers take), and only to an agent that isn't running, so the prompt never queues behind a turn that could add unreviewed work. `tasks.ready_prompt_seq` holds the task's last event seq when the prompt's turn started; byre asks again only once a reviewable snapshot or a message to the agent has come after it: one from the human (a chat message or a question's answer) or from byre (a subtask's report, a terminal command's result, Merge & continue, a merge-in), but not one from another agent or the lead, nor byre's mid-turn prose-question bounce. So the agent's own turns that change no files don't ask again, and anything that unblocks an agent that said it was waiting does. It doesn't ask while the task has an open question. It doesn't ask for a workflow stage task until its run has a `task.workflow_finished` event (by then the run's other stage tasks are being discarded, leaving the final one for the human), a task in any other state (an open question, a held merge or a merge after review among them), or a review that failed, and it skips the `reviewing` → `idle` change of a review with findings, since those go to the agent; the turn that answers them asks once it ends without new work. The prompt tells the agent not to call the tool, and to say what's left, when the task isn't ready. When that review is `skipped` (the snapshot has no diff from the default branch), byre sends `readyPromptUnchanged` instead, which says there is nothing to merge and offers only `suggest_discard` and `ask_human`. +An implementer says its task is ready to merge with the `ready_for_merge` MCP tool (`internal/agents/ready.go`), whose `overview` covers the whole task for a human switching between tasks: a summary, tradeoffs and compromises, what was not done, and future TODOs. Its description tells the agent to call it only once the review of its most recent changes has come back, never in the same turn as a change or while that review is pending. Each call records a `task.ready` event carrying it, which the lead gets as a signal and the transcript draws as a bubble (`ReadyBubble` in `mac/Sources/Byre/Chat.swift`) headed "ready for merge". The latest one, while the task is active and no question, `task.ready_cleared` or snapshot other than an `answer`, `fork` or `ask_human` one (the never-reviewed kinds) has come since it (a question, a withdrawal or dismissal, or newer work the overview doesn't cover), is drawn like an open question (the warn tint and border) and carries an **Other…** field and small Dismiss, Discard, **Merge & continue** and **Merge & complete** buttons; earlier ones are drawn like an answered question (the faint warn tint), without them. **Merge & complete** is the header's Merge, dialog included. **Merge & continue** opens the same dialog without the parent and after-review choices, its button also named **Merge & continue**, and sends `keep_open` (see `merge_to_main` below). The tool's optional `merge` (`complete`, the default, or `continue`) is carried on the event and picks which of the two the card highlights with an accent border. Text sent from **Other…** (Return sends, Shift-Return is a newline) dismisses the ready call (`DELETE /api/tasks/{id}/ready`) and then goes to the agent as a chat message (`Store.messageTask`), with any images pasted or dropped into the field. While the card is current, the composer is hidden by default as it is for an open question (`ChatModel.composerHiddenByDefault`). Recording it changes nothing else. byre asks for it (`readyPrompt`, sent as a byre message) when a task's state goes to `idle`, `ready_to_merge` or `discard_suggested` (`readyState`) or its workflow finishes (`Manager.Observe` calls `promptReady`) and the latest reviewable snapshot's review is `done` or `skipped`. It checks and sends under the session's delivery lock (the one human messages and answers take), and only to an agent that isn't running, so the prompt never queues behind a turn that could add unreviewed work. `tasks.ready_prompt_seq` holds the task's last event seq when the prompt's turn started; byre asks again only once a reviewable snapshot or a message to the agent has come after it: one from the human (a chat message or a question's answer) or from byre (a subtask's report, a terminal command's result, Merge & continue, a merge-in), but not one from another agent or the lead, nor byre's mid-turn prose-question bounce. The run's `task.workflow_finished` event coming after it also counts, so a stage's last turn that only calls `finish_stage` still gets asked. So the agent's own turns that change no files don't ask again, and anything that unblocks an agent that said it was waiting does. It doesn't ask while the task has an open question. A workflow stage task isn't asked until its run has a `task.workflow_finished` event (by then the run's other stage tasks are being discarded, leaving the final one for the human). Until then, a stage task whose own stage hasn't finished (`tasks.stage_output` empty) gets `finishStagePrompt` instead, under the same conditions: it names the stages that can't start without it, or says the workflow can't finish, and tells the agent to call `finish_stage`, so a stage at any position whose agent ended its turn without it doesn't stall the run (for an audit, every later audit too). One whose stage has finished and is waiting on other stages gets nothing. It doesn't ask a task in any other state (an open question, a held merge or a merge after review among them), or a review that failed, and it skips the `reviewing` → `idle` change of a review with findings, since those go to the agent; the turn that answers them asks once it ends without new work. The prompt tells the agent not to call the tool, and to say what's left, when the task isn't ready. When that review is `skipped` (the snapshot has no diff from the default branch), byre sends `readyPromptUnchanged` instead, which says there is nothing to merge and offers only `suggest_discard` and `ask_human`. An implementer that thinks its task shouldn't land at all (the approach failed, the task turned out unnecessary or already done) says so with the `suggest_discard` MCP tool (also `internal/agents/ready.go`), whose `reason` explains why; `readyPrompt` offers it as the alternative to `ready_for_merge`. Each call records a `task.discard_suggested` event, which the lead gets as a signal and the transcript draws with the same `ReadyBubble`, headed "suggests discarding". It takes part in "latest" with the ready-for-merge overviews, so whichever of the two came last is the current one, drawn and cleared under the same rules, and it carries the **Other…** field and only the Dismiss and Discard buttons. It only suggests: the task stays open until the human discards it. diff --git a/internal/agents/ready.go b/internal/agents/ready.go index 5f5d2af..fec8a4e 100644 --- a/internal/agents/ready.go +++ b/internal/agents/ready.go @@ -11,6 +11,7 @@ import ( "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/mcp" "g.fc.run/ai/byre/internal/store" + "g.fc.run/ai/byre/internal/workflow" ) const readyPromptAsk = "If it isn't ready (work remains that you mean to do, or you're waiting on the human), call neither: say what's left in one line and end your turn. " + @@ -24,6 +25,17 @@ const readyPromptUnchanged = "Your turn has ended, and this task's branch has no "If the task is finished, or shouldn't go further (it needed no changes, the approach failed, or it was already done), call suggest_discard with your reasoning, then end your turn with one line. " + readyPromptAsk +func finishStagePrompt(frozen store.FrozenWorkflow, stage string) string { + waiting := "the workflow can't finish" + next := workflow.Next(frozen.Definition, stage) + if len(next) > 0 { + waiting = fmt.Sprintf("%s can't start", quoteList(next)) + } + return fmt.Sprintf("Your turn has ended and the review of your latest work has finished, but you haven't called finish_stage, so stage %q of workflow %q hasn't finished and %s. "+ + "If the stage's work is done, call finish_stage with its output, then end your turn with one line. If work remains, do it now and then call finish_stage. "+ + "If you're waiting on the human for a decision or input, ask it with ask_human and end your turn.", stage, frozen.Name, waiting) +} + type readyArgs struct { Overview string `json:"overview"` Merge string `json:"merge"` @@ -157,11 +169,23 @@ func (m *Manager) tryPromptReady(ctx context.Context, taskID string, fromReview if task.Status != api.TaskStatusActive || !readyState(task.State) { return nil } + stagePrompt := "" if task.WorkflowStage != nil { finished, err := m.store.WorkflowFinished(ctx, *task.WorkflowRun) - if err != nil || !finished { + if err != nil { return err } + if !finished { + output, _, err := m.store.StageResult(ctx, taskID) + if err != nil || output != "" { + return err + } + frozen, err := m.store.TaskWorkflow(ctx, taskID) + if err != nil { + return err + } + stagePrompt = finishStagePrompt(frozen, *task.WorkflowStage) + } } sess, err = m.store.GetSession(ctx, sess.Id) if err != nil || sess.State == api.AgentSessionStateRunning || m.interrupted(sess.Id) { @@ -215,6 +239,9 @@ func (m *Manager) tryPromptReady(ctx context.Context, taskID string, fromReview if len(reviews) == 1 && reviews[0].State == api.ReviewStateSkipped { prompt = readyPromptUnchanged } + if stagePrompt != "" { + prompt = stagePrompt + } _, err = m.Send(ctx, sess.Id, "byre", prompt, "", "") if err != nil { return err diff --git a/internal/store/store.go b/internal/store/store.go index beb998b..9f63575 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -822,7 +822,11 @@ func (s *Store) ReadyPromptDue(ctx context.Context, id string) (bool, error) { WHERE t.id = ? AND e.task_id = t.id AND e.seq > t.ready_prompt_seq AND ((e.kind = 'snapshot.created' AND json_extract(e.payload, '$.trigger') NOT IN (?, ?, ?)) OR (e.kind = 'user.message' AND e.actor != ? AND e.actor NOT LIKE '% (lead)' AND json_extract(e.payload, '$.bounce') IS NOT 1)) - )`, id, string(api.SnapshotTriggerAnswer), string(api.SnapshotTriggerFork), string(api.SnapshotTriggerAskHuman), agentActor).Scan(&due) + ) OR EXISTS ( + SELECT 1 FROM events e, tasks t, tasks r + WHERE t.id = ? AND t.workflow_run != '' AND r.workflow_run = t.workflow_run AND e.task_id = r.id + AND e.kind = 'task.workflow_finished' AND e.seq > t.ready_prompt_seq + )`, id, string(api.SnapshotTriggerAnswer), string(api.SnapshotTriggerFork), string(api.SnapshotTriggerAskHuman), agentActor, id).Scan(&due) return due, err }