From 958d3d2ddfb5f25ff4b6ba89650445e12167c9d6 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Mon, 17 Aug 2026 12:00:10 +0200 Subject: [PATCH] workflows: script-based workflows with session-level CWD - Workflows are executable scripts in data/workflows/ - New 'workflows' 9P file lists available workflows - Goal file stores text; writing triggers workflow if status allows - goalstatus file for status read/write, goalwait for blocking - Session ctl accepts 'run [workflow]' command - Session now owns CWD; agents inherit via callback - Conductor workflow: creates agent, primes with instructions, exits - GUI workflow combo reads from workflows, not agents - Persistence includes goal, goalstatus, workflow, and session CWD --- Makefile | 3 +- cmd/olliesrv/internal/agent/agent.go | 33 +++--- cmd/olliesrv/internal/agent/turn.go | 2 +- cmd/olliesrv/internal/fs/spec.go | 127 +++++++++++++++------- cmd/olliesrv/internal/fs/support.go | 3 +- cmd/olliesrv/internal/session/persist.go | 53 +++++---- cmd/olliesrv/internal/session/registry.go | 30 +++-- cmd/olliesrv/internal/session/session.go | 38 ++++--- data/workflows/conductor | 32 ++++++ kde/gui/NewSessionDialog.qml | 10 +- kde/gui/ollie9pclient.cpp | 17 +++ kde/gui/ollie9pclient.h | 9 ++ 12 files changed, 245 insertions(+), 112 deletions(-) create mode 100755 data/workflows/conductor diff --git a/Makefile b/Makefile index 5dc17da..3ce1a43 100644 --- a/Makefile +++ b/Makefile @@ -67,11 +67,12 @@ install-kf5: build-kf5 test install-data install -Dm755 kde/dolphin/ollie-session-here $(BINDIR)/ollie-session-here install-data: - mkdir -p $(CONFIG_DIR)/agents $(CONFIG_DIR)/prompts $(CONFIG_DIR)/skills $(CONFIG_DIR)/tools + mkdir -p $(CONFIG_DIR)/agents $(CONFIG_DIR)/prompts $(CONFIG_DIR)/skills $(CONFIG_DIR)/tools $(CONFIG_DIR)/workflows @test -f $(CONFIG_DIR)/backends.conf || install -Dm600 data/backends.conf $(CONFIG_DIR)/backends.conf cp -a data/agents/. $(CONFIG_DIR)/agents/ cp -a data/prompts/. $(CONFIG_DIR)/prompts/ cp -a data/skills/. $(CONFIG_DIR)/skills/ + install -Dm755 data/workflows/* $(CONFIG_DIR)/workflows/ install -Dm644 cmd/toolsrv/internal/sandbox/sandbox.yaml $(CONFIG_DIR)/sandbox.yaml install -Dm755 data/scripts/ollie-remount $(BINDIR)/ollie-remount install -Dm755 data/scripts/logseq-cli $(BINDIR)/logseq-cli diff --git a/cmd/olliesrv/internal/agent/agent.go b/cmd/olliesrv/internal/agent/agent.go index 930d115..b841eb4 100644 --- a/cmd/olliesrv/internal/agent/agent.go +++ b/cmd/olliesrv/internal/agent/agent.go @@ -39,7 +39,7 @@ type Agent struct { // Execution state — owned by the agent, protected by stateMu. state string // "idle", "thinking", "calling: " reply string // last assistant response - cwd string // working directory for tool execution + getCwd func() string // returns session working directory id string // agent identity (unique principal) parentID string // immutable ID of the agent that spawned this agent depth int // sub-agent depth (0=top-level, 1=sub-agent, 2=sub-sub-agent) @@ -357,23 +357,20 @@ func (ag *Agent) SetToolServer(newToolServer func() *toolclient.ToolsrvConn, con } } -// CWD returns the agent's working directory. +// Cwd returns the agent's working directory (from session). func (ag *Agent) Cwd() string { - ag.stateMu.RLock() - c := ag.cwd - ag.stateMu.RUnlock() - return c -} - -// effectiveCwd returns the agent's cwd, falling back to os.Getwd() if empty. -func (ag *Agent) effectiveCwd() string { - if c := ag.Cwd(); c != "" { - return c + if ag.getCwd != nil { + return ag.getCwd() } wd, _ := os.Getwd() return wd } +// SetGetCwd sets the CWD getter callback. +func (ag *Agent) SetGetCwd(fn func() string) { + ag.getCwd = fn +} + // IsRunning returns true if the agent has an active turn in progress. func (ag *Agent) IsRunning() bool { return ag.currentAction.Load() != nil @@ -439,7 +436,7 @@ func (ag *Agent) SwitchProfile(name string) error { disp.SetAgentID(ag.id) } env := []string{"OLLIE_SESSION_ID=" + ag.sessionID, "OLLIE_UNAME=" + ag.id} - rt := BuildRuntime(cfg, disp, ag.cwd, env, ag.systemPrompt, ag.envBlock) + rt := BuildRuntime(cfg, disp, ag.Cwd(), env, ag.systemPrompt, ag.envBlock) if cfg.Backend != "" { newBe, err := ag.newBackend(cfg.Backend) if err != nil { @@ -542,11 +539,9 @@ func (ag *Agent) Close() { } } -// SetCWD updates the agent's working directory, preamble environment section, and dispatcher. +// SetCWD updates the preamble environment section and toolserver CWD. +// Note: This updates local state only. To change session CWD, use session.SetCwd(). func (ag *Agent) SetCWD(dir string) { - ag.stateMu.Lock() - ag.cwd = dir - ag.stateMu.Unlock() isGitRepo := util.IsGitRepo(dir) ag.envBlock = EnvironmentBlock(dir, runtime.GOOS, isGitRepo, "") ag.runtime.Preamble.Set(SectionEnv, ag.envBlock) @@ -737,7 +732,7 @@ type AgentParams struct { History *History Runtime *Runtime AgentsDir string - Cwd string // working directory for tool execution + GetCwd func() string // returns session working directory SystemPrompt string EnvBlock string NewToolServer func() *toolclient.ToolsrvConn @@ -760,7 +755,7 @@ func NewAgent(cfg AgentParams) *Agent { agentsDir: cfg.AgentsDir, id: cfg.ID, parentID: cfg.ParentID, - cwd: cfg.Cwd, + getCwd: cfg.GetCwd, systemPrompt: cfg.SystemPrompt, envBlock: cfg.EnvBlock, newToolServer: cfg.NewToolServer, diff --git a/cmd/olliesrv/internal/agent/turn.go b/cmd/olliesrv/internal/agent/turn.go index 91bbdb2..c81cf6a 100644 --- a/cmd/olliesrv/internal/agent/turn.go +++ b/cmd/olliesrv/internal/agent/turn.go @@ -361,7 +361,7 @@ func (ag *Agent) autoWarnLimit(ctx context.Context) int { func (ag *Agent) spawnContext(ctx context.Context) string { var parts []string // Inject AGENTS.md from the working directory if it exists. - if cwd := ag.effectiveCwd(); cwd != "" { + if cwd := ag.Cwd(); cwd != "" { if data, err := os.ReadFile(cwd + "/AGENTS.md"); err == nil && len(data) > 0 { parts = append(parts, string(data)) } diff --git a/cmd/olliesrv/internal/fs/spec.go b/cmd/olliesrv/internal/fs/spec.go index f243698..dd06d37 100644 --- a/cmd/olliesrv/internal/fs/spec.go +++ b/cmd/olliesrv/internal/fs/spec.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "os/user" "strconv" "strings" @@ -39,6 +40,34 @@ const ( var treeSpecUID, treeSpecGID string +// runWorkflow executes a workflow script in the background. +func runWorkflow(s *session.Session, workflow string) { + go func() { + scriptPath := util.CfgDir() + "/workflows/" + workflow + if _, err := os.Stat(scriptPath); err != nil { + s.SetGoalStatus("error: workflow not found: " + workflow) + return + } + cwd := s.Cwd() + cmd := exec.CommandContext(s.Ctx, scriptPath) + cmd.Env = append(os.Environ(), + "OLLIE_SESSION_ID="+s.ID, + "OLLIE_SESSION_NAME="+s.Name(), + "OLLIE_CWD="+cwd, + ) + cmd.Dir = cwd + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + msg = err.Error() + } + s.SetGoalStatus("error: " + msg) + } + // Script exited — don't auto-set complete; the conductor agent handles that. + }() +} + // buildTreeSpec constructs the full 9P namespace. func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl { mc := cfg.ModelCache @@ -97,6 +126,25 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl { return []byte(sb.String()), nil }), ), + virtfs.FileNode("workflows", 0444, + virtfs.Doc("Available workflows, one per line"), + virtfs.Read(func() ([]byte, error) { + dir := util.CfgDir() + "/workflows" + entries, err := os.ReadDir(dir) + if err != nil { + return nil, nil + } + var sb strings.Builder + for _, e := range entries { + if e.IsDir() { + continue + } + sb.WriteString(e.Name()) + sb.WriteByte('\n') + } + return []byte(sb.String()), nil + }), + ), virtfs.FileNode("ctl", 0666, virtfs.Doc("Server control. Write: 'invalidate', 'kill'"), virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) { @@ -189,7 +237,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 := "", "", "" + name, remote, workflow, cwd := "", "", "", "" for _, arg := range args { if k, v, ok := strings.Cut(arg, "="); ok { switch k { @@ -199,13 +247,19 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl { remote = v case "workflow": workflow = v + case "cwd": + cwd = v } } } + if cwd == "" { + cwd, _ = os.Getwd() + } sess, err := session.CreateEmpty(name, remote) if err != nil { return nil, err } + sess.SetCwd(cwd) if workflow != "" { sess.SetWorkflow(workflow) } @@ -280,20 +334,14 @@ func buildSessionChildren( }), ), virtfs.FileNode("goal", 0660, - virtfs.Doc("Session goal. Write to start a workflow; read for status."), + virtfs.Doc("Session goal text. Writing triggers the workflow if not already running."), virtfs.GID("agent"), virtfs.Read(func() ([]byte, error) { - text, status, agentID := s.Goal() + text, _ := s.Goal() if text == "" { return nil, nil } - var sb strings.Builder - fmt.Fprintf(&sb, "status=%s\n", status) - if agentID != "" { - fmt.Fprintf(&sb, "agent=%s\n", agentID) - } - fmt.Fprintf(&sb, "text=%s\n", text) - return []byte(sb.String()), nil + return []byte(text + "\n"), nil }), virtfs.Write(func(data []byte) error { input := strings.TrimSpace(string(data)) @@ -301,42 +349,32 @@ func buildSessionChildren( s.ClearGoal() return nil } - // Status write-back from the conductor. - if strings.HasPrefix(input, "status=") { - status := strings.TrimPrefix(strings.SplitN(input, "\n", 2)[0], "status=") - s.SetGoalStatus(status, "") - return nil + _, status := s.Goal() + s.SetGoal(input) + // Trigger workflow only if not already running. + if status == "" || status == "complete" || status == "blocked" || strings.HasPrefix(status, "error") { + runWorkflow(s, s.Workflow()) } - // New goal: spawn workflow agent in background. - goalText := input - workflow := s.Workflow() - s.SetGoal(goalText) - go func() { - ag, err := session.CreateAgentWithParams(s.Name(), session.AgentParams{ - Profile: workflow, - CWD: agentCwd(s), - }) - if err != nil { - s.SetGoalStatus("error: "+err.Error(), "") - return - } - wireAgentEvents(s.ID, ag) - s.SetGoalStatus("running", ag.ID()) - session.PublishEvent("session."+s.ID+".agent."+ag.ID()+".new", "") - ag.Submit(s.Ctx, goalText) - ag.EnsureTrailingNewline() - _, status, _ := s.Goal() - if status == "running" { - s.SetGoalStatus("complete", "") - } - }() + return nil + }), + ), + virtfs.FileNode("goalstatus", 0660, + virtfs.Doc("Goal status. Read/write."), + virtfs.GID("agent"), + virtfs.Read(func() ([]byte, error) { + _, status := s.Goal() + return []byte(status + "\n"), nil + }), + virtfs.Write(func(data []byte) error { + status := strings.TrimSpace(string(data)) + s.SetGoalStatus(status) return nil }), ), virtfs.FileNode("goalwait", 0444, virtfs.Doc("Blocks until goal status changes."), virtfs.BlockOnce(func() ([]byte, string, error) { - _, status, _ := s.Goal() + _, status := s.Goal() return []byte(status + "\n"), status, nil }, s.GoalSignal), ), @@ -392,6 +430,14 @@ func buildSessionChildren( } return []byte("ok\n"), nil }, + "run": func(args []string) ([]byte, error) { + workflow := s.Workflow() + if len(args) > 0 { + workflow = args[0] + } + runWorkflow(s, workflow) + return []byte("ok\n"), nil + }, }, data) }), ), @@ -414,6 +460,9 @@ func buildSessionChildren( virtfs.Rdwr(func(ctx context.Context, data []byte) ([]byte, error) { req := parseAgentNewRequest(data) req.Params.ParentID = req.ParentID + if req.Cwd != "" { + s.SetCwd(req.Cwd) + } if req.Prompt == "" { // Persistent agent creation (no sub-agent mode). diff --git a/cmd/olliesrv/internal/fs/support.go b/cmd/olliesrv/internal/fs/support.go index f251068..0586d2f 100644 --- a/cmd/olliesrv/internal/fs/support.go +++ b/cmd/olliesrv/internal/fs/support.go @@ -92,6 +92,7 @@ type agentNewRequest struct { Params session.AgentParams Prompt string ParentID string + Cwd string // sets session cwd if provided ForkAt int Timeout int // seconds; 0 = use default (600) MaxDepth int // max sub-agent depth; 0 = use default (1) @@ -126,7 +127,7 @@ func parseAgentNewRequest(data []byte) agentNewRequest { v = protocol.UnescapeValue(v) switch k { case "cwd": - r.Params.CWD = v + r.Cwd = v case "backend": r.Params.Backend = v case "model": diff --git a/cmd/olliesrv/internal/session/persist.go b/cmd/olliesrv/internal/session/persist.go index b4a7ea1..732bd4b 100644 --- a/cmd/olliesrv/internal/session/persist.go +++ b/cmd/olliesrv/internal/session/persist.go @@ -17,12 +17,15 @@ import ( // PersistedSession is the on-disk format for a saved session. type PersistedSession struct { - ID string `json:"id"` - Name string `json:"name"` - CWD string `json:"cwd"` - Remote string `json:"remote,omitempty"` - Paused bool `json:"paused,omitempty"` - Agents []PersistedAgent `json:"agents"` + ID string `json:"id"` + Name string `json:"name"` + CWD string `json:"cwd"` + Remote string `json:"remote,omitempty"` + Paused bool `json:"paused,omitempty"` + Workflow string `json:"workflow,omitempty"` + Goal string `json:"goal,omitempty"` + GoalStatus string `json:"goalStatus,omitempty"` + Agents []PersistedAgent `json:"agents"` } // PersistedAgent is per-agent state within a session. @@ -33,7 +36,6 @@ type PersistedAgent struct { Profile string `json:"profile"` Backend string `json:"backend"` Model string `json:"model"` - CWD string `json:"cwd"` Messages []backend.Message `json:"messages"` // Usage tracking TotalInputTokens int `json:"totalInputTokens,omitempty"` @@ -62,11 +64,16 @@ func PersistSession(name string) error { return nil } + goalText, goalStatus := sess.Goal() ps := PersistedSession{ - ID: sess.ID, - Name: sess.Name(), - Remote: sess.Remote, - Paused: sess.IsPaused(), + ID: sess.ID, + Name: sess.Name(), + CWD: sess.Cwd(), + Remote: sess.Remote, + Paused: sess.IsPaused(), + Workflow: sess.Workflow(), + Goal: goalText, + GoalStatus: goalStatus, } for _, ag := range agents { @@ -77,7 +84,6 @@ func PersistSession(name string) error { Profile: ag.Profile(), Backend: ag.BackendName(), Model: ag.ModelName(), - CWD: ag.Cwd(), Messages: ag.Messages(), } if usage := ag.Usage(); usage != nil { @@ -189,18 +195,22 @@ func restoreMultiAgentSession(ps *PersistedSession) (*RestoredSession, error) { sess.paused = true sess.mu.Unlock() } + if ps.CWD != "" { + sess.SetCwd(ps.CWD) + } + if ps.Workflow != "" { + sess.SetWorkflow(ps.Workflow) + } + if ps.Goal != "" { + sess.SetGoal(ps.Goal) + } + if ps.GoalStatus != "" { + sess.SetGoalStatus(ps.GoalStatus) + } var restoredAgents []RestoredAgent for _, pa := range ps.Agents { - cwd := pa.CWD - if cwd == "" { - cwd = ps.CWD - } - if cwd == "" { - cwd, _ = os.Getwd() - } - // For paused sessions, create a minimal agent without tool server if ps.Paused { history := agent.RestoreHistoryFromMessages(pa.Messages) @@ -211,7 +221,7 @@ func restoreMultiAgentSession(ps *PersistedSession) (*RestoredSession, error) { AgentsDir: pkgAgentsDir, ID: pa.ID, ParentID: pa.ParentID, - Cwd: cwd, + GetCwd: sess.Cwd, Log: pkgSink.NewLogger("agent"), SessionID: ps.ID, Save: sess.saveSession, @@ -233,7 +243,6 @@ func restoreMultiAgentSession(ps *PersistedSession) (*RestoredSession, error) { Profile: pa.Profile, ID: pa.ID, Name: pa.Name, - CWD: cwd, Backend: pa.Backend, Model: pa.Model, Remote: ps.Remote, diff --git a/cmd/olliesrv/internal/session/registry.go b/cmd/olliesrv/internal/session/registry.go index 858d041..50bc0f3 100644 --- a/cmd/olliesrv/internal/session/registry.go +++ b/cmd/olliesrv/internal/session/registry.go @@ -360,7 +360,6 @@ type AgentParams struct { Profile string ID string // uname; if empty, generate one Name string // display name; optional - CWD string // required Backend string // backend name; empty = config default Model string // model override; empty = config default Remote string // remote target override @@ -369,13 +368,14 @@ type AgentParams struct { } // buildAgent constructs a fully-wired agent within a session. -// Used by both CreateAgent and restore. +// Uses the session's CWD. func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) { if p.ID == "" { p.ID = NextUname() } - if p.CWD == "" { - return nil, fmt.Errorf("cwd is required") + cwd := sess.Cwd() + if cwd == "" { + return nil, fmt.Errorf("session cwd is required") } cfg := agent.LoadConfig(pkgAgentsDir, p.Profile, nil) @@ -398,7 +398,7 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) { infra, err := SetupToolServer(ToolServerConfig{ Ctx: sess.Ctx, - CWD: p.CWD, + CWD: cwd, RemoteTarget: remote, SessionID: sessID, Yolo: pkgYolo, @@ -416,7 +416,7 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) { } // Build prompt layers - layers := BuildPromptLayers(cfg, p.CWD, sessID, p.ID, infra.Platform, infra.IsGitRepo, "") + layers := BuildPromptLayers(cfg, cwd, sessID, p.ID, infra.Platform, infra.IsGitRepo, "") // Load tools LoadAutoLoadTools(cfg, infra.ToolsConn, sessID, p.ID, func(f string, a ...any) { @@ -425,7 +425,7 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) { // Build runtime env := []string{"OLLIE_SESSION_ID=" + sessID, "OLLIE_UNAME=" + p.ID} - rt := agent.BuildRuntime(cfg, infra.ToolsConn, p.CWD, env, layers.SystemPrompt, layers.EnvBlock) + rt := agent.BuildRuntime(cfg, infra.ToolsConn, cwd, env, layers.SystemPrompt, layers.EnvBlock) // Create backend beName := p.Backend @@ -450,7 +450,7 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) { Profile: p.Profile, AgentsDir: pkgAgentsDir, ID: p.ID, - Cwd: p.CWD, + GetCwd: sess.Cwd, ParentID: p.ParentID, SystemPrompt: layers.SystemPrompt, EnvBlock: layers.EnvBlock, @@ -473,6 +473,7 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) { func CreateAgent(sessName string, args []string) (*agent.Agent, error) { // Parse args into AgentParams. p := AgentParams{Profile: "default"} + var setCwd string for _, arg := range args { if k, v, ok := strings.Cut(arg, "="); ok { switch k { @@ -485,12 +486,19 @@ func CreateAgent(sessName string, args []string) (*agent.Agent, error) { case "remote": p.Remote = v case "cwd": - p.CWD = v + setCwd = v } } else if p.Profile == "default" { p.Profile = arg } } + sess := Lookup(sessName) + if sess == nil { + return nil, fmt.Errorf("session not found: %s", sessName) + } + if setCwd != "" { + sess.SetCwd(setCwd) + } return CreateAgentWithParams(sessName, p) } @@ -506,8 +514,8 @@ func CreateAgentWithParams(sessName string, p AgentParams) (*agent.Agent, error) p.Profile = "default" } - if p.CWD == "" { - return nil, fmt.Errorf("cwd= is required when creating an agent") + if sess.Cwd() == "" { + return nil, fmt.Errorf("session cwd is required") } ag, err := buildAgent(sess, p) diff --git a/cmd/olliesrv/internal/session/session.go b/cmd/olliesrv/internal/session/session.go index 2586a1e..ee195d0 100644 --- a/cmd/olliesrv/internal/session/session.go +++ b/cmd/olliesrv/internal/session/session.go @@ -39,14 +39,14 @@ type Session struct { // Mutable state protected by mu mu sync.RWMutex name string // friendly name + cwd string // session working directory toolsConn *toolclient.ToolsrvConn // multiplexed RPC connection paused bool // Goal — session-level objective. Writing triggers a workflow. goalMu sync.RWMutex goalText string - goalStatus string // "", "running", "complete", "blocked" - goalAgent string // ID of the conductor agent + goalStatus string // "", "running", "complete", "blocked", "error" goalSignalMu sync.Mutex goalSignalCh chan struct{} workflow string // workflow profile name (default: "conductor") @@ -92,6 +92,21 @@ func (s *Session) SetName(name string) { s.name = name } +// Cwd returns the session working directory. +func (s *Session) Cwd() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cwd +} + +// SetCwd sets the session working directory. +func (s *Session) SetCwd(cwd string) { + s.mu.Lock() + s.cwd = cwd + s.mu.Unlock() + go s.saveSession() +} + // ToolsConn returns the tool server connection. func (s *Session) ToolsConn() *toolclient.ToolsrvConn { s.mu.RLock() @@ -173,31 +188,28 @@ func (s *Session) IsPaused() bool { } // Goal returns the current goal text and status. -func (s *Session) Goal() (text, status, agentID string) { +func (s *Session) Goal() (text, status string) { s.goalMu.RLock() defer s.goalMu.RUnlock() - return s.goalText, s.goalStatus, s.goalAgent + return s.goalText, s.goalStatus } -// SetGoal sets the goal text (the objective). Called once when a goal is created. +// SetGoal sets the goal text (the objective). func (s *Session) SetGoal(text string) { s.goalMu.Lock() s.goalText = text - s.goalStatus = "" - s.goalAgent = "" s.goalMu.Unlock() s.notifyGoal() + go s.saveSession() } -// SetGoalStatus updates only the status and agent fields. Never touches the text. -func (s *Session) SetGoalStatus(status, agentID string) { +// SetGoalStatus updates the status field. +func (s *Session) SetGoalStatus(status string) { s.goalMu.Lock() s.goalStatus = status - if agentID != "" { - s.goalAgent = agentID - } s.goalMu.Unlock() s.notifyGoal() + go s.saveSession() } // ClearGoal resets the entire goal state. @@ -205,9 +217,9 @@ func (s *Session) ClearGoal() { s.goalMu.Lock() s.goalText = "" s.goalStatus = "" - s.goalAgent = "" s.goalMu.Unlock() s.notifyGoal() + go s.saveSession() } func (s *Session) notifyGoal() { diff --git a/data/workflows/conductor b/data/workflows/conductor new file mode 100755 index 0000000..b37baac --- /dev/null +++ b/data/workflows/conductor @@ -0,0 +1,32 @@ +#!/bin/bash +# Workflow: conductor +# Creates a conductor agent, primes it with instructions, and exits. +# The conductor agent handles the rest autonomously. +set -euo pipefail + +SESSION_NAME="$OLLIE_SESSION_NAME" +CWD="$OLLIE_CWD" + +# Set status to running. +echo "running" | ollie-9p write "session/$SESSION_NAME/goalstatus" + +# Create a conductor agent. +AGENT_ID=$(printf 'profile=conductor\ncwd=%s\n' "$CWD" \ + | ollie-9p rdwr "session/$SESSION_NAME/agent/new") +AGENT_ID=$(echo "$AGENT_ID" | tr -d '\n') + +# Prime the conductor with instructions. +# The agent receives OLLIE_SESSION_ID in its environment. +PROMPT_PATH="session/$SESSION_NAME/agent/$AGENT_ID/prompt" +cat <<'EOF' | ollie-9p write "$PROMPT_PATH" +Read the goal from session/$OLLIE_SESSION_ID/goal. + +Analyze and understand the goal. If anything is ambiguous, investigate the codebase to clarify. + +Break it down into concrete, actionable steps. Rewrite the goal file with your structured plan — use a checklist format so progress can be tracked. + +Delegate each step to sub-agents using subagent_spawn. As steps complete, update the goal file to mark progress. + +When all steps are complete, write "complete" to session/$OLLIE_SESSION_ID/goalstatus. +If you get stuck and need human input, write "blocked" to session/$OLLIE_SESSION_ID/goalstatus. +EOF diff --git a/kde/gui/NewSessionDialog.qml b/kde/gui/NewSessionDialog.qml index 014e0ef..7e57ee2 100644 --- a/kde/gui/NewSessionDialog.qml +++ b/kde/gui/NewSessionDialog.qml @@ -101,17 +101,17 @@ Dialog { ComboBox { id: workflowCombo Layout.fillWidth: true - enabled: ollie.rootAgentsLoaded - model: ollie.availableAgents + enabled: ollie.workflowsLoaded + model: ollie.availableWorkflows currentIndex: { - var idx = ollie.availableAgents.indexOf("conductor") + var idx = ollie.availableWorkflows.indexOf("conductor") return idx >= 0 ? idx : 0 } } } Component.onCompleted: { - if (!ollie.rootAgentsLoaded) ollie.loadRootAgents() + if (!ollie.workflowsLoaded) ollie.loadWorkflows() } } @@ -150,7 +150,7 @@ Dialog { errorLabel.text = "" nameField.text = "" remoteField.text = "" - var idx = ollie.availableAgents.indexOf("conductor") + var idx = ollie.availableWorkflows.indexOf("conductor") workflowCombo.currentIndex = idx >= 0 ? idx : 0 } } diff --git a/kde/gui/ollie9pclient.cpp b/kde/gui/ollie9pclient.cpp index 8910e16..4b8cf22 100644 --- a/kde/gui/ollie9pclient.cpp +++ b/kde/gui/ollie9pclient.cpp @@ -555,6 +555,22 @@ void Ollie9pClient::loadRootAgents() emit rootAgentsLoadedChanged(); } +void Ollie9pClient::loadWorkflows() +{ + if (m_workflowsLoaded) return; + if (!m_9p || !m_9p->isConnected()) return; + QByteArray out = m_9p->read(QStringLiteral("workflows")); + QString raw = QString::fromUtf8(out).trimmed(); + if (!raw.isEmpty()) { + m_availableWorkflows = raw.split('\n', Qt::SkipEmptyParts); + } else { + m_availableWorkflows = QStringList() << QStringLiteral("conductor"); + } + m_workflowsLoaded = true; + emit availableWorkflowsChanged(); + emit workflowsLoadedChanged(); +} + QStringList Ollie9pClient::getAvailableModels(const QString &backend) const { if (!m_rootModelsLoaded) return {}; @@ -665,6 +681,7 @@ void Ollie9pClient::ensureRootDataLoaded() { if (!m_rootBackendsLoaded) loadRootBackends(); if (!m_rootAgentsLoaded) loadRootAgents(); + if (!m_workflowsLoaded) loadWorkflows(); if (!m_rootModelsLoaded && m_9p && m_9p->isConnected()) { QByteArray out = m_9p->read(QStringLiteral("models")); QString raw = QString::fromUtf8(out); diff --git a/kde/gui/ollie9pclient.h b/kde/gui/ollie9pclient.h index 142f8b5..7d7e4e2 100644 --- a/kde/gui/ollie9pclient.h +++ b/kde/gui/ollie9pclient.h @@ -31,6 +31,8 @@ class Ollie9pClient : public QObject Q_PROPERTY(QStringList availableBackends READ availableBackends NOTIFY availableBackendsChanged) Q_PROPERTY(bool rootAgentsLoaded READ rootAgentsLoaded NOTIFY rootAgentsLoadedChanged) Q_PROPERTY(QStringList availableAgents READ availableAgents NOTIFY availableAgentsChanged) + Q_PROPERTY(bool workflowsLoaded READ workflowsLoaded NOTIFY workflowsLoadedChanged) + Q_PROPERTY(QStringList availableWorkflows READ availableWorkflows NOTIFY availableWorkflowsChanged) Q_PROPERTY(bool rootModelsLoaded READ rootModelsLoaded NOTIFY rootModelsLoadedChanged) Q_PROPERTY(QString currentBackend READ currentBackend WRITE setCurrentBackend NOTIFY currentBackendChanged) Q_PROPERTY(bool daemonConnected READ daemonConnected NOTIFY daemonConnectedChanged) @@ -49,6 +51,8 @@ public: QStringList availableBackends() const { return m_availableBackends; } bool rootAgentsLoaded() const { return m_rootAgentsLoaded; } QStringList availableAgents() const { return m_availableAgents; } + bool workflowsLoaded() const { return m_workflowsLoaded; } + QStringList availableWorkflows() const { return m_availableWorkflows; } bool rootModelsLoaded() const { return m_rootModelsLoaded; } QString currentBackend() const { return m_currentBackend; } bool daemonConnected() const { return m_daemonConnected; } @@ -78,6 +82,7 @@ public: Q_INVOKABLE void switchAgent(const QString &sessionId, const QString &agentId); Q_INVOKABLE void loadRootBackends(); Q_INVOKABLE void loadRootAgents(); + Q_INVOKABLE void loadWorkflows(); Q_INVOKABLE QStringList getAvailableModels(const QString &backend) const; Q_INVOKABLE bool createQuickSession(); Q_INVOKABLE QString createSession(const QString &name, const QString &remote, const QString &workflow = QString()); @@ -98,6 +103,8 @@ signals: void availableBackendsChanged(); void rootAgentsLoadedChanged(); void availableAgentsChanged(); + void workflowsLoadedChanged(); + void availableWorkflowsChanged(); void rootModelsLoadedChanged(); void currentBackendChanged(); void daemonConnectedChanged(); @@ -126,6 +133,8 @@ private: QStringList m_availableBackends; bool m_rootAgentsLoaded = false; QStringList m_availableAgents; + bool m_workflowsLoaded = false; + QStringList m_availableWorkflows; bool m_rootModelsLoaded = false; QVariantMap m_rootModels; QString m_currentBackend;