451 lines
11 KiB
Go
451 lines
11 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"ollie/pkg/config"
|
|
"ollie/pkg/tools/execute"
|
|
)
|
|
|
|
func (s *agent) handleCommand(ctx context.Context, input string) bool {
|
|
if strings.HasPrefix(input, "!") {
|
|
s.emit(infoEvent(""))
|
|
cmdStr := strings.TrimSpace(input[1:])
|
|
if cmdStr == "" {
|
|
return true
|
|
}
|
|
shellCmd := exec.Command("sh", "-c", cmdStr)
|
|
shellCmd.Dir = s.CWD()
|
|
shellCmd.Env = append(os.Environ(), "OLLIE_SESSION_ID="+s.sessionID)
|
|
s.envMu.RLock()
|
|
for k, v := range s.env {
|
|
shellCmd.Env = append(shellCmd.Env, k+"="+v)
|
|
}
|
|
s.envMu.RUnlock()
|
|
o, err := shellCmd.CombinedOutput()
|
|
if err != nil {
|
|
s.emit(infoEvent("error: " + err.Error()))
|
|
}
|
|
if len(o) > 0 {
|
|
s.emit(execEvent(strings.TrimRight(string(o), "\n")))
|
|
}
|
|
return true
|
|
}
|
|
|
|
if !strings.HasPrefix(input, "/") {
|
|
return false
|
|
}
|
|
|
|
parts := strings.Fields(input)
|
|
if len(parts) == 0 {
|
|
return false
|
|
}
|
|
cmd := parts[0]
|
|
args := parts[1:]
|
|
|
|
listMountDir := func(subdir string) {
|
|
mount := os.Getenv("OLLIE")
|
|
if mount == "" {
|
|
home, _ := os.UserHomeDir()
|
|
mount = home + "/mnt/ollie"
|
|
}
|
|
entries, err := os.ReadDir(mount + "/" + subdir)
|
|
if err != nil {
|
|
s.emit(infoEvent(fmt.Sprintf("%s: %v", cmd, err)))
|
|
return
|
|
}
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
s.emit(infoEvent(" " + e.Name()))
|
|
}
|
|
}
|
|
}
|
|
|
|
type cmdFn func([]string)
|
|
cmds := map[string]cmdFn{
|
|
"/i": func(args []string) {
|
|
prompt := strings.Join(args, " ")
|
|
if prompt == "" {
|
|
s.emit(infoEvent("error: /i requires a prompt"))
|
|
return
|
|
}
|
|
if s.IsRunning() {
|
|
s.Inject(prompt)
|
|
} else {
|
|
go s.Submit(context.Background(), prompt)
|
|
}
|
|
},
|
|
|
|
"/irw": func(args []string) {
|
|
prompt := strings.Join(args, " ")
|
|
if prompt == "" {
|
|
s.emit(infoEvent("error: /irw requires a prompt"))
|
|
return
|
|
}
|
|
s.injectRewrite(prompt)
|
|
},
|
|
|
|
"/backend": func(args []string) {
|
|
if len(args) == 0 {
|
|
s.emit(infoEvent(s.cfg.Backend.Name()))
|
|
return
|
|
}
|
|
if s.IsRunning() {
|
|
s.emit(infoEvent("error: cannot switch backend while agent is running"))
|
|
return
|
|
}
|
|
be, err := s.newBackend(args[0])
|
|
if err != nil {
|
|
s.emit(infoEvent(fmt.Sprintf("error: failed to switch backend: %v", err)))
|
|
return
|
|
}
|
|
s.cfg.Backend = be
|
|
s.emit(infoEvent(fmt.Sprintf("switched backend to: %s (model: %s)", be.Name(), be.Model())))
|
|
},
|
|
|
|
"/models": func(args []string) {
|
|
models := s.cfg.Backend.Models(ctx)
|
|
if len(models) == 0 {
|
|
s.emit(infoEvent("no models available"))
|
|
return
|
|
}
|
|
slices.Sort(models)
|
|
current := s.cfg.Backend.Model()
|
|
for _, m := range models {
|
|
marker := " "
|
|
if m == current {
|
|
marker = "* "
|
|
}
|
|
s.emit(infoEvent(marker + m))
|
|
}
|
|
},
|
|
|
|
"/model": func(args []string) {
|
|
if len(args) == 0 {
|
|
s.emit(infoEvent(s.cfg.Backend.Model()))
|
|
return
|
|
}
|
|
s.cfg.Backend.SetModel(args[0])
|
|
s.emit(infoEvent("switched model to: " + args[0]))
|
|
},
|
|
|
|
"/maxsteps": func(args []string) {
|
|
if len(args) == 0 {
|
|
if s.cfg.MaxSteps == 0 {
|
|
s.emit(infoEvent("maxsteps: unlimited"))
|
|
} else {
|
|
s.emit(infoEvent(fmt.Sprintf("maxsteps: %d", s.cfg.MaxSteps)))
|
|
}
|
|
return
|
|
}
|
|
n, err := strconv.Atoi(args[0])
|
|
if err != nil || n < 0 {
|
|
s.emit(infoEvent("error: maxsteps requires a non-negative integer (0 = unlimited)"))
|
|
return
|
|
}
|
|
s.cfg.MaxSteps = n
|
|
if n == 0 {
|
|
s.emit(infoEvent("maxsteps: unlimited"))
|
|
} else {
|
|
s.emit(infoEvent(fmt.Sprintf("maxsteps: %d", n)))
|
|
}
|
|
},
|
|
|
|
"/agents": func(args []string) {
|
|
seen := make(map[string]bool)
|
|
found := false
|
|
for _, dir := range AgentsDirs() {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
|
continue
|
|
}
|
|
name := strings.TrimSuffix(e.Name(), ".json")
|
|
if seen[name] {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
marker := " "
|
|
if name == s.agentName {
|
|
marker = "* "
|
|
}
|
|
s.emit(infoEvent(marker + name))
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
s.emit(infoEvent("no agents found"))
|
|
}
|
|
},
|
|
|
|
"/agent": func(args []string) {
|
|
if len(args) == 0 {
|
|
s.emit(infoEvent("active agent: " + s.agentName))
|
|
return
|
|
}
|
|
if s.IsRunning() {
|
|
s.emit(infoEvent("error: cannot switch agent while agent is running"))
|
|
return
|
|
}
|
|
name := args[0]
|
|
cfgPath := AgentConfigPath(s.agentsDir, name)
|
|
f, err := os.Open(cfgPath)
|
|
if err != nil {
|
|
s.emit(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err)))
|
|
return
|
|
}
|
|
cfg, err := config.Load(f)
|
|
f.Close()
|
|
if err != nil {
|
|
s.emit(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err)))
|
|
return
|
|
}
|
|
d := s.newDispatcher()
|
|
env := BuildAgentEnv(cfg, d, s.cwd, []string{"OLLIE_SESSION_ID=" + s.sessionID, "OLLIE=" + execute.MountPath(s.uname)})
|
|
if env.Backend != "" {
|
|
newBe, err := s.newBackend(env.Backend)
|
|
if err != nil {
|
|
s.emit(infoEvent(fmt.Sprintf("error: backend %q: %v", env.Backend, err)))
|
|
return
|
|
}
|
|
if env.Model != "" {
|
|
newBe.SetModel(env.Model)
|
|
}
|
|
s.cfg.Backend = newBe
|
|
} else if env.Model != "" {
|
|
s.cfg.Backend.SetModel(env.Model)
|
|
}
|
|
s.dispatcher = env.dispatcher
|
|
s.hooks = env.Hooks
|
|
s.cfg.preamble = env.preamble
|
|
s.cfg.Tools = env.tools
|
|
s.cfg.Exec = env.exec
|
|
s.cfg.GenerationParams = env.genParams
|
|
s.cfg.MaxSteps = env.maxSteps
|
|
s.agentName = name
|
|
s.session = nil
|
|
s.pushSessionEnv()
|
|
for _, msg := range env.Messages {
|
|
s.emit(infoEvent(msg))
|
|
}
|
|
s.emit(infoEvent("agent: " + name))
|
|
},
|
|
|
|
"/compact": func(args []string) {
|
|
if s.IsRunning() {
|
|
s.emit(infoEvent("error: cannot compact while agent is running"))
|
|
return
|
|
}
|
|
if s.session == nil {
|
|
s.emit(infoEvent("nothing to compact"))
|
|
return
|
|
}
|
|
snapshot := s.session.PreCompactionSnapshot()
|
|
s.setState("compacting")
|
|
n, err := s.runCompact(ctx, "manual")
|
|
s.setState("idle")
|
|
if err != nil {
|
|
s.emit(infoEvent("compact error: " + err.Error()))
|
|
return
|
|
}
|
|
if n == 0 {
|
|
s.emit(infoEvent("nothing to compact"))
|
|
return
|
|
}
|
|
if s.sessionsDir != "" && s.sessionID != "" {
|
|
histPath := s.sessionsDir + "/" + s.sessionID + ".compaction.jsonl"
|
|
if data, err := json.Marshal(snapshot); err == nil {
|
|
f, err := os.OpenFile(histPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
|
if err == nil {
|
|
f.Write(append(data, '\n')) //nolint:errcheck
|
|
f.Close() //nolint:errcheck
|
|
}
|
|
}
|
|
}
|
|
s.emit(infoEvent(fmt.Sprintf("compacted %d messages", n)))
|
|
s.saveSession()
|
|
},
|
|
|
|
"/context": func(args []string) {
|
|
if s.session == nil {
|
|
s.emit(infoEvent("no active session"))
|
|
return
|
|
}
|
|
ctxLen := s.cfg.Backend.ContextLength(ctx)
|
|
if ctxLen <= 0 {
|
|
ctxLen = defaultContextLength
|
|
}
|
|
estimated := s.session.estimateTokens()
|
|
pct := estimated * 100 / ctxLen
|
|
s.emit(infoEvent(fmt.Sprintf("~%d / %d tokens (%d%%)", estimated, ctxLen, pct)))
|
|
s.emit(infoEvent(strings.TrimRight(s.session.contextDebug(), "\n")))
|
|
},
|
|
|
|
"/cost": func(args []string) {
|
|
if s.session == nil {
|
|
s.emit(infoEvent("no active session"))
|
|
return
|
|
}
|
|
s.emit(infoEvent(fmt.Sprintf("last=$%.4f session=$%.4f",
|
|
s.session.LastTurnCostUSD, s.session.SessionCostUSD)))
|
|
},
|
|
|
|
"/usage": func(args []string) {
|
|
if s.session == nil {
|
|
s.emit(infoEvent("no active session"))
|
|
return
|
|
}
|
|
ctxLen := s.cfg.Backend.ContextLength(ctx)
|
|
if ctxLen <= 0 {
|
|
ctxLen = defaultContextLength
|
|
}
|
|
estimated := s.session.estimateTokens()
|
|
pct := estimated * 100 / ctxLen
|
|
usageStr := fmt.Sprintf("~%d / %d tokens (%d%%) | %d in, %d out, %d requests",
|
|
estimated, ctxLen, pct,
|
|
s.session.TotalInputTokens, s.session.TotalOutputTokens,
|
|
s.session.TotalRequests)
|
|
if s.session.Estimated {
|
|
usageStr += " [estimated]"
|
|
}
|
|
s.emit(infoEvent(usageStr))
|
|
},
|
|
|
|
"/history": func(args []string) {
|
|
if s.session == nil {
|
|
s.emit(infoEvent("no active session"))
|
|
return
|
|
}
|
|
for _, msg := range s.session.history() {
|
|
preview := msg.Content
|
|
if len(preview) > 200 {
|
|
preview = preview[:200] + "..."
|
|
}
|
|
s.emit(infoEvent(fmt.Sprintf("[%s] %s", msg.Role, preview)))
|
|
}
|
|
},
|
|
|
|
"/clear": func(args []string) {
|
|
if s.IsRunning() {
|
|
s.emit(infoEvent("error: cannot clear while agent is running"))
|
|
return
|
|
}
|
|
s.session = nil
|
|
s.emit(infoEvent("cleared"))
|
|
},
|
|
|
|
"/sessions": func(args []string) {
|
|
entries, err := os.ReadDir(s.sessionsDir)
|
|
if err != nil {
|
|
s.emit(infoEvent(fmt.Sprintf("sessions: %v", err)))
|
|
return
|
|
}
|
|
found := false
|
|
for i := len(entries) - 1; i >= 0; i-- {
|
|
e := entries[i]
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
|
continue
|
|
}
|
|
id := strings.TrimSuffix(e.Name(), ".json")
|
|
marker := " "
|
|
if id == s.sessionID {
|
|
marker = "* "
|
|
}
|
|
label := id
|
|
if data, readErr := os.ReadFile(s.sessionsDir + "/" + e.Name()); readErr == nil {
|
|
var ps PersistedSession
|
|
if json.Unmarshal(data, &ps) == nil {
|
|
goal := ""
|
|
for _, msg := range ps.Messages {
|
|
if msg.Role == "user" {
|
|
goal = msg.Content
|
|
break
|
|
}
|
|
}
|
|
if len(goal) > 60 {
|
|
goal = goal[:60] + "..."
|
|
}
|
|
label = fmt.Sprintf("%-24s [%s] %q", id, ps.Agent, goal)
|
|
}
|
|
}
|
|
s.emit(infoEvent(marker + label))
|
|
found = true
|
|
}
|
|
if !found {
|
|
s.emit(infoEvent("no sessions found in " + s.sessionsDir))
|
|
}
|
|
},
|
|
|
|
"/cwd": func(args []string) {
|
|
if len(args) == 0 {
|
|
s.emit(infoEvent("cwd: " + s.CWD()))
|
|
return
|
|
}
|
|
dir := strings.Join(args, " ")
|
|
if err := s.SetCWD(dir); err != nil {
|
|
s.emit(infoEvent("error: " + err.Error()))
|
|
return
|
|
}
|
|
s.emit(infoEvent("cwd: " + dir))
|
|
},
|
|
|
|
"/skills": func(args []string) { listMountDir("sk") },
|
|
"/tools": func(args []string) { listMountDir("t") },
|
|
|
|
"/sp": func(args []string) {
|
|
s.emit(infoEvent(s.cfg.preamble))
|
|
},
|
|
|
|
"/help": func(args []string) {
|
|
lines := []string{
|
|
"Available commands:",
|
|
" /agents - list available agent configs",
|
|
" /sessions - list saved sessions",
|
|
" /agent [name] - show or switch active agent",
|
|
" /backend [type] - show current backend, or switch to <type>",
|
|
" /model [name] - show current model, or switch to <name>",
|
|
" /models - list available models",
|
|
" /maxsteps [n] - show or set max tool-call steps (0 = unlimited)",
|
|
" /skills - list available skills",
|
|
" /tools - list available tools",
|
|
" /cwd [path] - show or change working directory",
|
|
" /i <prompt> - inject prompt into the running turn",
|
|
" /irw <prompt> - rewrite the pending inject",
|
|
" /queued [pop|clear] - manage queued prompts",
|
|
" /compact - summarize conversation and compact context",
|
|
" /context - show context size and message breakdown",
|
|
" /cost - show last turn and session cost",
|
|
" /usage - show token usage and context percentage",
|
|
" /history - dump bounded message history",
|
|
" /clear - clear session",
|
|
" /kill - kill session",
|
|
" /rn <name> - rename session",
|
|
" /sp - show rendered system prompt",
|
|
" /help - show this help",
|
|
" !<cmd> - run shell command",
|
|
}
|
|
for _, l := range lines {
|
|
s.emit(infoEvent(l))
|
|
}
|
|
},
|
|
}
|
|
|
|
fn, ok := cmds[cmd]
|
|
if !ok {
|
|
return false
|
|
}
|
|
s.emit(infoEvent(""))
|
|
fn(args)
|
|
return true
|
|
}
|