Run workflow stages with finish_stage handoffs and lead signals
This commit is contained in:
@@ -68,6 +68,7 @@ func (m *Manager) Fork(ctx context.Context, src api.Task, project api.Project, n
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ForkedFrom: &srcSess.VendorSessionId,
|
||||
ForkedAt: &at,
|
||||
WorkflowStage: srcSess.WorkflowStage,
|
||||
}, t.ProjectId)
|
||||
if err != nil {
|
||||
return api.Task{}, fmt.Errorf("task %s created, but its agent session was not: %w", t.Id, err)
|
||||
|
||||
@@ -30,6 +30,7 @@ const leadGuidance = `You are the human's lead agent in byre, shown in the left
|
||||
- Reviews: after each new snapshot of a task, a reviewer agent from the other vendor reviews the change. The results are on the task's reviews tab, read_transcript shows each review's findings, and a question shows the review of the turn that asked it. They are for the human to look at when they want; mention one only when its findings matter to what the human is deciding.
|
||||
- Dig into context with list_tasks, read_transcript and read_inbox, and with your own Read, Grep and Bash on a task's worktree path (from list_tasks). Don't edit files in task worktrees; ask the task's agent with send_message.
|
||||
- You can create tasks, start TODO items, message, interrupt and compact task agents, and merge or discard tasks. Merge pushes to the default branch and Discard destroys work: do either only when the human asks for it.
|
||||
- Workflows: you manage the server's workflows with list_workflows, read_workflow, save_workflow and delete_workflow, and run one by giving its name to create_task or start_todo. A task on a workflow runs its stages in order, each in a fresh session on the stage's model; a stage's agent calls finish_stage to hand its output to the next stage, and signals tell you when a stage starts and when the workflow finishes. Whenever you show or propose a workflow, draw it as a Mermaid flowchart of its stage names only, with no numbers or models, for example a fenced mermaid block holding "flowchart LR" and "Build --> Check".
|
||||
- Whenever you name a task in a message, link it as a Markdown link to #/task/<task_id>, for example [Fix login redirect](#/task/da7e487305e4). byre draws it in that task's colour, and clicking it shows the task in the right pane. To open a specific tab, append it: #/task/<task_id>/<tab>. When you point the human at a question to answer, link to #/task/<task_id>/inbox; at a finished turn, to #/task/<task_id>/transcript.
|
||||
- Keep messages short; the human reads them in half the screen. Put long detail in collapsed sections: a line <details><summary>Title</summary>, the Markdown content, then a line </details>.
|
||||
- You are long-running. Your context window is large. Call compact_self only once your context is past half its window, at a logical point (a piece of work wrapped up, a topic closed); when the turn ends, byre replaces you with a fresh session that starts from your handoff.
|
||||
@@ -261,7 +262,7 @@ func (m *Manager) leadTurnEnded(ctx context.Context, l *live, res endResult) err
|
||||
|
||||
func (m *Manager) Observe(ev api.Event) {
|
||||
switch ev.Kind {
|
||||
case "inbox.created", "inbox.updated", "task.created", "task.merged", "task.discarded", "turn.ended":
|
||||
case "inbox.created", "inbox.updated", "task.created", "task.merged", "task.discarded", "turn.ended", "task.workflow_stage", "task.workflow_finished":
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -390,6 +391,12 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error)
|
||||
case "task.discarded":
|
||||
return fmt.Sprintf("%s was discarded by %s.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil
|
||||
|
||||
case "task.workflow_stage":
|
||||
return fmt.Sprintf("%s started stage %v/%v of %q: %v.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["stage"], ev.Payload["stages"], ev.Payload["workflow"], ev.Payload["name"]), nil
|
||||
|
||||
case "task.workflow_finished":
|
||||
return fmt.Sprintf("%s finished workflow %q:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["workflow"], clip(fmt.Sprint(ev.Payload["output"]), 1500)), nil
|
||||
|
||||
case "turn.ended":
|
||||
if ev.Payload["summary"] != true {
|
||||
return "", nil
|
||||
|
||||
@@ -156,15 +156,16 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
tool("create_task",
|
||||
"Create a task with its own branch and worktree, and start an agent on it with prompt as its first message. "+
|
||||
"The new agent sees nothing else, so the prompt must be a complete brief. It starts from the project's default branch, "+
|
||||
"or from from_task_id's committed HEAD.",
|
||||
"or from from_task_id's committed HEAD. With workflow, the task runs that workflow's stages in order instead, each in a fresh session on the stage's model; "+
|
||||
"prompt is then optional and is added to every stage's first message as its brief, and model can't be given.",
|
||||
`{"type": "object", "properties": {
|
||||
"project_id": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"prompt": {"type": "string"},
|
||||
"from_task_id": {"type": "string"},
|
||||
"model": {"type": "string"},
|
||||
"workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of."}
|
||||
}, "required": ["project_id", "title", "prompt"]}`,
|
||||
"workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of and run."}
|
||||
}, "required": ["project_id", "title"]}`,
|
||||
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
|
||||
args := struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
@@ -178,8 +179,14 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(args.Title) == "" || strings.TrimSpace(args.Prompt) == "" {
|
||||
return "", errors.New("title and prompt are required")
|
||||
if strings.TrimSpace(args.Title) == "" {
|
||||
return "", errors.New("title is required")
|
||||
}
|
||||
if args.Workflow == "" && strings.TrimSpace(args.Prompt) == "" {
|
||||
return "", errors.New("prompt is required without a workflow")
|
||||
}
|
||||
if args.Workflow != "" && args.Model != "" {
|
||||
return "", errModelWithWorkflow
|
||||
}
|
||||
if user.GitName == "" || user.GitEmail == "" {
|
||||
return "", ErrNoGitIdentity
|
||||
@@ -199,10 +206,17 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
task, err := m.CreateTask(ctx, project, args.Title, user.Login, base, leadActor(user.Login), args.Workflow)
|
||||
task, err := m.CreateTask(ctx, project, args.Title, user.Login, base, leadActor(user.Login), args.Workflow, args.Prompt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if args.Workflow != "" {
|
||||
err = m.startWorkflow(ctx, task, user, leadActor(user.Login))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Task %s %q created from %s and its workflow %q started.", task.Id, task.Title, task.BaseRef, args.Workflow), nil
|
||||
}
|
||||
_, err = m.create(ctx, task, user, args.Prompt, m.modelOr(args.Model, l), leadActor(user.Login), nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
|
||||
@@ -211,12 +225,13 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
}),
|
||||
|
||||
tool("start_todo",
|
||||
"Start a task on an item of a project's TODO.md (its title, exactly as list_tasks' project or the file on the default branch has it).",
|
||||
"Start a task on an item of a project's TODO.md (its title, exactly as list_tasks' project or the file on the default branch has it). "+
|
||||
"With workflow, the task runs that workflow's stages with the item as their brief, and model can't be given.",
|
||||
`{"type": "object", "properties": {
|
||||
"project_id": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"model": {"type": "string"},
|
||||
"workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of."}
|
||||
"workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of and run."}
|
||||
}, "required": ["project_id", "title"]}`,
|
||||
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
|
||||
args := struct {
|
||||
@@ -229,6 +244,9 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if args.Workflow != "" && args.Model != "" {
|
||||
return "", errModelWithWorkflow
|
||||
}
|
||||
project, err := m.store.GetProject(ctx, args.ProjectID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -562,6 +580,13 @@ func (m *Manager) listTasksText(ctx context.Context) (string, error) {
|
||||
if t.ForkedFrom != nil {
|
||||
fmt.Fprintf(b, "; forked from %s at snapshot %d", *t.ForkedFrom, *t.ForkedAt)
|
||||
}
|
||||
if t.Workflow != nil {
|
||||
stage, err := m.workflowStatus(ctx, t.Id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(b, "; workflow %q, %s", *t.Workflow, stage)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if !listed {
|
||||
@@ -674,6 +699,10 @@ func transcriptLine(ev api.Event, root string) string {
|
||||
}
|
||||
case "task.merged", "task.discarded":
|
||||
return fmt.Sprintf("%s by %s", ev.Kind, ev.Actor)
|
||||
case "task.workflow_stage":
|
||||
return fmt.Sprintf("started stage %v/%v of workflow %q: %s", p["stage"], p["stages"], text("workflow"), text("name"))
|
||||
case "task.workflow_finished":
|
||||
return fmt.Sprintf("finished workflow %q: %s", text("workflow"), clip(text("output"), 300))
|
||||
case "review.finished":
|
||||
r := api.Review{}
|
||||
err := remarshal(p, &r)
|
||||
|
||||
@@ -74,6 +74,8 @@ type live struct {
|
||||
projectID string
|
||||
owner string
|
||||
reviewID string
|
||||
stage int
|
||||
stages int
|
||||
stopping bool
|
||||
interrupted bool
|
||||
asked bool
|
||||
@@ -126,6 +128,18 @@ func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, p
|
||||
}
|
||||
|
||||
func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, prompt, model, actor string, images []string) (api.AgentSession, error) {
|
||||
existing, err := m.store.ListSessions(ctx, task.Id)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
stage := 0
|
||||
if len(existing) > 0 && existing[len(existing)-1].WorkflowStage != nil {
|
||||
stage = *existing[len(existing)-1].WorkflowStage
|
||||
}
|
||||
return m.createAt(ctx, task, creator, prompt, model, actor, images, stage)
|
||||
}
|
||||
|
||||
func (m *Manager) createAt(ctx context.Context, task api.Task, creator api.User, prompt, model, actor string, images []string, stage int) (api.AgentSession, error) {
|
||||
if creator.GitName == "" || creator.GitEmail == "" {
|
||||
return api.AgentSession{}, ErrNoGitIdentity
|
||||
}
|
||||
@@ -157,6 +171,9 @@ func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, p
|
||||
CreatedBy: creator.Login,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if stage > 0 {
|
||||
sess.WorkflowStage = &stage
|
||||
}
|
||||
|
||||
err = m.store.CreateSession(ctx, sess, task.ProjectId)
|
||||
if err != nil {
|
||||
@@ -265,12 +282,20 @@ func (m *Manager) Recover(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
staged, err := m.recoverStages(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := m.store.SessionsInState(ctx, api.AgentSessionStateRunning)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, sess := range sessions {
|
||||
if staged[sess.Id] {
|
||||
continue
|
||||
}
|
||||
task, err := m.taskFor(ctx, sess)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -280,9 +305,13 @@ func (m *Manager) Recover(ctx context.Context) error {
|
||||
if err != nil {
|
||||
slog.Error("resume session", "session", sess.Id, "err", err)
|
||||
m.logUndelivered(ctx, sess.Id, "resume failed")
|
||||
err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, "byre", map[string]any{"error": err.Error()})
|
||||
if err != nil {
|
||||
return err
|
||||
derr := m.store.DropStageOutput(ctx, sess.Id)
|
||||
if derr != nil {
|
||||
return derr
|
||||
}
|
||||
serr := m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, "byre", map[string]any{"error": err.Error()})
|
||||
if serr != nil {
|
||||
return serr
|
||||
}
|
||||
if sess.Role == api.Implementer {
|
||||
m.abandonMerge(task.Id, task.ProjectId)
|
||||
@@ -518,6 +547,16 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
|
||||
return nil, err
|
||||
}
|
||||
tools := []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.exposePortTool(l)}
|
||||
if sess.Role == api.Implementer && sess.WorkflowStage != nil {
|
||||
frozen, err := m.store.TaskWorkflow(ctx, task.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.stage = *sess.WorkflowStage
|
||||
l.stages = len(frozen.Definition.Stages)
|
||||
g += stageGuidance(frozen, l.stage)
|
||||
tools = append(tools, m.finishStageTool(l))
|
||||
}
|
||||
env := portEnv(task)
|
||||
stdio := []mcp.Stdio{}
|
||||
if sess.Role == api.Reviewer {
|
||||
@@ -588,6 +627,12 @@ func (m *Manager) pump(l *live) {
|
||||
}
|
||||
|
||||
m.logUndelivered(ctx, l.id, "agent process exited mid-turn")
|
||||
if l.stage > 0 {
|
||||
err = m.store.DropStageOutput(ctx, l.id)
|
||||
if err != nil {
|
||||
slog.Error("drop stage output", "session", l.id, "err", err)
|
||||
}
|
||||
}
|
||||
err = m.store.SetSessionState(ctx, l.id, api.AgentSessionStateIdle, agentActor, map[string]any{"error": "agent process exited mid-turn"})
|
||||
if err != nil {
|
||||
slog.Error("record session exit", "session", l.id, "err", err)
|
||||
@@ -788,6 +833,18 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
|
||||
m.fileError(l, failure)
|
||||
}
|
||||
|
||||
stageFinished := false
|
||||
switch {
|
||||
case l.stage == 0:
|
||||
case finished && !handoff && mergingBy == "":
|
||||
stageFinished, err = m.store.FinishStage(ctx, l.id)
|
||||
default:
|
||||
err = m.store.DropStageOutput(ctx, l.id)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.store.SetSessionState(ctx, l.id, api.AgentSessionStateIdle, agentActor, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -809,6 +866,12 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
|
||||
default:
|
||||
go m.nextMerge(l.projectID)
|
||||
}
|
||||
if stageFinished {
|
||||
go m.advanceStage(l)
|
||||
if l.stage < l.stages {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
err = m.Deliver(ctx, l.id)
|
||||
if err != nil {
|
||||
slog.Error("deliver inbox responses", "session", l.id, "err", err)
|
||||
|
||||
+17
-10
@@ -16,8 +16,8 @@ import (
|
||||
"g.fc.run/misc/byre/internal/todo"
|
||||
)
|
||||
|
||||
func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, createdBy, base, actor, workflowName string) (api.Task, error) {
|
||||
frozen, err := m.freeze(ctx, workflowName)
|
||||
func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, createdBy, base, actor, workflowName, brief string) (api.Task, error) {
|
||||
frozen, err := m.freeze(ctx, workflowName, brief)
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
}
|
||||
@@ -28,11 +28,6 @@ func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator ap
|
||||
if creator.GitName == "" || creator.GitEmail == "" {
|
||||
return api.Task{}, ErrNoGitIdentity
|
||||
}
|
||||
frozen, err := m.freeze(ctx, workflowName)
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
}
|
||||
|
||||
src, found, err := git.ReadFile(project.RepoPath, project.DefaultBranch, todo.File)
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
@@ -55,14 +50,26 @@ func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator ap
|
||||
}
|
||||
}
|
||||
|
||||
frozen, err := m.freeze(ctx, workflowName, todoPrompt(item))
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
}
|
||||
|
||||
task, err := m.createTask(ctx, project, api.Task{Title: item.Title, CreatedBy: creator.Login, TodoItem: &item.Title}, frozen, project.DefaultBranch, actor)
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
}
|
||||
|
||||
_, err = m.create(ctx, task, creator, todoPrompt(item), model, actor, nil)
|
||||
if frozen != nil {
|
||||
err = m.startWorkflow(ctx, task, creator, actor)
|
||||
} else {
|
||||
_, err = m.create(ctx, task, creator, todoPrompt(item), model, actor, nil)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return api.Task{}, fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
|
||||
return api.Task{}, err
|
||||
}
|
||||
if actor == creator.Login {
|
||||
m.setStartedTodo(creator.Login, true)
|
||||
@@ -172,7 +179,7 @@ func (m *Manager) createTaskFromAgent(ctx context.Context, l *live, raw json.Raw
|
||||
model = sess.Model
|
||||
}
|
||||
|
||||
task, err := m.CreateTask(ctx, project, args.Title, creator.Login, base, creator.Login, "")
|
||||
task, err := m.CreateTask(ctx, project, args.Title, creator.Login, base, creator.Login, "", "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
@@ -48,7 +49,9 @@ func (m *Manager) TaskWorkflow(ctx context.Context, taskID string) (api.TaskWork
|
||||
return api.TaskWorkflow{Name: frozen.Name, Definition: string(file)}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) freeze(ctx context.Context, name string) (*store.FrozenWorkflow, error) {
|
||||
var errModelWithWorkflow = fmt.Errorf("%w: model can't be combined with workflow: each stage names its own model", ErrInvalid)
|
||||
|
||||
func (m *Manager) freeze(ctx context.Context, name, brief string) (*store.FrozenWorkflow, error) {
|
||||
if name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -56,11 +59,232 @@ func (m *Manager) freeze(ctx context.Context, name string) (*store.FrozenWorkflo
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
frozen.Brief = brief
|
||||
return &frozen, nil
|
||||
}
|
||||
|
||||
func stageMessage(frozen store.FrozenWorkflow, n int, previous string) string {
|
||||
stages := frozen.Definition.Stages
|
||||
stage := stages[n-1]
|
||||
|
||||
b := &strings.Builder{}
|
||||
fmt.Fprintf(b, "Workflow %q, stage %d of %d: %s\n\n%s", frozen.Name, n, len(stages), stage.Name, strings.TrimSpace(stage.Prompt))
|
||||
if strings.TrimSpace(frozen.Brief) != "" {
|
||||
fmt.Fprintf(b, "\n\n## Brief\n\n%s", strings.TrimSpace(frozen.Brief))
|
||||
}
|
||||
if n > 1 {
|
||||
fmt.Fprintf(b, "\n\n## From the previous stage (%s)\n\n%s", stages[n-2].Name, strings.TrimSpace(previous))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *Manager) startStage(ctx context.Context, task api.Task, creator api.User, frozen store.FrozenWorkflow, n int, previous, actor string) (api.AgentSession, error) {
|
||||
stage := frozen.Definition.Stages[n-1]
|
||||
err := m.store.AppendEvent(ctx, api.Event{
|
||||
Kind: "task.workflow_stage",
|
||||
ProjectId: &task.ProjectId,
|
||||
TaskId: &task.Id,
|
||||
Actor: actor,
|
||||
Payload: map[string]any{"id": task.Id, "workflow": frozen.Name, "stage": n, "stages": len(frozen.Definition.Stages), "name": stage.Name},
|
||||
})
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
return m.createAt(ctx, task, creator, stageMessage(frozen, n, previous), stage.Model, "byre", nil, n)
|
||||
}
|
||||
|
||||
func (m *Manager) startWorkflow(ctx context.Context, task api.Task, creator api.User, actor string) error {
|
||||
frozen, err := m.store.TaskWorkflow(ctx, task.Id)
|
||||
if err == nil {
|
||||
_, err = m.startStage(ctx, task, creator, frozen, 1, "", actor)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("task %s created, but its workflow did not start: %w", task.Id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) advanceStage(l *live) {
|
||||
_, err := m.nextStage(context.Background(), l.id)
|
||||
if err != nil {
|
||||
slog.Error("advance workflow stage", "session", l.id, "err", err)
|
||||
m.fileError(l, fmt.Sprintf("The workflow didn't move on from this stage: %v. byre tries again when it next starts.", err))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) recoverStages(ctx context.Context) (map[string]bool, error) {
|
||||
ids, err := m.store.FinishedStages(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
started := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
next, err := m.nextStage(ctx, id)
|
||||
if err != nil {
|
||||
slog.Error("advance workflow stage", "session", id, "err", err)
|
||||
continue
|
||||
}
|
||||
if next != "" {
|
||||
started[next] = true
|
||||
}
|
||||
slog.Info("advanced workflow stage", "session", id, "next", next)
|
||||
}
|
||||
return started, nil
|
||||
}
|
||||
|
||||
func (m *Manager) nextStage(ctx context.Context, sessionID string) (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
|
||||
}
|
||||
if task.Status != api.TaskStatusActive {
|
||||
return "", m.store.ClearStage(ctx, sess.Id)
|
||||
}
|
||||
frozen, err := m.store.TaskWorkflow(ctx, task.Id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
output, err := m.store.StageOutput(ctx, sess.Id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
next := *sess.WorkflowStage + 1
|
||||
if next > len(frozen.Definition.Stages) {
|
||||
return "", m.store.FinishWorkflow(ctx, task, sess.Id, frozen.Name, output)
|
||||
}
|
||||
|
||||
creator, err := m.store.GetUser(ctx, sess.CreatedBy)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if sess.State != api.AgentSessionStateCompleted {
|
||||
m.logUndelivered(ctx, sess.Id, "stage finished")
|
||||
err = m.retire(ctx, sess, "byre")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
successor, found, err := m.successor(ctx, sess, next)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sent := true
|
||||
switch {
|
||||
case !found:
|
||||
successor, err = m.startStage(ctx, task, creator, frozen, next, output, "byre")
|
||||
default:
|
||||
sent, err = m.resendStage(ctx, successor, task, frozen, next, output)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = m.store.ClearStage(ctx, sess.Id)
|
||||
if err != nil || !sent {
|
||||
return "", err
|
||||
}
|
||||
return successor.Id, nil
|
||||
}
|
||||
|
||||
func (m *Manager) successor(ctx context.Context, sess api.AgentSession, n int) (api.AgentSession, bool, error) {
|
||||
sessions, err := m.store.ListSessions(ctx, sess.TaskId)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, false, err
|
||||
}
|
||||
for _, s := range sessions {
|
||||
if s.CreatedAt.Before(sess.CreatedAt) || s.Id == sess.Id || s.WorkflowStage == nil || *s.WorkflowStage != n {
|
||||
continue
|
||||
}
|
||||
return s, true, nil
|
||||
}
|
||||
return api.AgentSession{}, false, nil
|
||||
}
|
||||
|
||||
func (m *Manager) resendStage(ctx context.Context, successor api.AgentSession, task api.Task, frozen store.FrozenWorkflow, n int, previous string) (bool, error) {
|
||||
started, err := m.store.SessionStarted(ctx, successor.Id)
|
||||
if err != nil || started || successor.State == api.AgentSessionStateCompleted {
|
||||
return false, err
|
||||
}
|
||||
if successor.State == api.AgentSessionStateRunning {
|
||||
err = m.store.SetSessionState(ctx, successor.Id, api.AgentSessionStateIdle, "byre", nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
_, err = m.turn(ctx, successor, task, "byre", stageMessage(frozen, n, previous), nil)
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func (m *Manager) workflowStatus(ctx context.Context, taskID string) (string, error) {
|
||||
finished, err := m.store.WorkflowFinished(ctx, taskID)
|
||||
if err != nil || finished {
|
||||
return "finished", err
|
||||
}
|
||||
frozen, err := m.store.TaskWorkflow(ctx, taskID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sessions, err := m.store.ListSessions(ctx, taskID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(sessions) == 0 || sessions[len(sessions)-1].WorkflowStage == nil {
|
||||
return "not started", nil
|
||||
}
|
||||
n := *sessions[len(sessions)-1].WorkflowStage
|
||||
stages := frozen.Definition.Stages
|
||||
return fmt.Sprintf("stage %d/%d %s", n, len(stages), stages[n-1].Name), nil
|
||||
}
|
||||
|
||||
func (m *Manager) finishStageTool(l *live) mcp.Tool {
|
||||
return mcp.Tool{
|
||||
Name: "finish_stage",
|
||||
Description: "Finish your stage of this task's workflow. Call it once the stage's work is done; byre starts the next stage, in a fresh session, when this turn ends " +
|
||||
"(after the last stage, the workflow is finished). Calling it again in the same turn replaces the output. " +
|
||||
"The call counts only for the turn that makes it: if that turn ends in an error or is interrupted, it is discarded, and you call it again once the stage's work is done.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"output": {"type": "string", "description": "What the next stage starts from: everything it needs from this stage, since it sees nothing else of your context. For the last stage, what the workflow produced."}
|
||||
},
|
||||
"required": ["output"]
|
||||
}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
args := struct {
|
||||
Output string `json:"output"`
|
||||
}{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed finish_stage call: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(args.Output) == "" {
|
||||
return "", fmt.Errorf("%w: output is required", ErrInvalid)
|
||||
}
|
||||
err = m.store.SetStageOutput(ctx, l.id, args.Output)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if l.stage == l.stages {
|
||||
return "This is the workflow's last stage: it finishes when this turn ends. End your turn now with your summary.", nil
|
||||
}
|
||||
return fmt.Sprintf("byre starts stage %d when this turn ends. End your turn now with your summary.", l.stage+1), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func stageGuidance(frozen store.FrozenWorkflow, n int) string {
|
||||
return fmt.Sprintf("\n- This task runs byre workflow %q, and you are its stage %d of %d, %q. When this stage's work is done, call byre's finish_stage tool with its output, then end your turn: "+
|
||||
"byre then starts the next stage in a fresh session, which gets the output and nothing else of your context. Until you call it, the task stays in your stage.",
|
||||
frozen.Name, n, len(frozen.Definition.Stages), frozen.Definition.Stages[n-1].Name)
|
||||
}
|
||||
|
||||
const workflowFileHelp = "A workflow's file is YAML: an optional description, then stages, each with name (one line, unique in the workflow), " +
|
||||
"vendor (claude or codex: gpt-* models run on codex, every other model on claude), model and prompt (the stage agent's instructions). " +
|
||||
"model (which also picks the vendor: gpt-* models run on Codex, every other model on Claude Code) and prompt (the stage agent's instructions). " +
|
||||
"The workflow's name is not in the file."
|
||||
|
||||
func (m *Manager) workflowTools(tool func(name, description, schema string, run func(ctx context.Context, user api.User, raw json.RawMessage) (string, error)) mcp.Tool) []mcp.Tool {
|
||||
|
||||
Reference in New Issue
Block a user