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
This commit is contained in:
Levi Neely 2026-08-14 14:48:12 +02:00
parent ab4668cb3f
commit 250ad4b233
6 changed files with 101 additions and 16 deletions

View File

@ -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,

View File

@ -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
}

View File

@ -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")

View File

@ -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")

View File

@ -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)
}
}
}

View File

@ -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) {