diff --git a/session/agent.go b/session/agent.go index 2d7098b..a06eabb 100644 --- a/session/agent.go +++ b/session/agent.go @@ -2,6 +2,7 @@ package session import ( "context" + "os" "sync" "sync/atomic" @@ -50,6 +51,8 @@ type Agent struct { startupMessages []string readPlanStep func() string saveSession func() // trigger debounced persistence + flushSave func() // immediately flush persistence + turnError func(ctx context.Context, errType, errMsg string) HookResult // overridable for tests } // Backend returns the active backend from the runtime. @@ -181,3 +184,27 @@ func (ag *Agent) SetCwd(dir string) { ag.cwd = dir ag.stateMu.Unlock() } + +// effectiveCwd returns the agent's cwd, falling back to os.Getwd() if empty. +func (ag *Agent) effectiveCwd() string { + if c := ag.Cwd(); c != "" { + return c + } + wd, _ := os.Getwd() + return wd +} + +// IsRunning returns true if the agent has an active turn in progress. +func (ag *Agent) IsRunning() bool { + return ag.currentAction.Load() != nil +} + +// Interrupt cancels the current in-progress agent turn. +// Returns true if an action was running and was cancelled. +func (ag *Agent) Interrupt(cause error) bool { + if h := ag.currentAction.Load(); h != nil { + h.cancel(cause) + return true + } + return false +} diff --git a/session/commands.go b/session/commands.go index df31c00..9dcaa2d 100644 --- a/session/commands.go +++ b/session/commands.go @@ -215,9 +215,9 @@ func (a *Session) handleCommand(ctx context.Context, input string) bool { return } snapshot := a.r.history.PreCompactionSnapshot() - a.setState("compacting") - n, err := a.runCompact(ctx, "manual") - a.setState("idle") + a.r.SetState("compacting") + n, err := a.r.runCompact(ctx, "manual") + a.r.SetState("idle") if err != nil { a.emit(infoEvent("compact error: " + err.Error())) return diff --git a/session/core_test.go b/session/core_test.go index a1f7500..42cd8f3 100644 --- a/session/core_test.go +++ b/session/core_test.go @@ -1830,7 +1830,7 @@ func TestCore_SetGenerationParams_Success(t *testing.T) { func TestAutoCompactLimit_DefaultWhenZero(t *testing.T) { be := &mockBackend{name: "mock", model: "test", ctxLen: 0} c := newCore(t, be, nil) - limit := c.autoCompactLimit(context.Background()) + limit := c.r.autoCompactLimit(context.Background()) want := defaultContextLength * 3 / 4 if limit != want { t.Errorf("autoCompactLimit with ctxLen=0 = %d; want %d", limit, want) diff --git a/session/loop_turnerror_test.go b/session/loop_turnerror_test.go index d61fbe2..0cd4e39 100644 --- a/session/loop_turnerror_test.go +++ b/session/loop_turnerror_test.go @@ -36,7 +36,7 @@ func TestTurnError_HookInterceptsRateLimit(t *testing.T) { be.respond = errStream(&backend.RateLimitError{Message: "quota exceeded"}) c := newCore(t, be, nil) - c.turnError = func(_ context.Context, errType, _ string) HookResult { + c.r.turnError = func(_ context.Context, errType, _ string) HookResult { atomic.AddInt32(&hookCalls, 1) if errType != "rate_limit" { t.Errorf("errType = %q; want rate_limit", errType) @@ -63,7 +63,7 @@ func TestTurnError_HookInterceptsToolUnsupported(t *testing.T) { be.respond = errStream(&backend.ToolUnsupportedError{Message: "model does not support tools"}) c := newCore(t, be, nil) - c.turnError = func(_ context.Context, errType, _ string) HookResult { + c.r.turnError = func(_ context.Context, errType, _ string) HookResult { atomic.AddInt32(&hookCalls, 1) if errType != "tool_unsupported" { t.Errorf("errType = %q; want tool_unsupported", errType) @@ -111,7 +111,7 @@ func TestTurnError_NonRetryableErrorNoHook(t *testing.T) { be.respond = errStream(&backend.ToolUnsupportedError{Message: "no tools"}) c := newCore(t, be, nil) - c.turnError = func(_ context.Context, errType, _ string) HookResult { + c.r.turnError = func(_ context.Context, errType, _ string) HookResult { atomic.AddInt32(&hookCalls, 1) return HookResult{Ran: true, Handled: true} } @@ -131,7 +131,7 @@ func TestTurnError_HookNotRunOnSuccess(t *testing.T) { // Default respond returns textStream("ok") — no error. c := newCore(t, be, nil) - c.turnError = func(_ context.Context, _, _ string) HookResult { + c.r.turnError = func(_ context.Context, _, _ string) HookResult { atomic.AddInt32(&hookCalls, 1) return HookResult{Ran: true} } diff --git a/session/session.go b/session/session.go index f6ca25a..85bf4c5 100644 --- a/session/session.go +++ b/session/session.go @@ -9,7 +9,6 @@ import ( "io" "os" "path/filepath" - "runtime/debug" "slices" "strconv" "strings" @@ -321,7 +320,6 @@ type Session struct { log *olog.Logger sessionsDir 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 mu sync.RWMutex auditLog *olog.Logger @@ -486,17 +484,9 @@ func New(cfg Config) *Session { } a.r.InitCond() a.r.state = "idle" - a.turnError = func(_ context.Context, errType, errMsg string) HookResult { - hookCtx, cancel := context.WithTimeout(context.Background(), time.Duration(hookTimeout)*time.Second) - defer cancel() - return a.r.runtime.Hooks.Run(hookCtx, HookTurnError, map[string]string{ - "session_id": a.id, - "cwd": a.CWD(), - "model": a.r.runtime.Backend.Model(), - "error_type": errType, - "error": errMsg, - }, a.log) - } + a.r.flushSave = a.flushSave + a.r.saveSession = a.saveSession + a.r.turnError = a.r.defaultTurnError a.pushSessionEnv() a.pushLockDir() return a @@ -850,97 +840,6 @@ func (a *Session) SetSessionID(newID string) error { const defaultContextLength = 128000 const defaultToolResultMaxBytes = 131072 -// autoCompactLimit returns the token threshold for auto-compaction (75%). -func (a *Session) autoCompactLimit(ctx context.Context) int { - ctxLen := a.r.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 (a *Session) autoWarnLimit(ctx context.Context) int { - ctxLen := a.r.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 (a *Session) spawnContext(ctx context.Context) string { - result := a.r.runtime.Hooks.Run(ctx, HookAgentSpawn, map[string]string{ - "session_id": a.id, - "agent": a.r.agentName, - "cwd": a.CWD(), - "model": a.r.runtime.Backend.Model(), - }, a.log) - if result.Warning != "" { - a.emit(infoEvent(result.Warning)) - } - if sum := result.Summary(); sum != "" { - a.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 (a *Session) runCompact(ctx context.Context, trigger string) (int, error) { - payload := map[string]string{"session_id": a.id, "trigger": trigger, "cwd": a.CWD()} - pre := a.r.runtime.Hooks.Run(ctx, HookPreCompact, payload, a.log) - if pre.Warning != "" { - a.emit(infoEvent(pre.Warning)) - } - if sum := pre.Summary(); sum != "" { - a.emit(infoEvent("preCompact: " + sum)) - } - if pre.Blocked { - a.emit(infoEvent("compact cancelled by hook")) - return 0, nil - } - if pre.Context != "" { - a.r.history.appendUserMessage(pre.Context) - } - // Use a cheaper model for compaction if configured. - compactModel := resolveCompactionModel(a.r.runtime.CompactionModel, a.r.runtime.Backend) - origModel := a.r.runtime.Backend.Model() - if compactModel != "" && compactModel != origModel { - a.r.runtime.Backend.SetModel(compactModel) - defer a.r.runtime.Backend.SetModel(origModel) - } - 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.id) - a.r.warnedContext = false - if sc := a.spawnContext(ctx); sc != "" { - a.r.history.appendUserMessage(sc) - } - } - post := a.r.runtime.Hooks.Run(ctx, HookPostCompact, payload, a.log) - if post.Warning != "" { - a.emit(infoEvent(post.Warning)) - } - if sum := post.Summary(); sum != "" { - a.emit(infoEvent("postCompact: " + sum)) - } - if post.Context != "" { - a.r.history.appendUserMessage(post.Context) - } - return n, nil -} - func (a *Session) activeSessionPath(id, suffix string) string { return filepath.Join(a.sessionsDir, "active", id+suffix) } @@ -993,22 +892,11 @@ func (a *Session) SaveSession(path string) error { a.r.runtime.Backend.Name(), a.r.runtime.Backend.Model(), a.CWD(), a.remote) } -func (a *Session) getActionCancel() context.CancelCauseFunc { - if a := a.r.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 (a *Session) Interrupt(cause error) bool { a.log.Debug("Interrupt() cause=%v", cause) - if cancel := a.getActionCancel(); cancel != nil { - cancel(cause) - return true - } - return false + return a.r.Interrupt(cause) } func (a *Session) Inject(prompt string) { @@ -1052,7 +940,7 @@ func (a *Session) PopQueue() (string, bool) { } func (a *Session) IsRunning() bool { - v := a.r.currentAction.Load() != nil + v := a.r.IsRunning() a.log.Debug("IsRunning() = %v", v) return v } @@ -1167,357 +1055,32 @@ func firstSentence(s string) string { 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. +// Submit processes one line of user input: slash commands are dispatched +// immediately; any other input is delegated to the agent for turn execution. func (a *Session) 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.r.currentAction.Swap(nil); a != nil { - a.cancel(fmt.Errorf("%v", r)) - } - a.r.SetState("idle") - a.emit(Event{Role: "error", Content: fmt.Sprintf("%v", r)}) - } - }() a.log.Debug("Submit() input_len=%d running=%v", len(input), a.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. + // Commands are handled at the session level (they reference sessionsDir, + // listHandlers, etc.). If the input is a command, handle it and return. if a.IsRunning() { if a.handleCommand(ctx, input) { return } - a.r.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. - a.r.submitMu.Lock() - defer a.r.submitMu.Unlock() - - if a.handleCommand(ctx, input) { - return - } - if a.IsRunning() { - a.r.fifo.Push(input) - return - } - - for input != "" && ctx.Err() == nil { - input = a.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 (a *Session) executeTurn(ctx context.Context, input string) string { - a.emit(Event{Role: "user", Content: input}) - - hookResult := a.r.runtime.Hooks.Run(ctx, HookPreTurn, map[string]string{ - "session_id": a.id, - "cwd": a.CWD(), - "prompt": input, - }, a.log) - if hookResult.Blocked { - a.emit(infoEvent("hook blocked prompt")) - return "" - } - if hookResult.Warning != "" { - a.emit(infoEvent(hookResult.Warning)) - } - if sum := hookResult.Summary(); sum != "" { - a.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 := a.r.history - var snapMessages []backend.Message - if a.r.history != nil { - snapMessages = cloneMessages(a.r.history.messages) - } - - if a.r.history == nil { - for _, msg := range a.r.startupMessages { - a.log.Debug("startup: %s", msg) - a.emit(infoEvent(msg)) - } - a.r.startupMessages = nil - a.r.history = newHistory(input) - if sc := a.spawnContext(ctx); sc != "" { - a.r.history.appendUserMessage(sc) - } - a.r.history.appendUserMessage(input) } else { - a.r.history.appendUserMessage(input) - } - - actCtx, actCancel := context.WithCancelCause(ctx) - handle := &actionHandle{cancel: actCancel} - a.r.currentAction.Store(handle) - a.r.SetState("thinking") - - a.auditLog.Debug("turn: start input=%s session=%s", auditTruncate(input), a.id) - - // Build per-turn agentConfig from the current runtime. - 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.r.readPlanStep, - TurnError: a.turnError, - } - - var replyBuf strings.Builder - a.r.cfg.Output = func(ev Event) { - switch ev.Role { - case "assistant": - replyBuf.WriteString(ev.Content) - case "call": - a.r.SetState("calling: " + ev.Name) - a.auditLog.Debug("call: %s %s", ev.Name, auditTruncate(string(ev.Content))) - case "tool": - a.auditLog.Debug("result: %s %s", ev.Name, auditTruncate(ev.Content)) - case "state": - a.r.SetState(ev.Content) - case "limitretry": - a.r.SetState("limitretry") - case "error": - a.auditLog.Debug("error: %s", ev.Content) - } - 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.r.history.addUsage(backend.Usage{ - InputTokens: in, - CachedInputTokens: cached, - CacheCreationTokens: creation, - OutputTokens: out, - CostUSD: costUSD, - }, est != 0) - a.r.notifyChange() - if maxCostStr := os.Getenv("OLLIE_MAX_SESSION_COST"); maxCostStr != "" { - 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) - } - } - } - a.emit(ev) - } - a.r.cfg.PopInject = func() string { - if p := a.r.pendingInject.Swap(nil); p != nil { - return *p - } - return "" - } - 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.id, - "cwd": a.CWD(), - "tool": name, - "args": string(args), - }, a.log) - } - 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.id, - "cwd": a.CWD(), - "tool": name, - "args": string(args), - "result": result, - }, a.log) - } - a.r.cfg.IncrToolCallCount = func() int64 { - return a.r.toolCallCount.Add(1) - } - 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 { + // Serialize commands with turns — acquire submitMu to check. + a.r.submitMu.Lock() + if a.handleCommand(ctx, input) { + a.r.submitMu.Unlock() return } - limit := a.autoCompactLimit(ctx) - if limit <= 0 || a.r.history.estimateTokens() < limit { - return - } - a.emit(Event{Role: "info", Content: "auto-compacting context...\n"}) - a.r.SetState("compacting") - if _, err := a.runCompact(ctx, "auto"); err != nil { - panic(fmt.Sprintf("mid-turn auto-compact: %v", err)) - } - a.r.SetState("thinking") + a.r.submitMu.Unlock() } - // Warn once when context usage crosses 60%; compact at 75%. - 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.r.SetState("compacting") - if _, err := a.runCompact(ctx, "auto"); err != nil { - panic(fmt.Sprintf("auto-compact: %v", err)) - } - a.r.SetState("thinking") - } 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.r.warnedContext = true - } - } - - // Spending cap: reject before spending more tokens. - 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.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.r.SetState("idle") - actCancel(nil) - a.r.currentAction.CompareAndSwap(handle, nil) - if snapSession == nil { - a.r.history = nil - } else { - a.r.history.messages = snapMessages - } - return "" - } - } - } - - if a.r.history != nil { - a.r.history.resetTurnAccumulators() - } - - // Run the turn, retrying once after compaction on context overflow. - var ( - overflowRetried bool - err error - ) - for { - err = run(actCtx, a.r.cfg, a.r.history) - actCancel(nil) - a.r.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) && a.r.history != nil { - overflowRetried = true - a.r.history.messages = snapMessages - a.emit(Event{Role: "info", Content: "context overflow — compacting and retrying...\n"}) - a.r.SetState("compacting") - if _, cerr := a.runCompact(ctx, "overflow"); cerr != nil { - break - } - a.r.history.appendUserMessage(input) - a.r.SetState("thinking") - a.r.history.resetTurnAccumulators() - replyBuf.Reset() - actCtx, actCancel = context.WithCancelCause(ctx) - handle = &actionHandle{cancel: actCancel} - a.r.currentAction.Store(handle) - continue - } - break - } - - a.mu.Lock() - a.r.SetReply(replyBuf.String()) - a.mu.Unlock() - replyBuf.Reset() - a.r.SetState("idle") - a.flushSave() - - if err != nil { - // Keep completed work — only remove cancelled tool results. - // Error results are valuable feedback for the agent. - 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.id) - a.saveSession() - return "" - } - a.emit(Event{Role: "error", Content: err.Error()}) - // Drain one FIFO item — the turnError hook may have queued a recovery prompt. - if next, ok := a.r.fifo.Pop(); ok { - return next - } - return "" - } - - stopResult := a.r.runtime.Hooks.Run(ctx, HookPostTurn, map[string]string{ - "session_id": a.id, - "cwd": a.CWD(), - }, a.log) - if stopResult.Warning != "" { - a.emit(infoEvent(stopResult.Warning)) - } - if sum := stopResult.Summary(); sum != "" { - a.emit(infoEvent("postTurn: " + sum)) - } - if !stopResult.Blocked && stopResult.Context != "" && a.r.history != nil { - a.r.history.appendUserMessage(stopResult.Context) - } - - if a.r.history != nil { - a.r.history.recordTurnCost(a.r.cfg.Backend.Model()) - appendUsageLog(a.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.r.Reply()), a.r.history.LastTurnCostUSD, a.r.history.SessionCostUSD, a.id) - a.r.notifyChange() - } - a.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 := a.r.pendingInject.Swap(nil); p != nil { - return *p - } - - // Drain one item from the FIFO; the outer loop handles the rest. - if next, ok := a.r.fifo.Pop(); ok { - return next - } - - return "" + // Delegate to the agent for turn execution (or FIFO queueing if running). + a.r.Submit(ctx, input) } func toolInfosToBackend(infos []tools.ToolInfo) []backend.Tool { diff --git a/session/turn.go b/session/turn.go new file mode 100644 index 0000000..3da5ee9 --- /dev/null +++ b/session/turn.go @@ -0,0 +1,466 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "runtime/debug" + "strconv" + "strings" + "time" + + "ollie/backend" +) + +// Submit processes one line of user input: it 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. +// +// Slash commands are NOT handled here — the caller (Session.Submit) dispatches +// those before delegating to Agent.Submit. +// +// Continuations (post-turn hook context, unconsumed inject, FIFO drain) are +// handled via an explicit loop rather than recursion to avoid stack growth. +func (ag *Agent) Submit(ctx context.Context, input string) { + defer func() { + if r := recover(); r != nil { + ag.log.Error("panic: %v\n%s", r, debug.Stack()) + if a := ag.currentAction.Swap(nil); a != nil { + a.cancel(fmt.Errorf("%v", r)) + } + ag.SetState("idle") + ag.emit(Event{Role: "error", Content: fmt.Sprintf("%v", r)}) + } + }() + ag.log.Debug("Agent.Submit() input_len=%d running=%v", len(input), ag.IsRunning()) + if input == "" { + return + } + + if ag.IsRunning() { + ag.fifo.Push(input) + return + } + + // Serialize turns so that e.g. a /compact arriving via ctl cannot race + // with an executeTurn arriving via prompt. + ag.submitMu.Lock() + defer ag.submitMu.Unlock() + + if ag.IsRunning() { + ag.fifo.Push(input) + return + } + + for input != "" && ctx.Err() == nil { + input = ag.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 (ag *Agent) executeTurn(ctx context.Context, input string) string { + ag.emit(Event{Role: "user", Content: input}) + + hookResult := ag.runtime.Hooks.Run(ctx, HookPreTurn, map[string]string{ + "session_id": ag.sessionID, + "cwd": ag.effectiveCwd(), + "prompt": input, + }, ag.log) + if hookResult.Blocked { + ag.emit(infoEvent("hook blocked prompt")) + return "" + } + if hookResult.Warning != "" { + ag.emit(infoEvent(hookResult.Warning)) + } + if sum := hookResult.Summary(); sum != "" { + ag.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 := ag.history + var snapMessages []backend.Message + if ag.history != nil { + snapMessages = cloneMessages(ag.history.messages) + } + + if ag.history == nil { + for _, msg := range ag.startupMessages { + ag.log.Debug("startup: %s", msg) + ag.emit(infoEvent(msg)) + } + ag.startupMessages = nil + ag.history = newHistory(input) + if sc := ag.spawnContext(ctx); sc != "" { + ag.history.appendUserMessage(sc) + } + ag.history.appendUserMessage(input) + } else { + ag.history.appendUserMessage(input) + } + + actCtx, actCancel := context.WithCancelCause(ctx) + handle := &actionHandle{cancel: actCancel} + ag.currentAction.Store(handle) + ag.SetState("thinking") + + ag.auditLog.Debug("turn: start input=%s session=%s", auditTruncate(input), ag.sessionID) + + // Build per-turn agentConfig from the current runtime. + ag.cfg = agentConfig{ + Backend: ag.runtime.Backend, + preamble: ag.runtime.Preamble, + Tools: ag.runtime.Tools, + Exec: ag.runtime.Exec, + ClassifyTool: ag.runtime.ClassifyTool, + ClassifyTier: ag.runtime.ClassifyTier, + GenerationParams: ag.runtime.GenParams, + MaxSteps: ag.runtime.MaxSteps, + ReadPlanStep: ag.readPlanStep, + TurnError: ag.turnError, + } + + var replyBuf strings.Builder + ag.cfg.Output = func(ev Event) { + switch ev.Role { + case "assistant": + replyBuf.WriteString(ev.Content) + case "call": + ag.SetState("calling: " + ev.Name) + ag.auditLog.Debug("call: %s %s", ev.Name, auditTruncate(string(ev.Content))) + case "tool": + ag.auditLog.Debug("result: %s %s", ev.Name, auditTruncate(ev.Content)) + case "state": + ag.SetState(ev.Content) + case "limitretry": + ag.SetState("limitretry") + case "error": + ag.auditLog.Debug("error: %s", ev.Content) + } + if ev.Role == "usage" && ag.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) + ag.history.addUsage(backend.Usage{ + InputTokens: in, + CachedInputTokens: cached, + CacheCreationTokens: creation, + OutputTokens: out, + CostUSD: costUSD, + }, est != 0) + ag.notifyChange() + if maxCostStr := os.Getenv("OLLIE_MAX_SESSION_COST"); maxCostStr != "" { + if limit, ferr := strconv.ParseFloat(maxCostStr, 64); ferr == nil && limit > 0 && ag.history.SessionCostUSD >= limit { + ag.emit(infoEvent(fmt.Sprintf("spending cap $%.2f reached — stopping", limit))) + ag.Interrupt(ErrInterrupted) + } + } + } + ag.emit(ev) + } + ag.cfg.PopInject = func() string { + if p := ag.pendingInject.Swap(nil); p != nil { + return *p + } + return "" + } + ag.cfg.PreTool = func(ctx context.Context, name string, args json.RawMessage) HookResult { + return ag.runtime.Hooks.Run(ctx, HookPreTool, map[string]string{ + "session_id": ag.sessionID, + "cwd": ag.effectiveCwd(), + "tool": name, + "args": string(args), + }, ag.log) + } + ag.cfg.PostTool = func(ctx context.Context, name string, args json.RawMessage, result string) HookResult { + return ag.runtime.Hooks.Run(ctx, HookPostTool, map[string]string{ + "session_id": ag.sessionID, + "cwd": ag.effectiveCwd(), + "tool": name, + "args": string(args), + "result": result, + }, ag.log) + } + ag.cfg.IncrToolCallCount = func() int64 { + return ag.toolCallCount.Add(1) + } + ag.cfg.SaveSession = func() { ag.saveSession() } + ag.cfg.ResultCache = &ag.resultCache + ag.cfg.AutoCompact = func(ctx context.Context) { + if ctx.Err() != nil || ag.history == nil { + return + } + limit := ag.autoCompactLimit(ctx) + if limit <= 0 || ag.history.estimateTokens() < limit { + return + } + ag.emit(Event{Role: "info", Content: "auto-compacting context...\n"}) + ag.SetState("compacting") + if _, err := ag.runCompact(ctx, "auto"); err != nil { + panic(fmt.Sprintf("mid-turn auto-compact: %v", err)) + } + ag.SetState("thinking") + } + + // Warn once when context usage crosses 60%; compact at 75%. + if ag.history != nil { + tokens := ag.history.estimateTokens() + if compactLimit := ag.autoCompactLimit(ctx); compactLimit > 0 && tokens >= compactLimit { + ag.emit(Event{Role: "info", Content: "auto-compacting context...\n"}) + ag.SetState("compacting") + if _, err := ag.runCompact(ctx, "auto"); err != nil { + panic(fmt.Sprintf("auto-compact: %v", err)) + } + ag.SetState("thinking") + } else if warnLimit := ag.autoWarnLimit(ctx); warnLimit > 0 && tokens >= warnLimit && !ag.warnedContext { + ctxLen := ag.cfg.Backend.ContextLength(ctx) + if ctxLen <= 0 { + ctxLen = defaultContextLength + } + pct := tokens * 100 / ctxLen + ag.emit(Event{Role: "info", Content: fmt.Sprintf("context at %d%% — will auto-compact at 75%%\n", pct)}) + ag.warnedContext = true + } + } + + // Spending cap: reject before spending more tokens. + if maxCostStr := os.Getenv("OLLIE_MAX_SESSION_COST"); maxCostStr != "" && ag.history != nil { + if limit, err := strconv.ParseFloat(maxCostStr, 64); err == nil && limit > 0 { + if ag.history.SessionCostUSD >= limit { + ag.emit(Event{Role: "error", Content: fmt.Sprintf("spending cap $%.2f reached (session total $%.4f)", limit, ag.history.SessionCostUSD)}) + ag.SetState("idle") + actCancel(nil) + ag.currentAction.CompareAndSwap(handle, nil) + if snapSession == nil { + ag.history = nil + } else { + ag.history.messages = snapMessages + } + return "" + } + } + } + + if ag.history != nil { + ag.history.resetTurnAccumulators() + } + + // Run the turn, retrying once after compaction on context overflow. + var ( + overflowRetried bool + err error + ) + for { + err = run(actCtx, ag.cfg, ag.history) + actCancel(nil) + ag.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) && ag.history != nil { + overflowRetried = true + ag.history.messages = snapMessages + ag.emit(Event{Role: "info", Content: "context overflow — compacting and retrying...\n"}) + ag.SetState("compacting") + if _, cerr := ag.runCompact(ctx, "overflow"); cerr != nil { + break + } + ag.history.appendUserMessage(input) + ag.SetState("thinking") + ag.history.resetTurnAccumulators() + replyBuf.Reset() + actCtx, actCancel = context.WithCancelCause(ctx) + handle = &actionHandle{cancel: actCancel} + ag.currentAction.Store(handle) + continue + } + break + } + + ag.SetReply(replyBuf.String()) + replyBuf.Reset() + ag.SetState("idle") + if ag.flushSave != nil { + ag.flushSave() + } + + if err != nil { + // Keep completed work — only remove cancelled tool results. + if ag.history != nil { + ag.history.removeCancelledToolResults() + } + if errors.Is(err, context.Canceled) || errors.Is(err, ErrInterrupted) { + ag.auditLog.Debug("turn: interrupted session=%s", ag.sessionID) + ag.saveSession() + return "" + } + ag.emit(Event{Role: "error", Content: err.Error()}) + // Drain one FIFO item — the turnError hook may have queued a recovery prompt. + if next, ok := ag.fifo.Pop(); ok { + return next + } + return "" + } + + stopResult := ag.runtime.Hooks.Run(ctx, HookPostTurn, map[string]string{ + "session_id": ag.sessionID, + "cwd": ag.effectiveCwd(), + }, ag.log) + if stopResult.Warning != "" { + ag.emit(infoEvent(stopResult.Warning)) + } + if sum := stopResult.Summary(); sum != "" { + ag.emit(infoEvent("postTurn: " + sum)) + } + if !stopResult.Blocked && stopResult.Context != "" && ag.history != nil { + ag.history.appendUserMessage(stopResult.Context) + } + + if ag.history != nil { + ag.history.recordTurnCost(ag.cfg.Backend.Model()) + appendUsageLog(ag.sessionID, ag.cfg.Backend.Name(), ag.cfg.Backend.Model(), ag.history) + if ag.history.LastTurnCostUSD > 0 { + ag.emit(Event{Role: "info", Content: fmt.Sprintf("costLast=$%.4f\n", ag.history.LastTurnCostUSD)}) + } + ag.auditLog.Debug("turn: end reply=%s cost=$%.4f session_total=$%.4f session=%s", + auditTruncate(ag.Reply()), ag.history.LastTurnCostUSD, ag.history.SessionCostUSD, ag.sessionID) + ag.notifyChange() + } + ag.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 := ag.pendingInject.Swap(nil); p != nil { + return *p + } + + // Drain one item from the FIFO; the outer loop handles the rest. + if next, ok := ag.fifo.Pop(); ok { + return next + } + + return "" +} + +// turnError is called on the first backend error of a turn. If the hook handles +// the error (exit 0), the turn exits immediately. +// This is the default implementation; it can be overridden via the turnError field. +func (ag *Agent) defaultTurnError(ctx context.Context, errType, errMsg string) HookResult { + hookCtx, cancel := context.WithTimeout(context.Background(), time.Duration(hookTimeout)*time.Second) + defer cancel() + return ag.runtime.Hooks.Run(hookCtx, HookTurnError, map[string]string{ + "session_id": ag.sessionID, + "cwd": ag.effectiveCwd(), + "model": ag.runtime.Backend.Model(), + "error_type": errType, + "error": errMsg, + }, ag.log) +} + +// autoCompactLimit returns the token threshold for auto-compaction (75%). +func (ag *Agent) autoCompactLimit(ctx context.Context) int { + ctxLen := ag.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 (ag *Agent) autoWarnLimit(ctx context.Context) int { + ctxLen := ag.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 (ag *Agent) spawnContext(ctx context.Context) string { + result := ag.runtime.Hooks.Run(ctx, HookAgentSpawn, map[string]string{ + "session_id": ag.sessionID, + "agent": ag.agentName, + "cwd": ag.effectiveCwd(), + "model": ag.runtime.Backend.Model(), + }, ag.log) + if result.Warning != "" { + ag.emit(infoEvent(result.Warning)) + } + if sum := result.Summary(); sum != "" { + ag.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 (ag *Agent) runCompact(ctx context.Context, trigger string) (int, error) { + payload := map[string]string{"session_id": ag.sessionID, "trigger": trigger, "cwd": ag.effectiveCwd()} + pre := ag.runtime.Hooks.Run(ctx, HookPreCompact, payload, ag.log) + if pre.Warning != "" { + ag.emit(infoEvent(pre.Warning)) + } + if sum := pre.Summary(); sum != "" { + ag.emit(infoEvent("preCompact: " + sum)) + } + if pre.Blocked { + ag.emit(infoEvent("compact cancelled by hook")) + return 0, nil + } + if pre.Context != "" { + ag.history.appendUserMessage(pre.Context) + } + // Use a cheaper model for compaction if configured. + compactModel := resolveCompactionModel(ag.runtime.CompactionModel, ag.runtime.Backend) + origModel := ag.runtime.Backend.Model() + if compactModel != "" && compactModel != origModel { + ag.runtime.Backend.SetModel(compactModel) + defer ag.runtime.Backend.SetModel(origModel) + } + n, _, err := ag.history.compact(ctx, ag.runtime.Backend) + if err != nil { + return 0, err + } + if n > 0 { + ag.auditLog.Debug("compact: removed %d messages trigger=%s session=%s", n, trigger, ag.sessionID) + ag.warnedContext = false + if sc := ag.spawnContext(ctx); sc != "" { + ag.history.appendUserMessage(sc) + } + } + post := ag.runtime.Hooks.Run(ctx, HookPostCompact, payload, ag.log) + if post.Warning != "" { + ag.emit(infoEvent(post.Warning)) + } + if sum := post.Summary(); sum != "" { + ag.emit(infoEvent("postCompact: " + sum)) + } + if post.Context != "" { + ag.history.appendUserMessage(post.Context) + } + return n, nil +}