package fs // Shared utilities for the 9P namespace handlers. import ( "bytes" "fmt" "strings" "sync" "ollie/cmd/olliesrv/internal/agent" "ollie/cmd/olliesrv/internal/backend" "ollie/cmd/olliesrv/internal/session" "ollie/toolsrv/protocol" "ollie/virtfs" ) // wireAgentEvents sets the state change callback on an agent. func wireAgentEvents(sessID string, ag *agent.Agent) { ag.SetOnStateChange(func(agentID, state string) { session.PublishEvent("session."+sessID+".agent."+agentID+".state", state) }) } // ctlCmd is one control verb: its name, a one-line description for the // self-describing help listing, and its handler. type ctlCmd struct { name string desc string fn func([]string) ([]byte, error) } // dispatch routes a ctl write to a command by its first word. Reading the ctl // file (empty write) or writing "help" returns the command listing. An unknown // verb returns an error listing the valid verbs. func dispatch(cmds []ctlCmd, data []byte) ([]byte, error) { input := strings.TrimSpace(string(data)) if input == "" || input == "help" { var sb strings.Builder for _, c := range cmds { fmt.Fprintf(&sb, "%-16s %s\n", c.name, c.desc) } return []byte(sb.String()), nil } parts := strings.Fields(input) command := parts[0] args := parts[1:] if name, value, ok := strings.Cut(command, "="); ok { command = name args = append([]string{value}, args...) } for _, c := range cmds { if c.name == command { return c.fn(args) } } names := make([]string, len(cmds)) for i, c := range cmds { names[i] = c.name } return nil, fmt.Errorf("unknown ctl command %q; valid: %s", command, strings.Join(names, ", ")) } // stripMarkers removes [[[...]]] marker lines, source fence delimiter lines, // and ... blocks from raw chat data. Content inside fences // is preserved — only the ``` lines themselves are stripped. func stripMarkers(data []byte) []byte { var out []byte inContext := false for len(data) > 0 { nl := bytes.IndexByte(data, '\n') if nl < 0 { if !inContext { out = append(out, data...) } break } line := data[:nl+1] data = data[nl+1:] trimmed := bytes.TrimRight(line, "\n") // Handle block start/end if bytes.Equal(bytes.TrimSpace(trimmed), []byte("")) { inContext = true continue } if bytes.Equal(bytes.TrimSpace(trimmed), []byte("")) { inContext = false continue } if inContext { continue } if len(trimmed) >= 7 && bytes.HasPrefix(trimmed, []byte("[[[")) && bytes.HasSuffix(trimmed, []byte("]]]")) { continue } stripped := bytes.TrimSpace(trimmed) if bytes.HasPrefix(stripped, []byte("```")) && !bytes.HasPrefix(stripped, []byte("````")) { continue } out = append(out, line...) } return out } // helpSpec stores the spec for help generation. Set during tree build. var helpSpec *virtfs.FsNodeDecl var ( helpOnce sync.Once helpText string helpFn = func() string { helpOnce.Do(func() { if helpSpec != nil { helpText = virtfs.GenerateHelp(*helpSpec) } }) return helpText } ) // agentNewRequest holds parsed results from an agent/new payload. type agentNewRequest struct { Params session.AgentParams Prompt string ParentID string ForkAt int Timeout int // seconds; 0 = use default (600) MaxDepth int // max sub-agent depth; 0 = use default (1) MaxParallel *int // max concurrent children; nil = default (5), -1 = unlimited, 0 = disabled } // 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). func parseAgentNewRequest(data []byte) agentNewRequest { s := strings.TrimSpace(string(data)) if s == "" { return agentNewRequest{Params: session.AgentParams{Profile: "default"}} } var tokens []string if strings.Contains(s, "\n") { tokens = strings.Split(s, "\n") } else { tokens = strings.Fields(s) } r := agentNewRequest{Params: session.AgentParams{Profile: "default"}} for _, tok := range tokens { tok = strings.TrimSpace(tok) if tok == "" { continue } if k, v, ok := strings.Cut(tok, "="); ok { v = protocol.UnescapeValue(v) switch k { case "backend": r.Params.Backend = v case "model": r.Params.Model = v case "name": r.Params.Name = v case "remote": r.Params.Remote = v case "profile": r.Params.Profile = v case "prompt": r.Prompt = v case "parent": r.ParentID = v case "fork_at": fmt.Sscanf(v, "%d", &r.ForkAt) case "timeout": fmt.Sscanf(v, "%d", &r.Timeout) case "max_depth": fmt.Sscanf(v, "%d", &r.MaxDepth) case "max_parallel": var v2 int if _, err := fmt.Sscanf(v, "%d", &v2); err == nil { r.MaxParallel = &v2 } } } else if r.Params.Profile == "default" { r.Params.Profile = tok } } return r } // messagesUpToTurn returns messages up to (and including) the Nth user turn. // Turn 1 = first user message and everything before the second user message. func messagesUpToTurn(msgs []backend.Message, n int) []backend.Message { userCount := 0 for i, m := range msgs { if m.Role == "user" { userCount++ if userCount > n { return msgs[:i] } } } return msgs }