ollie/fs/agentfiles.go

451 lines
12 KiB
Go

package fs
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"ollie/agent"
)
// ── listAgents — factory for per-agent subtrees ─────────────────
func listAgents(ctx HandlerCtx) ([]FsNodeDecl, error) {
sess := ctx.Session
if sess == nil || sess.Core == nil {
return nil, nil
}
var out []FsNodeDecl
for _, ag := range sess.Core.Agents() {
a := ag // capture
s := sess // capture for closures
al := sess.AgentLogFor(a.ID())
if al == nil {
continue
}
// The namespace uses the mutable agent name. The immutable ID remains
// available through the id file and is used for the log map.
dirName := a.Name()
agentDir := Dir(dirName,
UID(a.ID()),
GID("agent"),
Alias(a.ID()),
Leaf("prompt", 0666, Write(func(_ HandlerCtx, data []byte) error {
input := strings.TrimSpace(string(data))
if input == "" {
return nil
}
if input == "/invalidate" {
sess.InvalidateModelsCache()
return nil
}
al.mu.Lock()
al.prevPrompt = []byte(input)
al.mu.Unlock()
go func() {
a.Submit(sess.SessionCtx, input)
al.EnsureTrailingNewline()
}()
return nil
})),
// prompt.prev — last submitted prompt
Leaf("prompt.prev", 0444, Read(func(_ HandlerCtx) ([]byte, error) {
al.mu.RLock()
data := make([]byte, len(al.prevPrompt))
copy(data, al.prevPrompt)
al.mu.RUnlock()
return data, nil
})),
// fifo.in — queue a prompt
Leaf("fifo.in", 0222, Write(func(_ HandlerCtx, data []byte) error {
input := strings.TrimSpace(string(data))
if input == "" {
return nil
}
a.Queue(input)
return nil
})),
// fifo.out — pop a queued prompt
Leaf("fifo.out", 0444, Read(func(_ HandlerCtx) ([]byte, error) {
item, ok := a.PopQueue()
if !ok {
return nil, nil
}
return []byte(item), nil
})),
// chat — streaming chat log
Leaf("chat", 0444,
Stat(func() os.FileInfo {
al.mu.RLock()
length := len(al.log)
vers := al.logVers
al.mu.RUnlock()
cap := length
if cap > 64*1024 {
cap = 64 * 1024
}
return &SyntheticFileInfo{
Name_: "chat",
Mode_: 0444,
Size_: int64(cap),
Vers_: vers,
}
}),
Read(func(_ HandlerCtx) ([]byte, error) {
al.mu.RLock()
defer al.mu.RUnlock()
data := make([]byte, len(al.log))
copy(data, al.log)
return data, nil
}),
Stream(func(_ HandlerCtx, cctx context.Context, base string) ([]byte, string, error) {
return streamChat(al, cctx, base)
}),
),
// connection — agent execution environment connectivity. Opening this
// file performs a lightweight RPC against the session tool server.
Leaf("connection", 0444, Read(func(_ HandlerCtx) ([]byte, error) {
if s.toolsConn == nil {
return nil, fmt.Errorf("agent tool server unavailable")
}
if _, err := s.toolsConn.ListTools(); err != nil {
return nil, err
}
return []byte("connected\n"), nil
})),
// state — current agent state
Leaf("state", 0444, Read(func(_ HandlerCtx) ([]byte, error) {
return []byte(a.State() + "\n"), nil
})),
// statewait — blocking state change watcher
Leaf("statewait", 0444,
Read(func(_ HandlerCtx) ([]byte, error) {
return []byte(a.State() + "\n"), nil
}),
BlockOnce(func(_ HandlerCtx, cctx context.Context, base string) ([]byte, string, error) {
ctx, cancel := context.WithCancel(cctx)
defer cancel()
context.AfterFunc(sess.SessionCtx, cancel)
if base == "" {
base = a.State()
}
v, ok := a.WaitChange(ctx, agent.WatchState, base)
if !ok {
s := a.State()
return []byte(s + "\n"), s, nil
}
return []byte(v + "\n"), v, nil
}),
),
// log — agent log window
Leaf("log", 0444, Read(func(_ HandlerCtx) ([]byte, error) {
const maxWindow = 64 * 1024
al.mu.RLock()
log := al.log
start := 0
if len(log) > maxWindow {
start = len(log) - maxWindow
}
data := make([]byte, len(log)-start)
copy(data, log[start:])
al.mu.RUnlock()
return data, nil
})),
// offset — chat offset for parsing
Leaf("offset", 0444, Read(func(_ HandlerCtx) ([]byte, error) {
return []byte(fmt.Sprintf("%d\n", al.ChatOffset)), nil
})),
// plan — agent plan (markdown checklist, survives compaction)
Leaf("plan", 0666,
Read(func(_ HandlerCtx) ([]byte, error) {
al.mu.RLock()
data := make([]byte, len(al.plan))
copy(data, al.plan)
al.mu.RUnlock()
return data, nil
}),
Write(func(_ HandlerCtx, data []byte) error {
al.mu.Lock()
al.plan = make([]byte, len(data))
copy(al.plan, data)
al.mu.Unlock()
return nil
}),
),
// cfg — agent config (backend, model, etc.)
Leaf("cfg", 0666,
Read(func(_ HandlerCtx) ([]byte, error) {
return []byte(agentCfgContent(a, s, al)), nil
}),
Write(func(_ HandlerCtx, data []byte) error {
return handleAgentCfg(a, s, al, data)
}),
),
// ctl — agent control
Leaf("ctl", 0666, Write(func(_ HandlerCtx, data []byte) error {
return handleAgentCtl(a, s, data)
})),
// cwd — current working directory
Leaf("cwd", 0666,
Read(func(_ HandlerCtx) ([]byte, error) {
return []byte(s.Core.CWD() + "\n"), nil
}),
Write(func(_ HandlerCtx, data []byte) error {
input := strings.TrimSpace(string(data))
if input == "" {
return nil
}
return s.Core.SetCWD(input)
}),
),
// usage — token usage stats
Leaf("usage", 0444, Read(func(_ HandlerCtx) ([]byte, error) {
return []byte(a.UsageStr() + "\n"), nil
})),
// cost — estimated cost
Leaf("cost", 0444, Read(func(_ HandlerCtx) ([]byte, error) {
return []byte(a.CostStr()), nil
})),
// ctxsz — context size
Leaf("ctxsz", 0444, Read(func(_ HandlerCtx) ([]byte, error) {
return []byte(a.CtxSz() + "\n"), nil
})),
// models — available models
Leaf("models", 0444, Read(func(_ HandlerCtx) ([]byte, error) {
return []byte(s.CachedListModels() + "\n"), nil
})),
// systemprompt — rendered system prompt
Leaf("systemprompt", 0444, Read(func(_ HandlerCtx) ([]byte, error) {
return []byte(a.SystemPrompt()), nil
})),
// context — rendered context window
Leaf("context", 0444, Read(func(_ HandlerCtx) ([]byte, error) {
var sb strings.Builder
enc := json.NewEncoder(&sb)
enc.SetEscapeHTML(false)
for _, m := range a.Context() {
enc.Encode(m)
}
return []byte(sb.String()), nil
})),
// tail — executable script for tailing chat
Leaf("tail", 0555, Read(func(_ HandlerCtx) ([]byte, error) {
return []byte("#!/bin/sh\nexec tail -f \"$(dirname \"$0\")/chat\"\n"), nil
})),
// tools — list all available tools (read) / load a tool (write)
Leaf("tools", 0666,
Read(func(ctx HandlerCtx) ([]byte, error) {
// Use the session's dedicated tools management connection,
// NOT a.ToolServer() which would deadlock if the agent
// loop is mid-execution (it holds the Conn mutex).
if s.toolsConn == nil {
return []byte("(no tool server)\n"), nil
}
loaded, err := s.toolsConn.ListTools()
if err != nil {
return []byte(fmt.Sprintf("(error: %v)\n", err)), nil
}
var sb strings.Builder
for _, ti := range loaded {
if ti.Description != "" {
fmt.Fprintf(&sb, "%-20s %s\n", ti.Name, ti.Description)
} else {
sb.WriteString(ti.Name + "\n")
}
}
return []byte(sb.String()), nil
}),
Write(func(ctx HandlerCtx, data []byte) error {
return s.loadTool(string(data), a)
}),
),
// name — agent name
Leaf("name", 0666,
Read(func(_ HandlerCtx) ([]byte, error) {
return []byte(a.Name()), nil
}),
Write(func(_ HandlerCtx, data []byte) error {
newName := strings.TrimSpace(string(data))
if newName == "" {
return nil
}
a.SetName(newName)
persistSession(globalRootState, s.Name())
return nil
}),
),
// id — agent ID
Leaf("id", 0444, Read(func(_ HandlerCtx) ([]byte, error) {
return []byte(a.ID()), nil
})),
// proc/ — detached processes
TemplateDir("proc", func(ctx HandlerCtx) ([]FsNodeDecl, error) {
return listProcesses(ctx, s, a)
}),
)
agentDir.Rename = func(newName string) error {
a.SetName(newName)
persistSession(globalRootState, s.Name())
return nil
}
out = append(out, agentDir)
}
return out, nil
}
// ── chat stream helper ───────────────────────────────────────────
func streamChat(al *AgentLog, cctx context.Context, base string) ([]byte, string, error) {
ctx, cancel := context.WithCancel(cctx)
defer cancel()
context.AfterFunc(ctx, func() { al.chatCond.Broadcast() })
var offset int
if base != "" {
fmt.Sscanf(base, "%d", &offset)
} else {
al.mu.RLock()
offset = len(al.log)
al.mu.RUnlock()
}
al.mu.RLock()
for len(al.log) <= offset {
if ctx.Err() != nil {
al.mu.RUnlock()
return nil, "", nil
}
al.chatCond.Wait()
}
data := make([]byte, len(al.log)-offset)
copy(data, al.log[offset:])
newOffset := len(al.log)
al.mu.RUnlock()
return data, fmt.Sprintf("%d", newOffset), nil
}
// ── cfg helpers ──────────────────────────────────────────────────
func agentCfgContent(a *agent.Agent, s *Session, al *AgentLog) string {
al.mu.RLock()
defer al.mu.RUnlock()
p := a.GenParams()
var sb strings.Builder
fmt.Fprintf(&sb, "name=%s\n", s.id)
fmt.Fprintf(&sb, "backend=%s\n", a.BackendName())
fmt.Fprintf(&sb, "model=%s\n", a.ModelName())
fmt.Fprintf(&sb, "id=%s\n", a.ID())
fmt.Fprintf(&sb, "profile=%s\n", a.Profile())
fmt.Fprintf(&sb, "displayName=%s\n", a.Name())
fmt.Fprintf(&sb, "cwd=%s\n", s.Core.CWD())
fmt.Fprintf(&sb, "remote=%s\n", al.remote)
fmt.Fprintf(&sb, "maxTokens=%d\n", p.MaxTokens)
fmt.Fprintf(&sb, "temperature=%v\n", p.Temperature)
fmt.Fprintf(&sb, "topP=%v\n", p.TopP)
fmt.Fprintf(&sb, "topK=%v\n", p.TopK)
fmt.Fprintf(&sb, "stop=%v\n", p.Stop)
return sb.String()
}
func handleAgentCfg(a *agent.Agent, s *Session, al *AgentLog, data []byte) error {
input := strings.TrimSpace(string(data))
if input == "" {
return nil
}
parts := strings.SplitN(input, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid cfg format (expected key=value)")
}
switch parts[0] {
case "name":
if strings.TrimSpace(parts[1]) != "" {
a.SetName(strings.TrimSpace(parts[1]))
}
case "backend":
case "model":
default:
return fmt.Errorf("unknown cfg key: %s", parts[0])
}
return nil
}
// ── ctl helpers ──────────────────────────────────────────────────
func handleAgentCtl(a *agent.Agent, s *Session, data []byte) error {
input := strings.TrimSpace(string(data))
if input == "" {
return nil
}
cmd := strings.Fields(input)
if len(cmd) == 0 {
return nil
}
switch cmd[0] {
case "kill":
if s.Core != nil {
s.Core.RemoveAgent(a.ID())
PostEvent("A kill session/" + s.Name() + "/agent/" + a.ID())
}
case "stop":
a.Interrupt(agent.ErrInterrupted)
case "compact":
// Compact is handled via agent command mechanism
case "clear":
// Clear is handled via agent command mechanism
case "backend":
if len(cmd) > 1 {
// Backend selection is done via cfg or backend/agent config
}
case "model":
if len(cmd) > 1 {
if be := a.Backend(); be != nil {
be.SetModel(strings.Join(cmd[1:], " "))
}
}
case "name":
if len(cmd) > 1 {
a.SetName(strings.Join(cmd[1:], " "))
}
case "cwd":
if len(cmd) > 1 {
a.SetCWD(strings.Join(cmd[1:], " "))
}
case "skills":
// skill list handled elsewhere
default:
return fmt.Errorf("unknown agent ctl command: %s", cmd[0])
}
return nil
}