refactor agent core: extract runtime, simplify commands

This commit is contained in:
Levi Neely 2026-05-21 14:42:45 +02:00
parent 82ac183357
commit 9d4818af8d
10 changed files with 251 additions and 221 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

39
pkg/agent/runtime.go Normal file
View File

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