451 lines
12 KiB
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
|
|
}
|