365 lines
8.7 KiB
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
|
|
}
|