Add Away mode and an agent fork tool

This commit is contained in:
Ian Gulliver
2026-10-19 21:40:24 -07:00
parent bb60b685ba
commit f6f2440635
22 changed files with 617 additions and 317 deletions
+90 -6
View File
@@ -2,19 +2,88 @@ package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"g.fc.run/ai/byre/internal/api"
"g.fc.run/ai/byre/internal/mcp"
"g.fc.run/ai/byre/internal/store"
)
func (m *Manager) Fork(ctx context.Context, src api.Task, project api.Project, n int, creator api.User) (api.Task, error) {
t, _, err := m.fork(ctx, src, project, n, creator)
t, _, err := m.fork(ctx, src, project, n, creator, forkTitle(src, n))
return t, err
}
func forkTitle(src api.Task, n int) string {
return fmt.Sprintf("%s (fork of #%d)", src.Title, n)
}
func (m *Manager) forkTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "fork",
Description: "Fork this task here, for when two ways forward are both worth finishing and the human should compare the results rather than you picking one. " +
"byre snapshots your worktree now and starts a new task from that snapshot, with its own branch and worktree, whose agent continues this conversation up to just before this call " +
"and then gets prompt as its next message. You carry on here with the other way. " +
"Each fork doubles the work and gives the human another result to compare, so fork only when you strongly believe both paths are worth following.",
InputSchema: json.RawMessage(`{"type": "object", "properties": {
"title": {"type": "string", "description": "The new task's title, naming the path it takes."},
"prompt": {"type": "string", "description": "The forked agent's next message: tell it that it is the other side of a fork and which path to take."}
}, "required": ["title", "prompt"]}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
args := struct {
Title string `json:"title"`
Prompt string `json:"prompt"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed fork 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
}
creator, err := m.store.GetUser(ctx, sess.CreatedBy)
if err != nil {
return "", err
}
src, err := m.store.GetTask(ctx, l.taskID)
if err != nil {
return "", err
}
project, err := m.store.GetProject(ctx, src.ProjectId)
if err != nil {
return "", err
}
snap, err := m.cutSnapshot(ctx, liveTarget(l), m.ownsTask(l.id), api.SnapshotTriggerFork)
if err != nil {
return "", err
}
if snap == nil {
return "", fmt.Errorf("%w: task %s is closing", ErrConflict, src.Id)
}
t, sessionID, err := m.fork(ctx, src, project, snap.N, creator, args.Title)
if err != nil {
return "", err
}
_, _, err = m.Message(ctx, sessionID, agentActor, args.Prompt, "")
if err != nil {
return "", fmt.Errorf("task %s forked, but its agent did not start: %w", t.Id, err)
}
slog.Info("task forked by its agent", "task", src.Id, "fork", t.Id, "snapshot", snap.N)
return fmt.Sprintf("Forked at snapshot #%d as task %s %q, branch %s, worktree %s; its agent continues from just before this call with your prompt. Carry on here with your own path.",
snap.N, t.Id, t.Title, t.Branch, t.WorktreePath), nil
},
}
}
func (m *Manager) AnswerAgain(ctx context.Context, creator api.User, create api.InboxResponseCreate) (api.Task, error) {
item, err := m.store.GetInboxItem(ctx, create.ItemId)
if err != nil {
@@ -47,7 +116,7 @@ func (m *Manager) AnswerAgain(ctx context.Context, creator api.User, create api.
if err != nil {
return api.Task{}, err
}
t, sessionID, err := m.fork(ctx, src, project, n, creator)
t, sessionID, err := m.fork(ctx, src, project, n, creator, forkTitle(src, n))
if err != nil {
return api.Task{}, err
}
@@ -76,7 +145,7 @@ func (m *Manager) AnswerAgain(ctx context.Context, creator api.User, create api.
return t, nil
}
func (m *Manager) fork(ctx context.Context, src api.Task, project api.Project, n int, creator api.User) (api.Task, string, error) {
func (m *Manager) fork(ctx context.Context, src api.Task, project api.Project, n int, creator api.User, title string) (api.Task, string, error) {
if creator.GitName == "" || creator.GitEmail == "" {
return api.Task{}, "", ErrNoGitIdentity
}
@@ -114,7 +183,7 @@ func (m *Manager) fork(ctx context.Context, src api.Task, project api.Project, n
}
t, err := m.createTask(ctx, project, api.Task{
Title: fmt.Sprintf("%s (fork of #%d)", src.Title, n),
Title: title,
MergeTitle: src.MergeTitle,
CreatedBy: creator.Login,
TodoItem: src.TodoItem,
@@ -150,6 +219,8 @@ func (m *Manager) fork(ctx context.Context, src api.Task, project api.Project, n
func forkPoint(events []api.Event, sessionID string, seq int64) string {
at := ""
prev := ""
cut := ""
open := map[string]bool{}
for _, ev := range events {
if ev.AgentSessionId == nil || *ev.AgentSessionId != sessionID {
@@ -168,18 +239,31 @@ func forkPoint(events []api.Event, sessionID string, seq int64) string {
}
after := ev.Seq > seq
if after && (len(open) == 0 || id == "") {
break
return at
}
switch ev.Kind {
case "agent.tool_call":
if len(open) == 0 {
cut = at
if at == uuid {
cut = prev
}
}
open[id] = true
case "agent.tool_result":
delete(open, id)
}
if uuid != at {
prev = at
}
at = uuid
if after && len(open) == 0 {
break
return at
}
}
// Calls still open have no results yet, as when the agent forks from inside a tool call: cut before them.
if len(open) > 0 {
return cut
}
return at
}