This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-core/agent/commands.go

482 lines
12 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
)
func (s *agent) handleCommand(ctx context.Context, input string) bool {
if !strings.HasPrefix(input, "/") {
return false
}
parts := strings.Fields(input)
if len(parts) == 0 {
return false
}
cmd := parts[0]
args := parts[1:]
listFromHandler := func(name string) {
if h := s.listHandlers[name]; h != nil {
for _, item := range h() {
s.emit(infoEvent(" " + item))
}
}
}
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.runtime.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.runtime.Backend = be
s.emit(infoEvent(fmt.Sprintf("switched backend to: %s (model: %s)", be.Name(), be.Model())))
},
"/models": func(args []string) {
models := s.runtime.Backend.Models(ctx)
if len(models) == 0 {
s.emit(infoEvent("no models available"))
return
}
slices.Sort(models)
current := s.runtime.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.runtime.Backend.Model()))
return
}
s.runtime.Backend.SetModel(args[0])
s.emit(infoEvent("switched model to: " + args[0]))
},
"/maxsteps": func(args []string) {
if len(args) == 0 {
if s.runtime.MaxSteps == 0 {
s.emit(infoEvent("maxsteps: unlimited"))
} else {
s.emit(infoEvent(fmt.Sprintf("maxsteps: %d", s.runtime.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.runtime.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 := Load(f)
f.Close()
if err != nil {
s.emit(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err)))
return
}
d := s.newDispatcher()
env := []string{"OLLIE_SESSION_ID=" + s.sessionID, "OLLIE_UNAME=" + s.uname}
env = append(env, s.promptEnvExtra...)
rt := BuildRuntime(cfg, d, s.cwd, env, s.baseLayers...)
if rt.CfgBackend != "" {
newBe, err := s.newBackend(rt.CfgBackend)
if err != nil {
s.emit(infoEvent(fmt.Sprintf("error: backend %q: %v", rt.CfgBackend, err)))
return
}
if rt.CfgModel != "" {
newBe.SetModel(rt.CfgModel)
}
rt.Backend = newBe
} else {
rt.Backend = s.runtime.Backend
if rt.CfgModel != "" {
rt.Backend.SetModel(rt.CfgModel)
}
}
s.runtime = rt
s.agentName = name
s.session = nil
s.pushSessionEnv()
s.notifyChange()
for _, msg := range rt.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.activeSessionPath(s.sessionID, ".compaction.jsonl")
if err := os.MkdirAll(filepath.Dir(histPath), 0700); err != nil {
s.emit(infoEvent("compaction history save: " + err.Error()))
} else 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.runtime.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.runtime.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) {
allFlag := len(args) > 0 && args[0] == "-a"
type sessionFile struct {
id string
path string
}
var files []sessionFile
appendSessionFiles := func(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for i := len(entries) - 1; i >= 0; i-- {
e := entries[i]
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
files = append(files, sessionFile{
id: strings.TrimSuffix(e.Name(), ".json"),
path: filepath.Join(dir, e.Name()),
})
}
}
appendSessionFiles(s.sessionsDir)
appendSessionFiles(filepath.Join(s.sessionsDir, "active"))
cwd := s.CWD()
found := false
for _, file := range files {
data, readErr := os.ReadFile(file.path)
if readErr != nil {
continue
}
var ps PersistedSession
if json.Unmarshal(data, &ps) != nil {
continue
}
if !allFlag && ps.CWD != "" && ps.CWD != cwd {
continue
}
marker := " "
if file.id == s.sessionID {
marker = "* "
}
goal := ""
for _, msg := range ps.Messages {
if msg.Role == "user" {
goal = msg.Content
break
}
}
if len(goal) > 60 {
goal = goal[:60] + "..."
}
s.emit(infoEvent(marker + fmt.Sprintf("%-24s [%s] %q", file.id, ps.Agent, goal)))
found = true
}
if !found {
s.emit(infoEvent("no sessions for " + cwd))
}
},
"/save": func(args []string) {
if s.session == nil {
s.emit(infoEvent("error: no active session"))
return
}
if len(args) == 0 {
s.emit(infoEvent("error: /save requires a name"))
return
}
name := args[0]
path := s.sessionsDir + "/" + name + ".json"
if err := s.session.saveTo(path, name, s.agentName, s.CWD()); err != nil {
s.emit(infoEvent("error: " + err.Error()))
return
}
s.emit(infoEvent("saved: " + path))
},
"/resume": func(args []string) {
if len(args) == 0 {
s.emit(infoEvent("error: /resume requires a session id or name"))
return
}
if s.IsRunning() {
s.emit(infoEvent("error: cannot resume while agent is running"))
return
}
name := args[0]
path := s.sessionsDir + "/" + name + ".json"
data, err := os.ReadFile(path)
if err != nil {
s.emit(infoEvent(fmt.Sprintf("error: %v", err)))
return
}
var ps PersistedSession
if err := json.Unmarshal(data, &ps); err != nil {
s.emit(infoEvent(fmt.Sprintf("error: %v", err)))
return
}
s.session = RestoreSession(&ps)
s.emit(infoEvent(fmt.Sprintf("resumed session %s (%d messages)", name, len(ps.Messages))))
},
"/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) { listFromHandler("skills") },
"/tools": func(args []string) { listFromHandler("tools") },
"/sp": func(args []string) {
s.emit(infoEvent(s.runtime.Preamble))
},
"/help": func(args []string) {
lines := []string{
"Available commands:",
" /agents - list available agent configs",
" /sessions [-a] - list saved sessions (current cwd; -a for all)",
" /save <name> - save session as a named checkpoint",
" /resume <id> - resume a saved session",
" /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
}