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

365 lines
8.7 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"os"
"slices"
"strconv"
"strings"
)
// HandleCommand processes agent-level slash commands. Returns true if the
// input was recognized as a command, false otherwise.
// The emit function is used to send output events. sessionID and agentsDir
// are passed from the session to avoid the agent needing session-level knowledge.
func (ag *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:]
switch cmd {
case "/i":
ag.cmdInject(ctx, args)
case "/irw":
ag.cmdInjectRewrite(ctx, args)
case "/backend":
ag.cmdBackend(ctx, args)
case "/models":
ag.cmdModels(ctx, args)
case "/model":
ag.cmdModel(ctx, args)
case "/maxsteps":
ag.cmdMaxSteps(ctx, args)
case "/agents":
ag.cmdAgents(ctx, args)
case "/agent":
ag.cmdAgent(ctx, args)
case "/compact":
ag.cmdCompact(ctx, args)
case "/context":
ag.cmdContext(ctx, args)
case "/cost":
ag.cmdCost(ctx, args)
case "/usage":
ag.cmdUsage(ctx, args)
case "/history":
ag.cmdHistory(ctx, args)
case "/clear":
ag.cmdClear(ctx, args)
case "/sp":
ag.cmdSP(ctx, args)
default:
return false
}
return true
}
func (ag *Agent) cmdBackend(_ context.Context, args []string) {
if len(args) == 0 {
ag.emit(infoEvent(ag.runtime.Backend.Name()))
return
}
if ag.IsRunning() {
ag.emit(infoEvent("error: cannot switch backend while agent is running"))
return
}
be, err := ag.newBackend(args[0])
if err != nil {
ag.emit(infoEvent(fmt.Sprintf("error: failed to switch backend: %v", err)))
return
}
ag.runtime.Backend = be
ag.emit(infoEvent(fmt.Sprintf("switched backend to: %s (model: %s)", be.Name(), be.Model())))
}
func (ag *Agent) cmdModels(ctx context.Context, args []string) {
models := ag.runtime.Backend.Models(ctx)
if len(models) == 0 {
ag.emit(infoEvent("no models available"))
return
}
slices.Sort(models)
current := ag.runtime.Backend.Model()
for _, m := range models {
marker := " "
if m == current {
marker = "* "
}
ag.emit(infoEvent(marker + m))
}
}
func (ag *Agent) cmdModel(_ context.Context, args []string) {
if len(args) == 0 {
ag.emit(infoEvent(ag.runtime.Backend.Model()))
return
}
ag.runtime.Backend.SetModel(args[0])
ag.emit(infoEvent("switched model to: " + args[0]))
}
func (ag *Agent) cmdMaxSteps(_ context.Context, args []string) {
if len(args) == 0 {
if ag.runtime.MaxSteps == 0 {
ag.emit(infoEvent("maxsteps: unlimited"))
} else {
ag.emit(infoEvent(fmt.Sprintf("maxsteps: %d", ag.runtime.MaxSteps)))
}
return
}
n, err := strconv.Atoi(args[0])
if err != nil || n < 0 {
ag.emit(infoEvent("error: maxsteps requires a non-negative integer (0 = unlimited)"))
return
}
ag.runtime.MaxSteps = n
if n == 0 {
ag.emit(infoEvent("maxsteps: unlimited"))
} else {
ag.emit(infoEvent(fmt.Sprintf("maxsteps: %d", n)))
}
}
func (ag *Agent) cmdAgents(_ context.Context, _ []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 == ag.agentName {
marker = "* "
}
ag.emit(infoEvent(marker + name))
found = true
}
}
if !found {
ag.emit(infoEvent("no agents found"))
}
}
func (ag *Agent) cmdAgent(_ context.Context, args []string) {
if len(args) == 0 {
ag.emit(infoEvent("active agent: " + ag.agentName))
return
}
if ag.IsRunning() {
ag.emit(infoEvent("error: cannot switch agent while agent is running"))
return
}
name := args[0]
cfgPath := AgentConfigPath(ag.agentsDir, name)
f, err := os.Open(cfgPath)
if err != nil {
ag.emit(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err)))
return
}
cfg, err := Load(f)
f.Close()
if err != nil {
ag.emit(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err)))
return
}
d := ag.newDispatcher()
if d == nil {
ag.emit(infoEvent("error: no dispatcher configured"))
return
}
env := []string{"OLLIE_SESSION_ID=" + ag.sessionID, "OLLIE_UNAME=" + ag.id}
env = append(env, ag.promptEnvExtra...)
rt := BuildRuntime(cfg, d, ag.cwd, env, ag.baseLayers...)
if rt.CfgBackend != "" {
newBe, err := ag.newBackend(rt.CfgBackend)
if err != nil {
ag.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 = ag.runtime.Backend
if rt.CfgModel != "" {
rt.Backend.SetModel(rt.CfgModel)
}
}
ag.runtime = rt
ag.agentName = name
ag.history = nil
ag.notifyChange()
for _, msg := range rt.Messages {
ag.emit(infoEvent(msg))
}
ag.emit(infoEvent("agent: " + name))
}
func (ag *Agent) cmdCompact(ctx context.Context, _ []string) {
if ag.IsRunning() {
ag.emit(infoEvent("error: cannot compact while agent is running"))
return
}
if ag.history == nil {
ag.emit(infoEvent("nothing to compact"))
return
}
ag.SetState("compacting")
n, err := ag.runCompact(ctx, "manual")
ag.SetState("idle")
if err != nil {
ag.emit(infoEvent("compact error: " + err.Error()))
return
}
if n == 0 {
ag.emit(infoEvent("nothing to compact"))
return
}
ag.emit(infoEvent(fmt.Sprintf("compacted %d messages", n)))
ag.saveSession()
}
func (ag *Agent) cmdContext(ctx context.Context, _ []string) {
if ag.history == nil {
ag.emit(infoEvent("no active session"))
return
}
ctxLen := ag.runtime.Backend.ContextLength(ctx)
if ctxLen <= 0 {
ctxLen = defaultContextLength
}
estimated := ag.history.estimateTokens()
pct := estimated * 100 / ctxLen
ag.emit(infoEvent(fmt.Sprintf("~%d / %d tokens (%d%%)", estimated, ctxLen, pct)))
ag.emit(infoEvent(strings.TrimRight(ag.history.contextDebug(), "\n")))
}
func (ag *Agent) cmdCost(_ context.Context, _ []string) {
if ag.history == nil {
ag.emit(infoEvent("no active session"))
return
}
ag.emit(infoEvent(fmt.Sprintf("last=$%.4f session=$%.4f",
ag.history.LastTurnCostUSD, ag.history.SessionCostUSD)))
}
func (ag *Agent) cmdUsage(ctx context.Context, _ []string) {
if ag.history == nil {
ag.emit(infoEvent("no active session"))
return
}
ctxLen := ag.runtime.Backend.ContextLength(ctx)
if ctxLen <= 0 {
ctxLen = defaultContextLength
}
estimated := ag.history.estimateTokens()
pct := estimated * 100 / ctxLen
usageStr := fmt.Sprintf("~%d / %d tokens (%d%%) | %d in, %d out, %d requests",
estimated, ctxLen, pct,
ag.history.TotalInputTokens, ag.history.TotalOutputTokens,
ag.history.TotalRequests)
if ag.history.Estimated {
usageStr += " [estimated]"
}
ag.emit(infoEvent(usageStr))
}
func (ag *Agent) cmdHistory(_ context.Context, _ []string) {
if ag.history == nil {
ag.emit(infoEvent("no active session"))
return
}
for _, msg := range ag.history.history() {
preview := msg.Content
if len(preview) > 200 {
preview = preview[:200] + "..."
}
ag.emit(infoEvent(fmt.Sprintf("[%s] %s", msg.Role, preview)))
}
}
func (ag *Agent) cmdClear(_ context.Context, _ []string) {
if ag.IsRunning() {
ag.emit(infoEvent("error: cannot clear while agent is running"))
return
}
ag.history = nil
ag.emit(infoEvent("cleared"))
}
func (ag *Agent) cmdSP(_ context.Context, _ []string) {
ag.emit(infoEvent(ag.runtime.Preamble))
}
func (ag *Agent) cmdInject(_ context.Context, args []string) {
prompt := strings.Join(args, " ")
if prompt == "" {
ag.emit(infoEvent("error: /i requires a prompt"))
return
}
if ag.IsRunning() {
ag.Inject(prompt)
} else {
go ag.Submit(context.Background(), prompt)
}
}
func (ag *Agent) cmdInjectRewrite(_ context.Context, args []string) {
prompt := strings.Join(args, " ")
if prompt == "" {
ag.emit(infoEvent("error: /irw requires a prompt"))
return
}
ag.InjectRewrite(prompt)
}
// Inject queues a prompt for mid-turn injection.
func (ag *Agent) Inject(prompt string) {
if !ag.pendingInject.CompareAndSwap(nil, &prompt) {
ag.fifo.Push(prompt)
return
}
ag.emit(Event{Role: "info", Content: "\n"})
ag.emit(Event{Role: "user", Content: prompt})
}
// InjectRewrite replaces the pending inject.
func (ag *Agent) InjectRewrite(prompt string) {
ag.pendingInject.Store(&prompt)
ag.emit(Event{Role: "info", Content: "\n"})
ag.emit(Event{Role: "user", Content: prompt})
}
// CompactionSnapshot returns the pre-compaction snapshot for external persistence.
// Returns nil if no history exists.
func (ag *Agent) CompactionSnapshot() json.RawMessage {
if ag.history == nil {
return nil
}
snap := ag.history.PreCompactionSnapshot()
data, err := json.Marshal(snap)
if err != nil {
return nil
}
return data
}