session: move Submit/executeTurn to *Agent receiver
Agent now owns the full turn lifecycle: Submit, executeTurn, turnError, autoCompactLimit, autoWarnLimit, spawnContext, and runCompact all live on *Agent in turn.go. Session.Submit is a thin dispatcher: slash commands are handled at session level (they need sessionsDir, listHandlers, etc.), then non-command input is delegated to Agent.Submit. Session.IsRunning and Session.Interrupt delegate to Agent methods. turnError is an overridable func field on Agent (default: hook-based implementation in defaultTurnError). saveSession and flushSave are injected as callbacks from Session into Agent at construction time. This completes the agent/session ownership separation for the turn execution path. The Agent is now a self-contained entity that can run turns independently given its injected dependencies.
This commit is contained in:
parent
467e6999b1
commit
eef52beb74
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
Reference in New Issue