agent: refactor prompt architecture and session lifecycle

- 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 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-25 19:38:18 +02:00
parent d304014ba3
commit 77dd5c6168
6 changed files with 151 additions and 146 deletions

View File

@ -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) {

View File

@ -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)

View File

@ -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")
}
}

View File

@ -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)

View File

@ -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 {

View File

@ -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),
}