From 77dd5c6168e16a4e070d59715ffefc1d523d702e Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Sat, 25 Apr 2026 19:38:18 +0200 Subject: [PATCH] agent: refactor prompt architecture and session lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename loopConfig→agentConfig, agentCore→agent, loopcfg→cfg, systemPrompt→preamble for clarity: the struct represents agent runtime config, the field is its config, and preamble accurately describes the compiled system+agent prompt sent as system role - Separate preamble from agent context: preamble now contains only SYSTEM_PROMPT.md; agent prompt and agentSpawn hook output are injected into session history at context refresh points instead of being bolted onto the system role - Replace fireAgentSpawn (mutated preamble once, guarded by agentSpawnFired flag) with spawnContext (assembles agent prompt + hook output, returns string for caller to inject); remove agentSpawnFired entirely — session==nil is the sufficient guard - Three refresh points inject spawn context into session history: session start, post-clear (session=nil triggers same path), post-compaction (auto and manual) - Move all preTurn tool primes (tools-file, tools-reasoning, tools-memory, tools-subagent, tools-elevate) to agentSpawn in all agent configs so they are injected at session refresh points alongside bd prime, not repeated every turn Co-Authored-By: Claude Sonnet 4.6 --- pkg/agent/commands.go | 34 +++---- pkg/agent/core.go | 176 +++++++++++++++++------------------ pkg/agent/core_test.go | 60 +++++++----- pkg/agent/loop.go | 14 +-- pkg/agent/session.go | 7 +- pkg/agent/waitchange_test.go | 6 +- 6 files changed, 151 insertions(+), 146 deletions(-) diff --git a/pkg/agent/commands.go b/pkg/agent/commands.go index 38913a9..ca02a52 100644 --- a/pkg/agent/commands.go +++ b/pkg/agent/commands.go @@ -11,7 +11,7 @@ import ( "ollie/pkg/config" ) -func (s *agentCore) handleCommand(ctx context.Context, input string, handler EventHandler) bool { +func (s *agent) handleCommand(ctx context.Context, input string, handler EventHandler) bool { if strings.HasPrefix(input, "!") { handler(infoEvent("")) cmdStr := strings.TrimSpace(input[1:]) @@ -87,7 +87,7 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve "/backend": func(args []string) { if len(args) == 0 { - handler(infoEvent(s.loopcfg.Backend.Name())) + handler(infoEvent(s.cfg.Backend.Name())) return } if s.IsRunning() { @@ -99,17 +99,17 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve handler(infoEvent(fmt.Sprintf("error: failed to switch backend: %v", err))) return } - s.loopcfg.Backend = be + s.cfg.Backend = be handler(infoEvent(fmt.Sprintf("switched backend to: %s (model: %s)", be.Name(), be.Model()))) }, "/models": func(args []string) { - models := s.loopcfg.Backend.Models(ctx) + models := s.cfg.Backend.Models(ctx) if len(models) == 0 { handler(infoEvent("no models available")) return } - current := s.loopcfg.Backend.Model() + current := s.cfg.Backend.Model() for _, m := range models { marker := " " if m == current { @@ -121,14 +121,14 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve "/model": func(args []string) { if len(args) == 0 { - handler(infoEvent(s.loopcfg.Backend.Model())) + handler(infoEvent(s.cfg.Backend.Model())) return } if s.IsRunning() { handler(infoEvent("error: cannot switch model while agent is running")) return } - s.loopcfg.Backend.SetModel(args[0]) + s.cfg.Backend.SetModel(args[0]) handler(infoEvent("switched model to: " + args[0])) }, @@ -182,15 +182,14 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve env := BuildAgentEnv(cfg, d, s.cwd) s.dispatcher = env.dispatcher s.hooks = env.Hooks - s.loopcfg.systemPrompt = env.systemPrompt - s.loopcfg.Tools = env.tools - s.loopcfg.Exec = env.exec - s.loopcfg.GenerationParams = env.genParams + s.cfg.preamble = env.preamble + s.cfg.Tools = env.tools + s.cfg.Exec = env.exec + s.cfg.GenerationParams = env.genParams s.agentPrompt = env.agentPrompt s.agentName = name s.session = nil s.sessionID = NewSessionID() - s.agentSpawnFired = false s.pushSessionEnv() for _, msg := range env.Messages { handler(infoEvent(msg)) @@ -225,8 +224,11 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve } snapshot := s.session.PreCompactionSnapshot() s.setState("compacting") - n, _, err := s.session.compact(ctx, s.loopcfg.Backend) + n, _, err := s.session.compact(ctx, s.cfg.Backend) s.setState("idle") + if sc := s.spawnContext(ctx, handler); sc != "" { + s.session.appendUserMessage(sc) + } post := s.hooks.Run(ctx, HookPostCompact, payload, s.log) // TODO: route hook info to debug/err file instead of chat // if post.Ran { @@ -265,7 +267,7 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve handler(infoEvent("no active session")) return } - ctxLen := s.loopcfg.Backend.ContextLength(ctx) + ctxLen := s.cfg.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } @@ -280,7 +282,7 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve handler(infoEvent("no active session")) return } - ctxLen := s.loopcfg.Backend.ContextLength(ctx) + ctxLen := s.cfg.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } @@ -378,7 +380,7 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve "/tools": func(args []string) { listMountDir("t") }, "/sp": func(args []string) { - handler(infoEvent(s.loopcfg.systemPrompt)) + handler(infoEvent(s.cfg.preamble)) }, "/help": func(args []string) { diff --git a/pkg/agent/core.go b/pkg/agent/core.go index 4efad37..eee34e9 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -30,7 +30,7 @@ type AgentEnv struct { tools []backend.Tool exec toolExecutor Hooks Hooks - systemPrompt string + preamble string agentPrompt string // agent-specific suffix appended after the base system prompt genParams backend.GenerationParams Messages []string @@ -83,15 +83,7 @@ func BuildAgentEnv(cfg *config.Config, d tools.Dispatcher, cwd string) AgentEnv if err != nil { panic(fmt.Sprintf("loadSystemPrompt: %s: %v", path, err)) } - base := expandSystemPrompt(string(data), cwd) - sp := base - if agentPrompt != "" { - if sp != "" { - sp = sp + "\n\n" + agentPrompt - } else { - sp = agentPrompt - } - } + preamble := expandSystemPrompt(string(data), cwd) exec := func(ctx context.Context, name string, args json.RawMessage) (string, error) { server, ok := serverOf[name] @@ -110,12 +102,12 @@ func BuildAgentEnv(cfg *config.Config, d tools.Dispatcher, cwd string) AgentEnv } return AgentEnv{ - dispatcher: d, - tools: allTools, - exec: exec, - Hooks: hooks, - systemPrompt: sp, - agentPrompt: agentPrompt, + dispatcher: d, + tools: allTools, + exec: exec, + Hooks: hooks, + preamble: preamble, + agentPrompt: agentPrompt, genParams: genParams, Messages: messages, } @@ -178,7 +170,7 @@ type actionHandle struct { cancel context.CancelCauseFunc } -// AgentCoreConfig is the configuration for creating an agentCore. +// AgentCoreConfig is the configuration for creating an agent. type AgentCoreConfig struct { Backend backend.Backend ModelName string // if non-empty, overrides backend's default model @@ -194,11 +186,11 @@ type AgentCoreConfig struct { Log *olog.Logger // if nil, logging is disabled } -// agentCore is the Core implementation. It owns all agent and session state +// agent is the Core implementation. It owns all agent and session state // but has no knowledge of how output is rendered. -type agentCore struct { +type agent struct { session *Session - loopcfg loopConfig + cfg agentConfig hooks Hooks log *olog.Logger agentName string @@ -211,7 +203,6 @@ type agentCore struct { cwd string agentPrompt string // agent-specific prompt suffix; kept for system prompt rebuilds startupMessages []string - agentSpawnFired bool currentAction atomic.Pointer[actionHandle] fifo PromptFIFO pendingInject atomic.Pointer[string] @@ -225,7 +216,7 @@ type agentCore struct { } // SetEnv stores a session-scoped variable and propagates it to the execute server. -func (s *agentCore) SetEnv(key, value string) { +func (s *agent) SetEnv(key, value string) { s.envMu.Lock() if s.env == nil { s.env = make(map[string]string) @@ -243,7 +234,7 @@ func (s *agentCore) SetEnv(key, value string) { } // pushSessionEnv injects OLLIE_SESSION_ID into the execute server subprocess env. -func (s *agentCore) pushSessionEnv() { +func (s *agent) pushSessionEnv() { if s.dispatcher == nil || s.sessionID == "" { return } @@ -254,7 +245,7 @@ func (s *agentCore) pushSessionEnv() { } } -var _ Core = (*agentCore)(nil) // compile-time interface check +var _ Core = (*agent)(nil) // compile-time interface check var sweepTmpOnce sync.Once @@ -277,7 +268,7 @@ func sweepStaleTmpDirs() { }) } -// NewAgentCore creates an agentCore from the given configuration. +// NewAgentCore creates an agent from the given configuration. func NewAgentCore(cfg AgentCoreConfig) Core { sweepStaleTmpDirs() if cfg.ModelName != "" { @@ -286,9 +277,9 @@ func NewAgentCore(cfg AgentCoreConfig) Core { if cfg.NewBackend == nil { cfg.NewBackend = backend.NewWithName } - loopcfg := loopConfig{ + run := agentConfig{ Backend: cfg.Backend, - systemPrompt: cfg.Env.systemPrompt, + preamble: cfg.Env.preamble, Tools: cfg.Env.tools, Exec: cfg.Env.exec, GenerationParams: cfg.Env.genParams, @@ -302,9 +293,9 @@ func NewAgentCore(cfg AgentCoreConfig) Core { log = olog.NewWriter("core", olog.LevelError+1, io.Discard, io.Discard) } - a := &agentCore{ + a := &agent{ session: cfg.Session, - loopcfg: loopcfg, + cfg: run, hooks: cfg.Env.Hooks, log: log, agentName: cfg.AgentName, @@ -325,36 +316,36 @@ func NewAgentCore(cfg AgentCoreConfig) Core { } // Close releases resources for this session, including its tmpdir. -func (s *agentCore) Close() { +func (s *agent) Close() { s.log.Debug("Close() session=%q", s.sessionID) if s.sessionID != "" { os.RemoveAll(filepath.Join(ollieTmpDir(), s.sessionID)) //nolint:errcheck } } -func (s *agentCore) AgentName() string { +func (s *agent) AgentName() string { v := s.agentName s.log.Debug("AgentName() = %q", v) return v } -func (s *agentCore) BackendName() string { - v := s.loopcfg.Backend.Name() +func (s *agent) BackendName() string { + v := s.cfg.Backend.Name() s.log.Debug("BackendName() = %q", v) return v } -func (s *agentCore) ModelName() string { - v := s.loopcfg.Backend.Model() +func (s *agent) ModelName() string { + v := s.cfg.Backend.Model() s.log.Debug("ModelName() = %q", v) return v } -func (s *agentCore) State() string { +func (s *agent) State() string { s.mu.RLock() defer s.mu.RUnlock() return s.state } -func (s *agentCore) notifyChange() { +func (s *agent) notifyChange() { s.changeMu.Lock() s.changeCond.Broadcast() s.changeMu.Unlock() @@ -362,7 +353,7 @@ func (s *agentCore) notifyChange() { // WaitChange blocks until the named field changes from current, then returns // the new value. Returns ("", false) if ctx is cancelled. -func (s *agentCore) WaitChange(ctx context.Context, field, current string) (string, bool) { +func (s *agent) WaitChange(ctx context.Context, field, current string) (string, bool) { read := func() string { switch field { case WatchState: @@ -397,7 +388,7 @@ func (s *agentCore) WaitChange(ctx context.Context, field, current string) (stri return "", false } -func (s *agentCore) setState(state string) { +func (s *agent) setState(state string) { s.mu.Lock() s.state = state s.mu.Unlock() @@ -405,7 +396,7 @@ func (s *agentCore) setState(state string) { s.notifyChange() } -func (s *agentCore) Reply() string { +func (s *agent) Reply() string { s.mu.RLock() defer s.mu.RUnlock() s.log.Debug("Reply() len=%d", len(s.reply)) @@ -413,7 +404,7 @@ func (s *agentCore) Reply() string { } // CWD returns the current working directory for tool execution. -func (s *agentCore) CWD() string { +func (s *agent) CWD() string { if s.cwd != "" { s.log.Debug("CWD() = %q", s.cwd) return s.cwd @@ -425,7 +416,7 @@ func (s *agentCore) CWD() string { // SetCWD changes the working directory for tool execution and updates the // system prompt. Returns an error if the path does not exist. -func (s *agentCore) SetCWD(dir string) error { +func (s *agent) SetCWD(dir string) error { s.log.Debug("SetCWD(%q)", dir) dir = paths.ExpandHome(dir) if dir != "" { @@ -448,7 +439,7 @@ func (s *agentCore) SetCWD(dir string) error { // SetSessionID renames the session. It updates the in-memory ID, renames // persisted files on disk, and propagates to the execute server env. -func (s *agentCore) SetSessionID(newID string) error { +func (s *agent) SetSessionID(newID string) error { s.log.Debug("SetSessionID(%q) old=%q", newID, s.sessionID) oldID := s.sessionID if oldID == newID { @@ -484,40 +475,37 @@ const defaultContextLength = 128000 // autoCompactLimit returns the token threshold for auto-compaction. // Uses 75% of the model's context length, reserving room for output and the // compaction prompt. -func (s *agentCore) autoCompactLimit(ctx context.Context) int { - ctxLen := s.loopcfg.Backend.ContextLength(ctx) +func (s *agent) autoCompactLimit(ctx context.Context) int { + ctxLen := s.cfg.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } return ctxLen * 3 / 4 } -// fireAgentSpawn runs the agentSpawn hook and appends any returned context to -// the system prompt. It is idempotent: it fires at most once per agent lifetime -// (reset when the active agent changes). -func (s *agentCore) fireAgentSpawn(ctx context.Context, handler EventHandler) { - if s.agentSpawnFired { - return - } - s.agentSpawnFired = true +// 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, handler EventHandler) string { result := s.hooks.Run(ctx, HookAgentSpawn, map[string]string{ "session_id": s.sessionID, "agent": s.agentName, "cwd": s.CWD(), }, s.log) - // TODO: route hook info to debug/err file instead of chat - // if result.Ran { - // handler(infoEvent(hooksRan(1))) - // } if result.Warning != "" { handler(infoEvent(result.Warning)) } - if result.Context != "" { - s.loopcfg.systemPrompt += "\n\n---\n\n" + result.Context + var parts []string + if s.agentPrompt != "" { + parts = append(parts, s.agentPrompt) } + if result.Context != "" { + parts = append(parts, result.Context) + } + return strings.Join(parts, "\n\n---\n\n") } -func (s *agentCore) saveSession() { +func (s *agent) saveSession() { if s.session == nil || s.sessionID == "" || s.sessionsDir == "" { return } @@ -527,7 +515,7 @@ func (s *agentCore) saveSession() { } } -func (s *agentCore) getActionCancel() context.CancelCauseFunc { +func (s *agent) getActionCancel() context.CancelCauseFunc { if a := s.currentAction.Load(); a != nil { return a.cancel } @@ -536,7 +524,7 @@ func (s *agentCore) getActionCancel() context.CancelCauseFunc { // Interrupt cancels the current in-progress agent turn. // Returns true if an action was running and was cancelled. -func (s *agentCore) Interrupt(cause error) bool { +func (s *agent) Interrupt(cause error) bool { s.log.Debug("Interrupt() cause=%v", cause) if cancel := s.getActionCancel(); cancel != nil { cancel(cause) @@ -545,46 +533,46 @@ func (s *agentCore) Interrupt(cause error) bool { return false } -func (s *agentCore) Inject(prompt string) { +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 } - emit(s.loopcfg, Event{Role: "info", Content: "\n"}) - emit(s.loopcfg, Event{Role: "user", Content: prompt}) + emit(s.cfg, Event{Role: "info", Content: "\n"}) + emit(s.cfg, Event{Role: "user", Content: prompt}) } -func (s *agentCore) injectRewrite(prompt string) { +func (s *agent) injectRewrite(prompt string) { s.pendingInject.Store(&prompt) - emit(s.loopcfg, Event{Role: "info", Content: "\n"}) - emit(s.loopcfg, Event{Role: "user", Content: prompt}) + emit(s.cfg, Event{Role: "info", Content: "\n"}) + emit(s.cfg, Event{Role: "user", Content: prompt}) } -func (s *agentCore) Queue(prompt string) { +func (s *agent) Queue(prompt string) { s.log.Debug("Queue() len=%d", len(prompt)) s.fifo.Push(prompt) } -func (s *agentCore) PopQueue() (string, bool) { +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 *agentCore) IsRunning() bool { +func (s *agent) IsRunning() bool { v := s.currentAction.Load() != nil s.log.Debug("IsRunning() = %v", v) return v } -func (s *agentCore) CtxSz() string { +func (s *agent) CtxSz() string { if s.session == nil { s.log.Debug("CtxSz() no session") return "no active session" } - ctxLen := s.loopcfg.Backend.ContextLength(context.Background()) + ctxLen := s.cfg.Backend.ContextLength(context.Background()) if ctxLen <= 0 { ctxLen = defaultContextLength } @@ -595,7 +583,7 @@ func (s *agentCore) CtxSz() string { return v } -func (s *agentCore) Usage() string { +func (s *agent) Usage() string { if s.session == nil { s.log.Debug("Usage() no session") return "no active session" @@ -610,30 +598,30 @@ func (s *agentCore) Usage() string { return str } -func (s *agentCore) SystemPrompt() string { - s.log.Debug("SystemPrompt() len=%d", len(s.loopcfg.systemPrompt)) - return s.loopcfg.systemPrompt +func (s *agent) SystemPrompt() string { + s.log.Debug("SystemPrompt() len=%d", len(s.cfg.preamble)) + return s.cfg.preamble } -func (s *agentCore) GenerationParams() backend.GenerationParams { +func (s *agent) GenerationParams() backend.GenerationParams { s.mu.RLock() defer s.mu.RUnlock() - return s.loopcfg.GenerationParams + return s.cfg.GenerationParams } -func (s *agentCore) SetGenerationParams(params backend.GenerationParams) error { +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.loopcfg.GenerationParams = params + s.cfg.GenerationParams = params s.mu.Unlock() return nil } -func (s *agentCore) ListModels() string { +func (s *agent) ListModels() string { s.log.Debug("ListModels()") - models := s.loopcfg.Backend.Models(context.Background()) + models := s.cfg.Backend.Models(context.Background()) return strings.Join(models, "\n") } @@ -659,7 +647,7 @@ func firstSentence(s string) string { // // Continuations (post-turn hook context, unconsumed inject, FIFO drain) are // handled via an explicit loop rather than recursion to avoid stack growth. -func (s *agentCore) Submit(ctx context.Context, input string, handler EventHandler) { +func (s *agent) Submit(ctx context.Context, input string, handler EventHandler) { defer func() { if r := recover(); r != nil { s.log.Error("panic: %v\n%s", r, debug.Stack()) @@ -689,7 +677,7 @@ func (s *agentCore) Submit(ctx context.Context, input string, handler EventHandl // executeTurn runs a single agent turn and returns the next prompt to execute, // or "" if there is nothing more to do. -func (s *agentCore) executeTurn(ctx context.Context, input string, handler EventHandler) string { +func (s *agent) executeTurn(ctx context.Context, input string, handler EventHandler) string { handler(Event{Role: "user", Content: input}) hookResult := s.hooks.Run(ctx, HookPreTurn, map[string]string{ @@ -716,8 +704,11 @@ func (s *agentCore) executeTurn(ctx context.Context, input string, handler Event handler(infoEvent(msg)) } s.startupMessages = nil - s.fireAgentSpawn(ctx, handler) s.session = newSession(input) + if sc := s.spawnContext(ctx, handler); sc != "" { + s.session.appendUserMessage(sc) + } + s.session.appendUserMessage(input) } else { s.session.appendUserMessage(input) } @@ -727,7 +718,7 @@ func (s *agentCore) executeTurn(ctx context.Context, input string, handler Event s.currentAction.Store(handle) var replyBuf strings.Builder - s.loopcfg.Output = func(ev Event) { + s.cfg.Output = func(ev Event) { switch ev.Role { case "assistant": replyBuf.WriteString(ev.Content) @@ -744,7 +735,7 @@ func (s *agentCore) executeTurn(ctx context.Context, input string, handler Event } handler(ev) } - s.loopcfg.PopInject = func() string { + s.cfg.PopInject = func() string { if p := s.pendingInject.Swap(nil); p != nil { return *p } @@ -767,12 +758,15 @@ func (s *agentCore) executeTurn(ctx context.Context, input string, handler Event if pre.Context != "" { s.session.appendUserMessage(pre.Context) } - emit(s.loopcfg, Event{Role: "info", Content: "auto-compacting context...\n"}) + emit(s.cfg, Event{Role: "info", Content: "auto-compacting context...\n"}) s.setState("compacting") - if _, _, err := s.session.compact(ctx, s.loopcfg.Backend); err != nil { + if _, _, err := s.session.compact(ctx, s.cfg.Backend); err != nil { panic(fmt.Sprintf("auto-compact: %v", err)) } s.setState("thinking") + if sc := s.spawnContext(ctx, handler); sc != "" { + s.session.appendUserMessage(sc) + } post := s.hooks.Run(ctx, HookPostCompact, payload, s.log) // TODO: route hook info to debug/err file instead of chat // if post.Ran { @@ -788,7 +782,7 @@ func (s *agentCore) executeTurn(ctx context.Context, input string, handler Event } } - err := run(actCtx, s.loopcfg, s.session) + err := run(actCtx, s.cfg, s.session) actCancel(nil) s.currentAction.CompareAndSwap(handle, nil) diff --git a/pkg/agent/core_test.go b/pkg/agent/core_test.go index 11ea76f..3584ede 100644 --- a/pkg/agent/core_test.go +++ b/pkg/agent/core_test.go @@ -81,9 +81,9 @@ func defaultBE() *mockBackend { return &mockBackend{name: "mock", model: "test", ctxLen: 128000} } -// newCore builds a minimal *agentCore for tests, bypassing loadSystemPrompt -// by directly setting systemPrompt on AgentEnv. -func newCore(t *testing.T, be backend.Backend, hooks Hooks) *agentCore { +// newCore builds a minimal *agent for tests, bypassing loadSystemPrompt +// by directly setting preamble on AgentEnv. +func newCore(t *testing.T, be backend.Backend, hooks Hooks) *agent { t.Helper() if be == nil { be = defaultBE() @@ -93,7 +93,7 @@ func newCore(t *testing.T, be backend.Backend, hooks Hooks) *agentCore { } env := AgentEnv{ Hooks: hooks, - systemPrompt: "test system prompt", + preamble: "test system prompt", } c := NewAgentCore(AgentCoreConfig{ Backend: be, @@ -106,7 +106,7 @@ func newCore(t *testing.T, be backend.Backend, hooks Hooks) *agentCore { NewDispatcher: tools.NewDispatcher, }) t.Cleanup(c.Close) - return c.(*agentCore) + return c.(*agent) } // collectEvents runs Submit synchronously and returns all emitted events. @@ -218,7 +218,7 @@ func TestSubmit_ToolCallStateTransitions(t *testing.T) { c := newCore(t, be, nil) var stateAtExec string - c.loopcfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { + c.cfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { stateAtExec = c.State() return `{}`, nil } @@ -989,7 +989,7 @@ func TestRun_NoExec(t *testing.T) { return textStream("done"), nil } c := newCore(t, be, nil) - c.loopcfg.Exec = nil // no executor + c.cfg.Exec = nil // no executor collectEvents(context.Background(), c, "run tool") if callCount != 2 { t.Errorf("backend called %d times; want 2", callCount) @@ -1555,7 +1555,7 @@ func TestRun_ExecCancelledWithInject(t *testing.T) { c := newCore(t, be, nil) inject := "user interrupt" c.pendingInject.Store(&inject) - c.loopcfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { + c.cfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { cancel() // ctx cancelled during exec return "", fmt.Errorf("exec cancelled") } @@ -1773,8 +1773,16 @@ func TestAutoCompactLimit_DefaultWhenZero(t *testing.T) { func TestAgentSpawn_WithContext(t *testing.T) { c := newCore(t, nil, Hooks{HookAgentSpawn: []string{`echo "spawn context"`}}) collectEvents(context.Background(), c, "first") - if !strings.Contains(c.loopcfg.systemPrompt, "spawn context") { - t.Errorf("system prompt does not contain spawn context: %q", c.loopcfg.systemPrompt) + // spawn context goes into session history, not preamble + found := false + for _, m := range c.session.messages { + if strings.Contains(m.Content, "spawn context") { + found = true + break + } + } + if !found { + t.Errorf("spawn context not found in session history") } } @@ -1945,7 +1953,7 @@ func TestCommand_Backend_Switch(t *testing.T) { if !found { t.Errorf("/backend switch: expected 'injected' in info events; got %v", infos) } - if c.loopcfg.Backend != newBE { + if c.cfg.Backend != newBE { t.Errorf("/backend switch: backend not updated") } } @@ -2052,6 +2060,7 @@ func TestRestoreSession_RoundTrip(t *testing.T) { path := filepath.Join(dir, "sess.json") s := newSession("first user message") + s.appendUserMessage("first user message") s.appendUserMessage("second message") s.messages = append(s.messages, backend.Message{Role: "assistant", Content: "reply"}) @@ -2118,13 +2127,13 @@ func (m *mockEnvServer) get(k string) string { return m.env[k] } -func newCoreWithExecServer(t *testing.T, srv *mockEnvServer) *agentCore { +func newCoreWithExecServer(t *testing.T, srv *mockEnvServer) *agent { t.Helper() d := tools.NewDispatcher() d.AddServer("execute", srv) env := AgentEnv{ Hooks: Hooks{}, - systemPrompt: "test system prompt", + preamble: "test system prompt", dispatcher: d, } c := NewAgentCore(AgentCoreConfig{ @@ -2138,7 +2147,7 @@ func newCoreWithExecServer(t *testing.T, srv *mockEnvServer) *agentCore { NewDispatcher: tools.NewDispatcher, }) t.Cleanup(c.Close) - return c.(*agentCore) + return c.(*agent) } func TestSetEnv_PropagatestoExecuteServer(t *testing.T) { @@ -2228,13 +2237,13 @@ func TestFirstSentence_ShortNoSentenceEnd(t *testing.T) { // --- BuildAgentEnv (nil config path) --- -func setupCfgDir(t *testing.T, systemPrompt string) string { +func setupCfgDir(t *testing.T, preamble string) string { t.Helper() dir := t.TempDir() if err := os.MkdirAll(dir+"/prompts", 0700); err != nil { t.Fatal(err) } - if err := os.WriteFile(dir+"/prompts/SYSTEM_PROMPT.md", []byte(systemPrompt), 0600); err != nil { + if err := os.WriteFile(dir+"/prompts/SYSTEM_PROMPT.md", []byte(preamble), 0600); err != nil { t.Fatal(err) } t.Setenv("OLLIE_CFG_PATH", dir) @@ -2246,8 +2255,8 @@ func TestBuildAgentEnv_NilConfig(t *testing.T) { d := tools.NewDispatcher() env := BuildAgentEnv(nil, d, t.TempDir()) - if env.systemPrompt != "base prompt" { - t.Errorf("systemPrompt = %q; want %q", env.systemPrompt, "base prompt") + if env.preamble != "base prompt" { + t.Errorf("preamble = %q; want %q", env.preamble, "base prompt") } if len(env.Hooks) != 0 { t.Errorf("expected no hooks; got %v", env.Hooks) @@ -2266,11 +2275,12 @@ func TestBuildAgentEnv_AgentPromptAppended(t *testing.T) { cfg := &config.Config{Prompt: "agent suffix"} env := BuildAgentEnv(cfg, d, t.TempDir()) - if !strings.Contains(env.systemPrompt, "base prompt") { - t.Errorf("systemPrompt missing base: %q", env.systemPrompt) + // preamble contains only the system prompt; agent prompt is kept separate + if !strings.Contains(env.preamble, "base prompt") { + t.Errorf("preamble missing base: %q", env.preamble) } - if !strings.Contains(env.systemPrompt, "agent suffix") { - t.Errorf("systemPrompt missing agent suffix: %q", env.systemPrompt) + if env.agentPrompt != "agent suffix" { + t.Errorf("agentPrompt = %q; want %q", env.agentPrompt, "agent suffix") } } @@ -2381,13 +2391,13 @@ func TestBuildAgentEnv_TrustedTools(t *testing.T) { } func TestBuildAgentEnv_AgentPromptNoSystemPrompt(t *testing.T) { - // Empty system prompt file — agentPrompt should be the entire system prompt. + // Empty system prompt file — agentPrompt is kept separate from preamble. setupCfgDir(t, "") d := tools.NewDispatcher() cfg := &config.Config{Prompt: "only agent"} env := BuildAgentEnv(cfg, d, t.TempDir()) - if env.systemPrompt != "only agent" { - t.Errorf("systemPrompt = %q; want %q", env.systemPrompt, "only agent") + if env.agentPrompt != "only agent" { + t.Errorf("agentPrompt = %q; want %q", env.agentPrompt, "only agent") } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index d77be23..b639645 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -15,23 +15,23 @@ const maxRateLimitRetries = 3 type toolExecutor func(ctx context.Context, name string, args json.RawMessage) (string, error) -type loopConfig struct { +type agentConfig struct { Backend backend.Backend Tools []backend.Tool Exec toolExecutor Output EventHandler - systemPrompt string + preamble string // compiled system+agent prompt sent as the system role GenerationParams backend.GenerationParams PopInject func() string // returns and clears pending inject, or "" } -func run(ctx context.Context, cfg loopConfig, state state) error { +func run(ctx context.Context, cfg agentConfig, state state) error { var step int for { history := state.history() - if cfg.systemPrompt != "" { - history = append([]backend.Message{{Role: "system", Content: cfg.systemPrompt}}, history...) + if cfg.preamble != "" { + history = append([]backend.Message{{Role: "system", Content: cfg.preamble}}, history...) } // Stream the assistant's response, retrying on HTTP 429. @@ -243,13 +243,13 @@ func recordInterruption(state state, phase string) { state.update(backend.Message{Role: "assistant", Content: note}, nil) //nolint:errcheck } -func emit(cfg loopConfig, msg Event) { +func emit(cfg agentConfig, msg Event) { if cfg.Output != nil { cfg.Output(msg) } } -func retryCountdown(ctx context.Context, cfg loopConfig, wait time.Duration) error { +func retryCountdown(ctx context.Context, cfg agentConfig, wait time.Duration) error { deadline := time.Now().Add(wait) for { remaining := time.Until(deadline) diff --git a/pkg/agent/session.go b/pkg/agent/session.go index 21f87fe..88f494c 100644 --- a/pkg/agent/session.go +++ b/pkg/agent/session.go @@ -75,11 +75,10 @@ type Session struct { Estimated bool // true if any usage was estimated rather than reported by the backend } -// newSession creates a new Session with an initial user message. +// newSession creates a new empty Session. The caller is responsible for +// appending the initial user message via appendUserMessage. func newSession(goal string) *Session { - s := &Session{goal: goal} - s.messages = append(s.messages, backend.Message{Role: "user", Content: goal}) - return s + return &Session{goal: goal} } func (s *Session) history() []backend.Message { diff --git a/pkg/agent/waitchange_test.go b/pkg/agent/waitchange_test.go index 58ffdd0..71fe308 100644 --- a/pkg/agent/waitchange_test.go +++ b/pkg/agent/waitchange_test.go @@ -10,9 +10,9 @@ import ( olog "ollie/pkg/log" ) -// newTestCore returns a minimal agentCore with changeCond wired up. -func newTestCore(initialState string) *agentCore { - a := &agentCore{ +// newTestCore returns a minimal agent with changeCond wired up. +func newTestCore(initialState string) *agent { + a := &agent{ state: initialState, log: olog.NewWriter("test", olog.LevelError+1, io.Discard, io.Discard), }