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
This commit is contained in:
parent
743e40c2f8
commit
958d3d2ddf
3
Makefile
3
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
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ type Agent struct {
|
|||
// Execution state — owned by the agent, protected by stateMu.
|
||||
state string // "idle", "thinking", "calling: <tool>"
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue