Add server-side workflows with YAML files, task freeze and lead tools
This commit is contained in:
+11
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user