package agent import ( "context" "crypto/rand" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "runtime/debug" "slices" "strconv" "strings" "sync" "sync/atomic" "syscall" "time" "github.com/simonfxr/pubsub" "ollie/backend" olog "ollie/log" "ollie/paths" "ollie/tools" ) // toolClassifier reports whether a named tool is safe to run concurrently // with other read-class tools. nil means treat all tools as serial. type toolClassifier func(name string) bool // BuildRuntime constructs a Runtime from a pre-configured Dispatcher and // optional agent config. cwd sets the working directory reported in the // system prompt; if empty, the process working directory is used. // env provides additional environment variables injected into prompt resolution // subprocesses (e.g. OLLIE_SESSION_ID=xxx). // The caller is responsible for registering all servers on d before calling this. func BuildRuntime(cfg *Config, d tools.Dispatcher, cwd string, env []string, baseLayers ...string) *Runtime { var messages []string var allToolInfos []tools.ToolInfo var allTools []backend.Tool serverOf := make(map[string]string) if cfg == nil || cfg.ToolsEnabled() { var listErr error allToolInfos, listErr = d.ListTools() if listErr != nil { messages = append(messages, fmt.Sprintf("list tools: %v", listErr)) } for _, t := range allToolInfos { serverOf[t.Name] = t.Server } // Only built-in executors (with InputSchema) become backend tools. // Named tool scripts are promoted via the tool registry and appear in the preamble. allTools = toolInfosToBackend(allToolInfos) // Append named tool scripts for preamble listing only. allToolInfos = append(allToolInfos, tools.DiscoverTools()...) } hooks := Hooks{} var preamble string var genParams backend.GenerationParams var maxSteps int if cfg != nil { for k, v := range cfg.Hooks { hooks[k] = []string(v) } if resolved, err := resolvePrompt(cfg.Prompt, cwd, env); err != nil { fmt.Fprintf(os.Stderr, "resolve prompt: %v\n", err) } else { preamble = resolved } genParams = backend.GenerationParams{ MaxTokens: cfg.MaxTokens, MaxCompletionTokens: cfg.MaxCompletionTokens, Temperature: cfg.Temperature, TopP: cfg.TopP, TopK: cfg.TopK, MinP: cfg.MinP, TopA: cfg.TopA, FrequencyPenalty: cfg.FrequencyPenalty, PresencePenalty: cfg.PresencePenalty, RepetitionPenalty: cfg.RepetitionPenalty, ThinkingBudget: cfg.Reasoning, ReasoningEffort: cfg.ReasoningEffort, IncludeReasoning: cfg.IncludeReasoning, ResponseFormat: cfg.ResponseFormat, Stop: cfg.Stop, Verbosity: cfg.Verbosity, } maxSteps = cfg.MaxSteps if len(cfg.AllowTools) > 0 { if srv, ok := d.GetServer("execute"); ok { if rs, ok := srv.(tools.ToolRestrictionSetter); ok { rs.SetAllowTools(cfg.AllowTools) } } } } exec := func(ctx context.Context, name string, args json.RawMessage) (string, []backend.ContentBlock, error) { // Dynamically resolve the server for this tool — handles // lazy-promoted tools that appeared after BuildRuntime. infos, listErr := d.ListTools() if listErr != nil { return "", nil, listErr } server := "" for _, t := range infos { if t.Name == name { server = t.Server break } } if server == "" { return "", nil, fmt.Errorf("unknown tool: %s", name) } raw, err := d.Dispatch(ctx, server, name, args) if err != nil { return "", nil, err } text, blocks, isErr := extractToolResult(raw) if isErr { return "", nil, fmt.Errorf("%s", text) } return text, blocks, nil } var classify toolClassifier if srv, ok := d.GetServer("execute"); ok { if pc, ok := srv.(tools.ParallelClassifier); ok { classify = pc.IsParallelRead } } var tierFn func(string, json.RawMessage) ResultTier if srv, ok := d.GetServer("execute"); ok { if tc, ok := srv.(tools.TierClassifier); ok { tierFn = func(name string, args json.RawMessage) ResultTier { switch tc.ResultTierArgs(name, args) { case "cold": return TierCold case "warm": return TierWarm default: return TierHot } } } } var backendName, modelName, compactionModel string if cfg != nil { backendName = cfg.Backend modelName = cfg.Model compactionModel = cfg.CompactionModel } // Prepend base layers (system prompt, operational model, environment) // before the agent-specific preamble. if len(baseLayers) > 0 { var prefix strings.Builder for _, layer := range baseLayers { if layer != "" { prefix.WriteString(layer) prefix.WriteByte('\n') } } if prefix.Len() > 0 { preamble = prefix.String() + preamble } } // Append compact tool surface listing (name + description). // Full prompts are available on-demand via /tools write (net/dns pattern). var toolListing strings.Builder for _, ti := range allToolInfos { if ti.Description != "" && ti.Server == "" { // Only named tool scripts (Server==""), not built-in executors fmt.Fprintf(&toolListing, "- **%s** — %s\n", ti.Name, ti.Description) } } if toolListing.Len() > 0 { preamble += "\n# Available Tools\n\n" + toolListing.String() } return &Runtime{ Dispatcher: d, Tools: allTools, Exec: exec, ClassifyTool: classify, ClassifyTier: tierFn, Hooks: hooks, Preamble: preamble, GenParams: genParams, MaxSteps: maxSteps, CfgBackend: backendName, CfgModel: modelName, CompactionModel: compactionModel, Messages: messages, } } // DefaultPromptsDir returns the default directory for prompt templates. func DefaultPromptsDir() string { return paths.CfgDir() + "/prompts" } // PromptsDirs returns all prompt directories from OLLIE_PROMPTS_PATH (colon-separated). func PromptsDirs() []string { if p := os.Getenv("OLLIE_PROMPTS_PATH"); p != "" { return strings.Split(p, ":") } return []string{DefaultPromptsDir()} } // AgentsDirs returns all agent directories from OLLIE_AGENTS_PATH (colon-separated). func AgentsDirs() []string { if p := os.Getenv("OLLIE_AGENTS_PATH"); p != "" { return strings.Split(p, ":") } return []string{paths.CfgDir() + "/agents"} } // AgentConfigPath resolves the config file path for a named agent. // It searches all directories in OLLIE_AGENTS_PATH, falling back to agentsDir. func AgentConfigPath(agentsDir, name string) string { for _, dir := range AgentsDirs() { p := dir + "/" + name + ".json" if _, err := os.Stat(p); err == nil { return p } } return agentsDir + "/" + name + ".json" } // NewSessionID generates a unique, lexicographically sortable session identifier. // Format: - — sortable by creation time, unique // even if two sessions are created within the same nanosecond. func NewSessionID() string { b := make([]byte, 3) rand.Read(b) //nolint:errcheck return strconv.FormatInt(time.Now().UnixNano(), 10) + "-" + fmt.Sprintf("%06x", b) } // NewResponseID generates a unique identifier for a single assistant response. func NewResponseID() string { b := make([]byte, 3) rand.Read(b) //nolint:errcheck return "resp_" + strconv.FormatInt(time.Now().UnixNano(), 10) + "_" + fmt.Sprintf("%06x", b) } // NewReactionID generates a unique identifier for a user reaction. func NewReactionID() string { b := make([]byte, 3) rand.Read(b) //nolint:errcheck return "react_" + strconv.FormatInt(time.Now().UnixNano(), 10) + "_" + fmt.Sprintf("%06x", b) } // infoEvent wraps a plain-text message as an info Event. func infoEvent(text string) Event { return Event{Role: "info", Content: text + "\n"} } // actionHandle holds the cancel function for the current agent turn. type actionHandle struct { cancel context.CancelCauseFunc } // AgentCoreConfig is the configuration for creating an agent. type AgentCoreConfig struct { Backend backend.Backend ModelName string // if non-empty, overrides backend's default model AgentName string AgentsDir string SessionsDir string SessionID string Uname string // immutable user principal for 9P identity CWD string // working directory for tool execution and system prompt Session *Session Runtime *Runtime NewDispatcher func() tools.Dispatcher NewBackend func(string) (backend.Backend, error) // if nil, defaults to backend.NewWithName Log *olog.Logger // if nil, logging is disabled // MaxSteps overrides the agent JSON maxSteps when non-zero. // 0 means use the value from the agent config (or unlimited if absent). MaxSteps int // ReadPlanStep, if non-nil, is called to get the next unchecked plan step. // When provided, it is used directly instead of reading from the filesystem. ReadPlanStep func() string // ListHandlers provides harness-specific list implementations for slash // commands such as /skills, /tools, /agents. Keyed by command name without // the leading slash. Commands with no entry emit nothing. ListHandlers map[string]func() []string // PromptEnvExtra holds PRIME_* env vars for prompt resolution. // For remote sessions these come from HostInfo; for local, from PromptEnv(). // Stored so /agent reloads use the correct values. PromptEnvExtra []string // Remote is the SSH target for remote execution (empty = local). Remote string // BaseLayers are the system prompt, operational model, and environment // block prepended to the agent preamble. Stored so /agent reloads // preserve the server-injected context. BaseLayers []string } // agent is the Core implementation. It owns all agent and session state // but has no knowledge of how output is rendered. type agent struct { session *Session runtime *Runtime cfg agentConfig // per-turn config built from runtime; set in executeTurn log *olog.Logger agentName string agentsDir string sessionsDir string sessionID string uname 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 cwd string 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 fifo PromptFIFO bus *pubsub.Bus pendingInject atomic.Pointer[string] mu sync.RWMutex state string // "idle", "thinking", "calling: " reply string // assistant text from the most recently completed turn envMu sync.RWMutex env map[string]string // session-scoped env vars 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 saveMu sync.Mutex 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 // session. The counter is monotonically increasing and never resets. // Blocked calls (pre-tool hook exit 2) are not counted. func (s *agent) ToolCallCount() int64 { return s.toolCallCount.Load() } // SetEnv stores a session-scoped variable and propagates it to the execute server. func (s *agent) SetEnv(key, value string) { s.envMu.Lock() if s.env == nil { s.env = make(map[string]string) } s.env[key] = value s.envMu.Unlock() if s.runtime == nil || s.runtime.Dispatcher == nil { return } if srv, ok := s.runtime.Dispatcher.GetServer("execute"); ok { if es, ok := srv.(tools.EnvSetter); ok { es.SetEnv(key, value) } } } // pushSessionEnv injects OLLIE_SESSION_ID into the execute server subprocess env. func (s *agent) pushSessionEnv() { if s.runtime == nil || s.runtime.Dispatcher == nil || s.sessionID == "" { return } if srv, ok := s.runtime.Dispatcher.GetServer("execute"); ok { if es, ok := srv.(tools.EnvSetter); ok { es.SetEnv("OLLIE_SESSION_ID", s.sessionID) if s.uname != "" { es.SetEnv("OLLIE_UNAME", s.uname) } } } } // pushLockDir sets the flock directory on the execute server to the session tmpdir. func (s *agent) pushLockDir() { if s.runtime == nil || s.runtime.Dispatcher == nil || s.sessionID == "" { return } } var _ Core = (*agent)(nil) // compile-time interface check var sweepTmpOnce sync.Once // ollieTmpDir returns the base temp directory for session tmpdirs. func ollieTmpDir() string { if p := os.Getenv("OLLIE_TMP_PATH"); p != "" { return p } return filepath.Join(os.TempDir(), "ollie") } // readNextUnchecked reads a plan file and returns the first unchecked step. func readNextUnchecked(path string) string { data, err := os.ReadFile(path) if err != nil || len(data) == 0 { return "" } return NextUncheckedStep(data) } // NextUncheckedStep returns the first unchecked step from plan bytes. func NextUncheckedStep(data []byte) string { for _, line := range strings.Split(string(data), "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "- [ ]") { return strings.TrimSpace(trimmed[5:]) } } return "" } // sweepStaleTmpDirs removes session tmpdirs left by a previous crash. // All session tmpdirs are owned by a single process, so anything // present at startup is stale. func sweepStaleTmpDirs() { sweepTmpOnce.Do(func() { base := ollieTmpDir() os.RemoveAll(base) //nolint:errcheck os.MkdirAll(base, 0700) //nolint:errcheck }) } // NewAgentCore creates an agent from the given configuration. func NewAgentCore(cfg AgentCoreConfig) Core { sweepStaleTmpDirs() if cfg.ModelName != "" { cfg.Backend.SetModel(cfg.ModelName) } if cfg.NewBackend == nil { cfg.NewBackend = backend.NewWithName } rt := cfg.Runtime if rt == nil { rt = &Runtime{} } // Store the backend on the runtime so it's the single source of truth. rt.Backend = cfg.Backend // A non-zero MaxSteps in AgentCoreConfig takes precedence over the // value loaded from the agent JSON. if cfg.MaxSteps > 0 { rt.MaxSteps = cfg.MaxSteps } var readPlanStep func() string if cfg.SessionID != "" { os.MkdirAll(filepath.Join(ollieTmpDir(), cfg.SessionID), 0700) //nolint:errcheck } if cfg.ReadPlanStep != nil { readPlanStep = cfg.ReadPlanStep } log := cfg.Log if log == nil { log = olog.NewWriter("core", olog.LevelError+1, io.Discard, io.Discard) } a := &agent{ session: cfg.Session, runtime: rt, log: log, auditLog: log.Sub("audit"), agentName: cfg.AgentName, agentsDir: cfg.AgentsDir, sessionsDir: cfg.SessionsDir, sessionID: cfg.SessionID, uname: cfg.Uname, cwd: paths.ExpandHome(cfg.CWD), remote: cfg.Remote, promptEnvExtra: cfg.PromptEnvExtra, baseLayers: cfg.BaseLayers, startupMessages: rt.Messages, newDispatcher: cfg.NewDispatcher, newBackend: cfg.NewBackend, readPlanStep: readPlanStep, listHandlers: cfg.ListHandlers, state: "idle", bus: pubsub.NewBus(), } a.changeCond = sync.NewCond(&a.changeMu) 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{ "session_id": a.sessionID, "cwd": a.CWD(), "model": a.runtime.Backend.Model(), "error_type": errType, "error": errMsg, }, a.log) } a.pushSessionEnv() a.pushLockDir() return a } // Close releases resources for this session, including its tmpdir. func (s *agent) Close() { s.log.Debug("Close() session=%q", s.sessionID) s.flushSave() if s.runtime != nil && s.runtime.Dispatcher != nil { if srv, ok := s.runtime.Dispatcher.GetServer("execute"); ok { if c, ok := srv.(interface{ Close() }); ok { s.log.Debug("Close() calling execute.Close()") c.Close() } } } if s.sessionID != "" { os.RemoveAll(filepath.Join(ollieTmpDir(), s.sessionID)) //nolint:errcheck } } // execServer returns the execute server if available, or nil. func (s *agent) execServer() interface{} { if s.runtime == nil || s.runtime.Dispatcher == nil { return nil } srv, _ := s.runtime.Dispatcher.GetServer("execute") return srv } func (s *agent) Detach() bool { if srv := s.execServer(); srv != nil { if d, ok := srv.(interface{ Detach() bool }); ok { return d.Detach() } } return false } func (s *agent) ListDetached() []DetachedInfo { if srv := s.execServer(); srv != nil { type listDetacher interface { ListDetachedRaw() []any } if ld, ok := srv.(listDetacher); ok { raw := ld.ListDetachedRaw() out := make([]DetachedInfo, 0, len(raw)) for _, r := range raw { if m, ok := r.(map[string]any); ok { di := DetachedInfo{} if v, ok := m["pid"].(int); ok { di.PID = v } if v, ok := m["command"].(string); ok { di.Command = v } if v, ok := m["started"].(int64); ok { di.Started = v } if v, ok := m["exited"].(bool); ok { di.Exited = v } if v, ok := m["exit_code"].(int); ok { di.ExitCode = v } out = append(out, di) } } return out } } return nil } func (s *agent) SignalDetached(pid, signal int) error { if srv := s.execServer(); srv != nil { type signaler interface { SignalDetached(int, syscall.Signal) error } if sg, ok := srv.(signaler); ok { return sg.SignalDetached(pid, syscall.Signal(signal)) } } return fmt.Errorf("no execute server available") } func (s *agent) GetDetachedOutput(pid int) (string, error) { if srv := s.execServer(); srv != nil { type outputGetter interface { GetDetachedOutput(int) (string, error) } if og, ok := srv.(outputGetter); ok { return og.GetDetachedOutput(pid) } } return "", fmt.Errorf("no execute server available") } func (s *agent) DismissDetached(pid int) bool { if srv := s.execServer(); srv != nil { type dismisser interface { DismissDetached(int) bool } if d, ok := srv.(dismisser); ok { return d.DismissDetached(pid) } } return false } func (s *agent) InjectSystemEvent(content string) { s.Queue("\n" + content + "\n") } // classifyReaction returns a category and description for a reaction emoji. func classifyReaction(emoji string) (category, description string, positive bool) { switch emoji { case "👍", "✅": return "positive", "The response was good. Keep doing what you're doing.", true case "🚀", "🎉": return "excellent", "The response was exactly what was wanted.", true case "👎", "❌": return "negative", "The response was wrong or unhelpful.", false case "💩", "🤬": return "terrible", "The response was fundamentally wrong. Stop this approach entirely and reassess from scratch.", false case "🤔": return "confused", "The response was unclear or confusing.", false default: return "unknown", "", false } } func (s *agent) Reactions() map[string]string { result := make(map[string]string) if s.session == nil { return result } for _, reaction := range s.session.Reactions { result[reaction.ResponseID] = reaction.Emoji } return result } func (s *agent) React(emoji string) { _ = s.ReactTo("", emoji) } func (s *agent) ReactTo(responseID, emoji string) error { if s.session == nil { return fmt.Errorf("no active session") } category, _, _ := classifyReaction(emoji) if category == "unknown" { return fmt.Errorf("unsupported reaction: %s", emoji) } if responseID == "" { for i := len(s.session.messages) - 1; i >= 0; i-- { if s.session.messages[i].Role == "assistant" { responseID = s.session.messages[i].ID break } } } if responseID == "" { return fmt.Errorf("no assistant response to react to") } found := false for i := range s.session.messages { if s.session.messages[i].Role == "assistant" && s.session.messages[i].ID == responseID { found = true break } } if !found { return fmt.Errorf("assistant response not found: %s", responseID) } reaction := Reaction{ID: NewReactionID(), ResponseID: responseID, Emoji: emoji, Category: category, CreatedAt: time.Now()} replaced := false for i := range s.session.Reactions { if s.session.Reactions[i].ResponseID == responseID { if s.session.Reactions[i].Emoji == emoji { return nil } s.session.Reactions[i] = reaction replaced = true break } } if !replaced { s.session.Reactions = append(s.session.Reactions, reaction) } s.session.recomputeReactionCounts() s.saveSession() return nil } func (s *agent) AgentName() string { v := s.agentName s.log.Debug("AgentName() = %q", v) return v } func (s *agent) BackendName() string { v := s.runtime.Backend.Name() s.log.Debug("BackendName() = %q", v) return v } func (s *agent) ModelName() string { v := s.runtime.Backend.Model() s.log.Debug("ModelName() = %q", v) return v } func (s *agent) State() string { s.mu.RLock() defer s.mu.RUnlock() return s.state } func (s *agent) notifyChange() { s.changeMu.Lock() s.changeCond.Broadcast() s.changeMu.Unlock() } // WaitChange blocks until the named field changes from current, then returns // the new value. Returns ("", false) if ctx is cancelled. func (s *agent) WaitChange(ctx context.Context, field, current string) (string, bool) { read := func() string { switch field { case WatchState: return s.State() case WatchUsage: return s.Usage() case WatchCtxSz: return s.CtxSz() case WatchCWD: return s.CWD() case WatchAgent: return s.AgentName() } return "" } // context.AfterFunc fires in a separate goroutine when ctx is done, // broadcasting to unblock any waiters. stop := context.AfterFunc(ctx, func() { s.changeMu.Lock() s.changeCond.Broadcast() s.changeMu.Unlock() }) defer stop() s.changeMu.Lock() defer s.changeMu.Unlock() for ctx.Err() == nil { if v := read(); v != current { return v, true } s.changeCond.Wait() } return "", false } func (s *agent) setState(state string) { s.mu.Lock() s.state = state s.mu.Unlock() s.log.Debug("state -> %q", state) s.notifyChange() } func (s *agent) Reply() string { s.mu.RLock() defer s.mu.RUnlock() s.log.Debug("Reply() len=%d", len(s.reply)) return s.reply } // CWD returns the current working directory for tool execution. func (s *agent) CWD() string { if s.cwd != "" { s.log.Debug("CWD() = %q", s.cwd) return s.cwd } wd, _ := os.Getwd() s.log.Debug("CWD() = %q (from getwd)", wd) return wd } // SetCWD changes the working directory for tool execution and updates the // system prompt. Returns an error if the path does not exist. func (s *agent) SetCWD(dir string) error { s.log.Debug("SetCWD(%q)", dir) dir = paths.ExpandHome(dir) if dir != "" { if _, err := os.Stat(dir); err != nil { return fmt.Errorf("cwd: %w", err) } } oldCwd := s.cwd s.cwd = dir // Update cwd references in the system prompt. if oldCwd != "" && dir != "" && oldCwd != dir { s.runtime.Preamble = strings.ReplaceAll(s.runtime.Preamble, oldCwd, dir) } // Propagate to any tool server that knows how to handle it (e.g. execute). if s.runtime != nil && s.runtime.Dispatcher != nil { if srv, ok := s.runtime.Dispatcher.GetServer("execute"); ok { if ws, ok := srv.(tools.CWDSetter); ok { ws.SetCWD(dir) } } } s.notifyChange() return nil } // SetSessionID renames the session. It updates the in-memory ID, renames // persisted files on disk, and propagates to the execute server env. func (s *agent) SetSessionID(newID string) error { s.log.Debug("SetSessionID(%q) old=%q", newID, s.sessionID) oldID := s.sessionID if oldID == newID { return nil } // Rename active persisted files on disk. if s.sessionsDir != "" && oldID != "" { for _, suffix := range []string{".json", ".compaction.jsonl"} { oldPath := s.activeSessionPath(oldID, suffix) if _, err := os.Stat(oldPath); err == nil { if err := os.Rename(oldPath, s.activeSessionPath(newID, suffix)); err != nil { return fmt.Errorf("rename %s: %w", suffix, err) } } } } s.sessionID = newID // Update session ID references in the system prompt. s.runtime.Preamble = strings.ReplaceAll(s.runtime.Preamble, oldID, newID) // Rename tmpdir so isread markers remain valid after rename. oldTemp := filepath.Join(ollieTmpDir(), oldID) newTemp := filepath.Join(ollieTmpDir(), newID) if _, err := os.Stat(oldTemp); err == nil { os.Rename(oldTemp, newTemp) //nolint:errcheck } s.pushSessionEnv() return nil } // defaultContextLength is used when the backend cannot report the model's // actual context window (e.g. CodeWhisperer). 128k tokens is a safe default // for modern models. const defaultContextLength = 128000 const defaultToolResultMaxBytes = 131072 // autoCompactLimit returns the token threshold for auto-compaction (75%). func (s *agent) autoCompactLimit(ctx context.Context) int { ctxLen := s.runtime.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } return ctxLen * 3 / 4 } // autoWarnLimit returns the token threshold for a context-usage warning (60%). func (s *agent) autoWarnLimit(ctx context.Context) int { ctxLen := s.runtime.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } return ctxLen * 3 / 5 } // spawnContext assembles the agent context injected at each session refresh // point (session start, post-clear, post-compaction). It combines the // agent-specific prompt with any agentSpawn hook output. func (s *agent) spawnContext(ctx context.Context) string { result := s.runtime.Hooks.Run(ctx, HookAgentSpawn, map[string]string{ "session_id": s.sessionID, "agent": s.agentName, "cwd": s.CWD(), "model": s.runtime.Backend.Model(), }, s.log) if result.Warning != "" { s.emit(infoEvent(result.Warning)) } if sum := result.Summary(); sum != "" { s.emit(infoEvent("agentSpawn: " + sum)) } var parts []string if result.Context != "" { parts = append(parts, result.Context) } return strings.Join(parts, "\n\n---\n\n") } // runCompact executes a full compaction cycle: pre-hook, compact, spawn-context // re-injection, post-hook. Returns (n compacted, error). Returns (0, nil) if // the pre-hook blocked or there was nothing to compact. Caller manages setState. func (s *agent) runCompact(ctx context.Context, trigger string) (int, error) { payload := map[string]string{"session_id": s.sessionID, "trigger": trigger, "cwd": s.CWD()} pre := s.runtime.Hooks.Run(ctx, HookPreCompact, payload, s.log) if pre.Warning != "" { s.emit(infoEvent(pre.Warning)) } if sum := pre.Summary(); sum != "" { s.emit(infoEvent("preCompact: " + sum)) } if pre.Blocked { s.emit(infoEvent("compact cancelled by hook")) return 0, nil } if pre.Context != "" { s.session.appendUserMessage(pre.Context) } // Use a cheaper model for compaction if configured. compactModel := resolveCompactionModel(s.runtime.CompactionModel, s.runtime.Backend) origModel := s.runtime.Backend.Model() if compactModel != "" && compactModel != origModel { s.runtime.Backend.SetModel(compactModel) defer s.runtime.Backend.SetModel(origModel) } n, _, err := s.session.compact(ctx, s.runtime.Backend) if err != nil { return 0, err } if n > 0 { s.auditLog.Debug("compact: removed %d messages trigger=%s session=%s", n, trigger, s.sessionID) s.warnedContext = false if sc := s.spawnContext(ctx); sc != "" { s.session.appendUserMessage(sc) } } post := s.runtime.Hooks.Run(ctx, HookPostCompact, payload, s.log) if post.Warning != "" { s.emit(infoEvent(post.Warning)) } if sum := post.Summary(); sum != "" { s.emit(infoEvent("postCompact: " + sum)) } if post.Context != "" { s.session.appendUserMessage(post.Context) } return n, nil } func (s *agent) activeSessionPath(id, suffix string) string { return filepath.Join(s.sessionsDir, "active", id+suffix) } func (s *agent) saveSession() { s.saveMu.Lock() s.saveDirty = true if s.saveTimer == nil { s.saveTimer = time.AfterFunc(2*time.Second, s.flushSave) } s.saveMu.Unlock() } // flushSave immediately persists the session if dirty. func (s *agent) flushSave() { s.saveMu.Lock() dirty := s.saveDirty s.saveDirty = false if s.saveTimer != nil { s.saveTimer.Stop() s.saveTimer = nil } s.saveMu.Unlock() if !dirty { return } if s.session == nil || s.sessionID == "" || s.sessionsDir == "" { return } path := s.activeSessionPath(s.sessionID, ".json") if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { s.log.Error("session save: %v", err) return } if err := s.session.saveToFull(path, s.sessionID, s.agentName, s.runtime.Backend.Name(), s.runtime.Backend.Model(), s.CWD(), s.remote); err != nil { s.log.Error("session save: %v", err) } } // SaveSession writes the current session state to the given path, including // backend and model metadata for external restore. func (s *agent) SaveSession(path string) error { s.mu.RLock() defer s.mu.RUnlock() if s.session == nil { return fmt.Errorf("no active session") } return s.session.saveToFull(path, s.sessionID, s.agentName, s.runtime.Backend.Name(), s.runtime.Backend.Model(), s.CWD(), s.remote) } func (s *agent) getActionCancel() context.CancelCauseFunc { if a := s.currentAction.Load(); a != nil { return a.cancel } return nil } // Interrupt cancels the current in-progress agent turn. // Returns true if an action was running and was cancelled. func (s *agent) Interrupt(cause error) bool { s.log.Debug("Interrupt() cause=%v", cause) if cancel := s.getActionCancel(); cancel != nil { cancel(cause) return true } return false } func (s *agent) Inject(prompt string) { // If an inject is already pending, fall back to the normal FIFO so nothing // is lost. Use CompareAndSwap to avoid a race between the nil check and store. if !s.pendingInject.CompareAndSwap(nil, &prompt) { s.fifo.Push(prompt) return } s.emit(Event{Role: "info", Content: "\n"}) s.emit(Event{Role: "user", Content: prompt}) } func (s *agent) injectRewrite(prompt string) { s.pendingInject.Store(&prompt) s.emit(Event{Role: "info", Content: "\n"}) s.emit(Event{Role: "user", Content: prompt}) } func (s *agent) Queue(prompt string) { s.log.Debug("Queue() len=%d", len(prompt)) s.fifo.Push(prompt) s.bus.Publish("queued", prompt) if !s.IsRunning() { go s.drainQueue() } } func (s *agent) drainQueue() { if prompt, ok := s.fifo.Pop(); ok { s.Submit(context.Background(), prompt) } } func (s *agent) Bus() *pubsub.Bus { return s.bus } func (s *agent) emit(ev Event) { s.bus.Publish("event", ev) } func (s *agent) PopQueue() (string, bool) { v, ok := s.fifo.Pop() s.log.Debug("PopQueue() ok=%v len=%d", ok, len(v)) return v, ok } func (s *agent) IsRunning() bool { v := s.currentAction.Load() != nil s.log.Debug("IsRunning() = %v", v) return v } func (s *agent) CtxSz() string { if s.session == nil { s.log.Debug("CtxSz() no session") return "no active session" } ctxLen := s.runtime.Backend.ContextLength(context.Background()) if ctxLen <= 0 { ctxLen = defaultContextLength } estimated := s.session.estimateTokens() pct := estimated * 100 / ctxLen v := fmt.Sprintf("%d / %d (%d%%)", estimated, ctxLen, pct) s.log.Debug("CtxSz() = %q", v) return v } func (s *agent) Cost() string { if s.session == nil { return "no active session" } return fmt.Sprintf("costLast=$%.4f\ncostSession=$%.4f\n", s.session.LastTurnCostUSD, s.session.SessionCostUSD) } func (s *agent) Usage() string { if s.session == nil { s.log.Debug("Usage() no session") return "no active session" } str := fmt.Sprintf("%d in, %d out, %d requests", s.session.TotalInputTokens, s.session.TotalOutputTokens, s.session.TotalRequests) if s.session.TotalCachedInputTokens > 0 { str += fmt.Sprintf(", %d cached", s.session.TotalCachedInputTokens) } if s.session.Estimated { str += " [estimated]" } s.log.Debug("Usage() = %q", str) return str } func (s *agent) Context() []backend.Message { s.mu.RLock() var msgs []backend.Message if s.session != nil { msgs = slices.Clone(s.session.history()) } s.mu.RUnlock() if s.runtime.Preamble != "" { msgs = append([]backend.Message{{Role: "system", Content: s.runtime.Preamble}}, msgs...) } return msgs } func (s *agent) SystemPrompt() string { s.log.Debug("SystemPrompt() len=%d", len(s.runtime.Preamble)) return s.runtime.Preamble } func (s *agent) GenerationParams() backend.GenerationParams { s.mu.RLock() defer s.mu.RUnlock() return s.runtime.GenParams } func (s *agent) CompactionModel() string { s.mu.RLock() defer s.mu.RUnlock() return s.runtime.CompactionModel } func (s *agent) SetCompactionModel(model string) { s.mu.Lock() defer s.mu.Unlock() s.runtime.CompactionModel = model } func (s *agent) SetGenerationParams(params backend.GenerationParams) error { if s.IsRunning() { return fmt.Errorf("cannot change params while agent is running") } s.mu.Lock() s.runtime.GenParams = params s.mu.Unlock() return nil } func (s *agent) ListModels() string { s.log.Debug("ListModels()") models := s.runtime.Backend.Models(context.Background()) slices.Sort(models) return strings.Join(models, "\n") } // firstSentence returns the first sentence of s (up to the first period or // newline), trimmed. Falls back to s truncated at 80 chars if no sentence end // is found. func firstSentence(s string) string { for i, r := range s { if r == '.' || r == '\n' { return strings.TrimSpace(s[:i+1]) } } if len(s) > 80 { return s[:77] + "..." } return s } // Submit implements Core. It processes one line of user input: slash commands // and shell shortcuts are dispatched immediately; any other input // starts an agent turn that streams events to the bus. If a turn is already // in progress the prompt is queued as an in-stream interruption instead. // // Continuations (post-turn hook context, unconsumed inject, FIFO drain) are // handled via an explicit loop rather than recursion to avoid stack growth. func (s *agent) Submit(ctx context.Context, input string) { defer func() { if r := recover(); r != nil { s.log.Error("panic: %v\n%s", r, debug.Stack()) if a := s.currentAction.Swap(nil); a != nil { a.cancel(fmt.Errorf("%v", r)) } s.setState("idle") s.emit(Event{Role: "error", Content: fmt.Sprintf("%v", r)}) } }() s.log.Debug("Submit() input_len=%d running=%v", len(input), s.IsRunning()) if input == "" { return } // Fast path: inject and FIFO push use atomics and are safe without // the submit lock. Handle them before acquiring submitMu so they // don't block behind a long-running turn or command. if s.IsRunning() { if s.handleCommand(ctx, input) { return } s.fifo.Push(input) return } // Serialize commands and turns so that e.g. a /compact arriving via // ctl cannot race with an executeTurn arriving via prompt. s.submitMu.Lock() defer s.submitMu.Unlock() if s.handleCommand(ctx, input) { return } if s.IsRunning() { s.fifo.Push(input) return } for input != "" && ctx.Err() == nil { input = s.executeTurn(ctx, input) } } // executeTurn runs a single agent turn and returns the next prompt to execute, // or "" if there is nothing more to do. func (s *agent) executeTurn(ctx context.Context, input string) string { s.emit(Event{Role: "user", Content: input}) hookResult := s.runtime.Hooks.Run(ctx, HookPreTurn, map[string]string{ "session_id": s.sessionID, "cwd": s.CWD(), "prompt": input, }, s.log) if hookResult.Blocked { s.emit(infoEvent("hook blocked prompt")) return "" } if hookResult.Warning != "" { s.emit(infoEvent(hookResult.Warning)) } if sum := hookResult.Summary(); sum != "" { s.emit(infoEvent("preTurn: " + sum)) } if hookResult.Context != "" { input += "\n" + hookResult.Context } // Snapshot session state before this turn modifies it. Restored on failure // so the session is clean for the next attempt. snapSession := s.session var snapMessages []backend.Message if s.session != nil { snapMessages = cloneMessages(s.session.messages) } if s.session == nil { for _, msg := range s.startupMessages { s.log.Debug("startup: %s", msg) s.emit(infoEvent(msg)) } s.startupMessages = nil s.session = newSession(input) if sc := s.spawnContext(ctx); sc != "" { s.session.appendUserMessage(sc) } s.session.appendUserMessage(input) } else { s.session.appendUserMessage(input) } actCtx, actCancel := context.WithCancelCause(ctx) handle := &actionHandle{cancel: actCancel} s.currentAction.Store(handle) s.setState("thinking") s.auditLog.Debug("turn: start input=%s session=%s", auditTruncate(input), s.sessionID) // Build per-turn agentConfig from the current runtime. s.cfg = agentConfig{ Backend: s.runtime.Backend, preamble: s.runtime.Preamble, Tools: s.runtime.Tools, Exec: s.runtime.Exec, ClassifyTool: s.runtime.ClassifyTool, ClassifyTier: s.runtime.ClassifyTier, GenerationParams: s.runtime.GenParams, MaxSteps: s.runtime.MaxSteps, ReadPlanStep: s.readPlanStep, TurnError: s.turnError, } var replyBuf strings.Builder s.cfg.Output = func(ev Event) { switch ev.Role { case "assistant": replyBuf.WriteString(ev.Content) case "call": s.setState("calling: " + ev.Name) s.auditLog.Debug("call: %s %s", ev.Name, auditTruncate(string(ev.Content))) case "tool": s.auditLog.Debug("result: %s %s", ev.Name, auditTruncate(ev.Content)) case "state": s.setState(ev.Content) case "limitretry": s.setState("limitretry") case "error": s.auditLog.Debug("error: %s", ev.Content) } if ev.Role == "usage" && s.session != 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) s.session.addUsage(backend.Usage{ InputTokens: in, CachedInputTokens: cached, CacheCreationTokens: creation, OutputTokens: out, CostUSD: costUSD, }, est != 0) s.notifyChange() if maxCostStr := os.Getenv("OLLIE_MAX_SESSION_COST"); maxCostStr != "" { if limit, ferr := strconv.ParseFloat(maxCostStr, 64); ferr == nil && limit > 0 && s.session.SessionCostUSD >= limit { s.emit(infoEvent(fmt.Sprintf("spending cap $%.2f reached — stopping", limit))) s.Interrupt(ErrInterrupted) } } } s.emit(ev) } s.cfg.PopInject = func() string { if p := s.pendingInject.Swap(nil); p != nil { return *p } return "" } s.cfg.PreTool = func(ctx context.Context, name string, args json.RawMessage) HookResult { return s.runtime.Hooks.Run(ctx, HookPreTool, map[string]string{ "session_id": s.sessionID, "cwd": s.CWD(), "tool": name, "args": string(args), }, s.log) } s.cfg.PostTool = func(ctx context.Context, name string, args json.RawMessage, result string) HookResult { return s.runtime.Hooks.Run(ctx, HookPostTool, map[string]string{ "session_id": s.sessionID, "cwd": s.CWD(), "tool": name, "args": string(args), "result": result, }, s.log) } s.cfg.IncrToolCallCount = func() int64 { return s.toolCallCount.Add(1) } s.cfg.SaveSession = func() { s.saveSession() } s.cfg.ResultCache = &s.resultCache s.cfg.AutoCompact = func(ctx context.Context) { if ctx.Err() != nil || s.session == nil { return } limit := s.autoCompactLimit(ctx) if limit <= 0 || s.session.estimateTokens() < limit { return } s.emit(Event{Role: "info", Content: "auto-compacting context...\n"}) s.setState("compacting") if _, err := s.runCompact(ctx, "auto"); err != nil { panic(fmt.Sprintf("mid-turn auto-compact: %v", err)) } s.setState("thinking") } // Warn once when context usage crosses 60%; compact at 75%. if s.session != nil { tokens := s.session.estimateTokens() if compactLimit := s.autoCompactLimit(ctx); compactLimit > 0 && tokens >= compactLimit { s.emit(Event{Role: "info", Content: "auto-compacting context...\n"}) s.setState("compacting") if _, err := s.runCompact(ctx, "auto"); err != nil { panic(fmt.Sprintf("auto-compact: %v", err)) } s.setState("thinking") } else if warnLimit := s.autoWarnLimit(ctx); warnLimit > 0 && tokens >= warnLimit && !s.warnedContext { ctxLen := s.cfg.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } pct := tokens * 100 / ctxLen s.emit(Event{Role: "info", Content: fmt.Sprintf("context at %d%% — will auto-compact at 75%%\n", pct)}) s.warnedContext = true } } // Spending cap: reject before spending more tokens. if maxCostStr := os.Getenv("OLLIE_MAX_SESSION_COST"); maxCostStr != "" && s.session != nil { if limit, err := strconv.ParseFloat(maxCostStr, 64); err == nil && limit > 0 { if s.session.SessionCostUSD >= limit { s.emit(Event{Role: "error", Content: fmt.Sprintf("spending cap $%.2f reached (session total $%.4f)", limit, s.session.SessionCostUSD)}) s.setState("idle") actCancel(nil) s.currentAction.CompareAndSwap(handle, nil) if snapSession == nil { s.session = nil } else { s.session.messages = snapMessages } return "" } } } if s.session != nil { s.session.resetTurnAccumulators() } // Run the turn, retrying once after compaction on context overflow. var ( overflowRetried bool err error ) for { err = run(actCtx, s.cfg, s.session) actCancel(nil) s.currentAction.CompareAndSwap(handle, nil) if err == nil { break } if errors.Is(err, context.Canceled) || errors.Is(err, ErrInterrupted) { break } var ctxErr *backend.ContextOverflowError if !overflowRetried && errors.As(err, &ctxErr) && s.session != nil { overflowRetried = true s.session.messages = snapMessages s.emit(Event{Role: "info", Content: "context overflow — compacting and retrying...\n"}) s.setState("compacting") if _, cerr := s.runCompact(ctx, "overflow"); cerr != nil { break } s.session.appendUserMessage(input) s.setState("thinking") s.session.resetTurnAccumulators() replyBuf.Reset() actCtx, actCancel = context.WithCancelCause(ctx) handle = &actionHandle{cancel: actCancel} s.currentAction.Store(handle) continue } break } s.mu.Lock() s.reply = replyBuf.String() s.mu.Unlock() replyBuf.Reset() s.setState("idle") s.flushSave() if err != nil { // Keep completed work — only remove cancelled tool results. // Error results are valuable feedback for the agent. if s.session != nil { s.session.removeCancelledToolResults() } if errors.Is(err, context.Canceled) || errors.Is(err, ErrInterrupted) { s.auditLog.Debug("turn: interrupted session=%s", s.sessionID) s.saveSession() return "" } s.emit(Event{Role: "error", Content: err.Error()}) // Drain one FIFO item — the turnError hook may have queued a recovery prompt. if next, ok := s.fifo.Pop(); ok { return next } return "" } stopResult := s.runtime.Hooks.Run(ctx, HookPostTurn, map[string]string{ "session_id": s.sessionID, "cwd": s.CWD(), }, s.log) if stopResult.Warning != "" { s.emit(infoEvent(stopResult.Warning)) } if sum := stopResult.Summary(); sum != "" { s.emit(infoEvent("postTurn: " + sum)) } if !stopResult.Blocked && stopResult.Context != "" && s.session != nil { s.session.appendUserMessage(stopResult.Context) } if s.session != nil { s.session.recordTurnCost(s.cfg.Backend.Model()) appendUsageLog(s.sessionID, s.cfg.Backend.Name(), s.cfg.Backend.Model(), s.session) if s.session.LastTurnCostUSD > 0 { s.emit(Event{Role: "info", Content: fmt.Sprintf("costLast=$%.4f\n", s.session.LastTurnCostUSD)}) } s.auditLog.Debug("turn: end reply=%s cost=$%.4f session_total=$%.4f session=%s", auditTruncate(s.reply), s.session.LastTurnCostUSD, s.session.SessionCostUSD, s.sessionID) s.notifyChange() } s.saveSession() // Post-turn hook said "continue" — its context becomes the next prompt. if stopResult.Blocked && stopResult.Context != "" { return stopResult.Context } // Inject that was pending but never consumed (text-only response with no // tool calls) — treat it as the next user message. if p := s.pendingInject.Swap(nil); p != nil { return *p } // Drain one item from the FIFO; the outer loop handles the rest. if next, ok := s.fifo.Pop(); ok { return next } return "" } func toolInfosToBackend(infos []tools.ToolInfo) []backend.Tool { out := make([]backend.Tool, len(infos)) for i, t := range infos { out[i] = backend.Tool{ Name: t.Name, Description: t.Description, Parameters: t.InputSchema, } } return out } func extractToolResult(raw json.RawMessage) (text string, contentBlocks []backend.ContentBlock, isError bool) { var result struct { IsError bool `json:"isError"` Content []struct { Type string `json:"type"` Text string `json:"text"` MediaType string `json:"media_type"` Data string `json:"data"` } `json:"content"` } if err := json.Unmarshal(raw, &result); err != nil { return string(raw), nil, false } var parts []string for _, c := range result.Content { switch c.Type { case "text": parts = append(parts, c.Text) case "image": contentBlocks = append(contentBlocks, backend.ContentBlock{ Type: "image", ImageSource: &backend.ImageSource{ Type: "base64", MediaType: c.MediaType, Data: c.Data, }, }) } } return strings.Join(parts, "\n"), contentBlocks, result.IsError }