Bold questions

This commit is contained in:
Ian Gulliver
2026-10-14 19:35:34 -07:00
parent ab4c3d2c6f
commit 1ea055682b
54 changed files with 3696 additions and 762 deletions
+50
View File
@@ -0,0 +1,50 @@
package agents
import (
"context"
"log/slog"
"g.fc.run/misc/byre/internal/api"
)
const handoffPrompt = `Write a handoff for a fresh agent that will continue this task with none of your context. ` +
`Cover: the goal; decisions made and why; the current state (what is done, what is in progress, what is verified); ` +
`dead ends tried; open questions; and the files that matter. Be complete: the next agent sees nothing else. ` +
`Your final message is the handoff, and nothing else.`
const handoffPreamble = "You are continuing this task from a previous agent session, which ended so the work could go on with a fresh context. Its handoff follows.\n\n"
func (m *Manager) Compact(ctx context.Context, sessionID string) (api.AgentSession, error) {
return m.Send(ctx, sessionID, "byre", handoffPrompt, "")
}
func (m *Manager) continueFromHandoff(sessionID, handoff string) {
ctx := context.Background()
err := m.handoff(ctx, sessionID, handoff)
if err != nil {
slog.Error("continue from handoff", "session", sessionID, "err", err)
}
}
func (m *Manager) handoff(ctx context.Context, sessionID, handoff string) error {
sess, err := m.store.GetSession(ctx, sessionID)
if err != nil {
return err
}
task, err := m.store.GetTask(ctx, sess.TaskId)
if err != nil {
return err
}
creator, err := m.store.GetUser(ctx, sess.CreatedBy)
if err != nil {
return err
}
err = m.retire(ctx, sess, "byre")
if err != nil {
return err
}
_, err = m.create(ctx, task, creator, handoffPreamble+handoff, sess.Model, "byre")
return err
}
+63
View File
@@ -0,0 +1,63 @@
package agents
import (
"context"
"encoding/json"
"regexp"
"g.fc.run/misc/byre/internal/claude"
)
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.
- 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.`
const proseQuestionNudge = "Your final message asks the human something in prose. Ask it with mcp__byre__ask_human " +
"(context, question, options, recommended_option, why_blocked), then end your turn with a short summary."
var (
fencedCode = regexp.MustCompile("(?s)```.*?```")
inlineCode = regexp.MustCompile("`[^`\n]*`")
quoted = regexp.MustCompile(`"[^"\n]*"|“[^”\n]*”`)
blockquote = regexp.MustCompile(`(?m)^\s*>.*$`)
endsQuestion = regexp.MustCompile(`\?[*_)\]]*(\s|$)`)
)
func asksQuestion(text string) bool {
text = fencedCode.ReplaceAllString(text, "")
text = inlineCode.ReplaceAllString(text, "")
text = quoted.ReplaceAllString(text, "")
text = blockquote.ReplaceAllString(text, "")
return endsQuestion.MatchString(text)
}
func (m *Manager) bounceProseQuestion(l *live) claude.HookFunc {
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
in := struct {
LastAssistantMessage string `json:"last_assistant_message"`
}{}
err := json.Unmarshal(input, &in)
if err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
if l.asked || l.bounced || l.compacting || !asksQuestion(in.LastAssistantMessage) {
return map[string]any{}, nil
}
l.bounced = true
return map[string]any{"decision": "block", "reason": proseQuestionNudge}, nil
}
}
func remindGuidance(ctx context.Context, input json.RawMessage) (map[string]any, error) {
return map[string]any{
"hookSpecificOutput": map[string]any{
"hookEventName": "UserPromptSubmit",
"additionalContext": guidance,
},
}, nil
}
+9 -8
View File
@@ -12,7 +12,6 @@ import (
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/report"
"g.fc.run/misc/byre/internal/store"
)
@@ -38,7 +37,7 @@ func (m *Manager) askTool(l *live) mcp.Tool {
Description: "Ask the human a question through their inbox. Use this for every question; never ask in plain prose. " +
"The human answers from their inbox without having read your transcript, so every question must stand on its own: " +
"put the background they need in context. " +
"Offer concrete options and recommend one. If blocking is true, call report and end your turn after asking; " +
"Offer concrete options and recommend one. 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. " +
"Ask several questions in one turn by calling this once per question; they are answered together. " +
"To answer a follow-up question from the human, call this again with the thread_id they gave you.",
@@ -119,8 +118,12 @@ func (m *Manager) ask(ctx context.Context, l *live, raw json.RawMessage) (string
return "", err
}
m.mu.Lock()
l.asked = true
m.mu.Unlock()
if *args.Blocking {
return fmt.Sprintf("Question %s filed in the human's inbox. It is blocking: call report, then end your turn. The answer will arrive as your next message.", id), nil
return fmt.Sprintf("Question %s filed in the human's inbox. It is blocking: end your turn now. The answer will arrive as your next message.", id), nil
}
return fmt.Sprintf("Question %s filed in the human's inbox. Continue with work that doesn't depend on it; the answer will arrive as a later message.", id), nil
}
@@ -170,11 +173,11 @@ func (m *Manager) validateAsk(ctx context.Context, l *live, args askArgs) error
return nil
}
func (m *Manager) fileReport(l *live, r report.Report) string {
func (m *Manager) fileSummary(l *live, text string) string {
id := store.NewID()
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.Report, api.Fyi, false, store.ToPayload(r)))
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.Report, api.Fyi, false, map[string]any{"text": text}))
if err != nil {
slog.Error("file report", "session", l.id, "err", err)
slog.Error("file turn summary", "session", l.id, "err", err)
return ""
}
return id
@@ -273,8 +276,6 @@ func deliveryMessage(pending []store.Pending) string {
switch p.Item.Kind {
case api.Question:
fmt.Fprintf(b, "Your question %s: %s\n", p.Item.Id, p.Item.Payload["question"])
case api.Report:
fmt.Fprintf(b, "Your report %s\n", p.Item.Id)
case api.SessionError:
fmt.Fprintf(b, "Your error %s: %s\n", p.Item.Id, p.Item.Payload["error"])
case api.RateLimited:
+60
View File
@@ -2,6 +2,7 @@ package agents
import (
"context"
"encoding/json"
"strings"
"testing"
@@ -51,6 +52,65 @@ func TestValidateAsk(t *testing.T) {
}
}
func TestAsksQuestion(t *testing.T) {
cases := []struct {
name string
text string
want bool
}{
{"prose question (seq 61)", "Which of these do you want to start with?", true},
{"question mid-message", "I can do either.\n\nWhich do you want first? I'd start with the spinner.", true},
{"bold question", "**Should I deploy now?**", true},
{"question mark only inside quotes (seq 108)", "- **A:** block if the final message contains a \"?\".\n- **C:** block if the turn's report lists open questions.\n\nMy recommendation is A and C together.", false},
{"quoted question", "Without it, a question like \"Which of these do you want to start with?\" still reaches you as plain text.", false},
{"inline code", "The view calls `GET /api/inbox?task=x` on load.", false},
{"fenced code", "Run:\n```\nif ok? then\n```\nDone.", false},
{"url query", "See https://example.com/?a=1 for details.", false},
{"blockquote", "> did it work?\n\nIt did.", false},
{"no question", "I committed everything and the tests pass.", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := asksQuestion(c.text); got != c.want {
t.Fatalf("asksQuestion(%q) = %v, want %v", c.text, got, c.want)
}
})
}
}
func TestBounceProseQuestion(t *testing.T) {
m := &Manager{}
stop := func(l *live, text string) map[string]any {
t.Helper()
input, err := json.Marshal(map[string]any{"hook_event_name": "Stop", "last_assistant_message": text})
if err != nil {
t.Fatal(err)
}
out, err := m.bounceProseQuestion(l)(context.Background(), input)
if err != nil {
t.Fatal(err)
}
return out
}
l := &live{}
if out := stop(l, "Which of these do you want to start with?"); out["decision"] != "block" || out["reason"] != proseQuestionNudge {
t.Fatalf("prose question not bounced: %v", out)
}
if out := stop(l, "Which of these do you want to start with?"); len(out) != 0 {
t.Fatalf("bounced twice in one turn: %v", out)
}
asked := &live{asked: true}
if out := stop(asked, "Should I go ahead?"); len(out) != 0 {
t.Fatalf("bounced a turn that called ask_human: %v", out)
}
if out := stop(&live{}, "I committed everything and the tests pass."); len(out) != 0 {
t.Fatalf("bounced a message with no question: %v", out)
}
}
func TestDeliveryMessage(t *testing.T) {
str := func(s string) *string { return &s }
question := api.InboxItem{Id: "q1", ThreadId: "q1", Kind: api.Question, Payload: map[string]any{"question": "Which color?"}}
+157 -50
View File
@@ -7,13 +7,14 @@ import (
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/claude"
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/report"
"g.fc.run/misc/byre/internal/store"
)
@@ -37,8 +38,9 @@ var DefaultLimits = Limits{
}
type Manager struct {
store *store.Store
limits Limits
store *store.Store
limits Limits
worktreeRoot string
mu sync.Mutex
live map[string]*live
@@ -51,13 +53,19 @@ type Manager struct {
type live struct {
sess *claude.Session
gate *report.Gate
id string
dir string
model string
gitName string
gitEmail string
vendor api.AgentSessionVendor
taskID string
projectID string
stopping bool
interrupted bool
asked bool
bounced bool
compacting bool
rateLimited map[string]any
contextModel string
@@ -65,16 +73,21 @@ type live struct {
contextWindow int
}
func New(st *store.Store, limits Limits) *Manager {
func New(st *store.Store, limits Limits, worktreeRoot string) *Manager {
return &Manager{
store: st,
limits: limits,
live: map[string]*live{},
rateLimits: map[api.AgentSessionVendor]map[string]any{},
store: st,
limits: limits,
worktreeRoot: worktreeRoot,
live: map[string]*live{},
rateLimits: map[api.AgentSessionVendor]map[string]any{},
}
}
func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model string) (api.AgentSession, error) {
return m.create(ctx, task, creator, prompt, model, creator.Login)
}
func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, prompt, model, actor string) (api.AgentSession, error) {
if creator.GitName == "" || creator.GitEmail == "" {
return api.AgentSession{}, ErrNoGitIdentity
}
@@ -111,10 +124,10 @@ func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, p
return api.AgentSession{}, err
}
return m.turn(ctx, sess, task, creator.Login, prompt)
return m.turn(ctx, sess, task, actor, prompt)
}
func (m *Manager) Send(ctx context.Context, sessionID, actor, text string) (api.AgentSession, error) {
func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model string) (api.AgentSession, error) {
sess, err := m.store.GetSession(ctx, sessionID)
if err != nil {
return api.AgentSession{}, err
@@ -124,6 +137,14 @@ func (m *Manager) Send(ctx context.Context, sessionID, actor, text string) (api.
return api.AgentSession{}, fmt.Errorf("%w: session %s is %s", ErrConflict, sess.Id, sess.State)
}
if model != "" && model != sess.Model {
err = m.store.SetSessionModel(ctx, sess.Id, model, actor)
if err != nil {
return api.AgentSession{}, err
}
sess.Model = model
}
task, err := m.store.GetTask(ctx, sess.TaskId)
if err != nil {
return api.AgentSession{}, err
@@ -175,26 +196,7 @@ func (m *Manager) StopTask(ctx context.Context, taskID, actor string) error {
if sess.State == api.AgentSessionStateCompleted {
continue
}
m.mu.Lock()
l := m.live[sess.Id]
if l != nil {
l.stopping = true
}
m.mu.Unlock()
if l != nil {
l.sess.Kill()
}
if sess.State == api.AgentSessionStateQueued {
err = m.store.DeleteQueuedTurn(ctx, sess.Id)
if err != nil {
return err
}
}
err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateCompleted, actor, nil)
err = m.retire(ctx, sess, actor)
if err != nil {
return err
}
@@ -203,6 +205,28 @@ func (m *Manager) StopTask(ctx context.Context, taskID, actor string) error {
return m.drain(ctx)
}
func (m *Manager) retire(ctx context.Context, sess api.AgentSession, actor string) error {
m.mu.Lock()
l := m.live[sess.Id]
if l != nil {
l.stopping = true
}
m.mu.Unlock()
if l != nil {
l.sess.Kill()
}
if sess.State == api.AgentSessionStateQueued {
err := m.store.DeleteQueuedTurn(ctx, sess.Id)
if err != nil {
return err
}
}
return m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateCompleted, actor, nil)
}
func (m *Manager) Recover(ctx context.Context) error {
sessions, err := m.store.SessionsInState(ctx, api.AgentSessionStateRunning)
if err != nil {
@@ -325,6 +349,9 @@ func (m *Manager) start(ctx context.Context, sess api.AgentSession, task api.Tas
}
l, err := m.ensureLive(ctx, sess, task)
if err == nil {
err = m.syncModel(ctx, l, sess.Model)
}
if err != nil {
serr := m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, actor, map[string]any{"error": err.Error()})
if serr != nil {
@@ -341,6 +368,22 @@ func (m *Manager) start(ctx context.Context, sess api.AgentSession, task api.Tas
return m.store.GetSession(ctx, sess.Id)
}
func (m *Manager) syncModel(ctx context.Context, l *live, model string) error {
if model == l.model {
return nil
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
_, err := l.sess.Control(cctx, map[string]any{"subtype": "set_model", "model": model})
if err != nil {
return fmt.Errorf("set model %s: %w", model, err)
}
l.model = model
return nil
}
func (m *Manager) hasRoom(ctx context.Context, vendor api.AgentSessionVendor) (bool, error) {
counts, err := m.store.CountRunning(ctx)
if err != nil {
@@ -425,17 +468,23 @@ func transition(ctx context.Context, st *store.Store, id string, from, to api.Ag
}
func (m *Manager) message(ctx context.Context, l *live, actor, text string) error {
err := m.emit(ctx, l, actor, "user.message", map[string]any{"text": text})
head, err := git.Head(l.dir)
if err != nil {
return err
}
err = m.emit(ctx, l, actor, "user.message", map[string]any{"text": text, "head": head})
if err != nil {
return err
}
m.mu.Lock()
l.interrupted = false
l.asked = false
l.bounced = false
l.compacting = actor == "byre" && text == handoffPrompt
l.rateLimited = nil
m.mu.Unlock()
l.gate.Reset()
return l.sess.Send(text)
}
@@ -457,24 +506,27 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
return nil, err
}
l = &live{id: sess.Id, vendor: sess.Vendor, taskID: task.Id, projectID: task.ProjectId}
l.gate = &report.Gate{OnReport: func(r report.Report) {
payload := store.ToPayload(r)
payload["inbox_item_id"] = m.fileReport(l, r)
err := m.emit(context.Background(), l, agentActor, "agent.report", payload)
if err != nil {
slog.Error("record report", "session", l.id, "err", err)
}
}}
l = &live{
id: sess.Id,
dir: task.WorktreePath,
model: sess.Model,
gitName: creator.GitName,
gitEmail: creator.GitEmail,
vendor: sess.Vendor,
taskID: task.Id,
projectID: task.ProjectId,
}
opts := claude.Options{
Cwd: task.WorktreePath,
Model: sess.Model,
GitName: creator.GitName,
GitEmail: creator.GitEmail,
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{l.gate.Tool(), m.askTool(l)}}},
Guidance: guidance,
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{m.askTool(l), m.createTaskTool(l)}}},
Hooks: map[string][]claude.HookMatcher{
"Stop": {{Func: l.gate.StopHook}},
"UserPromptSubmit": {{Func: remindGuidance}},
"Stop": {{Func: m.bounceProseQuestion(l)}},
},
}
started, err := m.store.SessionStarted(ctx, sess.Id)
@@ -593,7 +645,31 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
if err != nil {
return err
}
err = m.emit(ctx, l, agentActor, "turn.ended", store.ToPayload(res))
m.mu.Lock()
interrupted := l.interrupted
rateLimited := l.rateLimited
compacting := l.compacting
m.mu.Unlock()
finished := !res.IsError && !interrupted && strings.TrimSpace(res.Result) != ""
handoff := compacting && finished
payload := store.ToPayload(res)
sha, err := git.CommitAll(l.dir, commitMessage(res), l.gitName, l.gitEmail)
if err != nil {
slog.Error("commit turn", "session", l.id, "err", err)
}
if sha != "" {
payload["commit"] = sha
}
switch {
case handoff:
payload["handoff"] = true
case finished && !compacting:
payload["inbox_item_id"] = m.fileSummary(l, res.Result)
}
err = m.emit(ctx, l, agentActor, "turn.ended", payload)
if err != nil {
return err
}
@@ -606,11 +682,6 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
}
}
m.mu.Lock()
interrupted := l.interrupted
rateLimited := l.rateLimited
m.mu.Unlock()
failure := fmt.Sprintf("The turn ended with an error (%s): %s", res.Subtype, res.Result)
switch {
case !res.IsError || interrupted:
@@ -629,12 +700,31 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
if err != nil {
slog.Error("start queued turns", "err", err)
}
if handoff {
go m.continueFromHandoff(l.id, res.Result)
return nil
}
err = m.Deliver(ctx, l.id)
if err != nil {
slog.Error("deliver inbox responses", "session", l.id, "err", err)
}
return nil
case "stream_event":
text, err := ev.TextDelta()
if err != nil || text == "" {
return err
}
m.store.Broadcast(api.Event{
Kind: "agent.delta",
ProjectId: &l.projectID,
TaskId: &l.taskID,
AgentSessionId: &l.id,
Actor: agentActor,
Payload: map[string]any{"text": text},
})
return nil
case "rate_limit_event":
info := struct {
RateLimitInfo map[string]any `json:"rate_limit_info"`
@@ -665,6 +755,23 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
return m.emit(ctx, l, agentActor, "agent.vendor", raw)
}
func commitMessage(res claude.Result) string {
line, _, _ := strings.Cut(strings.TrimSpace(res.Result), "\n")
sentence, _, found := strings.Cut(line, ". ")
if found {
sentence += "."
}
sentence = strings.TrimSpace(strings.ReplaceAll(sentence, "**", ""))
if sentence == "" {
return fmt.Sprintf("Work from a turn that ended without a summary (%s)", res.Subtype)
}
runes := []rune(sentence)
if len(runes) > 100 {
return string(runes[:99]) + "…"
}
return sentence
}
func (m *Manager) updateContext(ctx context.Context, l *live, used, window int) error {
if used == l.contextUsed && window == l.contextWindow {
return nil
+187 -16
View File
@@ -34,13 +34,15 @@ type env struct {
repo string
project api.Project
task api.Task
worktrees string
}
func newEnv(t *testing.T) *env {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
t.Cleanup(cancel)
e := &env{t: t, ctx: ctx, events: make(chan api.Event, 4096)}
e := &env{t: t, ctx: ctx, events: make(chan api.Event, 4096), worktrees: t.TempDir()}
var err error
e.store, err = store.Open(filepath.Join(t.TempDir(), "byre.db"), func(ev api.Event) { e.events <- ev })
@@ -96,7 +98,7 @@ func (e *env) addTask() api.Task {
}
func (e *env) manager() *agents.Manager {
m := agents.New(e.store, agents.DefaultLimits)
m := agents.New(e.store, agents.DefaultLimits, e.worktrees)
e.t.Cleanup(m.Close)
return m
}
@@ -147,7 +149,8 @@ func TestTurnAndFollowUp(t *testing.T) {
e := newEnv(t)
m := e.manager()
sess, err := m.Create(e.ctx, e.task, e.user, `Create a file hi.txt containing exactly the word hi, then git add and git commit it with the message "add hi".`, testModel)
startHead := gitOut(t, e.task.WorktreePath, "rev-parse", "HEAD")
sess, err := m.Create(e.ctx, e.task, e.user, `Create a file hi.txt containing exactly the word hi.`, testModel)
if err != nil {
t.Fatalf("create session: %v", err)
}
@@ -156,12 +159,29 @@ func TestTurnAndFollowUp(t *testing.T) {
}
events := e.waitIdle()
for _, kind := range []string{"session.created", "user.message", "agent.tool_call", "agent.tool_result", "agent.report", "turn.ended", "session.context"} {
for _, kind := range []string{"session.created", "user.message", "agent.tool_call", "agent.tool_result", "turn.ended", "session.context"} {
if !has(events, kind) {
t.Fatalf("missing %s in %v", kind, kinds(events))
}
}
for _, ev := range events {
if ev.Kind != "turn.ended" {
continue
}
id, _ := ev.Payload["inbox_item_id"].(string)
if id == "" {
t.Fatalf("turn.ended has no summary inbox item: %v", ev.Payload)
}
item, err := e.store.GetInboxItem(e.ctx, id)
if err != nil {
t.Fatalf("get summary item: %v", err)
}
if item.Kind != api.Report || item.Payload["text"] != ev.Payload["result"] {
t.Fatalf("summary item %+v doesn't carry the turn's final message %q", item, ev.Payload["result"])
}
}
window := 0
for _, ev := range events {
if ev.Kind == "session.context" {
@@ -172,32 +192,61 @@ func TestTurnAndFollowUp(t *testing.T) {
t.Fatalf("no context window reported after the turn ended")
}
log := gitOut(t, e.task.WorktreePath, "log", "-1", "--format=%an <%ae>")
if log != gitName+" <"+gitEmail+">" {
t.Fatalf("agent committed as %q", log)
head := strings.TrimSpace(gitOut(t, e.task.WorktreePath, "rev-parse", "HEAD"))
for _, ev := range events {
if ev.Kind == "user.message" && ev.Payload["head"] != strings.TrimSpace(startHead) {
t.Fatalf("user.message head %v, want the HEAD the turn started from %s", ev.Payload["head"], startHead)
}
if ev.Kind == "turn.ended" && ev.Payload["commit"] != head {
t.Fatalf("turn.ended commit %v, want the new HEAD %s", ev.Payload["commit"], head)
}
}
log := gitOut(t, e.task.WorktreePath, "log", "-1", "--format=%an <%ae>|%cn <%ce>", "--name-only")
if !strings.HasPrefix(log, gitName+" <"+gitEmail+">|"+gitName+" <"+gitEmail+">") || !strings.Contains(log, "hi.txt") {
t.Fatalf("byre's turn commit is wrong:\n%s", log)
}
if status := gitOut(t, e.task.WorktreePath, "status", "--porcelain", "--untracked-files=all"); status != "" {
t.Fatalf("worktree dirty after the turn commit:\n%s", status)
}
_, err = m.Send(e.ctx, sess.Id, login, "Reply with the single word ok.")
_, err = m.Send(e.ctx, sess.Id, login, "Reply with the single word ok.", "haiku")
if err != nil {
t.Fatalf("send: %v", err)
}
events = e.waitIdle()
reply := ""
switched := false
contextModel := ""
for _, ev := range events {
if ev.Kind == "agent.message" {
switch ev.Kind {
case "agent.message":
reply += ev.Payload["text"].(string)
case "session.model":
switched = ev.Payload["model"] == "haiku"
case "session.context":
contextModel = ev.Payload["model"].(string)
}
}
if !strings.Contains(strings.ToLower(reply), "ok") {
t.Fatalf("follow-up reply %q", reply)
}
if !switched || !strings.Contains(contextModel, "haiku") {
t.Fatalf("model switch: session.model recorded %v, context model %q", switched, contextModel)
}
stored, err := e.store.GetSession(e.ctx, sess.Id)
if err != nil {
t.Fatalf("get session: %v", err)
}
if stored.Model != "haiku" {
t.Fatalf("session model %q after switch", stored.Model)
}
_, err = m.Send(e.ctx, sess.Id, login, "again")
_, err = m.Send(e.ctx, sess.Id, login, "again", "")
if err != nil {
t.Fatalf("send while idle: %v", err)
}
_, err = m.Send(e.ctx, sess.Id, login, "too soon")
_, err = m.Send(e.ctx, sess.Id, login, "too soon", "")
if err == nil {
t.Fatalf("send while running should conflict")
}
@@ -240,7 +289,7 @@ func TestInterrupt(t *testing.T) {
func TestRecoverMidTurn(t *testing.T) {
t.Parallel()
e := newEnv(t)
first := agents.New(e.store, agents.DefaultLimits)
first := agents.New(e.store, agents.DefaultLimits, e.worktrees)
sess, err := first.Create(e.ctx, e.task, e.user, "Run the bash command `sleep 15` in the foreground (not in the background). After it finishes, create a file done.txt containing exactly the word done.", testModel)
if err != nil {
@@ -309,7 +358,7 @@ func TestStopTask(t *testing.T) {
t.Fatalf("after stop: state %s, live %d", stored.State, m.LiveCount())
}
_, err = m.Send(e.ctx, sess.Id, login, "hello?")
_, err = m.Send(e.ctx, sess.Id, login, "hello?", "")
if err == nil {
t.Fatalf("send to completed session should conflict")
}
@@ -317,7 +366,7 @@ func TestStopTask(t *testing.T) {
const colorPrompt = `I need a file color.txt containing exactly one color name. Don't pick it yourself: ` +
`ask me with ask_human (blocking) whether it should be red or blue, recommending red. ` +
`When you get my answer, write the file and commit it with the message "add color".`
`When you get my answer, write the file.`
func (e *env) openQuestions(sessionID string) []api.InboxItem {
e.t.Helper()
@@ -416,6 +465,128 @@ func TestAskHumanRoundTrip(t *testing.T) {
}
}
func TestCompactCarriesHandoff(t *testing.T) {
t.Parallel()
e := newEnv(t)
m := e.manager()
old, err := m.Create(e.ctx, e.task, e.user, `Remember the codeword AZURE-17; a later message will ask for it. Reply with the single word ok.`, testModel)
if err != nil {
t.Fatalf("create session: %v", err)
}
e.waitIdle()
_, err = m.Compact(e.ctx, old.Id)
if err != nil {
t.Fatalf("compact: %v", err)
}
events := e.waitFor("new session idle", func(ev api.Event) bool {
return ev.Kind == "session.state" && ev.Payload["state"] == string(api.AgentSessionStateIdle) &&
ev.AgentSessionId != nil && *ev.AgentSessionId != old.Id
})
for _, ev := range events {
if ev.Kind == "turn.ended" && *ev.AgentSessionId == old.Id {
if ev.Payload["handoff"] != true || ev.Payload["inbox_item_id"] != nil {
t.Fatalf("handoff turn.ended %v: want handoff and no inbox summary", ev.Payload)
}
}
}
sessions, err := e.store.ListSessions(e.ctx, e.task.Id)
if err != nil {
t.Fatalf("list sessions: %v", err)
}
if len(sessions) != 2 {
t.Fatalf("want the old and the new session, got %+v", sessions)
}
var next api.AgentSession
for _, s := range sessions {
switch s.Id {
case old.Id:
if s.State != api.AgentSessionStateCompleted {
t.Fatalf("old session is %s, want completed", s.State)
}
default:
next = s
}
}
if next.State != api.AgentSessionStateIdle || next.Model != old.Model {
t.Fatalf("new session %+v, want idle on model %q", next, old.Model)
}
_, err = m.Send(e.ctx, next.Id, login, "What was the codeword? Reply with just the codeword.", "")
if err != nil {
t.Fatalf("send to new session: %v", err)
}
reply := ""
for _, ev := range e.waitIdle() {
if ev.Kind == "agent.message" {
reply += ev.Payload["text"].(string)
}
}
if !strings.Contains(reply, "AZURE-17") {
t.Fatalf("handoff lost the codeword; new session said %q", reply)
}
}
func TestCreateTaskTool(t *testing.T) {
t.Parallel()
e := newEnv(t)
m := e.manager()
gitOut(t, e.task.WorktreePath, "-c", "user.name=x", "-c", "user.email=x@byre.invalid", "commit", "--allow-empty", "--message", "parent work")
parentHead := strings.TrimSpace(gitOut(t, e.task.WorktreePath, "rev-parse", "HEAD"))
_, err := m.Create(e.ctx, e.task, e.user, `Call the byre create_task tool once with title "child", base "this_task", and prompt "Reply with the single word ok." Then stop.`, testModel)
if err != nil {
t.Fatalf("create session: %v", err)
}
e.waitFor("child session", func(ev api.Event) bool {
return ev.Kind == "session.created" && ev.TaskId != nil && *ev.TaskId != e.task.Id
})
tasks, err := e.store.ListTasks(e.ctx, e.project.Id)
if err != nil {
t.Fatalf("list tasks: %v", err)
}
var child api.Task
for _, task := range tasks {
if task.Id != e.task.Id {
child = task
}
}
if child.Title != "child" || child.CreatedBy != login || filepath.Dir(child.WorktreePath) != filepath.Join(e.worktrees, e.project.Id) {
t.Fatalf("child task %+v", child)
}
sessions, err := e.store.ListSessions(e.ctx, child.Id)
if err != nil || len(sessions) != 1 || sessions[0].Model != testModel || sessions[0].CreatedBy != login {
t.Fatalf("child sessions %+v %v", sessions, err)
}
if child.BaseRef != parentHead {
t.Fatalf("child base %s, want the parent task's HEAD %s", child.BaseRef, parentHead)
}
}
func TestQuestionReachesInbox(t *testing.T) {
t.Parallel()
e := newEnv(t)
m := e.manager()
sess, err := m.Create(e.ctx, e.task, e.user, `I want a file hi.txt that says either red or blue. Before you create anything, find out from me which one.`, testModel)
if err != nil {
t.Fatalf("create session: %v", err)
}
events := e.waitIdle()
questions := e.openQuestions(sess.Id)
if len(questions) == 0 {
t.Fatalf("the prose question never became an inbox question: %v", kinds(events))
}
}
func TestFollowUpQuestionThread(t *testing.T) {
t.Parallel()
e := newEnv(t)
@@ -460,7 +631,7 @@ func TestFollowUpQuestionThread(t *testing.T) {
func TestConcurrencyQueue(t *testing.T) {
t.Parallel()
e := newEnv(t)
m := agents.New(e.store, agents.Limits{Global: 1, PerVendor: map[api.AgentSessionVendor]int{api.Claude: 1}})
m := agents.New(e.store, agents.Limits{Global: 1, PerVendor: map[api.AgentSessionVendor]int{api.Claude: 1}}, e.worktrees)
t.Cleanup(m.Close)
first, err := m.Create(e.ctx, e.task, e.user, longCommandPrompt, testModel)
@@ -487,7 +658,7 @@ func TestConcurrencyQueue(t *testing.T) {
t.Fatalf("cancel queued turn: %+v %v", cancelled, err)
}
second, err = m.Send(e.ctx, second.Id, login, "Reply with the single word ok.")
second, err = m.Send(e.ctx, second.Id, login, "Reply with the single word ok.", "")
if err != nil || second.State != api.AgentSessionStateQueued {
t.Fatalf("requeue: %+v %v", second, err)
}
+2 -3
View File
@@ -8,7 +8,6 @@ import (
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/claude"
"g.fc.run/misc/byre/internal/report"
"g.fc.run/misc/byre/internal/store"
)
@@ -41,8 +40,8 @@ func TestRateLimitedTurn(t *testing.T) {
t.Fatalf("create session: %v", err)
}
m := New(st, DefaultLimits)
l := &live{id: "s1", vendor: api.Claude, taskID: "t1", projectID: "p1", gate: &report.Gate{}}
m := New(st, DefaultLimits, t.TempDir())
l := &live{id: "s1", vendor: api.Claude, taskID: "t1", projectID: "p1"}
feed := func(typ, raw string) {
t.Helper()
+128
View File
@@ -0,0 +1,128 @@
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
)
func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, createdBy, base string) (api.Task, error) {
id := store.NewID()
t := api.Task{
Id: id,
ProjectId: project.Id,
Title: title,
Branch: "byre/" + id,
WorktreePath: filepath.Join(m.worktreeRoot, project.Id, id),
Status: api.TaskStatusActive,
CreatedBy: createdBy,
CreatedAt: time.Now().UTC(),
}
var err error
t.BaseRef, err = git.AddWorktree(project.RepoPath, t.WorktreePath, t.Branch, base)
if err != nil {
return api.Task{}, err
}
err = m.store.CreateTask(ctx, t)
if err != nil {
return api.Task{}, err
}
return t, nil
}
type createTaskArgs struct {
Title string `json:"title"`
Prompt string `json:"prompt"`
Base string `json:"base"`
Model string `json:"model"`
}
func (m *Manager) createTaskTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "create_task",
Description: "Create a new byre task in this project, with its own branch and worktree, and start an agent on it with prompt as its first message. " +
"Use it to run independent pieces of work in parallel. The new agent sees nothing of your context, so the prompt must be a complete brief. " +
"base picks the starting commit: default_branch starts from the project's default branch; this_task starts from this task's HEAD, " +
"which includes your committed work but not uncommitted edits (byre commits at the end of each turn). " +
"Returns at once with the new task's id; the new agent runs on its own.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"title": {"type": "string", "description": "Short task title, shown in the task list."},
"prompt": {"type": "string", "description": "The new agent's first message: a complete, self-contained brief."},
"base": {"type": "string", "enum": ["default_branch", "this_task"]},
"model": {"type": "string", "description": "Model for the new agent. Defaults to this session's model."}
},
"required": ["title", "prompt", "base"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
return m.createTaskFromAgent(ctx, l, raw)
},
}
}
func (m *Manager) createTaskFromAgent(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
args := createTaskArgs{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed create_task call: %w", err)
}
if strings.TrimSpace(args.Title) == "" || strings.TrimSpace(args.Prompt) == "" {
return "", errors.New("title and prompt are required")
}
sess, err := m.store.GetSession(ctx, l.id)
if err != nil {
return "", err
}
project, err := m.store.GetProject(ctx, l.projectID)
if err != nil {
return "", err
}
creator, err := m.store.GetUser(ctx, sess.CreatedBy)
if err != nil {
return "", err
}
base := ""
switch args.Base {
case "default_branch":
base = project.DefaultBranch
case "this_task":
base, err = git.Head(l.dir)
if err != nil {
return "", err
}
default:
return "", fmt.Errorf("base must be default_branch or this_task, not %q", args.Base)
}
model := args.Model
if model == "" {
model = sess.Model
}
task, err := m.CreateTask(ctx, project, args.Title, creator.Login, base)
if err != nil {
return "", err
}
_, err = m.create(ctx, task, creator, args.Prompt, model, agentActor)
if err != nil {
return "", fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
}
return fmt.Sprintf("Task %s %q created from %s and its agent started.", task.Id, task.Title, task.BaseRef), nil
}