Add server-side workflows with YAML files, task freeze and lead tools

This commit is contained in:
Ian Gulliver
2026-10-15 21:17:58 -07:00
parent 2f02f5ac3c
commit 2ce10a6c65
19 changed files with 1293 additions and 199 deletions
+11 -1
View File
@@ -6,6 +6,7 @@ import (
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/store"
)
func (m *Manager) Fork(ctx context.Context, src api.Task, project api.Project, n int, creator api.User) (api.Task, error) {
@@ -33,6 +34,15 @@ func (m *Manager) Fork(ctx context.Context, src api.Task, project api.Project, n
return api.Task{}, fmt.Errorf("%w: snapshot %d of task %s has no recorded agent message at or before it to fork the conversation from", ErrConflict, n, src.Id)
}
var frozen *store.FrozenWorkflow
if src.Workflow != nil {
f, err := m.store.TaskWorkflow(ctx, src.Id)
if err != nil {
return api.Task{}, err
}
frozen = &f
}
t, err := m.createTask(ctx, project, api.Task{
Title: fmt.Sprintf("%s (fork of #%d)", src.Title, n),
MergeTitle: src.MergeTitle,
@@ -40,7 +50,7 @@ func (m *Manager) Fork(ctx context.Context, src api.Task, project api.Project, n
TodoItem: src.TodoItem,
ForkedFrom: &src.Id,
ForkedAt: &n,
}, snap.CommitSha, creator.Login)
}, frozen, snap.CommitSha, creator.Login)
if err != nil {
return api.Task{}, err
}
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/store"
"g.fc.run/misc/byre/internal/workflow"
)
const leadGuidance = `You are the human's lead agent in byre, shown in the left pane of byre's web UI. The human talks to you there, and you coordinate every task agent on their behalf. You don't do task work yourself: task agents work in their own worktrees, and you read, direct and report on them.
@@ -67,7 +68,7 @@ func (m *Manager) createLead(ctx context.Context, owner api.User, prompt, model,
}
id := newUUID()
vendor := vendorOf(model)
vendor := workflow.VendorOf(model)
sess := api.AgentSession{
Id: id,
Owner: owner.Login,
+10 -6
View File
@@ -32,7 +32,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
}
taskOnly := `{"type": "object", "properties": {"task_id": {"type": "string"}}, "required": ["task_id"]}`
return []mcp.Tool{
return append([]mcp.Tool{
tool("list_tasks",
"List every project and its active tasks (id, title, worktree path, the agent's state and model, and open inbox items), "+
"and the titles of its TODO.md items on the default branch that no active task was started from.",
@@ -162,7 +162,8 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
"title": {"type": "string"},
"prompt": {"type": "string"},
"from_task_id": {"type": "string"},
"model": {"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"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
@@ -171,6 +172,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
Prompt string `json:"prompt"`
FromTaskID string `json:"from_task_id"`
Model string `json:"model"`
Workflow string `json:"workflow"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
@@ -197,7 +199,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
return "", err
}
}
task, err := m.CreateTask(ctx, project, args.Title, user.Login, base, leadActor(user.Login))
task, err := m.CreateTask(ctx, project, args.Title, user.Login, base, leadActor(user.Login), args.Workflow)
if err != nil {
return "", err
}
@@ -213,13 +215,15 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
`{"type": "object", "properties": {
"project_id": {"type": "string"},
"title": {"type": "string"},
"model": {"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"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
ProjectID string `json:"project_id"`
Title string `json:"title"`
Model string `json:"model"`
Workflow string `json:"workflow"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
@@ -229,7 +233,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
if err != nil {
return "", err
}
task, err := m.StartTodo(ctx, project, user, args.Title, m.modelOr(args.Model, l), leadActor(user.Login))
task, err := m.StartTodo(ctx, project, user, args.Title, m.modelOr(args.Model, l), leadActor(user.Login), args.Workflow)
if err != nil {
return "", err
}
@@ -362,7 +366,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
m.mu.Unlock()
return "byre will ask for your handoff once this turn ends.", nil
}),
}
}, m.workflowTools(tool)...)
}
func (m *Manager) SetFocus(ctx context.Context, login string, f api.Focus, actor string) error {
+2 -1
View File
@@ -19,6 +19,7 @@ import (
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
"g.fc.run/misc/byre/internal/workflow"
)
var (
@@ -144,7 +145,7 @@ func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, p
}
id := newUUID()
vendor := vendorOf(model)
vendor := workflow.VendorOf(model)
sess := api.AgentSession{
Id: id,
TaskId: task.Id,
+3 -10
View File
@@ -5,13 +5,13 @@ import (
"fmt"
"log/slog"
"os"
"strings"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/claude"
"g.fc.run/misc/byre/internal/codex"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/workflow"
)
type image struct {
@@ -30,13 +30,6 @@ type agentProcess interface {
drive(m *Manager, l *live)
}
func vendorOf(model string) api.AgentSessionVendor {
if strings.HasPrefix(model, "gpt-") {
return api.Codex
}
return api.Claude
}
func initialVendorSessionID(vendor api.AgentSessionVendor, id string) string {
if vendor == api.Codex {
return ""
@@ -45,8 +38,8 @@ func initialVendorSessionID(vendor api.AgentSessionVendor, id string) string {
}
func checkModel(sess api.AgentSession, model string) error {
if vendorOf(model) != sess.Vendor {
return fmt.Errorf("%w: session %s is %s; %s is a %s model", ErrInvalid, sess.Id, sess.Vendor, model, vendorOf(model))
if workflow.VendorOf(model) != sess.Vendor {
return fmt.Errorf("%w: session %s is %s; %s is a %s model", ErrInvalid, sess.Id, sess.Vendor, model, workflow.VendorOf(model))
}
return nil
}
+15 -7
View File
@@ -16,14 +16,22 @@ import (
"g.fc.run/misc/byre/internal/todo"
)
func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, createdBy, base, actor string) (api.Task, error) {
return m.createTask(ctx, project, api.Task{Title: title, CreatedBy: createdBy}, base, actor)
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)
if err != nil {
return api.Task{}, err
}
return m.createTask(ctx, project, api.Task{Title: title, CreatedBy: createdBy}, frozen, base, actor)
}
func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator api.User, title, model, actor string) (api.Task, error) {
func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator api.User, title, model, actor, workflowName string) (api.Task, error) {
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 {
@@ -47,7 +55,7 @@ func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator ap
}
}
task, err := m.createTask(ctx, project, api.Task{Title: item.Title, CreatedBy: creator.Login, TodoItem: &item.Title}, project.DefaultBranch, actor)
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
}
@@ -67,7 +75,7 @@ func todoPrompt(item api.TodoItem) string {
todo.File, todo.Markdown(item))
}
func (m *Manager) createTask(ctx context.Context, project api.Project, t api.Task, base, actor string) (api.Task, error) {
func (m *Manager) createTask(ctx context.Context, project api.Project, t api.Task, frozen *store.FrozenWorkflow, base, actor string) (api.Task, error) {
t.Id = store.NewID()
t.ProjectId = project.Id
if t.MergeTitle == "" {
@@ -84,7 +92,7 @@ func (m *Manager) createTask(ctx context.Context, project api.Project, t api.Tas
return api.Task{}, err
}
err = m.store.CreateTask(ctx, t, actor)
err = m.store.CreateTask(ctx, t, frozen, actor)
if err != nil {
return api.Task{}, err
}
@@ -164,7 +172,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
}
+160
View File
@@ -0,0 +1,160 @@
package agents
import (
"context"
"encoding/json"
"fmt"
"strings"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
"g.fc.run/misc/byre/internal/workflow"
)
func (m *Manager) SaveWorkflow(ctx context.Context, name string, file []byte, actor string) (api.WorkflowSummary, error) {
name, err := workflow.CleanName(name)
if err != nil {
return api.WorkflowSummary{}, err
}
def, err := workflow.Parse(file)
if err != nil {
return api.WorkflowSummary{}, err
}
err = m.store.PutWorkflow(ctx, name, def, actor)
if err != nil {
return api.WorkflowSummary{}, err
}
return api.WorkflowSummary{Name: name, Description: def.Description}, nil
}
func (m *Manager) WorkflowFile(ctx context.Context, name string) ([]byte, error) {
def, err := m.store.GetWorkflow(ctx, name)
if err != nil {
return nil, err
}
return workflow.Format(def)
}
func (m *Manager) TaskWorkflow(ctx context.Context, taskID string) (api.TaskWorkflow, error) {
frozen, err := m.store.TaskWorkflow(ctx, taskID)
if err != nil {
return api.TaskWorkflow{}, err
}
file, err := workflow.Format(frozen.Definition)
if err != nil {
return api.TaskWorkflow{}, err
}
return api.TaskWorkflow{Name: frozen.Name, Definition: string(file)}, nil
}
func (m *Manager) freeze(ctx context.Context, name string) (*store.FrozenWorkflow, error) {
if name == "" {
return nil, nil
}
frozen, err := m.store.FreezeWorkflow(ctx, name)
if err != nil {
return nil, err
}
return &frozen, nil
}
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). " +
"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 {
return []mcp.Tool{
tool("list_workflows",
"List the workflows on the server: each one's name and description.",
`{"type": "object", "properties": {}}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
workflows, err := m.store.ListWorkflows(ctx)
if err != nil {
return "", err
}
if len(workflows) == 0 {
return "No workflows.", nil
}
b := &strings.Builder{}
for _, w := range workflows {
fmt.Fprintf(b, "- %q", w.Name)
if w.Description != "" {
fmt.Fprintf(b, ": %s", w.Description)
}
b.WriteString("\n")
}
return b.String(), nil
}),
tool("read_workflow",
"Read a workflow's file: with name, the workflow on the server; with task_id, the copy that task froze when it was created. "+workflowFileHelp,
`{"type": "object", "properties": {
"name": {"type": "string"},
"task_id": {"type": "string"}
}}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
Name string `json:"name"`
TaskID string `json:"task_id"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
if (args.Name == "") == (args.TaskID == "") {
return "", fmt.Errorf("%w: give exactly one of name and task_id", ErrInvalid)
}
if args.TaskID != "" {
tw, err := m.TaskWorkflow(ctx, args.TaskID)
if err != nil {
return "", err
}
return fmt.Sprintf("Task %s froze workflow %q:\n\n%s", args.TaskID, tw.Name, tw.Definition), nil
}
file, err := m.WorkflowFile(ctx, args.Name)
return string(file), err
}),
tool("save_workflow",
"Create or replace a workflow on the server from its file. Tasks that already froze it keep their copy. "+
"The name is one line of 1 to 100 characters, shown to the human on buttons and pickers. "+workflowFileHelp,
`{"type": "object", "properties": {
"name": {"type": "string"},
"definition": {"type": "string", "description": "The workflow's file, as YAML."}
}, "required": ["name", "definition"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
Name string `json:"name"`
Definition string `json:"definition"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
w, err := m.SaveWorkflow(ctx, args.Name, []byte(args.Definition), leadActor(user.Login))
if err != nil {
return "", err
}
return fmt.Sprintf("Saved workflow %q.", w.Name), nil
}),
tool("delete_workflow",
"Delete a workflow from the server. Tasks that froze it keep their copy. Only when the human asks.",
`{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
Name string `json:"name"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
err = m.store.DeleteWorkflow(ctx, args.Name, leadActor(user.Login))
if err != nil {
return "", err
}
return fmt.Sprintf("Deleted workflow %q.", args.Name), nil
}),
}
}