From da4d047aa179f67f8f76e738d5a1bfef4b5e60ea Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 29 Jul 2026 18:35:45 +0200 Subject: [PATCH] agent: extract reasoning struct (swappable agent state) Move agent-specific fields into a nested 'reasoning' struct: - history, runtime, cfg (per-turn params) - agentName, agentsDir - baseLayers, promptEnvExtra - newDispatcher, newBackend - currentAction, warnedContext, resultCache The agent struct is now the session host (owns sess + reasoning). On /agent swap, a new reasoning can be built from the new config while the session (identity, state, bus, env) remains stable. --- agent/commands.go | 84 +++++----- agent/core.go | 320 +++++++++++++++++------------------- agent/core_test.go | 72 ++++---- agent/loop_cache_test.go | 20 +-- agent/loop_errlimit_test.go | 6 +- agent/loop_parallel_test.go | 16 +- agent/loop_truncate_test.go | 10 +- agent/reasoning.go | 36 ++++ 8 files changed, 295 insertions(+), 269 deletions(-) create mode 100644 agent/reasoning.go diff --git a/agent/commands.go b/agent/commands.go index c558f17..234ff39 100644 --- a/agent/commands.go +++ b/agent/commands.go @@ -58,30 +58,30 @@ func (a *agent) handleCommand(ctx context.Context, input string) bool { "/backend": func(args []string) { if len(args) == 0 { - a.emit(infoEvent(a.runtime.Backend.Name())) + a.emit(infoEvent(a.r.runtime.Backend.Name())) return } if a.IsRunning() { a.emit(infoEvent("error: cannot switch backend while agent is running")) return } - be, err := a.newBackend(args[0]) + be, err := a.r.newBackend(args[0]) if err != nil { a.emit(infoEvent(fmt.Sprintf("error: failed to switch backend: %v", err))) return } - a.runtime.Backend = be + a.r.runtime.Backend = be a.emit(infoEvent(fmt.Sprintf("switched backend to: %s (model: %s)", be.Name(), be.Model()))) }, "/models": func(args []string) { - models := a.runtime.Backend.Models(ctx) + models := a.r.runtime.Backend.Models(ctx) if len(models) == 0 { a.emit(infoEvent("no models available")) return } slices.Sort(models) - current := a.runtime.Backend.Model() + current := a.r.runtime.Backend.Model() for _, m := range models { marker := " " if m == current { @@ -93,19 +93,19 @@ func (a *agent) handleCommand(ctx context.Context, input string) bool { "/model": func(args []string) { if len(args) == 0 { - a.emit(infoEvent(a.runtime.Backend.Model())) + a.emit(infoEvent(a.r.runtime.Backend.Model())) return } - a.runtime.Backend.SetModel(args[0]) + a.r.runtime.Backend.SetModel(args[0]) a.emit(infoEvent("switched model to: " + args[0])) }, "/maxsteps": func(args []string) { if len(args) == 0 { - if a.runtime.MaxSteps == 0 { + if a.r.runtime.MaxSteps == 0 { a.emit(infoEvent("maxsteps: unlimited")) } else { - a.emit(infoEvent(fmt.Sprintf("maxsteps: %d", a.runtime.MaxSteps))) + a.emit(infoEvent(fmt.Sprintf("maxsteps: %d", a.r.runtime.MaxSteps))) } return } @@ -114,7 +114,7 @@ func (a *agent) handleCommand(ctx context.Context, input string) bool { a.emit(infoEvent("error: maxsteps requires a non-negative integer (0 = unlimited)")) return } - a.runtime.MaxSteps = n + a.r.runtime.MaxSteps = n if n == 0 { a.emit(infoEvent("maxsteps: unlimited")) } else { @@ -140,7 +140,7 @@ func (a *agent) handleCommand(ctx context.Context, input string) bool { } seen[name] = true marker := " " - if name == a.agentName { + if name == a.r.agentName { marker = "* " } a.emit(infoEvent(marker + name)) @@ -154,7 +154,7 @@ func (a *agent) handleCommand(ctx context.Context, input string) bool { "/agent": func(args []string) { if len(args) == 0 { - a.emit(infoEvent("active agent: " + a.agentName)) + a.emit(infoEvent("active agent: " + a.r.agentName)) return } if a.IsRunning() { @@ -162,7 +162,7 @@ func (a *agent) handleCommand(ctx context.Context, input string) bool { return } name := args[0] - cfgPath := AgentConfigPath(a.agentsDir, name) + cfgPath := AgentConfigPath(a.r.agentsDir, name) f, err := os.Open(cfgPath) if err != nil { a.emit(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err))) @@ -174,12 +174,12 @@ func (a *agent) handleCommand(ctx context.Context, input string) bool { a.emit(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err))) return } - d := a.newDispatcher() + d := a.r.newDispatcher() env := []string{"OLLIE_SESSION_ID=" + a.sess.ID(), "OLLIE_UNAME=" + a.sess.Uname()} - env = append(env, a.promptEnvExtra...) - rt := BuildRuntime(cfg, d, a.sess.CWD(), env, a.baseLayers...) + env = append(env, a.r.promptEnvExtra...) + rt := BuildRuntime(cfg, d, a.sess.CWD(), env, a.r.baseLayers...) if rt.CfgBackend != "" { - newBe, err := a.newBackend(rt.CfgBackend) + newBe, err := a.r.newBackend(rt.CfgBackend) if err != nil { a.emit(infoEvent(fmt.Sprintf("error: backend %q: %v", rt.CfgBackend, err))) return @@ -189,14 +189,14 @@ func (a *agent) handleCommand(ctx context.Context, input string) bool { } rt.Backend = newBe } else { - rt.Backend = a.runtime.Backend + rt.Backend = a.r.runtime.Backend if rt.CfgModel != "" { rt.Backend.SetModel(rt.CfgModel) } } - a.runtime = rt - a.agentName = name - a.history = nil + a.r.runtime = rt + a.r.agentName = name + a.r.history = nil a.pushSessionEnv() a.notifyChange() for _, msg := range rt.Messages { @@ -210,11 +210,11 @@ func (a *agent) handleCommand(ctx context.Context, input string) bool { a.emit(infoEvent("error: cannot compact while agent is running")) return } - if a.history == nil { + if a.r.history == nil { a.emit(infoEvent("nothing to compact")) return } - snapshot := a.history.PreCompactionSnapshot() + snapshot := a.r.history.PreCompactionSnapshot() a.setState("compacting") n, err := a.runCompact(ctx, "manual") a.setState("idle") @@ -243,56 +243,56 @@ func (a *agent) handleCommand(ctx context.Context, input string) bool { }, "/context": func(args []string) { - if a.history == nil { + if a.r.history == nil { a.emit(infoEvent("no active session")) return } - ctxLen := a.runtime.Backend.ContextLength(ctx) + ctxLen := a.r.runtime.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } - estimated := a.history.estimateTokens() + estimated := a.r.history.estimateTokens() pct := estimated * 100 / ctxLen a.emit(infoEvent(fmt.Sprintf("~%d / %d tokens (%d%%)", estimated, ctxLen, pct))) - a.emit(infoEvent(strings.TrimRight(a.history.contextDebug(), "\n"))) + a.emit(infoEvent(strings.TrimRight(a.r.history.contextDebug(), "\n"))) }, "/cost": func(args []string) { - if a.history == nil { + if a.r.history == nil { a.emit(infoEvent("no active session")) return } a.emit(infoEvent(fmt.Sprintf("last=$%.4f session=$%.4f", - a.history.LastTurnCostUSD, a.history.SessionCostUSD))) + a.r.history.LastTurnCostUSD, a.r.history.SessionCostUSD))) }, "/usage": func(args []string) { - if a.history == nil { + if a.r.history == nil { a.emit(infoEvent("no active session")) return } - ctxLen := a.runtime.Backend.ContextLength(ctx) + ctxLen := a.r.runtime.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } - estimated := a.history.estimateTokens() + estimated := a.r.history.estimateTokens() pct := estimated * 100 / ctxLen usageStr := fmt.Sprintf("~%d / %d tokens (%d%%) | %d in, %d out, %d requests", estimated, ctxLen, pct, - a.history.TotalInputTokens, a.history.TotalOutputTokens, - a.history.TotalRequests) - if a.history.Estimated { + a.r.history.TotalInputTokens, a.r.history.TotalOutputTokens, + a.r.history.TotalRequests) + if a.r.history.Estimated { usageStr += " [estimated]" } a.emit(infoEvent(usageStr)) }, "/history": func(args []string) { - if a.history == nil { + if a.r.history == nil { a.emit(infoEvent("no active session")) return } - for _, msg := range a.history.history() { + for _, msg := range a.r.history.history() { preview := msg.Content if len(preview) > 200 { preview = preview[:200] + "..." @@ -306,7 +306,7 @@ func (a *agent) handleCommand(ctx context.Context, input string) bool { a.emit(infoEvent("error: cannot clear while agent is running")) return } - a.history = nil + a.r.history = nil a.emit(infoEvent("cleared")) }, @@ -373,7 +373,7 @@ func (a *agent) handleCommand(ctx context.Context, input string) bool { }, "/save": func(args []string) { - if a.history == nil { + if a.r.history == nil { a.emit(infoEvent("error: no active session")) return } @@ -383,7 +383,7 @@ func (a *agent) handleCommand(ctx context.Context, input string) bool { } name := args[0] path := a.sessionsDir + "/" + name + ".json" - if err := a.history.saveTo(path, name, a.agentName, a.CWD()); err != nil { + if err := a.r.history.saveTo(path, name, a.r.agentName, a.CWD()); err != nil { a.emit(infoEvent("error: " + err.Error())) return } @@ -411,7 +411,7 @@ func (a *agent) handleCommand(ctx context.Context, input string) bool { a.emit(infoEvent(fmt.Sprintf("error: %v", err))) return } - a.history = RestoreHistory(&ps) + a.r.history = RestoreHistory(&ps) a.emit(infoEvent(fmt.Sprintf("resumed session %s (%d messages)", name, len(ps.Messages)))) }, @@ -432,7 +432,7 @@ func (a *agent) handleCommand(ctx context.Context, input string) bool { "/tools": func(args []string) { listFromHandler("tools") }, "/sp": func(args []string) { - a.emit(infoEvent(a.runtime.Preamble)) + a.emit(infoEvent(a.r.runtime.Preamble)) }, "/help": func(args []string) { diff --git a/agent/core.go b/agent/core.go index 5b8584a..b773442 100644 --- a/agent/core.go +++ b/agent/core.go @@ -311,30 +311,20 @@ type AgentCoreConfig struct { // but has no knowledge of how output is rendered. type agent struct { sess *session.Session - history *History - runtime *Runtime - cfg agentConfig // per-turn config built from runtime; set in executeTurn + r *reasoning log *olog.Logger - agentName string - agentsDir string sessionsDir string - newDispatcher func() tools.Dispatcher - newBackend func(string) (backend.Backend, error) readPlanStep func() string listHandlers map[string]func() []string turnError func(ctx context.Context, errType, errMsg string) HookResult // overridable for tests remote string // SSH target for remote execution - promptEnvExtra []string // PRIME_* vars for prompt resolution - baseLayers []string // system prompt, operational model, env block — for /agent reloads startupMessages []string - currentAction atomic.Pointer[actionHandle] toolCallCount atomic.Int64 pendingInject atomic.Pointer[string] mu sync.RWMutex changeMu sync.Mutex changeCond *sync.Cond submitMu sync.Mutex // serializes Submit calls (commands + turns) - warnedContext bool // true after a context-usage warning; cleared on compaction auditLog *olog.Logger // Debounced session persistence @@ -342,8 +332,6 @@ type agent struct { saveDirty bool saveTimer *time.Timer - // Cross-turn tool result cache (read-safe tools only) - resultCache sync.Map } // ToolCallCount returns the total number of tool calls executed in this @@ -356,10 +344,10 @@ func (a *agent) ToolCallCount() int64 { // SetEnv stores a session-scoped variable and propagates it to the execute server. func (a *agent) SetEnv(key, value string) { a.sess.SetEnv(key, value) - if a.runtime == nil || a.runtime.Dispatcher == nil { + if a.r.runtime == nil || a.r.runtime.Dispatcher == nil { return } - if srv, ok := a.runtime.Dispatcher.GetServer("execute"); ok { + if srv, ok := a.r.runtime.Dispatcher.GetServer("execute"); ok { if es, ok := srv.(tools.EnvSetter); ok { es.SetEnv(key, value) } @@ -368,10 +356,10 @@ func (a *agent) SetEnv(key, value string) { // pushSessionEnv injects OLLIE_SESSION_ID into the execute server subprocess env. func (a *agent) pushSessionEnv() { - if a.runtime == nil || a.runtime.Dispatcher == nil || a.sess.ID() == "" { + if a.r.runtime == nil || a.r.runtime.Dispatcher == nil || a.sess.ID() == "" { return } - if srv, ok := a.runtime.Dispatcher.GetServer("execute"); ok { + if srv, ok := a.r.runtime.Dispatcher.GetServer("execute"); ok { if es, ok := srv.(tools.EnvSetter); ok { es.SetEnv("OLLIE_SESSION_ID", a.sess.ID()) if a.sess.Uname() != "" { @@ -383,7 +371,7 @@ func (a *agent) pushSessionEnv() { // pushLockDir sets the flock directory on the execute server to the session tmpdir. func (a *agent) pushLockDir() { - if a.runtime == nil || a.runtime.Dispatcher == nil || a.sess.ID() == "" { + if a.r.runtime == nil || a.r.runtime.Dispatcher == nil || a.sess.ID() == "" { return } @@ -471,19 +459,21 @@ func NewAgentCore(cfg AgentCoreConfig) Core { Uname: cfg.Uname, CWD: paths.ExpandHome(cfg.CWD), }), - history: cfg.History, - runtime: rt, + r: &reasoning{ + history: cfg.History, + runtime: rt, + agentName: cfg.AgentName, + agentsDir: cfg.AgentsDir, + promptEnvExtra: cfg.PromptEnvExtra, + baseLayers: cfg.BaseLayers, + newDispatcher: cfg.NewDispatcher, + newBackend: cfg.NewBackend, + }, log: log, auditLog: log.Sub("audit"), - agentName: cfg.AgentName, - agentsDir: cfg.AgentsDir, sessionsDir: cfg.SessionsDir, remote: cfg.Remote, - promptEnvExtra: cfg.PromptEnvExtra, - baseLayers: cfg.BaseLayers, startupMessages: rt.Messages, - newDispatcher: cfg.NewDispatcher, - newBackend: cfg.NewBackend, readPlanStep: readPlanStep, listHandlers: cfg.ListHandlers, } @@ -491,10 +481,10 @@ func NewAgentCore(cfg AgentCoreConfig) Core { a.turnError = func(_ context.Context, errType, errMsg string) HookResult { hookCtx, cancel := context.WithTimeout(context.Background(), time.Duration(hookTimeout)*time.Second) defer cancel() - return a.runtime.Hooks.Run(hookCtx, HookTurnError, map[string]string{ + return a.r.runtime.Hooks.Run(hookCtx, HookTurnError, map[string]string{ "session_id": a.sess.ID(), "cwd": a.CWD(), - "model": a.runtime.Backend.Model(), + "model": a.r.runtime.Backend.Model(), "error_type": errType, "error": errMsg, }, a.log) @@ -508,8 +498,8 @@ func NewAgentCore(cfg AgentCoreConfig) Core { func (a *agent) Close() { a.log.Debug("Close() session=%q", a.sess.ID()) a.flushSave() - if a.runtime != nil && a.runtime.Dispatcher != nil { - if srv, ok := a.runtime.Dispatcher.GetServer("execute"); ok { + if a.r.runtime != nil && a.r.runtime.Dispatcher != nil { + if srv, ok := a.r.runtime.Dispatcher.GetServer("execute"); ok { if c, ok := srv.(interface{ Close() }); ok { a.log.Debug("Close() calling execute.Close()") c.Close() @@ -523,10 +513,10 @@ func (a *agent) Close() { // execServer returns the execute server if available, or nil. func (a *agent) execServer() interface{} { - if a.runtime == nil || a.runtime.Dispatcher == nil { + if a.r.runtime == nil || a.r.runtime.Dispatcher == nil { return nil } - srv, _ := a.runtime.Dispatcher.GetServer("execute") + srv, _ := a.r.runtime.Dispatcher.GetServer("execute") return srv } @@ -634,10 +624,10 @@ func classifyReaction(emoji string) (category, description string, positive bool func (a *agent) Reactions() map[string]string { result := make(map[string]string) - if a.history == nil { + if a.r.history == nil { return result } - for _, reaction := range a.history.Reactions { + for _, reaction := range a.r.history.Reactions { result[reaction.ResponseID] = reaction.Emoji } return result @@ -648,7 +638,7 @@ func (a *agent) React(emoji string) { } func (a *agent) ReactTo(responseID, emoji string) error { - if a.history == nil { + if a.r.history == nil { return fmt.Errorf("no active session") } category, _, _ := classifyReaction(emoji) @@ -656,9 +646,9 @@ func (a *agent) ReactTo(responseID, emoji string) error { return fmt.Errorf("unsupported reaction: %s", emoji) } if responseID == "" { - for i := len(a.history.messages) - 1; i >= 0; i-- { - if a.history.messages[i].Role == "assistant" { - responseID = a.history.messages[i].ID + for i := len(a.r.history.messages) - 1; i >= 0; i-- { + if a.r.history.messages[i].Role == "assistant" { + responseID = a.r.history.messages[i].ID break } } @@ -667,8 +657,8 @@ func (a *agent) ReactTo(responseID, emoji string) error { return fmt.Errorf("no assistant response to react to") } found := false - for i := range a.history.messages { - if a.history.messages[i].Role == "assistant" && a.history.messages[i].ID == responseID { + for i := range a.r.history.messages { + if a.r.history.messages[i].Role == "assistant" && a.r.history.messages[i].ID == responseID { found = true break } @@ -679,36 +669,36 @@ func (a *agent) ReactTo(responseID, emoji string) error { reaction := Reaction{ID: NewReactionID(), ResponseID: responseID, Emoji: emoji, Category: category, CreatedAt: time.Now()} replaced := false - for i := range a.history.Reactions { - if a.history.Reactions[i].ResponseID == responseID { - if a.history.Reactions[i].Emoji == emoji { + for i := range a.r.history.Reactions { + if a.r.history.Reactions[i].ResponseID == responseID { + if a.r.history.Reactions[i].Emoji == emoji { return nil } - a.history.Reactions[i] = reaction + a.r.history.Reactions[i] = reaction replaced = true break } } if !replaced { - a.history.Reactions = append(a.history.Reactions, reaction) + a.r.history.Reactions = append(a.r.history.Reactions, reaction) } - a.history.recomputeReactionCounts() + a.r.history.recomputeReactionCounts() a.saveSession() return nil } func (a *agent) AgentName() string { - v := a.agentName + v := a.r.agentName a.log.Debug("AgentName() = %q", v) return v } func (a *agent) BackendName() string { - v := a.runtime.Backend.Name() + v := a.r.runtime.Backend.Name() a.log.Debug("BackendName() = %q", v) return v } func (a *agent) ModelName() string { - v := a.runtime.Backend.Model() + v := a.r.runtime.Backend.Model() a.log.Debug("ModelName() = %q", v) return v } @@ -800,11 +790,11 @@ func (a *agent) SetCWD(dir string) error { a.sess.SetCWD(dir) // Update cwd references in the system prompt. if oldCwd != "" && dir != "" && oldCwd != dir { - a.runtime.Preamble = strings.ReplaceAll(a.runtime.Preamble, oldCwd, dir) + a.r.runtime.Preamble = strings.ReplaceAll(a.r.runtime.Preamble, oldCwd, dir) } // Propagate to any tool server that knows how to handle it (e.g. execute). - if a.runtime != nil && a.runtime.Dispatcher != nil { - if srv, ok := a.runtime.Dispatcher.GetServer("execute"); ok { + if a.r.runtime != nil && a.r.runtime.Dispatcher != nil { + if srv, ok := a.r.runtime.Dispatcher.GetServer("execute"); ok { if ws, ok := srv.(tools.CWDSetter); ok { ws.SetCWD(dir) } @@ -835,7 +825,7 @@ func (a *agent) SetSessionID(newID string) error { } a.sess.SetID(newID) // Update session ID references in the system prompt. - a.runtime.Preamble = strings.ReplaceAll(a.runtime.Preamble, oldID, newID) + a.r.runtime.Preamble = strings.ReplaceAll(a.r.runtime.Preamble, oldID, newID) // Rename tmpdir so isread markers remain valid after rename. oldTemp := filepath.Join(ollieTmpDir(), oldID) newTemp := filepath.Join(ollieTmpDir(), newID) @@ -854,7 +844,7 @@ const defaultToolResultMaxBytes = 131072 // autoCompactLimit returns the token threshold for auto-compaction (75%). func (a *agent) autoCompactLimit(ctx context.Context) int { - ctxLen := a.runtime.Backend.ContextLength(ctx) + ctxLen := a.r.runtime.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } @@ -863,7 +853,7 @@ func (a *agent) autoCompactLimit(ctx context.Context) int { // autoWarnLimit returns the token threshold for a context-usage warning (60%). func (a *agent) autoWarnLimit(ctx context.Context) int { - ctxLen := a.runtime.Backend.ContextLength(ctx) + ctxLen := a.r.runtime.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } @@ -874,11 +864,11 @@ func (a *agent) autoWarnLimit(ctx context.Context) int { // point (session start, post-clear, post-compaction). It combines the // agent-specific prompt with any agentSpawn hook output. func (a *agent) spawnContext(ctx context.Context) string { - result := a.runtime.Hooks.Run(ctx, HookAgentSpawn, map[string]string{ + result := a.r.runtime.Hooks.Run(ctx, HookAgentSpawn, map[string]string{ "session_id": a.sess.ID(), - "agent": a.agentName, + "agent": a.r.agentName, "cwd": a.CWD(), - "model": a.runtime.Backend.Model(), + "model": a.r.runtime.Backend.Model(), }, a.log) if result.Warning != "" { a.emit(infoEvent(result.Warning)) @@ -898,7 +888,7 @@ func (a *agent) spawnContext(ctx context.Context) string { // the pre-hook blocked or there was nothing to compact. Caller manages setState. func (a *agent) runCompact(ctx context.Context, trigger string) (int, error) { payload := map[string]string{"session_id": a.sess.ID(), "trigger": trigger, "cwd": a.CWD()} - pre := a.runtime.Hooks.Run(ctx, HookPreCompact, payload, a.log) + pre := a.r.runtime.Hooks.Run(ctx, HookPreCompact, payload, a.log) if pre.Warning != "" { a.emit(infoEvent(pre.Warning)) } @@ -910,27 +900,27 @@ func (a *agent) runCompact(ctx context.Context, trigger string) (int, error) { return 0, nil } if pre.Context != "" { - a.history.appendUserMessage(pre.Context) + a.r.history.appendUserMessage(pre.Context) } // Use a cheaper model for compaction if configured. - compactModel := resolveCompactionModel(a.runtime.CompactionModel, a.runtime.Backend) - origModel := a.runtime.Backend.Model() + compactModel := resolveCompactionModel(a.r.runtime.CompactionModel, a.r.runtime.Backend) + origModel := a.r.runtime.Backend.Model() if compactModel != "" && compactModel != origModel { - a.runtime.Backend.SetModel(compactModel) - defer a.runtime.Backend.SetModel(origModel) + a.r.runtime.Backend.SetModel(compactModel) + defer a.r.runtime.Backend.SetModel(origModel) } - n, _, err := a.history.compact(ctx, a.runtime.Backend) + n, _, err := a.r.history.compact(ctx, a.r.runtime.Backend) if err != nil { return 0, err } if n > 0 { a.auditLog.Debug("compact: removed %d messages trigger=%s session=%s", n, trigger, a.sess.ID()) - a.warnedContext = false + a.r.warnedContext = false if sc := a.spawnContext(ctx); sc != "" { - a.history.appendUserMessage(sc) + a.r.history.appendUserMessage(sc) } } - post := a.runtime.Hooks.Run(ctx, HookPostCompact, payload, a.log) + post := a.r.runtime.Hooks.Run(ctx, HookPostCompact, payload, a.log) if post.Warning != "" { a.emit(infoEvent(post.Warning)) } @@ -938,7 +928,7 @@ func (a *agent) runCompact(ctx context.Context, trigger string) (int, error) { a.emit(infoEvent("postCompact: " + sum)) } if post.Context != "" { - a.history.appendUserMessage(post.Context) + a.r.history.appendUserMessage(post.Context) } return n, nil } @@ -969,7 +959,7 @@ func (a *agent) flushSave() { if !dirty { return } - if a.history == nil || a.sess.ID() == "" || a.sessionsDir == "" { + if a.r.history == nil || a.sess.ID() == "" || a.sessionsDir == "" { return } path := a.activeSessionPath(a.sess.ID(), ".json") @@ -977,8 +967,8 @@ func (a *agent) flushSave() { a.log.Error("session save: %v", err) return } - if err := a.history.saveToFull(path, a.sess.ID(), a.agentName, - a.runtime.Backend.Name(), a.runtime.Backend.Model(), a.CWD(), a.remote); err != nil { + if err := a.r.history.saveToFull(path, a.sess.ID(), a.r.agentName, + a.r.runtime.Backend.Name(), a.r.runtime.Backend.Model(), a.CWD(), a.remote); err != nil { a.log.Error("session save: %v", err) } } @@ -988,15 +978,15 @@ func (a *agent) flushSave() { func (a *agent) SaveSession(path string) error { a.mu.RLock() defer a.mu.RUnlock() - if a.history == nil { + if a.r.history == nil { return fmt.Errorf("no active session") } - return a.history.saveToFull(path, a.sess.ID(), a.agentName, - a.runtime.Backend.Name(), a.runtime.Backend.Model(), a.CWD(), a.remote) + return a.r.history.saveToFull(path, a.sess.ID(), a.r.agentName, + a.r.runtime.Backend.Name(), a.r.runtime.Backend.Model(), a.CWD(), a.remote) } func (a *agent) getActionCancel() context.CancelCauseFunc { - if a := a.currentAction.Load(); a != nil { + if a := a.r.currentAction.Load(); a != nil { return a.cancel } return nil @@ -1054,21 +1044,21 @@ func (a *agent) PopQueue() (string, bool) { } func (a *agent) IsRunning() bool { - v := a.currentAction.Load() != nil + v := a.r.currentAction.Load() != nil a.log.Debug("IsRunning() = %v", v) return v } func (a *agent) CtxSz() string { - if a.history == nil { + if a.r.history == nil { a.log.Debug("CtxSz() no session") return "no active session" } - ctxLen := a.runtime.Backend.ContextLength(context.Background()) + ctxLen := a.r.runtime.Backend.ContextLength(context.Background()) if ctxLen <= 0 { ctxLen = defaultContextLength } - estimated := a.history.estimateTokens() + estimated := a.r.history.estimateTokens() pct := estimated * 100 / ctxLen v := fmt.Sprintf("%d / %d (%d%%)", estimated, ctxLen, pct) a.log.Debug("CtxSz() = %q", v) @@ -1076,25 +1066,25 @@ func (a *agent) CtxSz() string { } func (a *agent) Cost() string { - if a.history == nil { + if a.r.history == nil { return "no active session" } return fmt.Sprintf("costLast=$%.4f\ncostSession=$%.4f\n", - a.history.LastTurnCostUSD, a.history.SessionCostUSD) + a.r.history.LastTurnCostUSD, a.r.history.SessionCostUSD) } func (a *agent) Usage() string { - if a.history == nil { + if a.r.history == nil { a.log.Debug("Usage() no session") return "no active session" } str := fmt.Sprintf("%d in, %d out, %d requests", - a.history.TotalInputTokens, a.history.TotalOutputTokens, - a.history.TotalRequests) - if a.history.TotalCachedInputTokens > 0 { - str += fmt.Sprintf(", %d cached", a.history.TotalCachedInputTokens) + a.r.history.TotalInputTokens, a.r.history.TotalOutputTokens, + a.r.history.TotalRequests) + if a.r.history.TotalCachedInputTokens > 0 { + str += fmt.Sprintf(", %d cached", a.r.history.TotalCachedInputTokens) } - if a.history.Estimated { + if a.r.history.Estimated { str += " [estimated]" } a.log.Debug("Usage() = %q", str) @@ -1104,37 +1094,37 @@ func (a *agent) Usage() string { func (a *agent) Context() []backend.Message { a.mu.RLock() var msgs []backend.Message - if a.history != nil { - msgs = slices.Clone(a.history.history()) + if a.r.history != nil { + msgs = slices.Clone(a.r.history.history()) } a.mu.RUnlock() - if a.runtime.Preamble != "" { - msgs = append([]backend.Message{{Role: "system", Content: a.runtime.Preamble}}, msgs...) + if a.r.runtime.Preamble != "" { + msgs = append([]backend.Message{{Role: "system", Content: a.r.runtime.Preamble}}, msgs...) } return msgs } func (a *agent) SystemPrompt() string { - a.log.Debug("SystemPrompt() len=%d", len(a.runtime.Preamble)) - return a.runtime.Preamble + a.log.Debug("SystemPrompt() len=%d", len(a.r.runtime.Preamble)) + return a.r.runtime.Preamble } func (a *agent) GenerationParams() backend.GenerationParams { a.mu.RLock() defer a.mu.RUnlock() - return a.runtime.GenParams + return a.r.runtime.GenParams } func (a *agent) CompactionModel() string { a.mu.RLock() defer a.mu.RUnlock() - return a.runtime.CompactionModel + return a.r.runtime.CompactionModel } func (a *agent) SetCompactionModel(model string) { a.mu.Lock() defer a.mu.Unlock() - a.runtime.CompactionModel = model + a.r.runtime.CompactionModel = model } func (a *agent) SetGenerationParams(params backend.GenerationParams) error { @@ -1142,14 +1132,14 @@ func (a *agent) SetGenerationParams(params backend.GenerationParams) error { return fmt.Errorf("cannot change params while agent is running") } a.mu.Lock() - a.runtime.GenParams = params + a.r.runtime.GenParams = params a.mu.Unlock() return nil } func (a *agent) ListModels() string { a.log.Debug("ListModels()") - models := a.runtime.Backend.Models(context.Background()) + models := a.r.runtime.Backend.Models(context.Background()) slices.Sort(models) return strings.Join(models, "\n") } @@ -1180,7 +1170,7 @@ func (a *agent) Submit(ctx context.Context, input string) { defer func() { if r := recover(); r != nil { a.log.Error("panic: %v\n%s", r, debug.Stack()) - if a := a.currentAction.Swap(nil); a != nil { + if a := a.r.currentAction.Swap(nil); a != nil { a.cancel(fmt.Errorf("%v", r)) } a.setState("idle") @@ -1226,7 +1216,7 @@ func (a *agent) Submit(ctx context.Context, input string) { func (a *agent) executeTurn(ctx context.Context, input string) string { a.emit(Event{Role: "user", Content: input}) - hookResult := a.runtime.Hooks.Run(ctx, HookPreTurn, map[string]string{ + hookResult := a.r.runtime.Hooks.Run(ctx, HookPreTurn, map[string]string{ "session_id": a.sess.ID(), "cwd": a.CWD(), "prompt": input, @@ -1247,50 +1237,50 @@ func (a *agent) executeTurn(ctx context.Context, input string) string { // Snapshot session state before this turn modifies it. Restored on failure // so the session is clean for the next attempt. - snapSession := a.history + snapSession := a.r.history var snapMessages []backend.Message - if a.history != nil { - snapMessages = cloneMessages(a.history.messages) + if a.r.history != nil { + snapMessages = cloneMessages(a.r.history.messages) } - if a.history == nil { + if a.r.history == nil { for _, msg := range a.startupMessages { a.log.Debug("startup: %s", msg) a.emit(infoEvent(msg)) } a.startupMessages = nil - a.history = newHistory(input) + a.r.history = newHistory(input) if sc := a.spawnContext(ctx); sc != "" { - a.history.appendUserMessage(sc) + a.r.history.appendUserMessage(sc) } - a.history.appendUserMessage(input) + a.r.history.appendUserMessage(input) } else { - a.history.appendUserMessage(input) + a.r.history.appendUserMessage(input) } actCtx, actCancel := context.WithCancelCause(ctx) handle := &actionHandle{cancel: actCancel} - a.currentAction.Store(handle) + a.r.currentAction.Store(handle) a.setState("thinking") a.auditLog.Debug("turn: start input=%s session=%s", auditTruncate(input), a.sess.ID()) // Build per-turn agentConfig from the current runtime. - a.cfg = agentConfig{ - Backend: a.runtime.Backend, - preamble: a.runtime.Preamble, - Tools: a.runtime.Tools, - Exec: a.runtime.Exec, - ClassifyTool: a.runtime.ClassifyTool, - ClassifyTier: a.runtime.ClassifyTier, - GenerationParams: a.runtime.GenParams, - MaxSteps: a.runtime.MaxSteps, + a.r.cfg = agentConfig{ + Backend: a.r.runtime.Backend, + preamble: a.r.runtime.Preamble, + Tools: a.r.runtime.Tools, + Exec: a.r.runtime.Exec, + ClassifyTool: a.r.runtime.ClassifyTool, + ClassifyTier: a.r.runtime.ClassifyTier, + GenerationParams: a.r.runtime.GenParams, + MaxSteps: a.r.runtime.MaxSteps, ReadPlanStep: a.readPlanStep, TurnError: a.turnError, } var replyBuf strings.Builder - a.cfg.Output = func(ev Event) { + a.r.cfg.Output = func(ev Event) { switch ev.Role { case "assistant": replyBuf.WriteString(ev.Content) @@ -1306,11 +1296,11 @@ func (a *agent) executeTurn(ctx context.Context, input string) string { case "error": a.auditLog.Debug("error: %s", ev.Content) } - if ev.Role == "usage" && a.history != nil { + if ev.Role == "usage" && a.r.history != nil { var in, out, est, cached, creation int var costUSD float64 fmt.Sscanf(ev.Content, "%d %d %d %g %d %d", &in, &out, &est, &costUSD, &cached, &creation) - a.history.addUsage(backend.Usage{ + a.r.history.addUsage(backend.Usage{ InputTokens: in, CachedInputTokens: cached, CacheCreationTokens: creation, @@ -1319,7 +1309,7 @@ func (a *agent) executeTurn(ctx context.Context, input string) string { }, est != 0) a.notifyChange() if maxCostStr := os.Getenv("OLLIE_MAX_SESSION_COST"); maxCostStr != "" { - if limit, ferr := strconv.ParseFloat(maxCostStr, 64); ferr == nil && limit > 0 && a.history.SessionCostUSD >= limit { + if limit, ferr := strconv.ParseFloat(maxCostStr, 64); ferr == nil && limit > 0 && a.r.history.SessionCostUSD >= limit { a.emit(infoEvent(fmt.Sprintf("spending cap $%.2f reached — stopping", limit))) a.Interrupt(ErrInterrupted) } @@ -1327,22 +1317,22 @@ func (a *agent) executeTurn(ctx context.Context, input string) string { } a.emit(ev) } - a.cfg.PopInject = func() string { + a.r.cfg.PopInject = func() string { if p := a.pendingInject.Swap(nil); p != nil { return *p } return "" } - a.cfg.PreTool = func(ctx context.Context, name string, args json.RawMessage) HookResult { - return a.runtime.Hooks.Run(ctx, HookPreTool, map[string]string{ + a.r.cfg.PreTool = func(ctx context.Context, name string, args json.RawMessage) HookResult { + return a.r.runtime.Hooks.Run(ctx, HookPreTool, map[string]string{ "session_id": a.sess.ID(), "cwd": a.CWD(), "tool": name, "args": string(args), }, a.log) } - a.cfg.PostTool = func(ctx context.Context, name string, args json.RawMessage, result string) HookResult { - return a.runtime.Hooks.Run(ctx, HookPostTool, map[string]string{ + a.r.cfg.PostTool = func(ctx context.Context, name string, args json.RawMessage, result string) HookResult { + return a.r.runtime.Hooks.Run(ctx, HookPostTool, map[string]string{ "session_id": a.sess.ID(), "cwd": a.CWD(), "tool": name, @@ -1350,17 +1340,17 @@ func (a *agent) executeTurn(ctx context.Context, input string) string { "result": result, }, a.log) } - a.cfg.IncrToolCallCount = func() int64 { + a.r.cfg.IncrToolCallCount = func() int64 { return a.toolCallCount.Add(1) } - a.cfg.SaveSession = func() { a.saveSession() } - a.cfg.ResultCache = &a.resultCache - a.cfg.AutoCompact = func(ctx context.Context) { - if ctx.Err() != nil || a.history == nil { + a.r.cfg.SaveSession = func() { a.saveSession() } + a.r.cfg.ResultCache = &a.r.resultCache + a.r.cfg.AutoCompact = func(ctx context.Context) { + if ctx.Err() != nil || a.r.history == nil { return } limit := a.autoCompactLimit(ctx) - if limit <= 0 || a.history.estimateTokens() < limit { + if limit <= 0 || a.r.history.estimateTokens() < limit { return } a.emit(Event{Role: "info", Content: "auto-compacting context...\n"}) @@ -1372,8 +1362,8 @@ func (a *agent) executeTurn(ctx context.Context, input string) string { } // Warn once when context usage crosses 60%; compact at 75%. - if a.history != nil { - tokens := a.history.estimateTokens() + if a.r.history != nil { + tokens := a.r.history.estimateTokens() if compactLimit := a.autoCompactLimit(ctx); compactLimit > 0 && tokens >= compactLimit { a.emit(Event{Role: "info", Content: "auto-compacting context...\n"}) a.setState("compacting") @@ -1381,37 +1371,37 @@ func (a *agent) executeTurn(ctx context.Context, input string) string { panic(fmt.Sprintf("auto-compact: %v", err)) } a.setState("thinking") - } else if warnLimit := a.autoWarnLimit(ctx); warnLimit > 0 && tokens >= warnLimit && !a.warnedContext { - ctxLen := a.cfg.Backend.ContextLength(ctx) + } else if warnLimit := a.autoWarnLimit(ctx); warnLimit > 0 && tokens >= warnLimit && !a.r.warnedContext { + ctxLen := a.r.cfg.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } pct := tokens * 100 / ctxLen a.emit(Event{Role: "info", Content: fmt.Sprintf("context at %d%% — will auto-compact at 75%%\n", pct)}) - a.warnedContext = true + a.r.warnedContext = true } } // Spending cap: reject before spending more tokens. - if maxCostStr := os.Getenv("OLLIE_MAX_SESSION_COST"); maxCostStr != "" && a.history != nil { + if maxCostStr := os.Getenv("OLLIE_MAX_SESSION_COST"); maxCostStr != "" && a.r.history != nil { if limit, err := strconv.ParseFloat(maxCostStr, 64); err == nil && limit > 0 { - if a.history.SessionCostUSD >= limit { - a.emit(Event{Role: "error", Content: fmt.Sprintf("spending cap $%.2f reached (session total $%.4f)", limit, a.history.SessionCostUSD)}) + if a.r.history.SessionCostUSD >= limit { + a.emit(Event{Role: "error", Content: fmt.Sprintf("spending cap $%.2f reached (session total $%.4f)", limit, a.r.history.SessionCostUSD)}) a.setState("idle") actCancel(nil) - a.currentAction.CompareAndSwap(handle, nil) + a.r.currentAction.CompareAndSwap(handle, nil) if snapSession == nil { - a.history = nil + a.r.history = nil } else { - a.history.messages = snapMessages + a.r.history.messages = snapMessages } return "" } } } - if a.history != nil { - a.history.resetTurnAccumulators() + if a.r.history != nil { + a.r.history.resetTurnAccumulators() } // Run the turn, retrying once after compaction on context overflow. @@ -1420,9 +1410,9 @@ func (a *agent) executeTurn(ctx context.Context, input string) string { err error ) for { - err = run(actCtx, a.cfg, a.history) + err = run(actCtx, a.r.cfg, a.r.history) actCancel(nil) - a.currentAction.CompareAndSwap(handle, nil) + a.r.currentAction.CompareAndSwap(handle, nil) if err == nil { break @@ -1431,21 +1421,21 @@ func (a *agent) executeTurn(ctx context.Context, input string) string { break } var ctxErr *backend.ContextOverflowError - if !overflowRetried && errors.As(err, &ctxErr) && a.history != nil { + if !overflowRetried && errors.As(err, &ctxErr) && a.r.history != nil { overflowRetried = true - a.history.messages = snapMessages + a.r.history.messages = snapMessages a.emit(Event{Role: "info", Content: "context overflow — compacting and retrying...\n"}) a.setState("compacting") if _, cerr := a.runCompact(ctx, "overflow"); cerr != nil { break } - a.history.appendUserMessage(input) + a.r.history.appendUserMessage(input) a.setState("thinking") - a.history.resetTurnAccumulators() + a.r.history.resetTurnAccumulators() replyBuf.Reset() actCtx, actCancel = context.WithCancelCause(ctx) handle = &actionHandle{cancel: actCancel} - a.currentAction.Store(handle) + a.r.currentAction.Store(handle) continue } break @@ -1461,8 +1451,8 @@ func (a *agent) executeTurn(ctx context.Context, input string) string { if err != nil { // Keep completed work — only remove cancelled tool results. // Error results are valuable feedback for the agent. - if a.history != nil { - a.history.removeCancelledToolResults() + if a.r.history != nil { + a.r.history.removeCancelledToolResults() } if errors.Is(err, context.Canceled) || errors.Is(err, ErrInterrupted) { a.auditLog.Debug("turn: interrupted session=%s", a.sess.ID()) @@ -1477,7 +1467,7 @@ func (a *agent) executeTurn(ctx context.Context, input string) string { return "" } - stopResult := a.runtime.Hooks.Run(ctx, HookPostTurn, map[string]string{ + stopResult := a.r.runtime.Hooks.Run(ctx, HookPostTurn, map[string]string{ "session_id": a.sess.ID(), "cwd": a.CWD(), }, a.log) @@ -1487,18 +1477,18 @@ func (a *agent) executeTurn(ctx context.Context, input string) string { if sum := stopResult.Summary(); sum != "" { a.emit(infoEvent("postTurn: " + sum)) } - if !stopResult.Blocked && stopResult.Context != "" && a.history != nil { - a.history.appendUserMessage(stopResult.Context) + if !stopResult.Blocked && stopResult.Context != "" && a.r.history != nil { + a.r.history.appendUserMessage(stopResult.Context) } - if a.history != nil { - a.history.recordTurnCost(a.cfg.Backend.Model()) - appendUsageLog(a.sess.ID(), a.cfg.Backend.Name(), a.cfg.Backend.Model(), a.history) - if a.history.LastTurnCostUSD > 0 { - a.emit(Event{Role: "info", Content: fmt.Sprintf("costLast=$%.4f\n", a.history.LastTurnCostUSD)}) + if a.r.history != nil { + a.r.history.recordTurnCost(a.r.cfg.Backend.Model()) + appendUsageLog(a.sess.ID(), a.r.cfg.Backend.Name(), a.r.cfg.Backend.Model(), a.r.history) + if a.r.history.LastTurnCostUSD > 0 { + a.emit(Event{Role: "info", Content: fmt.Sprintf("costLast=$%.4f\n", a.r.history.LastTurnCostUSD)}) } a.auditLog.Debug("turn: end reply=%s cost=$%.4f session_total=$%.4f session=%s", - auditTruncate(a.sess.Reply()), a.history.LastTurnCostUSD, a.history.SessionCostUSD, a.sess.ID()) + auditTruncate(a.sess.Reply()), a.r.history.LastTurnCostUSD, a.r.history.SessionCostUSD, a.sess.ID()) a.notifyChange() } a.saveSession() diff --git a/agent/core_test.go b/agent/core_test.go index c2aab8b..2af84f7 100644 --- a/agent/core_test.go +++ b/agent/core_test.go @@ -231,7 +231,7 @@ func TestSubmit_ToolCallStateTransitions(t *testing.T) { c := newCore(t, be, nil) var stateAtExec string - c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { stateAtExec = c.State() return `{}`, nil, nil } @@ -460,21 +460,21 @@ func TestManualCompact(t *testing.T) { } // Seed a session with more than hotTailSize+warmIndexSize messages so compact() doesn't short-circuit. - c.history = newHistory("goal") + c.r.history = newHistory("goal") for i := range 15 { - c.history.messages = append(c.history.messages, + c.r.history.messages = append(c.r.history.messages, backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d with enough text to count", i)}, backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)}, ) } - before := len(c.history.messages) + before := len(c.r.history.messages) evs := collectEvents(context.Background(), c, "/compact") - if c.history == nil { + if c.r.history == nil { t.Fatal("session nil after compact") } - if after := len(c.history.messages); after >= before { + if after := len(c.r.history.messages); after >= before { t.Errorf("messages: before=%d after=%d; want fewer after compact", before, after) } if callCount == 0 { @@ -514,9 +514,9 @@ func TestAutoCompact(t *testing.T) { } - c.history = newHistory("goal") + c.r.history = newHistory("goal") for range 15 { - c.history.messages = append(c.history.messages, + c.r.history.messages = append(c.r.history.messages, backend.Message{Role: "assistant", Content: "a long response that exceeds seven tokens of content"}, backend.Message{Role: "user", Content: "follow up question with enough text to push over the limit"}, ) @@ -600,12 +600,12 @@ func TestCommand_Help(t *testing.T) { func TestCommand_Clear(t *testing.T) { c := newCore(t, nil, nil) collectEvents(context.Background(), c, "first turn") - if c.history == nil { + if c.r.history == nil { t.Fatal("session nil after first turn") } oldID := c.sess.ID() collectEvents(context.Background(), c, "/clear") - if c.history != nil { + if c.r.history != nil { t.Error("session not nil after /clear") } if c.sess.ID() != oldID { @@ -785,9 +785,9 @@ func TestCommand_Compact_NilSession(t *testing.T) { func TestCommand_Compact_PreHookBlocks(t *testing.T) { c := newCore(t, nil, Hooks{HookPreCompact: []string{"exit 2"}}) - c.history = newHistory("goal") + c.r.history = newHistory("goal") for i := range 5 { - c.history.messages = append(c.history.messages, + c.r.history.messages = append(c.r.history.messages, backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d", i)}, backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)}, ) @@ -985,7 +985,7 @@ func TestRun_NoExec(t *testing.T) { } c := newCore(t, be, nil) - c.runtime.Exec = nil // no executor + c.r.runtime.Exec = nil // no executor collectEvents(context.Background(), c, "run tool") if callCount != 2 { t.Errorf("backend called %d times; want 2", callCount) @@ -1225,7 +1225,7 @@ func TestCommand_Agent_NotFound(t *testing.T) { func TestCommand_Agents_Empty(t *testing.T) { c := newCore(t, nil, nil) // agentsDir is a fresh temp dir - t.Setenv("OLLIE_AGENTS_PATH", c.agentsDir) + t.Setenv("OLLIE_AGENTS_PATH", c.r.agentsDir) evs := collectEvents(context.Background(), c, "/agents") found := false for _, s := range byRole(evs, "info") { @@ -1435,8 +1435,8 @@ func TestCommand_SP(t *testing.T) { func TestCommand_Agents_List(t *testing.T) { c := newCore(t, nil, nil) - t.Setenv("OLLIE_AGENTS_PATH", c.agentsDir) - if err := os.WriteFile(c.agentsDir+"/myagent.json", []byte(`{}`), 0600); err != nil { + t.Setenv("OLLIE_AGENTS_PATH", c.r.agentsDir) + if err := os.WriteFile(c.r.agentsDir+"/myagent.json", []byte(`{}`), 0600); err != nil { t.Fatal(err) } evs := collectEvents(context.Background(), c, "/agents") @@ -1594,7 +1594,7 @@ func TestRun_ExecCancelledWithInject(t *testing.T) { c := newCore(t, be, nil) inject := "user interrupt" c.pendingInject.Store(&inject) - c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { cancel() // ctx cancelled during exec return "", nil, fmt.Errorf("exec cancelled") } @@ -1625,9 +1625,9 @@ func TestAutoCompact_WithHookContext(t *testing.T) { return textStream("answer"), nil } - c.history = newHistory("goal") + c.r.history = newHistory("goal") for range 15 { - c.history.messages = append(c.history.messages, + c.r.history.messages = append(c.r.history.messages, backend.Message{Role: "assistant", Content: "a long response that exceeds seven tokens of content"}, backend.Message{Role: "user", Content: "follow up question with enough text to push over the limit"}, ) @@ -1844,7 +1844,7 @@ func TestAgentSpawn_WithContext(t *testing.T) { collectEvents(context.Background(), c, "first") // spawn context goes into session history, not preamble found := false - for _, m := range c.history.messages { + for _, m := range c.r.history.messages { if strings.Contains(m.Content, "spawn context") { found = true break @@ -1887,9 +1887,9 @@ func TestManualCompact_BackendError(t *testing.T) { callCount++ return nil, fmt.Errorf("compact backend error") } - c.history = newHistory("goal") + c.r.history = newHistory("goal") for i := range 15 { - c.history.messages = append(c.history.messages, + c.r.history.messages = append(c.r.history.messages, backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d", i)}, backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)}, ) @@ -1921,9 +1921,9 @@ func TestManualCompact_EmptySummary(t *testing.T) { return ch, nil } - c.history = newHistory("goal") + c.r.history = newHistory("goal") for i := range 15 { - c.history.messages = append(c.history.messages, + c.r.history.messages = append(c.r.history.messages, backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d", i)}, backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)}, ) @@ -1949,15 +1949,15 @@ func TestManualCompact_WithToolMessages(t *testing.T) { return textStream("summary"), nil } - c.history = newHistory("goal") + c.r.history = newHistory("goal") // Seed enough messages to pass the compaction threshold. for i := range 10 { - c.history.messages = append(c.history.messages, + c.r.history.messages = append(c.r.history.messages, backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d", i)}, backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)}, ) } - c.history.messages = append(c.history.messages, + c.r.history.messages = append(c.r.history.messages, backend.Message{ Role: "assistant", Content: "calling tool", @@ -2018,7 +2018,7 @@ func TestHookTimeout_Branch(t *testing.T) { func TestCommand_Backend_Switch(t *testing.T) { c := newCore(t, nil, nil) newBE := &mockBackend{name: "injected", model: "new-model"} - c.newBackend = func(string) (backend.Backend, error) { return newBE, nil } + c.r.newBackend = func(string) (backend.Backend, error) { return newBE, nil } evs := collectEvents(context.Background(), c, "/backend other") infos := byRole(evs, "info") @@ -2031,14 +2031,14 @@ func TestCommand_Backend_Switch(t *testing.T) { if !found { t.Errorf("/backend switch: expected 'injected' in info events; got %v", infos) } - if c.runtime.Backend != newBE { + if c.r.runtime.Backend != newBE { t.Errorf("/backend switch: backend not updated") } } func TestCommand_Backend_Error(t *testing.T) { c := newCore(t, nil, nil) - c.newBackend = func(string) (backend.Backend, error) { return nil, fmt.Errorf("no such backend") } + c.r.newBackend = func(string) (backend.Backend, error) { return nil, fmt.Errorf("no such backend") } evs := collectEvents(context.Background(), c, "/backend bad") infos := byRole(evs, "info") @@ -2187,21 +2187,21 @@ func TestRestoreSession_RoundTrip(t *testing.T) { func TestReactionTargetsResponseAndReplaces(t *testing.T) { c := newCore(t, nil, nil) - c.history = &History{messages: []backend.Message{ + c.r.history = &History{messages: []backend.Message{ {Role: "assistant", ID: "r1", Content: "first"}, {Role: "assistant", ID: "r2", Content: "second"}, }} if err := c.ReactTo("r1", "👍"); err != nil { t.Fatalf("ReactTo: %v", err) } - if len(c.history.Reactions) != 1 || c.history.Reactions[0].ResponseID != "r1" || c.history.PositiveReactions != 1 { - t.Fatalf("reaction state = %+v positive=%d", c.history.Reactions, c.history.PositiveReactions) + if len(c.r.history.Reactions) != 1 || c.r.history.Reactions[0].ResponseID != "r1" || c.r.history.PositiveReactions != 1 { + t.Fatalf("reaction state = %+v positive=%d", c.r.history.Reactions, c.r.history.PositiveReactions) } if err := c.ReactTo("r1", "👎"); err != nil { t.Fatalf("replace ReactTo: %v", err) } - if len(c.history.Reactions) != 1 || c.history.PositiveReactions != 0 || c.history.NegativeReactions != 1 { - t.Fatalf("replaced reaction state = %+v positive=%d negative=%d", c.history.Reactions, c.history.PositiveReactions, c.history.NegativeReactions) + if len(c.r.history.Reactions) != 1 || c.r.history.PositiveReactions != 0 || c.r.history.NegativeReactions != 1 { + t.Fatalf("replaced reaction state = %+v positive=%d negative=%d", c.r.history.Reactions, c.r.history.PositiveReactions, c.r.history.NegativeReactions) } ctx := c.Context() if got := ctx[len(ctx)-1].Content; !strings.Contains(got, "response r1: negative") { @@ -2323,7 +2323,7 @@ func TestSetEnv_StoredInCore(t *testing.T) { func TestSetEnv_NilDispatcher_NoPanic(t *testing.T) { c := newCore(t, nil, nil) - c.runtime.Dispatcher = nil + c.r.runtime.Dispatcher = nil // Must not panic. c.SetEnv("K", "V") env := c.sess.Env() diff --git a/agent/loop_cache_test.go b/agent/loop_cache_test.go index e38a870..8719f56 100644 --- a/agent/loop_cache_test.go +++ b/agent/loop_cache_test.go @@ -20,8 +20,8 @@ func TestResultCache_HitSkipsExec(t *testing.T) { }) c := newCore(t, be, nil) - c.runtime.ClassifyTool = func(string) bool { return true } - c.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.ClassifyTool = func(string) bool { return true } + c.r.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) { atomic.AddInt32(&execCount, 1) return "contents of a.txt", nil, nil } @@ -53,8 +53,8 @@ func TestResultCache_DifferentArgsMiss(t *testing.T) { }) c := newCore(t, be, nil) - c.runtime.ClassifyTool = func(string) bool { return true } - c.runtime.Exec = func(_ context.Context, name string, args json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.ClassifyTool = func(string) bool { return true } + c.r.runtime.Exec = func(_ context.Context, name string, args json.RawMessage) (string, []backend.ContentBlock, error) { atomic.AddInt32(&execCount, 1) return "result for " + string(args), nil, nil } @@ -77,8 +77,8 @@ func TestResultCache_SerialToolNotCached(t *testing.T) { }) c := newCore(t, be, nil) - c.runtime.ClassifyTool = func(name string) bool { return false } // all serial - c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.ClassifyTool = func(name string) bool { return false } // all serial + c.r.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { atomic.AddInt32(&execCount, 1) return "ok", nil, nil } @@ -122,8 +122,8 @@ func TestResultCache_ErrorNotCached(t *testing.T) { }) c := newCore(t, be, nil) - c.runtime.ClassifyTool = func(string) bool { return true } - c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.ClassifyTool = func(string) bool { return true } + c.r.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { n := atomic.AddInt32(&execCount, 1) if n == 1 { return "", nil, &mockErr{"transient failure"} @@ -157,8 +157,8 @@ func TestResultCache_IdenticalParallelReads(t *testing.T) { }) c := newCore(t, be, nil) - c.runtime.ClassifyTool = func(string) bool { return true } // both parallel-safe → same batch - c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.ClassifyTool = func(string) bool { return true } // both parallel-safe → same batch + c.r.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { atomic.AddInt32(&execCount, 1) return "file contents", nil, nil } diff --git a/agent/loop_errlimit_test.go b/agent/loop_errlimit_test.go index b7e4ee7..9975388 100644 --- a/agent/loop_errlimit_test.go +++ b/agent/loop_errlimit_test.go @@ -33,7 +33,7 @@ func TestConsecutiveErrors_HardLimit(t *testing.T) { be.respond = alwaysFailStream() c := newCore(t, be, nil) - c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { return "", nil, fmt.Errorf("always fails") } @@ -92,7 +92,7 @@ func TestConsecutiveErrors_SoftLimitNudge(t *testing.T) { } c := newCore(t, be, nil) - c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { return "", nil, fmt.Errorf("always fails") } @@ -148,7 +148,7 @@ func TestConsecutiveErrors_ResetOnSuccess(t *testing.T) { } c := newCore(t, be, nil) - c.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) { if name == "good_tool" { return "ok", nil, nil } diff --git a/agent/loop_parallel_test.go b/agent/loop_parallel_test.go index 94267f2..2b9bab9 100644 --- a/agent/loop_parallel_test.go +++ b/agent/loop_parallel_test.go @@ -52,8 +52,8 @@ func TestParallel_ConcurrentExecution(t *testing.T) { }) c := newCore(t, be, nil) - c.runtime.ClassifyTool = func(string) bool { return true } - c.runtime.Exec = func(ctx context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.ClassifyTool = func(string) bool { return true } + c.r.runtime.Exec = func(ctx context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) { started <- struct{}{} select { case <-gate: @@ -92,10 +92,10 @@ func TestParallel_SerialToolBreaksBatch(t *testing.T) { var order []string c := newCore(t, be, nil) - c.runtime.ClassifyTool = func(name string) bool { + c.r.runtime.ClassifyTool = func(name string) bool { return name == "read_a" || name == "read_c" } - c.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) { mu.Lock() order = append(order, name) mu.Unlock() @@ -133,10 +133,10 @@ func TestParallel_CancellationFillsRemaining(t *testing.T) { c := newCore(t, be, nil) // tool_a and tool_b are parallel-safe; tool_c is serial. - c.runtime.ClassifyTool = func(name string) bool { + c.r.runtime.ClassifyTool = func(name string) bool { return name == "tool_a" || name == "tool_b" } - c.runtime.Exec = func(execCtx context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.Exec = func(execCtx context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) { cancel() // cancel on first execution; propagates to all <-execCtx.Done() return "", nil, execCtx.Err() @@ -176,8 +176,8 @@ func TestParallel_NilClassifyToolIsSerial(t *testing.T) { var order []string c := newCore(t, be, nil) - c.runtime.ClassifyTool = nil // no classifier → all serial - c.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.ClassifyTool = nil // no classifier → all serial + c.r.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) { mu.Lock() order = append(order, name) mu.Unlock() diff --git a/agent/loop_truncate_test.go b/agent/loop_truncate_test.go index 1ff7bc3..60f8a3e 100644 --- a/agent/loop_truncate_test.go +++ b/agent/loop_truncate_test.go @@ -20,7 +20,7 @@ func TestTruncation_LargeResultTruncated(t *testing.T) { }) c := newCore(t, be, nil) - c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { return large, nil, nil } @@ -51,7 +51,7 @@ func TestTruncation_SmallResultNotTruncated(t *testing.T) { }) c := newCore(t, be, nil) - c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { return "short result", nil, nil } @@ -77,7 +77,7 @@ func TestTruncation_ErrorTruncated(t *testing.T) { }) c := newCore(t, be, nil) - c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { return "", nil, &mockErr{longErr} } @@ -107,8 +107,8 @@ func TestTruncation_CachedResultAlreadyTruncated(t *testing.T) { }) c := newCore(t, be, nil) - c.runtime.ClassifyTool = func(string) bool { return true } // read-safe → cacheable - c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { + c.r.runtime.ClassifyTool = func(string) bool { return true } // read-safe → cacheable + c.r.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) { return large, nil, nil } diff --git a/agent/reasoning.go b/agent/reasoning.go new file mode 100644 index 0000000..06437a9 --- /dev/null +++ b/agent/reasoning.go @@ -0,0 +1,36 @@ +package agent + +import ( + "sync" + "sync/atomic" + + "ollie/backend" + "ollie/tools" +) + +// reasoning holds the state of the current reasoning entity (the "agent" +// in the traditional sense). It is swappable: when the user runs /agent, +// a new reasoning is built from the new agent config while the session +// host remains stable. +type reasoning struct { + history *History + runtime *Runtime + cfg agentConfig // per-turn config built from runtime + agentName string + agentsDir string + baseLayers []string // system prompt layers for /agent reloads + promptEnvExtra []string // PRIME_* vars for prompt resolution + newDispatcher func() tools.Dispatcher + newBackend func(string) (backend.Backend, error) + currentAction atomic.Pointer[actionHandle] + warnedContext bool + resultCache sync.Map +} + +// Backend returns the active backend from the runtime. +func (r *reasoning) Backend() backend.Backend { + if r.runtime == nil { + return nil + } + return r.runtime.Backend +}