diff --git a/docs/operations.md b/docs/operations.md index 1e1a048..2cf2564 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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__`, Codex as `` 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 `
` 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. diff --git a/internal/agents/codex.go b/internal/agents/codex.go index fd83b66..8080be4 100644 --- a/internal/agents/codex.go +++ b/internal/agents/codex.go @@ -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 } diff --git a/internal/agents/guidance.go b/internal/agents/guidance.go index 43fac38..2249a8b 100644 --- a/internal/agents/guidance.go +++ b/internal/agents/guidance.go @@ -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)
.*?
`) + 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) { diff --git a/internal/agents/tasks.go b/internal/agents/tasks.go index 27a2a69..78eeb58 100644 --- a/internal/agents/tasks.go +++ b/internal/agents/tasks.go @@ -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 diff --git a/internal/store/store.go b/internal/store/store.go index 52f8936..28fa862 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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+` diff --git a/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift index b26b6c9..bb10f49 100644 --- a/mac/Sources/Byre/TranscriptBuilder.swift +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -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