Bounce prose questions only when they ask the human to decide

This commit is contained in:
Ian Gulliver
2026-10-18 11:59:22 -07:00
parent aac99c48f9
commit 2658dcbfc4
6 changed files with 71 additions and 11 deletions
+1 -1
View File
@@ -200,7 +200,7 @@ For Claude, byre chooses the session ID (`vendor_session_id`) up front. Codex pi
Every session gets byre's guidance (`guidance` in `internal/agents/guidance.go`): ask questions with `ask_human`, end each turn with a final message that summarizes it (including every departure from the plan), put everything the human needs in a question's context since that turn has no summary, don't commit (byre commits each turn), and don't push, touch other branches or run git maintenance. A task agent's guidance also names its port range, tells it to do the setup AGENTS.md describes in a new worktree (see "Worktree setup and ports"), and quotes the task's title, which may be all the human typed when creating the task. A task started from a `TODO.md` item gets one more line naming the item and telling the agent to remove it from `TODO.md` in its worktree when the work is done (`taskGuidance`; see "TODO"). A subtask gets one naming its parent and saying that its final messages go to the parent's agent (`subtaskGuidance`; see "Subtasks"). For Claude it is passed as `--append-system-prompt`, which Claude Code records on a session's first request and reuses on resume, so a running session keeps the text it started with; a `UserPromptSubmit` hook also attaches it as `additionalContext` to every user turn, which reaches existing sessions too. For Codex it is the thread's `developerInstructions`, and every `turn/start` also carries it as `additionalContext` under the key `byre`. The guidance names byre's tools vendor-neutrally ("byre's ask_human tool"): Claude sees them as `mcp__byre__<name>`, Codex as `<name>` in the namespace `byre` (Codex reserves the `mcp__` prefix).
A turn whose final message asks the human something in prose is bounced once (`takeBounce` in `internal/agents/guidance.go`): if `ask_human` wasn't called during the turn and the final message has a sentence ending in `?` outside code spans, code blocks, quotes and blockquotes, the agent is told to ask through `ask_human` and carries on. For Claude this is a Stop hook (`bounceProseQuestion`) that blocks the stop; Claude Code reports the block as a "Stop hook error" notification, which is expected. For Codex, byre doesn't end its turn on `turn/completed`: it records the nudge as a `byre` `user.message` with `mid_turn: true` and starts another Codex turn with it, and only that turn's end ends byre's turn. The per-turn flags reset when the next user message is sent. An agent told explicitly to ask in plain text will refuse the bounce.
A turn whose final message asks the human something in prose is bounced once (`takeBounce` in `internal/agents/guidance.go`): if `ask_human` wasn't called during the turn and the final message's last paragraph has a sentence ending in `?` outside code spans, code blocks, quotes, blockquotes, table rows and `<details>` sections, the agent is told to ask through `ask_human` and carries on. Questions earlier in the message (quoted, answered, or listed for someone else) don't count. A task started as a TODO discussion (its first `user.message` holds `discussPrompt` from `internal/agents/tasks.go`) is never bounced, since that prompt tells the agent not to use `ask_human`, and neither is a task with an open question already in the inbox, since the prose then points at that question. A bounce is recorded as a `byre` `user.message` with `mid_turn: true` and `bounce: true` carrying the nudge; the transcript draws neither it nor the agent message before it, so the rewritten message takes the blocked one's place. For Claude the bounce is a Stop hook (`bounceProseQuestion`) that blocks the stop; Claude Code reports the block as a "Stop hook error" notification, which is expected. For Codex, byre doesn't end its turn on `turn/completed`: it records the nudge and starts another Codex turn with it, and only that turn's end ends byre's turn. The per-turn flags reset when the next user message is sent. An agent told explicitly to ask in plain text will refuse the bounce.
A turn's final message is its summary, and it lives only in the transcript: nothing is filed in the inbox, so the human reads it once. When a task turn ends normally (not an error, not interrupted, not a handoff) with non-empty `result` text, didn't call `ask_human`, and isn't a Merge turn or discard turn (the task closes right after one, so its final message is never left to read), its `turn.ended` event carries `summary: true`. A Merge turn that called `stop_merge` keeps the task open and does carry it. That flag drives the lead's "finished a turn" signal and the "Turn summaries" notification (see "Lead agent" and "Notifications"). A turn that asked a question has no summary, since the final message comes after the question it explains; the guidance tells agents to put everything the human needs in the question's context. There is no report tool: models write a closing recap whatever they are told, so byre uses it rather than asking for a second one.
+10 -2
View File
@@ -259,8 +259,16 @@ func (m *Manager) codexTurnCompleted(ctx context.Context, l *live, s *codex.Sess
final := l.lastText
m.mu.Unlock()
if turn.Status == codex.TurnStatusCompleted && l.implementer() && m.takeBounce(l, final) {
err := m.emit(ctx, l, "byre", "user.message", map[string]any{"text": proseQuestionNudge, "mid_turn": true})
bounce := false
if turn.Status == codex.TurnStatusCompleted && l.implementer() {
var err error
bounce, err = m.takeBounce(ctx, l, final)
if err != nil {
return err
}
}
if bounce {
err := m.emit(ctx, l, "byre", "user.message", map[string]any{"text": proseQuestionNudge, "mid_turn": true, "bounce": true})
if err != nil {
return err
}
+39 -7
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"regexp"
"strings"
"unicode/utf8"
"g.fc.run/misc/byre/internal/api"
@@ -30,6 +31,9 @@ var (
inlineCode = regexp.MustCompile("`[^`\n]*`")
quoted = regexp.MustCompile(`"[^"\n]*"|“[^”\n]*”`)
blockquote = regexp.MustCompile(`(?m)^\s*>.*$`)
tableRow = regexp.MustCompile(`(?m)^\s*\|.*$`)
details = regexp.MustCompile(`(?s)<details>.*?</details>`)
paragraphs = regexp.MustCompile(`\n\s*\n`)
endsQuestion = regexp.MustCompile(`\?[*_)\]]*(\s|$)`)
)
@@ -38,7 +42,10 @@ func asksQuestion(text string) bool {
text = inlineCode.ReplaceAllString(text, "")
text = quoted.ReplaceAllString(text, "")
text = blockquote.ReplaceAllString(text, "")
return endsQuestion.MatchString(text)
text = tableRow.ReplaceAllString(text, "")
text = details.ReplaceAllString(text, "")
paras := paragraphs.Split(strings.TrimSpace(text), -1)
return endsQuestion.MatchString(paras[len(paras)-1])
}
func (m *Manager) bounceProseQuestion(l *live) claude.HookFunc {
@@ -51,21 +58,46 @@ func (m *Manager) bounceProseQuestion(l *live) claude.HookFunc {
return nil, err
}
if !m.takeBounce(l, in.LastAssistantMessage) {
return map[string]any{}, nil
bounce, err := m.takeBounce(ctx, l, in.LastAssistantMessage)
if err != nil || !bounce {
return map[string]any{}, err
}
err = m.emit(ctx, l, "byre", "user.message", map[string]any{"text": proseQuestionNudge, "mid_turn": true, "bounce": true})
if err != nil {
return nil, err
}
return map[string]any{"decision": "block", "reason": proseQuestionNudge}, nil
}
}
func (m *Manager) takeBounce(l *live, final string) bool {
func (m *Manager) takeBounce(ctx context.Context, l *live, final string) (bool, error) {
if !asksQuestion(final) {
return false, nil
}
first, err := m.store.FirstPrompt(ctx, l.taskID)
if err != nil {
return false, err
}
if strings.Contains(first, discussPrompt) {
return false, nil
}
open, err := m.store.ListInbox(ctx, store.InboxFilter{TaskID: l.taskID, State: api.Open})
if err != nil {
return false, err
}
for _, item := range open {
if item.Kind == api.Question {
return false, nil
}
}
m.mu.Lock()
defer m.mu.Unlock()
if l.asked || l.bounced || l.compacting || !asksQuestion(final) {
return false
if l.asked || l.bounced || l.compacting {
return false, nil
}
l.bounced = true
return true
return true, nil
}
func (m *Manager) taskGuidance(ctx context.Context, task api.Task) (string, error) {
+3 -1
View File
@@ -88,9 +88,11 @@ func todoPrompt(item api.TodoItem, discuss bool) string {
if !discuss {
return p
}
return p + "\n\nThe human wants to discuss this item with you before any of it is built: a conversation, not a questionnaire. Read what you need to understand it, then end your turn with your thoughts on it in your final message: how you read it, how you would approach it, and what trade-offs or doubts you see. Don't use ask_human for this; the human replies in the chat. Keep the conversation going turn by turn, and change nothing until they tell you to build it."
return p + "\n\n" + discussPrompt
}
const discussPrompt = "The human wants to discuss this item with you before any of it is built: a conversation, not a questionnaire. Read what you need to understand it, then end your turn with your thoughts on it in your final message: how you read it, how you would approach it, and what trade-offs or doubts you see. Don't use ask_human for this; the human replies in the chat. Keep the conversation going turn by turn, and change nothing until they tell you to build it."
func (m *Manager) createTask(ctx context.Context, project api.Project, t api.Task, frozen *store.FrozenWorkflow, base, actor string) (api.Task, error) {
t.Id = store.NewID()
t.ProjectId = project.Id
+9
View File
@@ -1352,6 +1352,15 @@ func (s *Store) TaskEvents(ctx context.Context, taskID string, after int64, limi
return scanEvents(rows)
}
func (s *Store) FirstPrompt(ctx context.Context, taskID string) (string, error) {
text := ""
err := s.db.QueryRowContext(ctx, `
SELECT coalesce(json_extract(payload, '$.text'), '') FROM events
WHERE task_id = ? AND kind = 'user.message' ORDER BY seq LIMIT 1
`, taskID).Scan(&text)
return text, err
}
func (s *Store) BoardPosts(ctx context.Context, taskID string, after int64) ([]api.Event, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT `+eventColumns+`
+9
View File
@@ -177,6 +177,7 @@ private final class Ctx {
var drawn: [Row]?
var quiet = false
var handoff: Row?
var reply: Row?
}
@MainActor
@@ -433,6 +434,13 @@ final class TranscriptBuilder {
}
continue
}
if ev.kind == "user.message" && bool(p, "bounce") {
if let reply = ctx.reply {
(sink ?? root).rows.removeAll { $0 === reply }
ctx.held?.rows.removeAll { $0 === reply }
}
continue
}
if ev.kind == "session.context" && sink != nil {
continue
}
@@ -486,6 +494,7 @@ final class TranscriptBuilder {
ctx.run = nil
if ev.kind == "agent.message" && parent.isEmpty {
ctx.drawn = nil
ctx.reply = row
}
if ev.kind == "turn.ended" {
ctx.open = false