Implement workflow variants system

- runWorkflow accepts a variant parameter; sources {workflow}-{variant}.conf
  as env vars before exec'ing the script
- Session stores variant; persisted and restored
- session/new accepts variant= parameter
- 'run' ctl command accepts optional variant as second arg
- workflows file now lists variants: name<TAB>default,variant1,...
- review workflow reads AUTHOR_PROFILE/REVIEWER_PROFILE env vars
- Add review-code.conf and review-writing.conf example variants
This commit is contained in:
Levi Neely 2026-08-19 17:57:22 +02:00
parent 59f729f591
commit 5fdd80c26c
6 changed files with 107 additions and 10 deletions

View File

@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"os/user"
"sort"
"strconv"
"strings"
"syscall"
@ -60,9 +61,12 @@ func startAsync(ctx context.Context, fn func()) bool {
}
// runWorkflow executes a workflow script in the background.
func runWorkflow(s *session.Session, workflow string) {
// If variant is non-empty and not "default", the matching conf sidecar is
// sourced as environment variables before the script runs.
func runWorkflow(s *session.Session, workflow, variant string) {
startAsync(s.Ctx, func() {
scriptPath := util.CfgDir() + "/workflows/" + workflow
workflowDir := util.CfgDir() + "/workflows/"
scriptPath := workflowDir + workflow
if _, err := os.Stat(scriptPath); err != nil {
s.SetGoalStatus("error: workflow not found: " + workflow)
return
@ -74,6 +78,24 @@ func runWorkflow(s *session.Session, workflow string) {
"OLLIE_SESSION_NAME="+s.Name(),
"OLLIE_CWD="+cwd,
)
// Source variant conf if specified.
if variant != "" && variant != "default" {
confPath := workflowDir + workflow + "-" + variant + ".conf"
confData, err := os.ReadFile(confPath)
if err != nil {
s.SetGoalStatus("error: variant conf not found: " + workflow + "-" + variant + ".conf")
return
}
for _, line := range strings.Split(string(confData), "\n") {
line = strings.TrimSpace(line)
if line == "" || line[0] == '#' {
continue
}
if _, _, ok := strings.Cut(line, "="); ok {
cmd.Env = append(cmd.Env, line)
}
}
}
cmd.Dir = cwd
out, err := cmd.CombinedOutput()
if err != nil {
@ -146,19 +168,52 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl {
}),
),
virtfs.FileNode("workflows", 0444,
virtfs.Doc("Available workflows, one per line"),
virtfs.Doc("Available workflows with variants. Format: name\\tdefault,variant1,variant2"),
virtfs.Read(func() ([]byte, error) {
dir := util.CfgDir() + "/workflows"
entries, err := os.ReadDir(dir)
if err != nil {
return nil, nil
}
var sb strings.Builder
// Collect executable workflow scripts (no extension).
workflows := make(map[string][]string)
for _, e := range entries {
if e.IsDir() {
continue
}
sb.WriteString(e.Name())
name := e.Name()
if strings.Contains(name, ".") {
continue // skip .conf files
}
workflows[name] = []string{"default"}
}
// Find variant confs: {name}-{variant}.conf
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".conf") {
continue
}
base := strings.TrimSuffix(name, ".conf")
// Find the longest workflow name that is a prefix.
for wf := range workflows {
if strings.HasPrefix(base, wf+"-") {
variant := strings.TrimPrefix(base, wf+"-")
if variant != "" {
workflows[wf] = append(workflows[wf], variant)
}
}
}
}
var sb strings.Builder
names := make([]string, 0, len(workflows))
for wf := range workflows {
names = append(names, wf)
}
sort.Strings(names)
for _, wf := range names {
sb.WriteString(wf)
sb.WriteByte('\t')
sb.WriteString(strings.Join(workflows[wf], ","))
sb.WriteByte('\n')
}
return []byte(sb.String()), nil
@ -256,7 +311,7 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl {
virtfs.Doc("Create session"),
virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
args := strings.Fields(string(data))
name, remote, workflow, cwd := "", "", "", ""
name, remote, workflow, variant, cwd := "", "", "", "", ""
yolo := false
for _, arg := range args {
if k, v, ok := strings.Cut(arg, "="); ok {
@ -267,6 +322,8 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl {
remote = v
case "workflow":
workflow = v
case "variant":
variant = v
case "cwd":
cwd = v
case "yolo":
@ -285,6 +342,9 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl {
if workflow != "" {
sess.SetWorkflow(workflow)
}
if variant != "" {
sess.SetVariant(variant)
}
return []byte(sess.Name() + "\n"), nil
}),
),
@ -375,7 +435,7 @@ func buildSessionChildren(
s.SetGoal(input)
// Trigger workflow only if not already running.
if status == "" || status == "complete" || status == "blocked" || strings.HasPrefix(status, "error") {
runWorkflow(s, s.Workflow())
runWorkflow(s, s.Workflow(), s.Variant())
}
return nil
}),
@ -454,10 +514,14 @@ func buildSessionChildren(
},
"run": func(args []string) ([]byte, error) {
workflow := s.Workflow()
variant := s.Variant()
if len(args) > 0 {
workflow = args[0]
}
runWorkflow(s, workflow)
if len(args) > 1 {
variant = args[1]
}
runWorkflow(s, workflow, variant)
return []byte("ok\n"), nil
},
}, data)

View File

@ -24,6 +24,7 @@ type PersistedSession struct {
Yolo bool `json:"yolo,omitempty"`
Paused bool `json:"paused,omitempty"`
Workflow string `json:"workflow,omitempty"`
Variant string `json:"variant,omitempty"`
Goal string `json:"goal,omitempty"`
GoalStatus string `json:"goalStatus,omitempty"`
Agents []PersistedAgent `json:"agents"`
@ -75,6 +76,7 @@ func PersistSession(name string) error {
Yolo: sess.Yolo,
Paused: sess.IsPaused(),
Workflow: sess.Workflow(),
Variant: sess.Variant(),
Goal: goalText,
GoalStatus: goalStatus,
}
@ -206,6 +208,9 @@ func restoreMultiAgentSession(ps *PersistedSession) (*RestoredSession, error) {
if ps.Workflow != "" {
sess.SetWorkflow(ps.Workflow)
}
if ps.Variant != "" {
sess.SetVariant(ps.Variant)
}
if ps.Goal != "" {
sess.SetGoal(ps.Goal)
}

View File

@ -51,6 +51,7 @@ type Session struct {
goalSignalMu sync.Mutex
goalSignalCh chan struct{}
workflow string // workflow profile name (default: "conductor")
variant string // workflow variant (empty = "default")
// Autosave
saveMu sync.Mutex
@ -274,6 +275,21 @@ func (s *Session) SetWorkflow(w string) {
s.mu.Unlock()
}
// Variant returns the workflow variant name (empty means "default").
func (s *Session) Variant() string {
s.mu.RLock()
v := s.variant
s.mu.RUnlock()
return v
}
// SetVariant sets the workflow variant name.
func (s *Session) SetVariant(v string) {
s.mu.Lock()
s.variant = v
s.mu.Unlock()
}
// FindAgent returns the agent matching the given name or ID, or nil.
func (s *Session) FindAgent(nameOrID string) *agent.Agent {
for _, ag := range s.agents {

View File

@ -8,17 +8,19 @@ set -euo pipefail
SESSION_NAME="$OLLIE_SESSION_NAME"
CWD="$OLLIE_CWD"
MAX_ROUNDS=${OLLIE_REFINE_MAX:-3}
AUTHOR_PROFILE=${AUTHOR_PROFILE:-author}
REVIEWER_PROFILE=${REVIEWER_PROFILE:-reviewer}
# Set status to running.
echo "running" | ollie-9p write "session/$SESSION_NAME/goalstatus"
# Create the author agent.
AUTHOR=$(printf 'profile=author\ncwd=%s\nname=author\n' "$CWD" \
AUTHOR=$(printf 'profile=%s\ncwd=%s\nname=author\n' "$AUTHOR_PROFILE" "$CWD" \
| ollie-9p rdwr "session/$SESSION_NAME/agent/new")
AUTHOR=$(echo "$AUTHOR" | tr -d '\n')
# Create the reviewer agent.
REVIEWER=$(printf 'profile=reviewer\ncwd=%s\nname=reviewer\n' "$CWD" \
REVIEWER=$(printf 'profile=%s\ncwd=%s\nname=reviewer\n' "$REVIEWER_PROFILE" "$CWD" \
| ollie-9p rdwr "session/$SESSION_NAME/agent/new")
REVIEWER=$(echo "$REVIEWER" | tr -d '\n')

View File

@ -0,0 +1,5 @@
# Variant: code
# Optimized for code review cycles — more iterations, code-focused profiles.
OLLIE_REFINE_MAX=5
AUTHOR_PROFILE=author
REVIEWER_PROFILE=reviewer

View File

@ -0,0 +1,5 @@
# Variant: writing
# Optimized for document drafting — fewer iterations, writing-focused profiles.
OLLIE_REFINE_MAX=3
AUTHOR_PROFILE=author
REVIEWER_PROFILE=reviewer