diff --git a/pkg/agent/commands.go b/pkg/agent/commands.go index 17aec46..64f4b32 100644 --- a/pkg/agent/commands.go +++ b/pkg/agent/commands.go @@ -94,7 +94,7 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { "/backend": func(args []string) { if len(args) == 0 { - s.emit(infoEvent(s.cfg.Backend.Name())) + s.emit(infoEvent(s.runtime.Backend.Name())) return } if s.IsRunning() { @@ -106,18 +106,18 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { s.emit(infoEvent(fmt.Sprintf("error: failed to switch backend: %v", err))) return } - s.cfg.Backend = be + s.runtime.Backend = be s.emit(infoEvent(fmt.Sprintf("switched backend to: %s (model: %s)", be.Name(), be.Model()))) }, "/models": func(args []string) { - models := s.cfg.Backend.Models(ctx) + models := s.runtime.Backend.Models(ctx) if len(models) == 0 { s.emit(infoEvent("no models available")) return } slices.Sort(models) - current := s.cfg.Backend.Model() + current := s.runtime.Backend.Model() for _, m := range models { marker := " " if m == current { @@ -129,19 +129,19 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { "/model": func(args []string) { if len(args) == 0 { - s.emit(infoEvent(s.cfg.Backend.Model())) + s.emit(infoEvent(s.runtime.Backend.Model())) return } - s.cfg.Backend.SetModel(args[0]) + s.runtime.Backend.SetModel(args[0]) s.emit(infoEvent("switched model to: " + args[0])) }, "/maxsteps": func(args []string) { if len(args) == 0 { - if s.cfg.MaxSteps == 0 { + if s.runtime.MaxSteps == 0 { s.emit(infoEvent("maxsteps: unlimited")) } else { - s.emit(infoEvent(fmt.Sprintf("maxsteps: %d", s.cfg.MaxSteps))) + s.emit(infoEvent(fmt.Sprintf("maxsteps: %d", s.runtime.MaxSteps))) } return } @@ -150,7 +150,7 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { s.emit(infoEvent("error: maxsteps requires a non-negative integer (0 = unlimited)")) return } - s.cfg.MaxSteps = n + s.runtime.MaxSteps = n if n == 0 { s.emit(infoEvent("maxsteps: unlimited")) } else { @@ -211,31 +211,28 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { return } d := s.newDispatcher() - env := BuildAgentEnv(cfg, d, s.cwd, []string{"OLLIE_SESSION_ID=" + s.sessionID, "OLLIE=" + execute.MountPath(s.uname)}) - if env.Backend != "" { - newBe, err := s.newBackend(env.Backend) + rt := BuildRuntime(cfg, d, s.cwd, []string{"OLLIE_SESSION_ID=" + s.sessionID, "OLLIE=" + execute.MountPath(s.uname)}) + if rt.CfgBackend != "" { + newBe, err := s.newBackend(rt.CfgBackend) if err != nil { - s.emit(infoEvent(fmt.Sprintf("error: backend %q: %v", env.Backend, err))) + s.emit(infoEvent(fmt.Sprintf("error: backend %q: %v", rt.CfgBackend, err))) return } - if env.Model != "" { - newBe.SetModel(env.Model) + if rt.CfgModel != "" { + newBe.SetModel(rt.CfgModel) + } + rt.Backend = newBe + } else { + rt.Backend = s.runtime.Backend + if rt.CfgModel != "" { + rt.Backend.SetModel(rt.CfgModel) } - s.cfg.Backend = newBe - } else if env.Model != "" { - s.cfg.Backend.SetModel(env.Model) } - s.dispatcher = env.dispatcher - s.hooks = env.Hooks - s.cfg.preamble = env.preamble - s.cfg.Tools = env.tools - s.cfg.Exec = env.exec - s.cfg.GenerationParams = env.genParams - s.cfg.MaxSteps = env.maxSteps + s.runtime = rt s.agentName = name s.session = nil s.pushSessionEnv() - for _, msg := range env.Messages { + for _, msg := range rt.Messages { s.emit(infoEvent(msg)) } s.emit(infoEvent("agent: " + name)) @@ -281,7 +278,7 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { s.emit(infoEvent("no active session")) return } - ctxLen := s.cfg.Backend.ContextLength(ctx) + ctxLen := s.runtime.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } @@ -305,7 +302,7 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { s.emit(infoEvent("no active session")) return } - ctxLen := s.cfg.Backend.ContextLength(ctx) + ctxLen := s.runtime.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } @@ -403,7 +400,7 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { "/tools": func(args []string) { listMountDir("t") }, "/sp": func(args []string) { - s.emit(infoEvent(s.cfg.preamble)) + s.emit(infoEvent(s.runtime.Preamble)) }, "/help": func(args []string) { diff --git a/pkg/agent/core.go b/pkg/agent/core.go index ecf935e..c8185cd 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -29,29 +29,15 @@ import ( // with other read-class tools. nil means treat all tools as serial. type toolClassifier func(name string) bool -// AgentEnv holds the runtime state derived from an agent config file. -type AgentEnv struct { - dispatcher tools.Dispatcher - tools []backend.Tool - exec toolExecutor - classifyTool toolClassifier - classifyTier func(name string) ResultTier - Hooks Hooks - preamble string - genParams backend.GenerationParams - maxSteps int - Backend string // from config; empty means no override - Model string // from config; empty means no override - Messages []string -} -// BuildAgentEnv constructs an AgentEnv from a pre-configured Dispatcher and + +// BuildRuntime constructs a Runtime from a pre-configured Dispatcher and // optional agent config. cwd sets the working directory reported in the // system prompt; if empty, the process working directory is used. // env provides additional environment variables injected into prompt resolution // subprocesses (e.g. OLLIE_SESSION_ID=xxx). // The caller is responsible for registering all servers on d before calling this. -func BuildAgentEnv(cfg *config.Config, d tools.Dispatcher, cwd string, env []string) AgentEnv { +func BuildRuntime(cfg *config.Config, d tools.Dispatcher, cwd string, env []string) *Runtime { var messages []string var allToolInfos []tools.ToolInfo @@ -157,18 +143,18 @@ func BuildAgentEnv(cfg *config.Config, d tools.Dispatcher, cwd string, env []str modelName = cfg.Model } - return AgentEnv{ - dispatcher: d, - tools: allTools, - exec: exec, - classifyTool: classify, - classifyTier: tierFn, + return &Runtime{ + Dispatcher: d, + Tools: allTools, + Exec: exec, + ClassifyTool: classify, + ClassifyTier: tierFn, Hooks: hooks, - preamble: preamble, - genParams: genParams, - maxSteps: maxSteps, - Backend: backendName, - Model: modelName, + Preamble: preamble, + GenParams: genParams, + MaxSteps: maxSteps, + CfgBackend: backendName, + CfgModel: modelName, Messages: messages, } } @@ -243,7 +229,7 @@ type AgentCoreConfig struct { Uname string // immutable user principal for 9P identity CWD string // working directory for tool execution and system prompt Session *Session - Env AgentEnv + Runtime *Runtime NewDispatcher func() tools.Dispatcher NewBackend func(string) (backend.Backend, error) // if nil, defaults to backend.NewWithName Log *olog.Logger // if nil, logging is disabled @@ -256,17 +242,18 @@ type AgentCoreConfig struct { // but has no knowledge of how output is rendered. type agent struct { session *Session - cfg agentConfig - hooks Hooks + runtime *Runtime + cfg agentConfig // per-turn config built from runtime; set in executeTurn log *olog.Logger agentName string agentsDir string sessionsDir string sessionID string uname string - dispatcher tools.Dispatcher newDispatcher func() tools.Dispatcher newBackend func(string) (backend.Backend, error) + readPlanStep func() string + turnError func(ctx context.Context, errType, errMsg string) HookResult // overridable for tests cwd string startupMessages []string currentAction atomic.Pointer[actionHandle] @@ -301,10 +288,10 @@ func (s *agent) SetEnv(key, value string) { } s.env[key] = value s.envMu.Unlock() - if s.dispatcher == nil { + if s.runtime == nil || s.runtime.Dispatcher == nil { return } - if srv, ok := s.dispatcher.GetServer("execute"); ok { + if srv, ok := s.runtime.Dispatcher.GetServer("execute"); ok { if es, ok := srv.(tools.EnvSetter); ok { es.SetEnv(key, value) } @@ -313,10 +300,10 @@ func (s *agent) SetEnv(key, value string) { // pushSessionEnv injects OLLIE_SESSION_ID into the execute server subprocess env. func (s *agent) pushSessionEnv() { - if s.dispatcher == nil || s.sessionID == "" { + if s.runtime == nil || s.runtime.Dispatcher == nil || s.sessionID == "" { return } - if srv, ok := s.dispatcher.GetServer("execute"); ok { + if srv, ok := s.runtime.Dispatcher.GetServer("execute"); ok { if es, ok := srv.(tools.EnvSetter); ok { es.SetEnv("OLLIE_SESSION_ID", s.sessionID) if s.uname != "" { @@ -328,10 +315,10 @@ func (s *agent) pushSessionEnv() { // pushLockDir sets the flock directory on the execute server to the session tmpdir. func (s *agent) pushLockDir() { - if s.dispatcher == nil || s.sessionID == "" { + if s.runtime == nil || s.runtime.Dispatcher == nil || s.sessionID == "" { return } - if srv, ok := s.dispatcher.GetServer("execute"); ok { + if srv, ok := s.runtime.Dispatcher.GetServer("execute"); ok { if ls, ok := srv.(tools.LockDirSetter); ok { ls.SetLockDir(filepath.Join(ollieTmpDir(), s.sessionID)) } @@ -385,28 +372,23 @@ func NewAgentCore(cfg AgentCoreConfig) Core { if cfg.NewBackend == nil { cfg.NewBackend = backend.NewWithName } - run := agentConfig{ - Backend: cfg.Backend, - preamble: cfg.Env.preamble, - Tools: cfg.Env.tools, - Exec: cfg.Env.exec, - ClassifyTool: cfg.Env.classifyTool, - ClassifyTier: cfg.Env.classifyTier, - ToolResultMaxBytes: defaultToolResultMaxBytes, - GenerationParams: cfg.Env.genParams, - MaxSteps: cfg.Env.maxSteps, + rt := cfg.Runtime + if rt == nil { + rt = &Runtime{} } + // Store the backend on the runtime so it's the single source of truth. + rt.Backend = cfg.Backend // A non-zero MaxSteps in AgentCoreConfig takes precedence over the - // value loaded from the agent JSON, allowing callers to impose a - // per-session limit at construction time (e.g. via a CLI flag). + // value loaded from the agent JSON. if cfg.MaxSteps > 0 { - run.MaxSteps = cfg.MaxSteps + rt.MaxSteps = cfg.MaxSteps } + var readPlanStep func() string if cfg.SessionID != "" { os.MkdirAll(filepath.Join(ollieTmpDir(), cfg.SessionID), 0700) //nolint:errcheck if mount := os.Getenv("OLLIE"); mount != "" { planPath := filepath.Join(mount, "s", cfg.SessionID, "plan") - run.ReadPlanStep = func() string { + readPlanStep = func() string { return readNextUnchecked(planPath) } } @@ -419,8 +401,7 @@ func NewAgentCore(cfg AgentCoreConfig) Core { a := &agent{ session: cfg.Session, - cfg: run, - hooks: cfg.Env.Hooks, + runtime: rt, log: log, auditLog: log.Sub("audit"), agentName: cfg.AgentName, @@ -429,37 +410,35 @@ func NewAgentCore(cfg AgentCoreConfig) Core { sessionID: cfg.SessionID, uname: cfg.Uname, cwd: paths.ExpandHome(cfg.CWD), - startupMessages: cfg.Env.Messages, - dispatcher: cfg.Env.dispatcher, + startupMessages: rt.Messages, newDispatcher: cfg.NewDispatcher, newBackend: cfg.NewBackend, + readPlanStep: readPlanStep, state: "idle", bus: pubsub.NewBus(), } a.changeCond = sync.NewCond(&a.changeMu) - a.pushSessionEnv() - a.pushLockDir() - a.cfg.TurnError = func(_ context.Context, errType, errMsg string) HookResult { - // Use a detached context so the hook can safely write "stop" to ctl - // without cancelling its own execution via the action context. + a.turnError = func(_ context.Context, errType, errMsg string) HookResult { hookCtx, cancel := context.WithTimeout(context.Background(), time.Duration(hookTimeout)*time.Second) defer cancel() - return a.hooks.Run(hookCtx, HookTurnError, map[string]string{ + return a.runtime.Hooks.Run(hookCtx, HookTurnError, map[string]string{ "session_id": a.sessionID, "cwd": a.CWD(), - "model": a.cfg.Backend.Model(), + "model": a.runtime.Backend.Model(), "error_type": errType, "error": errMsg, }, a.log) } + a.pushSessionEnv() + a.pushLockDir() return a } // Close releases resources for this session, including its tmpdir. func (s *agent) Close() { s.log.Debug("Close() session=%q", s.sessionID) - if s.dispatcher != nil { - if srv, ok := s.dispatcher.GetServer("execute"); ok { + if s.runtime != nil && s.runtime.Dispatcher != nil { + if srv, ok := s.runtime.Dispatcher.GetServer("execute"); ok { if c, ok := srv.(interface{ Close() }); ok { s.log.Debug("Close() calling execute.Close()") c.Close() @@ -477,12 +456,12 @@ func (s *agent) AgentName() string { return v } func (s *agent) BackendName() string { - v := s.cfg.Backend.Name() + v := s.runtime.Backend.Name() s.log.Debug("BackendName() = %q", v) return v } func (s *agent) ModelName() string { - v := s.cfg.Backend.Model() + v := s.runtime.Backend.Model() s.log.Debug("ModelName() = %q", v) return v } @@ -574,8 +553,8 @@ func (s *agent) SetCWD(dir string) error { } s.cwd = dir // Propagate to any tool server that knows how to handle it (e.g. execute). - if s.dispatcher != nil { - if srv, ok := s.dispatcher.GetServer("execute"); ok { + if s.runtime != nil && s.runtime.Dispatcher != nil { + if srv, ok := s.runtime.Dispatcher.GetServer("execute"); ok { if ws, ok := srv.(tools.CWDSetter); ok { ws.SetCWD(dir) } @@ -606,7 +585,7 @@ func (s *agent) SetSessionID(newID string) error { } s.sessionID = newID // Update session ID references in the system prompt. - s.cfg.preamble = strings.ReplaceAll(s.cfg.preamble, oldID, newID) + s.runtime.Preamble = strings.ReplaceAll(s.runtime.Preamble, oldID, newID) // Rename tmpdir so isread markers remain valid after rename. oldTemp := filepath.Join(ollieTmpDir(), oldID) newTemp := filepath.Join(ollieTmpDir(), newID) @@ -625,7 +604,7 @@ const defaultToolResultMaxBytes = 131072 // autoCompactLimit returns the token threshold for auto-compaction (75%). func (s *agent) autoCompactLimit(ctx context.Context) int { - ctxLen := s.cfg.Backend.ContextLength(ctx) + ctxLen := s.runtime.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } @@ -634,7 +613,7 @@ func (s *agent) autoCompactLimit(ctx context.Context) int { // autoWarnLimit returns the token threshold for a context-usage warning (60%). func (s *agent) autoWarnLimit(ctx context.Context) int { - ctxLen := s.cfg.Backend.ContextLength(ctx) + ctxLen := s.runtime.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } @@ -645,11 +624,11 @@ func (s *agent) autoWarnLimit(ctx context.Context) int { // point (session start, post-clear, post-compaction). It combines the // agent-specific prompt with any agentSpawn hook output. func (s *agent) spawnContext(ctx context.Context) string { - result := s.hooks.Run(ctx, HookAgentSpawn, map[string]string{ + result := s.runtime.Hooks.Run(ctx, HookAgentSpawn, map[string]string{ "session_id": s.sessionID, "agent": s.agentName, "cwd": s.CWD(), - "model": s.cfg.Backend.Model(), + "model": s.runtime.Backend.Model(), }, s.log) if result.Warning != "" { s.emit(infoEvent(result.Warning)) @@ -666,7 +645,7 @@ func (s *agent) spawnContext(ctx context.Context) string { // the pre-hook blocked or there was nothing to compact. Caller manages setState. func (s *agent) runCompact(ctx context.Context, trigger string) (int, error) { payload := map[string]string{"session_id": s.sessionID, "trigger": trigger, "cwd": s.CWD()} - pre := s.hooks.Run(ctx, HookPreCompact, payload, s.log) + pre := s.runtime.Hooks.Run(ctx, HookPreCompact, payload, s.log) if pre.Warning != "" { s.emit(infoEvent(pre.Warning)) } @@ -677,7 +656,7 @@ func (s *agent) runCompact(ctx context.Context, trigger string) (int, error) { if pre.Context != "" { s.session.appendUserMessage(pre.Context) } - n, _, err := s.session.compact(ctx, s.cfg.Backend) + n, _, err := s.session.compact(ctx, s.runtime.Backend) if err != nil { return 0, err } @@ -688,7 +667,7 @@ func (s *agent) runCompact(ctx context.Context, trigger string) (int, error) { s.session.appendUserMessage(sc) } } - post := s.hooks.Run(ctx, HookPostCompact, payload, s.log) + post := s.runtime.Hooks.Run(ctx, HookPostCompact, payload, s.log) if post.Warning != "" { s.emit(infoEvent(post.Warning)) } @@ -783,7 +762,7 @@ func (s *agent) CtxSz() string { s.log.Debug("CtxSz() no session") return "no active session" } - ctxLen := s.cfg.Backend.ContextLength(context.Background()) + ctxLen := s.runtime.Backend.ContextLength(context.Background()) if ctxLen <= 0 { ctxLen = defaultContextLength } @@ -827,21 +806,21 @@ func (s *agent) Context() []backend.Message { msgs = slices.Clone(s.session.history()) } s.mu.RUnlock() - if s.cfg.preamble != "" { - msgs = append([]backend.Message{{Role: "system", Content: s.cfg.preamble}}, msgs...) + if s.runtime.Preamble != "" { + msgs = append([]backend.Message{{Role: "system", Content: s.runtime.Preamble}}, msgs...) } return msgs } func (s *agent) SystemPrompt() string { - s.log.Debug("SystemPrompt() len=%d", len(s.cfg.preamble)) - return s.cfg.preamble + s.log.Debug("SystemPrompt() len=%d", len(s.runtime.Preamble)) + return s.runtime.Preamble } func (s *agent) GenerationParams() backend.GenerationParams { s.mu.RLock() defer s.mu.RUnlock() - return s.cfg.GenerationParams + return s.runtime.GenParams } func (s *agent) SetGenerationParams(params backend.GenerationParams) error { @@ -849,14 +828,14 @@ func (s *agent) SetGenerationParams(params backend.GenerationParams) error { return fmt.Errorf("cannot change params while agent is running") } s.mu.Lock() - s.cfg.GenerationParams = params + s.runtime.GenParams = params s.mu.Unlock() return nil } func (s *agent) ListModels() string { s.log.Debug("ListModels()") - models := s.cfg.Backend.Models(context.Background()) + models := s.runtime.Backend.Models(context.Background()) slices.Sort(models) return strings.Join(models, "\n") } @@ -933,7 +912,7 @@ func (s *agent) Submit(ctx context.Context, input string) { func (s *agent) executeTurn(ctx context.Context, input string) string { s.emit(Event{Role: "user", Content: input}) - hookResult := s.hooks.Run(ctx, HookPreTurn, map[string]string{ + hookResult := s.runtime.Hooks.Run(ctx, HookPreTurn, map[string]string{ "session_id": s.sessionID, "cwd": s.CWD(), "prompt": input, @@ -979,6 +958,21 @@ func (s *agent) executeTurn(ctx context.Context, input string) string { s.auditLog.Debug("turn: start input=%s session=%s", auditTruncate(input), s.sessionID) + // Build per-turn agentConfig from the current runtime. + s.cfg = agentConfig{ + Backend: s.runtime.Backend, + preamble: s.runtime.Preamble, + Tools: s.runtime.Tools, + Exec: s.runtime.Exec, + ClassifyTool: s.runtime.ClassifyTool, + ClassifyTier: s.runtime.ClassifyTier, + ToolResultMaxBytes: s.runtime.toolResultMaxBytes(), + GenerationParams: s.runtime.GenParams, + MaxSteps: s.runtime.MaxSteps, + ReadPlanStep: s.readPlanStep, + TurnError: s.turnError, + } + var replyBuf strings.Builder s.cfg.Output = func(ev Event) { switch ev.Role { @@ -1023,7 +1017,7 @@ func (s *agent) executeTurn(ctx context.Context, input string) string { return "" } s.cfg.PreTool = func(ctx context.Context, name string, args json.RawMessage) HookResult { - return s.hooks.Run(ctx, HookPreTool, map[string]string{ + return s.runtime.Hooks.Run(ctx, HookPreTool, map[string]string{ "session_id": s.sessionID, "cwd": s.CWD(), "tool": name, @@ -1031,7 +1025,7 @@ func (s *agent) executeTurn(ctx context.Context, input string) string { }, s.log) } s.cfg.PostTool = func(ctx context.Context, name string, args json.RawMessage, result string) HookResult { - return s.hooks.Run(ctx, HookPostTool, map[string]string{ + return s.runtime.Hooks.Run(ctx, HookPostTool, map[string]string{ "session_id": s.sessionID, "cwd": s.CWD(), "tool": name, @@ -1164,7 +1158,7 @@ func (s *agent) executeTurn(ctx context.Context, input string) string { return "" } - stopResult := s.hooks.Run(ctx, HookPostTurn, map[string]string{ + stopResult := s.runtime.Hooks.Run(ctx, HookPostTurn, map[string]string{ "session_id": s.sessionID, "cwd": s.CWD(), }, s.log) diff --git a/pkg/agent/core_test.go b/pkg/agent/core_test.go index ae1a9e8..8f9e021 100644 --- a/pkg/agent/core_test.go +++ b/pkg/agent/core_test.go @@ -83,7 +83,7 @@ func defaultBE() *mockBackend { } // newCore builds a minimal *agent for tests, bypassing loadSystemPrompt -// by directly setting preamble on AgentEnv. +// by directly setting Preamble on Runtime. func newCore(t *testing.T, be backend.Backend, hooks Hooks) *agent { t.Helper() t.Setenv("OLLIE", "") @@ -93,9 +93,9 @@ func newCore(t *testing.T, be backend.Backend, hooks Hooks) *agent { if hooks == nil { hooks = Hooks{} } - env := AgentEnv{ + env := &Runtime{ Hooks: hooks, - preamble: "test system prompt", + Preamble: "test system prompt", } c := NewAgentCore(AgentCoreConfig{ Backend: be, @@ -104,7 +104,7 @@ func newCore(t *testing.T, be backend.Backend, hooks Hooks) *agent { SessionsDir: t.TempDir(), SessionID: NewSessionID(), CWD: t.TempDir(), - Env: env, + Runtime: env, NewDispatcher: tools.NewDispatcher, }) t.Cleanup(c.Close) @@ -229,7 +229,7 @@ func TestSubmit_ToolCallStateTransitions(t *testing.T) { c := newCore(t, be, nil) var stateAtExec string - c.cfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { + c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { stateAtExec = c.State() return `{}`, nil } @@ -993,7 +993,7 @@ func TestRun_NoExec(t *testing.T) { return textStream("done"), nil } c := newCore(t, be, nil) - c.cfg.Exec = nil // no executor + c.runtime.Exec = nil // no executor collectEvents(context.Background(), c, "run tool") if callCount != 2 { t.Errorf("backend called %d times; want 2", callCount) @@ -1563,7 +1563,7 @@ func TestRun_ExecCancelledWithInject(t *testing.T) { c := newCore(t, be, nil) inject := "user interrupt" c.pendingInject.Store(&inject) - c.cfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { + c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { cancel() // ctx cancelled during exec return "", fmt.Errorf("exec cancelled") } @@ -1742,8 +1742,8 @@ func TestSetSessionID_Rename(t *testing.T) { func TestSetSessionID_UpdatesPreamble(t *testing.T) { oldID := NewSessionID() - env := AgentEnv{ - preamble: "session is " + oldID + " end", + env := &Runtime{ + Preamble: "session is " + oldID + " end", } c := NewAgentCore(AgentCoreConfig{ Backend: defaultBE(), @@ -1752,7 +1752,7 @@ func TestSetSessionID_UpdatesPreamble(t *testing.T) { SessionsDir: t.TempDir(), SessionID: oldID, CWD: t.TempDir(), - Env: env, + Runtime: env, NewDispatcher: tools.NewDispatcher, }).(*agent) t.Cleanup(c.Close) @@ -1995,7 +1995,7 @@ func TestCommand_Backend_Switch(t *testing.T) { if !found { t.Errorf("/backend switch: expected 'injected' in info events; got %v", infos) } - if c.cfg.Backend != newBE { + if c.runtime.Backend != newBE { t.Errorf("/backend switch: backend not updated") } } @@ -2174,10 +2174,10 @@ func newCoreWithExecServer(t *testing.T, srv *mockEnvServer) *agent { t.Setenv("OLLIE", "") d := tools.NewDispatcher() d.AddServer("execute", srv) - env := AgentEnv{ + env := &Runtime{ Hooks: Hooks{}, - preamble: "test system prompt", - dispatcher: d, + Preamble: "test system prompt", + Dispatcher: d, } c := NewAgentCore(AgentCoreConfig{ Backend: defaultBE(), @@ -2186,7 +2186,7 @@ func newCoreWithExecServer(t *testing.T, srv *mockEnvServer) *agent { SessionsDir: t.TempDir(), SessionID: NewSessionID(), CWD: t.TempDir(), - Env: env, + Runtime: env, NewDispatcher: tools.NewDispatcher, }) t.Cleanup(c.Close) @@ -2242,7 +2242,7 @@ func TestSetEnv_ShellCommandSeesEnv(t *testing.T) { func TestSetEnv_NilDispatcher_NoPanic(t *testing.T) { c := newCore(t, nil, nil) - c.dispatcher = nil + c.runtime.Dispatcher = nil // Must not panic. c.SetEnv("K", "V") if c.env["K"] != "V" { @@ -2278,7 +2278,7 @@ func TestFirstSentence_ShortNoSentenceEnd(t *testing.T) { } } -// --- BuildAgentEnv (nil config path) --- +// --- BuildRuntime (nil config path) --- func setupCfgDir(t *testing.T) string { t.Helper() @@ -2287,37 +2287,37 @@ func setupCfgDir(t *testing.T) string { return dir } -func TestBuildAgentEnv_NilConfig(t *testing.T) { +func TestBuildRuntime_NilConfig(t *testing.T) { setupCfgDir(t) d := tools.NewDispatcher() - env := BuildAgentEnv(nil, d, t.TempDir(), nil) + env := BuildRuntime(nil, d, t.TempDir(), nil) - if env.preamble != "" { - t.Errorf("preamble = %q; want empty", env.preamble) + if env.Preamble != "" { + t.Errorf("preamble = %q; want empty", env.Preamble) } if len(env.Hooks) != 0 { t.Errorf("expected no hooks; got %v", env.Hooks) } - if len(env.tools) != 0 { - t.Errorf("expected no tools; got %v", env.tools) + if len(env.Tools) != 0 { + t.Errorf("expected no tools; got %v", env.Tools) } if len(env.Messages) != 0 { t.Errorf("expected no startup messages; got %v", env.Messages) } } -func TestBuildAgentEnv_PromptBecomesPreamble(t *testing.T) { +func TestBuildRuntime_PromptBecomesPreamble(t *testing.T) { setupCfgDir(t) d := tools.NewDispatcher() cfg := &config.Config{Prompt: config.Prompt{Value: []string{"the prompt"}}} - env := BuildAgentEnv(cfg, d, t.TempDir(), nil) + env := BuildRuntime(cfg, d, t.TempDir(), nil) - if env.preamble != "the prompt" { - t.Errorf("preamble = %q; want %q", env.preamble, "the prompt") + if env.Preamble != "the prompt" { + t.Errorf("preamble = %q; want %q", env.Preamble, "the prompt") } } -func TestBuildAgentEnv_HooksAndParams(t *testing.T) { +func TestBuildRuntime_HooksAndParams(t *testing.T) { setupCfgDir(t) d := tools.NewDispatcher() temp := 0.7 @@ -2326,31 +2326,31 @@ func TestBuildAgentEnv_HooksAndParams(t *testing.T) { MaxTokens: 512, Temperature: &temp, } - env := BuildAgentEnv(cfg, d, t.TempDir(), nil) + env := BuildRuntime(cfg, d, t.TempDir(), nil) if cmds := env.Hooks[HookPreTurn]; len(cmds) != 1 || cmds[0] != "echo hi" { t.Errorf("Hooks[preTurn] = %v", cmds) } - if env.genParams.MaxTokens != 512 { - t.Errorf("MaxTokens = %d; want 512", env.genParams.MaxTokens) + if env.GenParams.MaxTokens != 512 { + t.Errorf("MaxTokens = %d; want 512", env.GenParams.MaxTokens) } - if env.genParams.Temperature == nil || *env.genParams.Temperature != 0.7 { - t.Errorf("Temperature = %v; want 0.7", env.genParams.Temperature) + if env.GenParams.Temperature == nil || *env.GenParams.Temperature != 0.7 { + t.Errorf("Temperature = %v; want 0.7", env.GenParams.Temperature) } } -func TestBuildAgentEnv_ExecUnknownTool(t *testing.T) { +func TestBuildRuntime_ExecUnknownTool(t *testing.T) { setupCfgDir(t) d := tools.NewDispatcher() - env := BuildAgentEnv(nil, d, t.TempDir(), nil) + env := BuildRuntime(nil, d, t.TempDir(), nil) - _, err := env.exec(context.Background(), "no_such_tool", json.RawMessage(`{}`)) + _, err := env.Exec(context.Background(), "no_such_tool", json.RawMessage(`{}`)) if err == nil || !strings.Contains(err.Error(), "unknown tool") { t.Errorf("expected unknown tool error; got %v", err) } } -// --- mock dispatcher for BuildAgentEnv tests --- +// --- mock dispatcher for BuildRuntime tests --- type mockDispatcher struct { tools []tools.ToolInfo @@ -2379,64 +2379,64 @@ func (m *mockDispatcher) Dispatch(ctx context.Context, server, tool string, args return nil, fmt.Errorf("not implemented") } -func TestBuildAgentEnv_ListToolsError(t *testing.T) { +func TestBuildRuntime_ListToolsError(t *testing.T) { setupCfgDir(t) d := &mockDispatcher{listErr: fmt.Errorf("boom")} - env := BuildAgentEnv(nil, d, t.TempDir(), nil) + env := BuildRuntime(nil, d, t.TempDir(), nil) if len(env.Messages) != 1 || !strings.Contains(env.Messages[0], "boom") { t.Errorf("Messages = %v", env.Messages) } } -func TestBuildAgentEnv_ToolsPopulated(t *testing.T) { +func TestBuildRuntime_ToolsPopulated(t *testing.T) { setupCfgDir(t) d := &mockDispatcher{ tools: []tools.ToolInfo{{Server: "s1", Name: "mytool", Description: "desc", InputSchema: json.RawMessage(`{}`)}}, } - env := BuildAgentEnv(nil, d, t.TempDir(), nil) - if len(env.tools) != 1 || env.tools[0].Name != "mytool" { - t.Errorf("tools = %+v", env.tools) + env := BuildRuntime(nil, d, t.TempDir(), nil) + if len(env.Tools) != 1 || env.Tools[0].Name != "mytool" { + t.Errorf("tools = %+v", env.Tools) } } -func TestBuildAgentEnv_ToolsDisabled(t *testing.T) { +func TestBuildRuntime_ToolsDisabled(t *testing.T) { setupCfgDir(t) d := &mockDispatcher{ tools: []tools.ToolInfo{{Server: "s1", Name: "mytool", Description: "desc", InputSchema: json.RawMessage(`{}`)}}, } f := false cfg := &config.Config{Tools: &f} - env := BuildAgentEnv(cfg, d, t.TempDir(), nil) - if len(env.tools) != 0 { - t.Errorf("expected no tools when disabled; got %+v", env.tools) + env := BuildRuntime(cfg, d, t.TempDir(), nil) + if len(env.Tools) != 0 { + t.Errorf("expected no tools when disabled; got %+v", env.Tools) } } -func TestBuildAgentEnv_PromptOnly(t *testing.T) { +func TestBuildRuntime_PromptOnly(t *testing.T) { setupCfgDir(t) d := tools.NewDispatcher() cfg := &config.Config{Prompt: config.Prompt{Value: []string{"only agent"}}} - env := BuildAgentEnv(cfg, d, t.TempDir(), nil) - if env.preamble != "only agent" { - t.Errorf("preamble = %q; want %q", env.preamble, "only agent") + env := BuildRuntime(cfg, d, t.TempDir(), nil) + if env.Preamble != "only agent" { + t.Errorf("preamble = %q; want %q", env.Preamble, "only agent") } } -func TestBuildAgentEnv_ExecPrompt(t *testing.T) { +func TestBuildRuntime_ExecPrompt(t *testing.T) { setupCfgDir(t) d := tools.NewDispatcher() cfg := &config.Config{Prompt: config.Prompt{ Value: []string{"echo hello", "echo 'You are a security auditor.'", "echo world"}, IsExec: true, }} - env := BuildAgentEnv(cfg, d, t.TempDir(), nil) + env := BuildRuntime(cfg, d, t.TempDir(), nil) want := "hello\nYou are a security auditor.\nworld" - if env.preamble != want { - t.Errorf("preamble = %q; want %q", env.preamble, want) + if env.Preamble != want { + t.Errorf("preamble = %q; want %q", env.Preamble, want) } } -func TestBuildAgentEnv_ExecDispatchSuccess(t *testing.T) { +func TestBuildRuntime_ExecDispatchSuccess(t *testing.T) { setupCfgDir(t) d := &mockDispatcher{ tools: []tools.ToolInfo{{Server: "s1", Name: "mytool"}}, @@ -2444,8 +2444,8 @@ func TestBuildAgentEnv_ExecDispatchSuccess(t *testing.T) { return json.RawMessage(`{"content":[{"type":"text","text":"ok"}]}`), nil }, } - env := BuildAgentEnv(nil, d, t.TempDir(), nil) - result, err := env.exec(context.Background(), "mytool", json.RawMessage(`{}`)) + env := BuildRuntime(nil, d, t.TempDir(), nil) + result, err := env.Exec(context.Background(), "mytool", json.RawMessage(`{}`)) if err != nil { t.Fatal(err) } @@ -2454,7 +2454,7 @@ func TestBuildAgentEnv_ExecDispatchSuccess(t *testing.T) { } } -func TestBuildAgentEnv_ExecDispatchError(t *testing.T) { +func TestBuildRuntime_ExecDispatchError(t *testing.T) { setupCfgDir(t) d := &mockDispatcher{ tools: []tools.ToolInfo{{Server: "s1", Name: "mytool"}}, @@ -2462,14 +2462,14 @@ func TestBuildAgentEnv_ExecDispatchError(t *testing.T) { return nil, fmt.Errorf("dispatch failed") }, } - env := BuildAgentEnv(nil, d, t.TempDir(), nil) - _, err := env.exec(context.Background(), "mytool", json.RawMessage(`{}`)) + env := BuildRuntime(nil, d, t.TempDir(), nil) + _, err := env.Exec(context.Background(), "mytool", json.RawMessage(`{}`)) if err == nil || !strings.Contains(err.Error(), "dispatch failed") { t.Errorf("err = %v", err) } } -func TestBuildAgentEnv_ExecToolResultIsError(t *testing.T) { +func TestBuildRuntime_ExecToolResultIsError(t *testing.T) { setupCfgDir(t) d := &mockDispatcher{ tools: []tools.ToolInfo{{Server: "s1", Name: "mytool"}}, @@ -2477,8 +2477,8 @@ func TestBuildAgentEnv_ExecToolResultIsError(t *testing.T) { return json.RawMessage(`{"isError":true,"content":[{"type":"text","text":"bad thing"}]}`), nil }, } - env := BuildAgentEnv(nil, d, t.TempDir(), nil) - _, err := env.exec(context.Background(), "mytool", json.RawMessage(`{}`)) + env := BuildRuntime(nil, d, t.TempDir(), nil) + _, err := env.Exec(context.Background(), "mytool", json.RawMessage(`{}`)) if err == nil || !strings.Contains(err.Error(), "bad thing") { t.Errorf("err = %v", err) } diff --git a/pkg/agent/loop_cache_test.go b/pkg/agent/loop_cache_test.go index 0ff83e9..ff6018b 100644 --- a/pkg/agent/loop_cache_test.go +++ b/pkg/agent/loop_cache_test.go @@ -20,8 +20,8 @@ func TestResultCache_HitSkipsExec(t *testing.T) { }) c := newCore(t, be, nil) - c.cfg.ClassifyTool = func(string) bool { return true } - c.cfg.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, error) { + c.runtime.ClassifyTool = func(string) bool { return true } + c.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, error) { atomic.AddInt32(&execCount, 1) return "contents of a.txt", nil } @@ -53,8 +53,8 @@ func TestResultCache_DifferentArgsMiss(t *testing.T) { }) c := newCore(t, be, nil) - c.cfg.ClassifyTool = func(string) bool { return true } - c.cfg.Exec = func(_ context.Context, name string, args json.RawMessage) (string, error) { + c.runtime.ClassifyTool = func(string) bool { return true } + c.runtime.Exec = func(_ context.Context, name string, args json.RawMessage) (string, error) { atomic.AddInt32(&execCount, 1) return "result for " + string(args), nil } @@ -77,8 +77,8 @@ func TestResultCache_SerialToolNotCached(t *testing.T) { }) c := newCore(t, be, nil) - c.cfg.ClassifyTool = func(name string) bool { return false } // all serial - c.cfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { + c.runtime.ClassifyTool = func(name string) bool { return false } // all serial + c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { atomic.AddInt32(&execCount, 1) return "ok", nil } @@ -120,8 +120,8 @@ func TestResultCache_ErrorNotCached(t *testing.T) { }) c := newCore(t, be, nil) - c.cfg.ClassifyTool = func(string) bool { return true } - c.cfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { + c.runtime.ClassifyTool = func(string) bool { return true } + c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { n := atomic.AddInt32(&execCount, 1) if n == 1 { return "", &mockErr{"transient failure"} @@ -155,8 +155,8 @@ func TestResultCache_IdenticalParallelReads(t *testing.T) { }) c := newCore(t, be, nil) - c.cfg.ClassifyTool = func(string) bool { return true } // both parallel-safe → same batch - c.cfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { + c.runtime.ClassifyTool = func(string) bool { return true } // both parallel-safe → same batch + c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { atomic.AddInt32(&execCount, 1) return "file contents", nil } diff --git a/pkg/agent/loop_errlimit_test.go b/pkg/agent/loop_errlimit_test.go index 6716923..fbc4841 100644 --- a/pkg/agent/loop_errlimit_test.go +++ b/pkg/agent/loop_errlimit_test.go @@ -32,7 +32,7 @@ func TestConsecutiveErrors_HardLimit(t *testing.T) { be.respond = alwaysFailStream() c := newCore(t, be, nil) - c.cfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { + c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { return "", fmt.Errorf("always fails") } @@ -88,7 +88,7 @@ func TestConsecutiveErrors_SoftLimitNudge(t *testing.T) { } c := newCore(t, be, nil) - c.cfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { + c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { return "", fmt.Errorf("always fails") } @@ -141,7 +141,7 @@ func TestConsecutiveErrors_ResetOnSuccess(t *testing.T) { } c := newCore(t, be, nil) - c.cfg.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, error) { + c.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, error) { if name == "good_tool" { return "ok", nil } diff --git a/pkg/agent/loop_parallel_test.go b/pkg/agent/loop_parallel_test.go index 2c43c42..92e5447 100644 --- a/pkg/agent/loop_parallel_test.go +++ b/pkg/agent/loop_parallel_test.go @@ -50,8 +50,8 @@ func TestParallel_ConcurrentExecution(t *testing.T) { }) c := newCore(t, be, nil) - c.cfg.ClassifyTool = func(string) bool { return true } - c.cfg.Exec = func(ctx context.Context, name string, _ json.RawMessage) (string, error) { + c.runtime.ClassifyTool = func(string) bool { return true } + c.runtime.Exec = func(ctx context.Context, name string, _ json.RawMessage) (string, error) { started <- struct{}{} select { case <-gate: @@ -90,10 +90,10 @@ func TestParallel_SerialToolBreaksBatch(t *testing.T) { var order []string c := newCore(t, be, nil) - c.cfg.ClassifyTool = func(name string) bool { + c.runtime.ClassifyTool = func(name string) bool { return name == "read_a" || name == "read_c" } - c.cfg.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, error) { + c.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, error) { mu.Lock() order = append(order, name) mu.Unlock() @@ -131,10 +131,10 @@ func TestParallel_CancellationFillsRemaining(t *testing.T) { c := newCore(t, be, nil) // tool_a and tool_b are parallel-safe; tool_c is serial. - c.cfg.ClassifyTool = func(name string) bool { + c.runtime.ClassifyTool = func(name string) bool { return name == "tool_a" || name == "tool_b" } - c.cfg.Exec = func(execCtx context.Context, name string, _ json.RawMessage) (string, error) { + c.runtime.Exec = func(execCtx context.Context, name string, _ json.RawMessage) (string, error) { cancel() // cancel on first execution; propagates to all <-execCtx.Done() return "", execCtx.Err() @@ -174,8 +174,8 @@ func TestParallel_NilClassifyToolIsSerial(t *testing.T) { var order []string c := newCore(t, be, nil) - c.cfg.ClassifyTool = nil // no classifier → all serial - c.cfg.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, error) { + c.runtime.ClassifyTool = nil // no classifier → all serial + c.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, error) { mu.Lock() order = append(order, name) mu.Unlock() diff --git a/pkg/agent/loop_texttool_test.go b/pkg/agent/loop_texttool_test.go index 9c9dda5..3c61b0a 100644 --- a/pkg/agent/loop_texttool_test.go +++ b/pkg/agent/loop_texttool_test.go @@ -19,7 +19,7 @@ func TestTextToolCall_DetectedAsUnsupported(t *testing.T) { } c := newCore(t, be, nil) - c.cfg.Tools = []backend.Tool{{Name: "execute_code", Description: "run code"}} + c.runtime.Tools = []backend.Tool{{Name: "execute_code", Description: "run code"}} evs := collectEvents(context.Background(), c, "run something") @@ -42,7 +42,7 @@ func TestTextToolCall_NoFalsePositive(t *testing.T) { } c := newCore(t, be, nil) - c.cfg.Tools = []backend.Tool{{Name: "execute_code", Description: "run code"}} + c.runtime.Tools = []backend.Tool{{Name: "execute_code", Description: "run code"}} evs := collectEvents(context.Background(), c, "explain tools") diff --git a/pkg/agent/loop_truncate_test.go b/pkg/agent/loop_truncate_test.go index 818a0ea..fc22f15 100644 --- a/pkg/agent/loop_truncate_test.go +++ b/pkg/agent/loop_truncate_test.go @@ -21,9 +21,9 @@ func TestTruncation_LargeResultTruncated(t *testing.T) { }) c := newCore(t, be, nil) - c.cfg.ClassifyTool = nil - c.cfg.ToolResultMaxBytes = maxBytes - c.cfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { + c.runtime.ClassifyTool = nil + c.runtime.ToolResultMaxBytes = maxBytes + c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { return large, nil } @@ -54,8 +54,8 @@ func TestTruncation_SmallResultNotTruncated(t *testing.T) { }) c := newCore(t, be, nil) - c.cfg.ToolResultMaxBytes = 8192 - c.cfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { + c.runtime.ToolResultMaxBytes = 8192 + c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { return "short result", nil } @@ -81,8 +81,8 @@ func TestTruncation_ZeroDisabled(t *testing.T) { }) c := newCore(t, be, nil) - c.cfg.ToolResultMaxBytes = 0 // disabled - c.cfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { + c.runtime.ToolResultMaxBytes = 0 // disabled + c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { return large, nil } @@ -109,8 +109,8 @@ func TestTruncation_ErrorNotTruncated(t *testing.T) { }) c := newCore(t, be, nil) - c.cfg.ToolResultMaxBytes = maxBytes - c.cfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { + c.runtime.ToolResultMaxBytes = maxBytes + c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { return "", &mockErr{longErr} } @@ -138,9 +138,9 @@ func TestTruncation_CachedResultAlreadyTruncated(t *testing.T) { }) c := newCore(t, be, nil) - c.cfg.ClassifyTool = func(string) bool { return true } // read-safe → cacheable - c.cfg.ToolResultMaxBytes = maxBytes - c.cfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { + c.runtime.ClassifyTool = func(string) bool { return true } // read-safe → cacheable + c.runtime.ToolResultMaxBytes = maxBytes + c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { return large, nil } diff --git a/pkg/agent/loop_turnerror_test.go b/pkg/agent/loop_turnerror_test.go index 426cf55..a1b2db4 100644 --- a/pkg/agent/loop_turnerror_test.go +++ b/pkg/agent/loop_turnerror_test.go @@ -35,7 +35,7 @@ func TestTurnError_HookInterceptsRateLimit(t *testing.T) { be.respond = errStream(&backend.RateLimitError{Message: "quota exceeded"}) c := newCore(t, be, nil) - c.cfg.TurnError = func(_ context.Context, errType, _ string) HookResult { + c.turnError = func(_ context.Context, errType, _ string) HookResult { atomic.AddInt32(&hookCalls, 1) if errType != "rate_limit" { t.Errorf("errType = %q; want rate_limit", errType) @@ -62,7 +62,7 @@ func TestTurnError_HookInterceptsToolUnsupported(t *testing.T) { be.respond = errStream(&backend.ToolUnsupportedError{Message: "model does not support tools"}) c := newCore(t, be, nil) - c.cfg.TurnError = func(_ context.Context, errType, _ string) HookResult { + c.turnError = func(_ context.Context, errType, _ string) HookResult { atomic.AddInt32(&hookCalls, 1) if errType != "tool_unsupported" { t.Errorf("errType = %q; want tool_unsupported", errType) @@ -110,7 +110,7 @@ func TestTurnError_NonRetryableErrorNoHook(t *testing.T) { be.respond = errStream(&backend.ToolUnsupportedError{Message: "no tools"}) c := newCore(t, be, nil) - c.cfg.TurnError = func(_ context.Context, errType, _ string) HookResult { + c.turnError = func(_ context.Context, errType, _ string) HookResult { atomic.AddInt32(&hookCalls, 1) return HookResult{Ran: true, Handled: true} } @@ -130,7 +130,7 @@ func TestTurnError_HookNotRunOnSuccess(t *testing.T) { // Default respond returns textStream("ok") — no error. c := newCore(t, be, nil) - c.cfg.TurnError = func(_ context.Context, _, _ string) HookResult { + c.turnError = func(_ context.Context, _, _ string) HookResult { atomic.AddInt32(&hookCalls, 1) return HookResult{Ran: true} } diff --git a/pkg/agent/runtime.go b/pkg/agent/runtime.go new file mode 100644 index 0000000..190c1de --- /dev/null +++ b/pkg/agent/runtime.go @@ -0,0 +1,39 @@ +package agent + +import ( + "ollie/pkg/backend" + "ollie/pkg/tools" +) + +func (r *Runtime) toolResultMaxBytes() int { + if r.ToolResultMaxBytes > 0 { + return r.ToolResultMaxBytes + } + return defaultToolResultMaxBytes +} + +// Runtime holds the swappable per-agent configuration. It contains everything +// that changes on an /agent switch but is stable across turns within the same +// agent. The agent struct stores a pointer to the active Runtime; switching +// agents replaces it atomically. +type Runtime struct { + Backend backend.Backend + Dispatcher tools.Dispatcher + Hooks Hooks + Preamble string // compiled system prompt + Tools []backend.Tool + Exec toolExecutor + ClassifyTool toolClassifier + ClassifyTier func(string) ResultTier + GenParams backend.GenerationParams + MaxSteps int + ToolResultMaxBytes int // 0 means use defaultToolResultMaxBytes + // CfgBackend and CfgModel are the backend/model overrides from the agent + // config JSON. Empty means no override. Applied by the caller after + // BuildRuntime returns. + CfgBackend string + CfgModel string + // Messages holds non-fatal warnings from runtime construction + // (e.g. tool listing errors). Surfaced once at session start. + Messages []string +}