Make every question blocking and drop inbox priority

This commit is contained in:
Ian Gulliver
2026-10-16 08:35:49 -07:00
parent 1427814014
commit 78cca2fcbe
25 changed files with 268 additions and 322 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ import (
const guidance = `You are running inside byre. The human reads your work in byre's web UI, not a terminal.
- Ask every question with byre's ask_human tool, never in prose.
- End every turn with a short final message for the human: what you did, anything you departed from in the plan or instructions, risks, and what's next. byre shows it as the turn's summary at the end of the transcript. State every departure from the plan.
- A turn that asks a blocking question has no summary; put everything the human needs in the question's context.
- A turn that asks a question has no summary; put everything the human needs in the question's context.
- In that final message and in ask_human context, put long supporting detail (logs, full lists, data) in collapsed sections the human expands on demand: a line <details><summary>Title</summary>, the Markdown content, then a line </details>.
- Don't commit. byre commits everything in the worktree at the end of each turn, using the turn's final message.
- Don't push, don't switch or touch other branches, and don't run git maintenance (gc, prune, worktree prune). byre handles merging and pushing.`
+11 -28
View File
@@ -25,7 +25,6 @@ type askArgs struct {
Question string `json:"question"`
WhyBlocked string `json:"why_blocked"`
Attachments []api.Attachment `json:"attachments"`
Blocking *bool `json:"blocking"`
ThreadID string `json:"thread_id"`
}
@@ -43,8 +42,7 @@ func (m *Manager) askTool(l *live) mcp.Tool {
"its first line is a short name, and the lines indented beneath it say what it would concretely do, " +
"its example, code, commands and trade-offs, so the human never has to match a paragraph above to an option below. " +
"Text above the list covers only what all the choices share. " +
"If blocking is true, end your turn after asking; " +
"the answer arrives as your next message. If blocking is false, continue with work that doesn't depend on the answer. " +
"End your turn after asking; the answer arrives as your next message. " +
"One call is one decision, answered by picking from its own choices. When you have several decisions, such as a design with several choices, " +
"never bundle them into one call that asks for approval of them all: call this once per decision, each with its own context and choices; they are answered together. " +
"To answer a follow-up question from the human, call this again with the thread_id they gave you.",
@@ -70,10 +68,9 @@ func (m *Manager) askTool(l *live) mcp.Tool {
},
"required": ["kind"]
}},
"blocking": {"type": "boolean"},
"thread_id": {"type": "string"}
},
"required": ["context", "question", "why_blocked", "blocking"]
"required": ["context", "question", "why_blocked"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
return m.ask(ctx, l, raw)
@@ -99,11 +96,6 @@ func (m *Manager) ask(ctx context.Context, l *live, raw json.RawMessage) (string
thread = id
}
priority := api.Fyi
if *args.Blocking {
priority = api.Blocked
}
attachments := args.Attachments
if attachments == nil {
attachments = []api.Attachment{}
@@ -129,21 +121,17 @@ func (m *Manager) ask(ctx context.Context, l *live, raw json.RawMessage) (string
"attachments": attachments,
}
err = m.store.CreateInboxItem(ctx, m.newItem(l, id, thread, api.Question, priority, *args.Blocking, store.ToPayload(payload)))
err = m.store.CreateInboxItem(ctx, m.newItem(l, id, thread, api.Question, store.ToPayload(payload)))
if err != nil {
return "", err
}
m.mu.Lock()
l.asked = true
l.askedBlock = l.askedBlock || *args.Blocking
m.mu.Unlock()
cancel := fmt.Sprintf("If the human answers it some other way, such as in a chat message, withdraw it with cancel_question and question_id %q.", id)
if *args.Blocking {
return fmt.Sprintf("Question filed in the human's inbox. question_id: %s. It is blocking: end your turn now. The answer will arrive as your next message. %s", id, cancel), nil
}
return fmt.Sprintf("Question filed in the human's inbox. question_id: %s. Continue with work that doesn't depend on it; the answer will arrive as a later message. %s", id, cancel), nil
return fmt.Sprintf("Question filed in the human's inbox. question_id: %s. End your turn now. The answer will arrive as your next message. "+
"If the human answers it some other way, such as in a chat message, withdraw it with cancel_question and question_id %q.", id, id), nil
}
type cancelArgs struct {
@@ -233,9 +221,6 @@ func (m *Manager) validateAsk(ctx context.Context, l *live, args askArgs) error
if radios == len(choices) && len(choices) > 0 && recommendedRadios != 1 {
problems = append(problems, "mark exactly one pick-one choice as recommended with `- (*) choice`")
}
if args.Blocking == nil {
problems = append(problems, "blocking is required")
}
for i, a := range args.Attachments {
if !slices.Contains(attachmentKinds, a.Kind) {
problems = append(problems, fmt.Sprintf("attachments[%d].kind %q is not one of %v", i, a.Kind, attachmentKinds))
@@ -262,7 +247,7 @@ func (m *Manager) validateAsk(ctx context.Context, l *live, args askArgs) error
func (m *Manager) fileError(l *live, msg string) {
id := store.NewID()
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.SessionError, api.Blocked, true, map[string]any{"error": msg}))
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.SessionError, map[string]any{"error": msg}))
if err != nil {
slog.Error("file session error", "session", l.id, "err", err)
}
@@ -271,13 +256,13 @@ func (m *Manager) fileError(l *live, msg string) {
func (m *Manager) fileRateLimited(l *live, info map[string]any, failure string) {
id := store.NewID()
payload := map[string]any{"vendor": string(l.vendor), "rate_limit": info, "error": failure}
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.RateLimited, api.Blocked, true, payload))
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.RateLimited, payload))
if err != nil {
slog.Error("file rate limit", "session", l.id, "err", err)
}
}
func (m *Manager) newItem(l *live, id, thread string, kind api.InboxItemKind, priority api.InboxItemPriority, blocking bool, payload map[string]any) api.InboxItem {
func (m *Manager) newItem(l *live, id, thread string, kind api.InboxItemKind, payload map[string]any) api.InboxItem {
return api.InboxItem{
Id: id,
ProjectId: l.projectID,
@@ -285,8 +270,6 @@ func (m *Manager) newItem(l *live, id, thread string, kind api.InboxItemKind, pr
AgentSessionId: l.id,
ThreadId: thread,
Kind: kind,
Priority: priority,
Blocking: blocking,
Payload: payload,
State: api.Open,
CreatedAt: time.Now().UTC(),
@@ -329,7 +312,7 @@ func (m *Manager) Deliver(ctx context.Context, sessionID string) error {
return err
}
if waiting > 0 {
slog.Info("deliver held: blocking question waiting", "session", sessionID, "waiting", waiting, "responses", ids)
slog.Info("deliver held: question waiting", "session", sessionID, "waiting", waiting, "responses", ids)
return nil
}
@@ -399,11 +382,11 @@ func (m *Manager) responsesMidTurn(ctx context.Context, l *live) string {
waiting, err := m.store.AwaitingHuman(ctx, l.id)
if err != nil {
slog.Error("count blocking questions", "session", l.id, "err", err)
slog.Error("count waiting questions", "session", l.id, "err", err)
return ""
}
if waiting > 0 {
slog.Info("deliver held mid-turn: blocking question waiting", "session", l.id, "waiting", waiting, "responses", ids)
slog.Info("deliver held mid-turn: question waiting", "session", l.id, "waiting", waiting, "responses", ids)
return ""
}
+2 -2
View File
@@ -17,7 +17,7 @@ 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 and errors that task agents file, turns they finish with their final message, questions the human answered, tasks created, merged or discarded. Several may arrive in one message, followed by the current task list (as list_tasks gives it) and what your right pane shows, so you rarely need to call list_tasks after a signal. The human doesn't see signals. 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 finished turns, then tasks ready to merge), with one line saying what it is. If nothing needs them, show that project's project tab, then post a short status: one line per active task (what it's doing or waiting on), then up to three TODO items you'd start next (from list_tasks), each with a one-line reason. Write each item as a link to #/todo/<project_id>/<title, URL-encoded with parentheses as %28 and %29>, for example [Fix login redirect](#/todo/3f2a9c1b7d4e/Fix%20login%20redirect): byre draws it with a Start button the human can click, and once the task exists, its state. Start one with start_todo only when the human picks it. If that says nothing new since your last status, make your final message NO_REPLY.
- "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 (questions and errors, then finished turns, then tasks ready to merge), with one line saying what it is. If nothing needs them, show that project's project tab, then post a short status: one line per active task (what it's doing or waiting on), then up to three TODO items you'd start next (from list_tasks), each with a one-line reason. Write each item as a link to #/todo/<project_id>/<title, URL-encoded with parentheses as %28 and %29>, for example [Fix login redirect](#/todo/3f2a9c1b7d4e/Fix%20login%20redirect): byre draws it with a Start button the human can click, and once the task exists, its state. Start one with start_todo only when the human picks it. If that says nothing new since your last status, 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, or a project's tasks as a whole: the project's project tab (show with project_id when no single task of that project is involved).
- A question that needs the human: that task's inbox tab, with one line naming it and what you recommend.
@@ -462,7 +462,7 @@ func (m *Manager) itemText(ctx context.Context, item api.InboxItem) string {
switch item.Kind {
case api.Question:
b := &strings.Builder{}
fmt.Fprintf(b, "%s asked question %s (%s): %v\n", label, item.Id, item.Priority, p["question"])
fmt.Fprintf(b, "%s asked question %s: %v\n", label, item.Id, p["question"])
recommended := map[string]bool{}
for _, r := range anyStrings(p["recommended"]) {
recommended[r] = true
+5 -10
View File
@@ -107,7 +107,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
m.mu.Unlock()
return "", err
}
return fmt.Sprintf("Responded to %s. The agent gets it once it is idle and has no other blocking question waiting.", c.ItemId), nil
return fmt.Sprintf("Responded to %s. The agent gets it once it is idle and has no other question waiting.", c.ItemId), nil
}),
tool("send_message",
@@ -563,18 +563,13 @@ func (m *Manager) listTasksText(ctx context.Context) (string, error) {
agent += " on " + sess.Model
}
}
blocked, fyi := 0, 0
open := 0
for _, i := range items {
if i.TaskId != t.Id {
continue
}
if i.Priority == api.Blocked {
blocked++
} else {
fyi++
if i.TaskId == t.Id {
open++
}
}
fmt.Fprintf(b, "- %s %q: %s; inbox %d blocked, %d fyi; worktree %s", t.Id, t.Title, agent, blocked, fyi, t.WorktreePath)
fmt.Fprintf(b, "- %s %q: %s; inbox %d open; worktree %s", t.Id, t.Title, agent, open, t.WorktreePath)
if t.TodoItem != nil {
fmt.Fprintf(b, "; from TODO item %q", *t.TodoItem)
}
+2 -4
View File
@@ -80,7 +80,6 @@ type live struct {
stopping bool
interrupted bool
asked bool
askedBlock bool
bounced bool
compacting bool
wantCompact bool
@@ -489,7 +488,6 @@ func (m *Manager) message(ctx context.Context, l *live, actor, text string, imag
m.mu.Lock()
l.interrupted = false
l.asked = false
l.askedBlock = false
l.bounced = false
l.wantCompact = false
l.compacting = compacting
@@ -771,7 +769,7 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
interrupted := l.interrupted
rateLimited := l.rateLimited
compacting := l.compacting
askedBlock := l.askedBlock
asked := l.asked
m.mu.Unlock()
finished := !res.isError && !interrupted && strings.TrimSpace(res.result) != ""
@@ -788,7 +786,7 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
switch {
case handoff:
payload["handoff"] = true
case finished && !compacting && !askedBlock:
case finished && !compacting && !asked:
payload["summary"] = true
}
err = m.emit(ctx, l, agentActor, "turn.ended", payload)