File no report for a turn that asks a blocking question
This commit is contained in:
+2
-2
@@ -73,11 +73,11 @@ Agents don't commit; byre does. Each `user.message` event that starts a turn rec
|
||||
|
||||
byre chooses the Claude session ID (`vendor_session_id`) up front, so every session can be resumed. Processes start on demand: the first message after a restart resumes the session with `--resume`. Sessions are `running` from the moment a turn is sent until its `result` arrives; on startup, every session still marked `running` was cut off mid-turn, and byre resumes it and sends a message telling the agent to re-check the working tree. Stopping the server kills all agent processes. Merging or discarding a task kills its sessions and marks them `completed`.
|
||||
|
||||
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), don't commit (byre commits each turn), and don't push, touch other branches or run git maintenance. 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"). 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.
|
||||
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 blocking question's context since that turn files no report, don't commit (byre commits each turn), and don't push, touch other branches or run git maintenance. 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"). 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.
|
||||
|
||||
A Stop hook (`bounceProseQuestion` in `internal/agents/guidance.go`) bounces a turn whose final message asks the human something in prose: if `ask_human` wasn't called during the turn and the final message (`last_assistant_message`) has a sentence ending in `?` outside code spans, code blocks, quotes and blockquotes, the stop is blocked once with an instruction to ask through `ask_human`. The per-turn flags reset when the next user message is sent. Claude Code reports the block as a "Stop hook error" notification; that is expected. An agent told explicitly to ask in plain text will refuse the bounce.
|
||||
|
||||
A turn's final message is its summary. When a turn ends normally (not an error, not interrupted) with non-empty `result` text, byre files that text as a `report` inbox item. 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. When a turn ends normally (not an error, not interrupted) with non-empty `result` text, byre files that text as a `report` inbox item, unless the turn called `ask_human` with `blocking` true. The final message comes after the question, so a report would land in the inbox after the question it explains; such a turn's final message stays in the transcript only, and 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.
|
||||
|
||||
Running turns of task agents are capped globally and per vendor (`DefaultLimits` in `internal/agents/manager.go`); lead agents' turns always start and don't count. A turn that would exceed a cap leaves its session `queued`, with the message held in the `turn_queue` table; whenever a turn ends, a process exits, or a task is merged or discarded, the oldest queued turns whose vendor has room start. Interrupting a `queued` session cancels its queued turn. Idle sessions with a live process don't count against the caps. Whether a turn resumes the Claude conversation is decided by whether the session has ever sent one.
|
||||
|
||||
|
||||
@@ -14,6 +14,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 mcp__byre__ask_human, 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 in the transcript and the inbox. State every departure from the plan.
|
||||
- A turn that asks a blocking question files no report; 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.`
|
||||
|
||||
@@ -131,6 +131,7 @@ func (m *Manager) ask(ctx context.Context, l *live, raw json.RawMessage) (string
|
||||
|
||||
m.mu.Lock()
|
||||
l.asked = true
|
||||
l.askedBlock = l.askedBlock || *args.Blocking
|
||||
m.mu.Unlock()
|
||||
|
||||
m.snapshotSoon(l, api.SnapshotTriggerAskHuman)
|
||||
|
||||
@@ -80,6 +80,7 @@ type live struct {
|
||||
stopping bool
|
||||
interrupted bool
|
||||
asked bool
|
||||
askedBlock bool
|
||||
bounced bool
|
||||
compacting bool
|
||||
wantCompact bool
|
||||
@@ -599,6 +600,7 @@ 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
|
||||
@@ -808,6 +810,7 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
|
||||
interrupted := l.interrupted
|
||||
rateLimited := l.rateLimited
|
||||
compacting := l.compacting
|
||||
askedBlock := l.askedBlock
|
||||
m.mu.Unlock()
|
||||
|
||||
finished := !res.IsError && !interrupted && strings.TrimSpace(res.Result) != ""
|
||||
@@ -825,7 +828,7 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
|
||||
switch {
|
||||
case handoff:
|
||||
payload["handoff"] = true
|
||||
case finished && !compacting:
|
||||
case finished && !compacting && !askedBlock:
|
||||
payload["inbox_item_id"] = m.fileSummary(l, res.Result)
|
||||
}
|
||||
err = m.emit(ctx, l, agentActor, "turn.ended", payload)
|
||||
|
||||
@@ -394,18 +394,26 @@ const colorPrompt = `I need a file color.txt containing exactly one color name.
|
||||
`When you get my answer, write the file.`
|
||||
|
||||
func (e *env) openQuestions(sessionID string) []api.InboxItem {
|
||||
return e.openItems(sessionID, api.Question)
|
||||
}
|
||||
|
||||
func (e *env) openReports(sessionID string) []api.InboxItem {
|
||||
return e.openItems(sessionID, api.Report)
|
||||
}
|
||||
|
||||
func (e *env) openItems(sessionID string, kind api.InboxItemKind) []api.InboxItem {
|
||||
e.t.Helper()
|
||||
items, err := e.store.ListInbox(e.ctx, store.InboxFilter{SessionID: sessionID, State: api.Open})
|
||||
if err != nil {
|
||||
e.t.Fatalf("list inbox: %v", err)
|
||||
}
|
||||
questions := []api.InboxItem{}
|
||||
matched := []api.InboxItem{}
|
||||
for _, item := range items {
|
||||
if item.Kind == api.Question {
|
||||
questions = append(questions, item)
|
||||
if item.Kind == kind {
|
||||
matched = append(matched, item)
|
||||
}
|
||||
}
|
||||
return questions
|
||||
return matched
|
||||
}
|
||||
|
||||
func (e *env) respond(m *agents.Manager, item api.InboxItem, typ api.InboxResponseType, option, text string, state api.InboxState) {
|
||||
@@ -470,6 +478,9 @@ func TestAskHumanRoundTrip(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(e.task.WorktreePath, "color.txt")); err == nil {
|
||||
t.Fatalf("agent wrote color.txt before getting an answer")
|
||||
}
|
||||
if reports := e.openReports(sess.Id); len(reports) != 0 {
|
||||
t.Fatalf("turn that asked a blocking question filed a report: %+v", reports)
|
||||
}
|
||||
|
||||
waiting, err := e.store.AwaitingHuman(e.ctx, sess.Id)
|
||||
if err != nil || waiting != 1 {
|
||||
@@ -496,6 +507,9 @@ func TestAskHumanRoundTrip(t *testing.T) {
|
||||
if strings.TrimSpace(string(out)) != "blue" {
|
||||
t.Fatalf("color.txt contains %q, want blue", out)
|
||||
}
|
||||
if reports := e.openReports(sess.Id); len(reports) != 1 {
|
||||
t.Fatalf("want one report from the answered turn, got %+v", reports)
|
||||
}
|
||||
|
||||
pending, err := e.store.Undelivered(e.ctx, sess.Id)
|
||||
if err != nil || len(pending) != 0 {
|
||||
|
||||
Reference in New Issue
Block a user