Bounce prose questions only when they ask the human to decide
This commit is contained in:
+1
-1
@@ -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).
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -259,8 +259,16 @@ func (m *Manager) codexTurnCompleted(ctx context.Context, l *live, s *codex.Sess
|
|||||||
final := l.lastText
|
final := l.lastText
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
if turn.Status == codex.TurnStatusCompleted && l.implementer() && m.takeBounce(l, final) {
|
bounce := false
|
||||||
err := m.emit(ctx, l, "byre", "user.message", map[string]any{"text": proseQuestionNudge, "mid_turn": true})
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"g.fc.run/misc/byre/internal/api"
|
"g.fc.run/misc/byre/internal/api"
|
||||||
@@ -30,6 +31,9 @@ var (
|
|||||||
inlineCode = regexp.MustCompile("`[^`\n]*`")
|
inlineCode = regexp.MustCompile("`[^`\n]*`")
|
||||||
quoted = regexp.MustCompile(`"[^"\n]*"|“[^”\n]*”`)
|
quoted = regexp.MustCompile(`"[^"\n]*"|“[^”\n]*”`)
|
||||||
blockquote = regexp.MustCompile(`(?m)^\s*>.*$`)
|
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|$)`)
|
endsQuestion = regexp.MustCompile(`\?[*_)\]]*(\s|$)`)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,7 +42,10 @@ func asksQuestion(text string) bool {
|
|||||||
text = inlineCode.ReplaceAllString(text, "")
|
text = inlineCode.ReplaceAllString(text, "")
|
||||||
text = quoted.ReplaceAllString(text, "")
|
text = quoted.ReplaceAllString(text, "")
|
||||||
text = blockquote.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 {
|
func (m *Manager) bounceProseQuestion(l *live) claude.HookFunc {
|
||||||
@@ -51,21 +58,46 @@ func (m *Manager) bounceProseQuestion(l *live) claude.HookFunc {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !m.takeBounce(l, in.LastAssistantMessage) {
|
bounce, err := m.takeBounce(ctx, l, in.LastAssistantMessage)
|
||||||
return map[string]any{}, nil
|
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
|
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()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if l.asked || l.bounced || l.compacting || !asksQuestion(final) {
|
if l.asked || l.bounced || l.compacting {
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
l.bounced = true
|
l.bounced = true
|
||||||
return true
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) taskGuidance(ctx context.Context, task api.Task) (string, error) {
|
func (m *Manager) taskGuidance(ctx context.Context, task api.Task) (string, error) {
|
||||||
|
|||||||
@@ -88,9 +88,11 @@ func todoPrompt(item api.TodoItem, discuss bool) string {
|
|||||||
if !discuss {
|
if !discuss {
|
||||||
return p
|
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) {
|
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.Id = store.NewID()
|
||||||
t.ProjectId = project.Id
|
t.ProjectId = project.Id
|
||||||
|
|||||||
@@ -1352,6 +1352,15 @@ func (s *Store) TaskEvents(ctx context.Context, taskID string, after int64, limi
|
|||||||
return scanEvents(rows)
|
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) {
|
func (s *Store) BoardPosts(ctx context.Context, taskID string, after int64) ([]api.Event, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
SELECT `+eventColumns+`
|
SELECT `+eventColumns+`
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ private final class Ctx {
|
|||||||
var drawn: [Row]?
|
var drawn: [Row]?
|
||||||
var quiet = false
|
var quiet = false
|
||||||
var handoff: Row?
|
var handoff: Row?
|
||||||
|
var reply: Row?
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -433,6 +434,13 @@ final class TranscriptBuilder {
|
|||||||
}
|
}
|
||||||
continue
|
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 {
|
if ev.kind == "session.context" && sink != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -486,6 +494,7 @@ final class TranscriptBuilder {
|
|||||||
ctx.run = nil
|
ctx.run = nil
|
||||||
if ev.kind == "agent.message" && parent.isEmpty {
|
if ev.kind == "agent.message" && parent.isEmpty {
|
||||||
ctx.drawn = nil
|
ctx.drawn = nil
|
||||||
|
ctx.reply = row
|
||||||
}
|
}
|
||||||
if ev.kind == "turn.ended" {
|
if ev.kind == "turn.ended" {
|
||||||
ctx.open = false
|
ctx.open = false
|
||||||
|
|||||||
Reference in New Issue
Block a user