Tell agents to shorten a merge title over 72 characters

This commit is contained in:
Ian Gulliver
2026-10-16 13:04:35 -07:00
parent 5b22e5bc70
commit 1296abcf4b
3 changed files with 14 additions and 1 deletions
+1 -1
View File
@@ -65,7 +65,7 @@ The UI's labels (see "Task state" under the task switcher) read `state` for **Wo
`POST /api/tasks/{id}/merge-in` (the transcript's **Sync from main** button, labelled with the default branch) brings the default branch into the task branch through the agent: it sends `mergeInPrompt` (`internal/agents/mergein.go`) as a `byre` message to the task's session, or starts a session with it when the task has none. The prompt has the agent run `git merge --no-ff --no-commit`, resolve conflicts, regenerate generated files rather than hand-merging them, check the build and tests, and leave the merge uncommitted; byre's end-of-turn commit concludes it as a merge commit. If resolving the conflicts took substantive changes rather than combining both sides as written, the prompt tells the agent to stop there and end its turn describing them; the turn's snapshot then gets its review like any other (see "Reviews"). byre doesn't inspect the merge itself: whether a change is substantive, and whether to stop, is the agent's call. On a Merge turn the prompt adds that byre squashes and pushes when the turn ends, and tells the agent to call the `stop_merge` MCP tool first if it stops (see below). The prompt ends by showing the task's current merge title and asking the agent to set a better one with `set_merge_title` if it no longer describes all the work on the branch. It is refused (409) while a session is `running`. `POST /api/tasks/{id}/merge-in` (the transcript's **Sync from main** button, labelled with the default branch) brings the default branch into the task branch through the agent: it sends `mergeInPrompt` (`internal/agents/mergein.go`) as a `byre` message to the task's session, or starts a session with it when the task has none. The prompt has the agent run `git merge --no-ff --no-commit`, resolve conflicts, regenerate generated files rather than hand-merging them, check the build and tests, and leave the merge uncommitted; byre's end-of-turn commit concludes it as a merge commit. If resolving the conflicts took substantive changes rather than combining both sides as written, the prompt tells the agent to stop there and end its turn describing them; the turn's snapshot then gets its review like any other (see "Reviews"). byre doesn't inspect the merge itself: whether a change is substantive, and whether to stop, is the agent's call. On a Merge turn the prompt adds that byre squashes and pushes when the turn ends, and tells the agent to call the `stop_merge` MCP tool first if it stops (see below). The prompt ends by showing the task's current merge title and asking the agent to set a better one with `set_merge_title` if it no longer describes all the work on the branch. It is refused (409) while a session is `running`.
A task's `merge_title` is the title of the squash commit Merge makes. It is the task title until the agent calls the `set_merge_title` MCP tool (`internal/agents/mergein.go`), which it may do until a merge is confirmed: one line of at most 72 characters (`maxMergeTitle`, checked by `cleanMergeTitle`), stored in `tasks.merge_title` (empty there means "use the title"), recorded as a `task.merge_title` event that the transcript shows as a note. Malformed titles are rejected with a corrective message, and so is a call once the task is closed or while `merging_by`, `finalizing` or `merge_queued_by` is set. The Merge confirm dialog shows the title in a text field the human can edit (Enter confirms), lists the co-authors, and sends the field's value as `merge_title` in the body of `POST /api/tasks/{id}/merge`. Merge validates it like the tool does (400 when malformed) and, when it differs from the stored title, stores it with the human as the event's actor before starting or holding the merge. A task's `merge_title` is the title of the squash commit Merge makes. It is the task title until the agent calls the `set_merge_title` MCP tool (`internal/agents/mergein.go`), which it may do until a merge is confirmed: one line of at most 72 characters (`maxMergeTitle`, checked by `cleanMergeTitle`), stored in `tasks.merge_title` (empty there means "use the title"), recorded as a `task.merge_title` event that the transcript shows as a note. Task titles have no length limit (a TODO item becomes the title whole), so when an implementer session's agent process starts with a merge title over 72 characters, `longTitleGuidance` (`internal/agents/guidance.go`) adds a line to its guidance quoting the title and its length and telling the agent to call `set_merge_title` early in its first turn; a process started once the title fits gets no such line. Malformed titles are rejected with a corrective message, and so is a call once the task is closed or while `merging_by`, `finalizing` or `merge_queued_by` is set. The Merge confirm dialog shows the title in a text field the human can edit (Enter confirms), lists the co-authors, and sends the field's value as `merge_title` in the body of `POST /api/tasks/{id}/merge`. Merge validates it like the tool does (400 when malformed) and, when it differs from the stored title, stores it with the human as the event's actor before starting or holding the merge.
A task's participants are the users who put a message bubble with their name in its transcript, derived from its events (`Store.Participants`): the actors of its `user.message` events, joined to `users` so `byre` and `agent` drop out. That covers messages sent to the agent and inbox answers, whose delivery message carries the name of the first responder in it (`Deliver` in `internal/agents/inbox.go`); dismissing an item puts nothing in the transcript and doesn't count. A fork's participants also include the user who forked it, the actor of its `task.created` event (whose payload carries `forked_from`), whose "forked this task" note opens its transcript. They are ordered by their first such event. `GET /api/tasks/{id}/co-authors` returns the participants other than the caller, with their git name and email from `PUT /api/me` (empty for a user who hasn't set one). The Merge confirm fetches it and lists `Name <email>` for each co-author, and separately those with no git identity, who get no trailer. A task's participants are the users who put a message bubble with their name in its transcript, derived from its events (`Store.Participants`): the actors of its `user.message` events, joined to `users` so `byre` and `agent` drop out. That covers messages sent to the agent and inbox answers, whose delivery message carries the name of the first responder in it (`Deliver` in `internal/agents/inbox.go`); dismissing an item puts nothing in the transcript and doesn't count. A fork's participants also include the user who forked it, the actor of its `task.created` event (whose payload carries `forked_from`), whose "forked this task" note opens its transcript. They are ordered by their first such event. `GET /api/tasks/{id}/co-authors` returns the participants other than the caller, with their git name and email from `PUT /api/me` (empty for a user who hasn't set one). The Merge confirm fetches it and lists `Name <email>` for each co-author, and separately those with no git identity, who get no trailer.
+10
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"regexp" "regexp"
"unicode/utf8"
"g.fc.run/misc/byre/internal/api" "g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/claude" "g.fc.run/misc/byre/internal/claude"
@@ -92,6 +93,15 @@ func (m *Manager) taskGuidance(ctx context.Context, task api.Task) (string, erro
return g + forkGuidance(task, src), nil return g + forkGuidance(task, src), nil
} }
func longTitleGuidance(task api.Task) string {
n := utf8.RuneCountInString(task.MergeTitle)
if n <= maxMergeTitle {
return ""
}
return fmt.Sprintf("\n- This task's merge commit title, %q, is %d characters, over the %d allowed, so Merge will refuse it. Early in your first turn, call set_merge_title with a one-line title of at most %d characters that describes the work.",
task.MergeTitle, n, maxMergeTitle, maxMergeTitle)
}
func subtaskGuidance(parent api.Task) string { func subtaskGuidance(parent api.Task) string {
return fmt.Sprintf("\n- This task is a subtask of byre task %q (%s), whose agent created it and manages it. Your final message at the end of each turn is sent to that agent, which may answer with more instructions. "+ return fmt.Sprintf("\n- This task is a subtask of byre task %q (%s), whose agent created it and manages it. Your final message at the end of each turn is sent to that agent, which may answer with more instructions. "+
"Your work reaches %s's branch or the default branch when the subtask is merged.", "Your work reaches %s's branch or the default branch when the subtask is merged.",
+3
View File
@@ -680,6 +680,9 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api
return nil, err return nil, err
} }
tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l)}, m.subtaskTools(l)...) tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l)}, m.subtaskTools(l)...)
if sess.Role == api.Implementer {
g += longTitleGuidance(task)
}
if sess.Role == api.Implementer && task.WorkflowStage != nil { if sess.Role == api.Implementer && task.WorkflowStage != nil {
frozen, err := m.store.TaskWorkflow(ctx, task.Id) frozen, err := m.store.TaskWorkflow(ctx, task.Id)
if err != nil { if err != nil {