From 250ad4b23386286ad1a7e3d5d5e97a31632e6300 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Fri, 14 Aug 2026 14:48:12 +0200 Subject: [PATCH] agent/new: sub-agent support via rdwr with prompt= Writing to session/{s}/agent/new with a prompt= key now blocks until the agent completes its task, then returns the reply and destroys the transient agent. Without prompt=, behaves as before (creates agent, returns ID). Also: - Move ParsePayload/UnescapeValue to shared ollie/toolsrv package - Remove duplicate implementations from cmd/toolsrv/internal/server - Add session.CreateAgentWithParams for direct AgentParams usage - Eliminate flattenParams/unescapeValue redundancy in fs package --- cmd/olliesrv/internal/fs/spec.go | 25 +++++++-- cmd/olliesrv/internal/fs/support.go | 52 +++++++++++++++++++ cmd/olliesrv/internal/session/registry.go | 21 +++++--- cmd/toolsrv/internal/server/proc.go | 2 +- cmd/toolsrv/internal/server/proc_test.go | 6 ++- .../internal/server => toolsrv}/parse.go | 11 ++-- 6 files changed, 101 insertions(+), 16 deletions(-) rename {cmd/toolsrv/internal/server => toolsrv}/parse.go (75%) diff --git a/cmd/olliesrv/internal/fs/spec.go b/cmd/olliesrv/internal/fs/spec.go index e1cd534..3617ec0 100644 --- a/cmd/olliesrv/internal/fs/spec.go +++ b/cmd/olliesrv/internal/fs/spec.go @@ -288,13 +288,32 @@ func buildSessionChildren( ), virtfs.DirNode("agent", virtfs.FileNode("new", 0666, - virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) { - ag, err := session.CreateAgent(s.Name(), strings.Fields(string(data))) + virtfs.Doc("Create agent. With prompt=, runs as sub-agent: blocks until done, returns reply."), + virtfs.Rdwr(func(ctx context.Context, data []byte) ([]byte, error) { + params, prompt := parseAgentNewRequest(data) + + ag, err := session.CreateAgentWithParams(s.Name(), params) if err != nil { return nil, fmt.Errorf("create agent: %w", err) } wireAgentEvents(s.ID, ag) - return []byte(ag.ID() + "\n"), nil + + if prompt == "" { + return []byte(ag.ID() + "\n"), nil + } + + // Sub-agent mode: submit, block until idle, return reply, destroy. + session.PublishEvent("session."+s.ID+".agent."+ag.ID()+".new", "") + ag.Submit(ctx, prompt) + ag.EnsureTrailingNewline() + reply := ag.Reply() + s.RemoveAgent(ag.ID()) + session.PublishEvent("session."+s.ID+".agent."+ag.ID()+".kill", "") + + if reply == "" { + return []byte("(no reply)\n"), nil + } + return []byte(reply), nil }), ), virtfs.FileNode("idx", 0444, diff --git a/cmd/olliesrv/internal/fs/support.go b/cmd/olliesrv/internal/fs/support.go index 42cc1cf..777e3df 100644 --- a/cmd/olliesrv/internal/fs/support.go +++ b/cmd/olliesrv/internal/fs/support.go @@ -10,6 +10,7 @@ import ( "ollie/cmd/olliesrv/internal/agent" "ollie/cmd/olliesrv/internal/session" + "ollie/toolsrv" "ollie/virtfs" ) @@ -77,3 +78,54 @@ var ( return helpText } ) + +// parseAgentNewRequest parses agent/new payloads in both formats: +// - Newline-separated key=value (new format, supports multi-line values via \n escape) +// - Space-separated key=value (legacy format from GUI) +// +// A bare word (no =) is treated as the profile name (legacy positional arg). +// Returns AgentParams and the prompt (empty if not a sub-agent request). +func parseAgentNewRequest(data []byte) (session.AgentParams, string) { + s := strings.TrimSpace(string(data)) + if s == "" { + return session.AgentParams{Profile: "default"}, "" + } + + var tokens []string + if strings.Contains(s, "\n") { + tokens = strings.Split(s, "\n") + } else { + tokens = strings.Fields(s) + } + + p := session.AgentParams{Profile: "default"} + var prompt string + for _, tok := range tokens { + tok = strings.TrimSpace(tok) + if tok == "" { + continue + } + if k, v, ok := strings.Cut(tok, "="); ok { + v = toolsrv.UnescapeValue(v) + switch k { + case "cwd": + p.CWD = v + case "backend": + p.Backend = v + case "model": + p.Model = v + case "name": + p.Name = v + case "remote": + p.Remote = v + case "profile": + p.Profile = v + case "prompt": + prompt = v + } + } else if p.Profile == "default" { + p.Profile = tok + } + } + return p, prompt +} diff --git a/cmd/olliesrv/internal/session/registry.go b/cmd/olliesrv/internal/session/registry.go index b708785..1772f2d 100644 --- a/cmd/olliesrv/internal/session/registry.go +++ b/cmd/olliesrv/internal/session/registry.go @@ -457,12 +457,7 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) { // CreateAgent creates a new agent within an existing session. // Args: [profile] [key=value...] where keys include name, backend, model, remote, cwd. func CreateAgent(sessName string, args []string) (*agent.Agent, error) { - sess := Lookup(sessName) - if sess == nil { - return nil, fmt.Errorf("session not found: %s", sessName) - } - - // Parse args + // Parse args into AgentParams. p := AgentParams{Profile: "default"} for _, arg := range args { if k, v, ok := strings.Cut(arg, "="); ok { @@ -482,6 +477,20 @@ func CreateAgent(sessName string, args []string) (*agent.Agent, error) { p.Profile = arg } } + return CreateAgentWithParams(sessName, p) +} + +// CreateAgentWithParams creates a new agent within an existing session using +// a pre-built AgentParams struct. +func CreateAgentWithParams(sessName string, p AgentParams) (*agent.Agent, error) { + sess := Lookup(sessName) + if sess == nil { + return nil, fmt.Errorf("session not found: %s", sessName) + } + + if p.Profile == "" { + p.Profile = "default" + } if p.CWD == "" { return nil, fmt.Errorf("cwd= is required when creating an agent") diff --git a/cmd/toolsrv/internal/server/proc.go b/cmd/toolsrv/internal/server/proc.go index e7041d2..73b8acf 100644 --- a/cmd/toolsrv/internal/server/proc.go +++ b/cmd/toolsrv/internal/server/proc.go @@ -217,7 +217,7 @@ func (st *State) allocID() int { // If background is true, returns immediately with pid. func (st *State) NewProc(ctx context.Context, payload string, background bool) (result string, pid int, err error) { // Parse payload - args := ParsePayload(payload) + args := toolsrv.ParsePayload(payload) toolName := args["tool"] if toolName == "" { return "", 0, fmt.Errorf("missing 'tool' in payload") diff --git a/cmd/toolsrv/internal/server/proc_test.go b/cmd/toolsrv/internal/server/proc_test.go index 4daac6b..a99c3f3 100644 --- a/cmd/toolsrv/internal/server/proc_test.go +++ b/cmd/toolsrv/internal/server/proc_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" "time" + + "ollie/toolsrv" ) func TestState_Basic(t *testing.T) { @@ -189,10 +191,10 @@ func TestParsePayload(t *testing.T) { } for _, tt := range tests { - got := ParsePayload(tt.input) + got := toolsrv.ParsePayload(tt.input) for k, v := range tt.want { if got[k] != v { - t.Errorf("ParsePayload(%q)[%q] = %q, want %q", tt.input, k, got[k], v) + t.Errorf("toolsrv.ParsePayload(%q)[%q] = %q, want %q", tt.input, k, got[k], v) } } } diff --git a/cmd/toolsrv/internal/server/parse.go b/toolsrv/parse.go similarity index 75% rename from cmd/toolsrv/internal/server/parse.go rename to toolsrv/parse.go index eac1264..a508336 100644 --- a/cmd/toolsrv/internal/server/parse.go +++ b/toolsrv/parse.go @@ -1,9 +1,9 @@ -// parse.go - Key=value payload parsing for toolsrv. -package server +// parse.go — Shared key=value payload parsing. +package toolsrv import "strings" -// ParsePayload parses key=value lines into a map. +// ParsePayload parses newline-separated key=value pairs into a map. // Values are unescaped: \n → newline, \\ → backslash. func ParsePayload(payload string) map[string]string { args := make(map[string]string) @@ -24,8 +24,11 @@ func ParsePayload(payload string) map[string]string { return args } -// UnescapeValue reverses escape sequences: \n → newline, \\ → backslash. +// UnescapeValue reverses wire-format escape sequences: \n → newline, \\ → backslash. func UnescapeValue(s string) string { + if !strings.ContainsRune(s, '\\') { + return s + } result := make([]byte, 0, len(s)) for i := 0; i < len(s); i++ { if s[i] == '\\' && i+1 < len(s) {