Add Away mode and an agent fork tool
This commit is contained in:
@@ -34,7 +34,7 @@ func (m *Manager) MergeAfterReview(ctx context.Context, task api.Task, user api.
|
||||
}
|
||||
}
|
||||
|
||||
latest, err := m.store.LastSnapshotNot(ctx, task.Id, api.SnapshotTriggerAnswer)
|
||||
latest, err := m.store.LastReviewable(ctx, task.Id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return api.Task{}, fmt.Errorf("%w: task %s has no snapshot under review", ErrConflict, task.Id)
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func (m *Manager) startMergeAfterReview(ctx context.Context, r api.Review, mar s
|
||||
if task.Discarding {
|
||||
return "the task is being discarded"
|
||||
}
|
||||
latest, err := m.store.LastSnapshotNot(ctx, task.Id, api.SnapshotTriggerAnswer)
|
||||
latest, err := m.store.LastReviewable(ctx, task.Id)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"g.fc.run/ai/byre/internal/api"
|
||||
"g.fc.run/ai/byre/internal/store"
|
||||
)
|
||||
|
||||
const awayText = "I'm away right now, so I can't answer this. Pick the answer you think most likely and carry on; when I'm back I can answer again, which forks the task from here with my answer. " +
|
||||
"If you strongly believe two answers are both worth following to the end, use your fork tool, if you have one, to follow the other one in a fork. " +
|
||||
"If this needs something only I can give, such as a test result, a password or a permission, don't guess: note it, and carry on with other work or end your turn. " +
|
||||
"This grants no authorization I must give explicitly, such as to deploy, push or commit."
|
||||
|
||||
func (m *Manager) SetAway(ctx context.Context, login string, away bool) (api.User, error) {
|
||||
u, err := m.store.UpdateAway(ctx, login, away)
|
||||
if err != nil || !away {
|
||||
return u, err
|
||||
}
|
||||
items, err := m.store.ListInbox(ctx, store.InboxFilter{State: api.Open})
|
||||
if err != nil {
|
||||
return u, err
|
||||
}
|
||||
for _, item := range items {
|
||||
task, err := m.store.GetTask(ctx, item.TaskId)
|
||||
if err != nil {
|
||||
return u, err
|
||||
}
|
||||
if task.CreatedBy != login {
|
||||
continue
|
||||
}
|
||||
err = m.answerAway(ctx, login, item.Id)
|
||||
if err != nil {
|
||||
slog.Error("answer question while away", "item", item.Id, "user", login, "err", err)
|
||||
}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (m *Manager) answerAway(ctx context.Context, login, itemID string) error {
|
||||
text := awayText
|
||||
_, err := m.Respond(ctx, login, []api.InboxResponseCreate{{ItemId: itemID, Type: api.Other, Text: &text}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("question answered while away", "item", itemID, "user", login)
|
||||
return nil
|
||||
}
|
||||
+90
-6
@@ -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
|
||||
}
|
||||
|
||||
@@ -146,6 +146,22 @@ func (m *Manager) ask(ctx context.Context, l *live, raw json.RawMessage) (string
|
||||
l.asked = true
|
||||
m.mu.Unlock()
|
||||
|
||||
task, err := m.store.GetTask(ctx, l.taskID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
creator, err := m.store.GetUser(ctx, task.CreatedBy)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if creator.Away {
|
||||
err = m.answerAway(ctx, creator.Login, id)
|
||||
if err == nil {
|
||||
return fmt.Sprintf("Question %s filed, but %s is away, so byre answered it for them at once; the answer reaches you next.", id, creator.Login), nil
|
||||
}
|
||||
slog.Error("answer question while away", "item", id, "user", creator.Login, "err", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Question filed in the human's inbox. question_id: %s. End your turn now. The answer will arrive as your next message. "+
|
||||
"If the human answers it some other way, such as in a chat message, withdraw it with cancel_question and question_id %q.", id, id), nil
|
||||
}
|
||||
|
||||
@@ -752,6 +752,9 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api
|
||||
}
|
||||
l.notes = "\n\n" + taskNotesGuidance + "\n\n" + notes
|
||||
tools = append(tools, m.suggestNoteTool(l), m.readyForMergeTool(l), m.suggestDiscardTool(l), m.respondToFindingTool(l))
|
||||
if sess.Vendor == api.Claude {
|
||||
tools = append(tools, m.forkTool(l))
|
||||
}
|
||||
}
|
||||
if sess.Role == api.Implementer && sess.Member != "" {
|
||||
sessions, err := m.store.ListSessions(ctx, task.Id)
|
||||
|
||||
@@ -130,7 +130,7 @@ func (m *Manager) tryPromptReady(ctx context.Context, taskID string, fromReview
|
||||
if err != nil || sess.State == api.AgentSessionStateRunning || m.interrupted(sess.Id) {
|
||||
return err
|
||||
}
|
||||
latest, err := m.store.LastSnapshotNot(ctx, taskID, api.SnapshotTriggerAnswer)
|
||||
latest, err := m.store.LastReviewable(ctx, taskID)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ func (m *Manager) takeSnapshot(ctx context.Context, t snapTarget, trigger api.Sn
|
||||
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
reviewed, err := m.store.LastSnapshotNot(ctx, t.taskID, api.SnapshotTriggerAnswer)
|
||||
reviewed, err := m.store.LastReviewable(ctx, t.taskID)
|
||||
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
@@ -229,6 +229,19 @@ func (m *Manager) sessionTarget(ctx context.Context, sess api.AgentSession, task
|
||||
}
|
||||
|
||||
func (m *Manager) snapshotForAnswer(ctx context.Context, t snapTarget, held bool) int {
|
||||
snap, err := m.cutSnapshot(ctx, t, held, api.SnapshotTriggerAnswer)
|
||||
if err != nil {
|
||||
slog.Error("take snapshot", "task", t.taskID, "trigger", api.SnapshotTriggerAnswer, "err", err)
|
||||
m.recordError(&live{id: t.sessionID, taskID: t.taskID, projectID: t.projectID}, fmt.Sprintf("byre couldn't snapshot the worktree before handing over an answer, so the answer went to the agent without one: %v", err))
|
||||
return 0
|
||||
}
|
||||
if snap == nil {
|
||||
return 0
|
||||
}
|
||||
return snap.N
|
||||
}
|
||||
|
||||
func (m *Manager) cutSnapshot(ctx context.Context, t snapTarget, held bool, trigger api.SnapshotTrigger) (*api.Snapshot, error) {
|
||||
if !held {
|
||||
unlock := m.lockTask(t.taskID)
|
||||
defer unlock()
|
||||
@@ -242,16 +255,9 @@ func (m *Manager) snapshotForAnswer(ctx context.Context, t snapTarget, held bool
|
||||
closed := st.closed
|
||||
m.snapMu.Unlock()
|
||||
if closed {
|
||||
return 0
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
snap, err := m.takeSnapshot(ctx, t, api.SnapshotTriggerAnswer, 0, true)
|
||||
if err != nil {
|
||||
slog.Error("take snapshot", "task", t.taskID, "trigger", api.SnapshotTriggerAnswer, "err", err)
|
||||
m.recordError(&live{id: t.sessionID, taskID: t.taskID, projectID: t.projectID}, fmt.Sprintf("byre couldn't snapshot the worktree before handing over an answer, so the answer went to the agent without one: %v", err))
|
||||
return 0
|
||||
}
|
||||
return snap.N
|
||||
return m.takeSnapshot(ctx, t, trigger, 0, true)
|
||||
}
|
||||
|
||||
func (m *Manager) takeSnapshotTool(l *live) mcp.Tool {
|
||||
|
||||
Reference in New Issue
Block a user