diff --git a/agent/agent.go b/agent/agent.go
index 8074c92..36780d3 100644
--- a/agent/agent.go
+++ b/agent/agent.go
@@ -22,8 +22,7 @@ import (
type Agent struct {
history *History
runtime *Runtime
- cfg agentConfig // per-turn config built from runtime
- name string // display name (defaults to id)
+ name string // display name (defaults to id)
profile string // config profile name (e.g. "default")
agentsDir string
systemPrompt string // system prompt for /agent reloads
@@ -493,6 +492,25 @@ func (ag *Agent) wireToolsChanged() {
}
}
+// toolsNeedRefresh reports whether the tool registry has changed since the
+// last time Tools was populated. If the tool server doesn't support revision
+// tracking (e.g. remote Conn), this always returns true (safe fallback).
+func (ag *Agent) toolsNeedRefresh() bool {
+ type revisionProvider interface {
+ ToolRegistryRevision() uint64
+ }
+ srv, ok := ag.runtime.ToolServer.(revisionProvider)
+ if !ok {
+ return true // no revision support — always refetch
+ }
+ rev := srv.ToolRegistryRevision()
+ if rev != ag.runtime.toolRegistryRevision {
+ ag.runtime.toolRegistryRevision = rev
+ return true
+ }
+ return false
+}
+
// SaveFull persists the full session state (history + metadata) to the given path.
func (ag *Agent) SaveFull(path, sessionID, sessionName, cwd, remote string) error {
// Switching profiles intentionally clears the conversation history, but
diff --git a/agent/agent_config.go b/agent/agent_config.go
index 61068c4..83aabfc 100644
--- a/agent/agent_config.go
+++ b/agent/agent_config.go
@@ -3,6 +3,8 @@ package agent
import (
"encoding/json"
"io"
+
+ "ollie/backend"
)
// Prompt holds the agent prompt. It unmarshals from either a JSON string
@@ -86,3 +88,25 @@ func Load(r io.Reader) (*AgentConfig, error) {
func (c *AgentConfig) ToolsEnabled() bool {
return c.Tools == nil || *c.Tools
}
+
+// GenParams returns the generation parameters derived from this config.
+func (c *AgentConfig) GenParams() backend.GenerationParams {
+ return backend.GenerationParams{
+ MaxTokens: c.MaxTokens,
+ MaxCompletionTokens: c.MaxCompletionTokens,
+ Temperature: c.Temperature,
+ TopP: c.TopP,
+ TopK: c.TopK,
+ MinP: c.MinP,
+ TopA: c.TopA,
+ FrequencyPenalty: c.FrequencyPenalty,
+ PresencePenalty: c.PresencePenalty,
+ RepetitionPenalty: c.RepetitionPenalty,
+ ThinkingBudget: c.Reasoning,
+ ReasoningEffort: c.ReasoningEffort,
+ IncludeReasoning: c.IncludeReasoning,
+ ResponseFormat: c.ResponseFormat,
+ Stop: c.Stop,
+ Verbosity: c.Verbosity,
+ }
+}
diff --git a/agent/build_runtime.go b/agent/build_runtime.go
index ed91b93..f13bc64 100644
--- a/agent/build_runtime.go
+++ b/agent/build_runtime.go
@@ -43,24 +43,7 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string
} else {
agentPrompt = resolved
}
- genParams = backend.GenerationParams{
- MaxTokens: cfg.MaxTokens,
- MaxCompletionTokens: cfg.MaxCompletionTokens,
- Temperature: cfg.Temperature,
- TopP: cfg.TopP,
- TopK: cfg.TopK,
- MinP: cfg.MinP,
- TopA: cfg.TopA,
- FrequencyPenalty: cfg.FrequencyPenalty,
- PresencePenalty: cfg.PresencePenalty,
- RepetitionPenalty: cfg.RepetitionPenalty,
- ThinkingBudget: cfg.Reasoning,
- ReasoningEffort: cfg.ReasoningEffort,
- IncludeReasoning: cfg.IncludeReasoning,
- ResponseFormat: cfg.ResponseFormat,
- Stop: cfg.Stop,
- Verbosity: cfg.Verbosity,
- }
+ genParams = cfg.GenParams()
maxSteps = cfg.MaxSteps
if len(cfg.AllowTools) > 0 {
if rs, ok := srv.(interface{ SetAllowTools([]string) }); ok {
@@ -131,10 +114,8 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string
}
}
- var backendName, modelName, compactionModel string
+ var compactionModel string
if cfg != nil {
- backendName = cfg.Backend
- modelName = cfg.Model
compactionModel = cfg.CompactionModel
}
@@ -151,8 +132,6 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string
Preamble: preamble,
GenParams: genParams,
MaxSteps: maxSteps,
- CfgBackend: backendName,
- CfgModel: modelName,
CompactionModel: compactionModel,
Messages: messages,
}
diff --git a/agent/commands.go b/agent/commands.go
index 83368aa..061b7e1 100644
--- a/agent/commands.go
+++ b/agent/commands.go
@@ -183,20 +183,20 @@ func (ag *Agent) cmdAgent(_ context.Context, args []string) {
env := []string{"OLLIE_SESSION_ID=" + ag.sessionID, "OLLIE_UNAME=" + ag.id}
env = append(env, ag.promptEnvExtra...)
rt := BuildRuntime(cfg, disp, ag.cwd, env, ag.systemPrompt, ag.envBlock)
- if rt.CfgBackend != "" {
- newBe, err := ag.newBackend(rt.CfgBackend)
+ if cfg.Backend != "" {
+ newBe, err := ag.newBackend(cfg.Backend)
if err != nil {
- ag.emit(infoEvent(fmt.Sprintf("error: backend %q: %v", rt.CfgBackend, err)))
+ ag.emit(infoEvent(fmt.Sprintf("error: backend %q: %v", cfg.Backend, err)))
return
}
- if rt.CfgModel != "" {
- newBe.SetModel(rt.CfgModel)
+ if cfg.Model != "" {
+ newBe.SetModel(cfg.Model)
}
rt.Backend = newBe
} else {
rt.Backend = ag.runtime.Backend
- if rt.CfgModel != "" {
- rt.Backend.SetModel(rt.CfgModel)
+ if cfg.Model != "" {
+ rt.Backend.SetModel(cfg.Model)
}
}
ag.runtime = rt
diff --git a/agent/loop.go b/agent/loop.go
index df0154a..6dbdcb7 100644
--- a/agent/loop.go
+++ b/agent/loop.go
@@ -98,43 +98,26 @@ func fileStat(path string) (int64, int64) {
return info.ModTime().UnixNano(), info.Size()
}
-func toolOutputFormat(cfg agentConfig, name string) string {
- if cfg.OutputFormat == nil {
+func toolOutputFormat(rt *Runtime, name string) string {
+ if rt.OutputFormat == nil {
return ""
}
- return cfg.OutputFormat(name)
+ return rt.OutputFormat(name)
}
-type agentConfig struct {
- Backend backend.Backend
- Tools []backend.Tool
- Exec toolExecutor
- ClassifyTool func(name string) bool // nil=treat all as serial; true=parallel-read-safe
- OutputFormat func(name string) string // empty means plaintext fence
- ClassifyTier func(name string, args json.RawMessage) ResultTier // nil=TierHot for all; tools self-classify retention
- ResetsCounter func(name string) bool // nil=false for all; true=reset step counter
- Output EventHandler
- preamble string // compiled system+agent prompt sent as the system role
- GenerationParams backend.GenerationParams
- PopInject func() string // returns and clears pending inject, or ""
- AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place
- Save func() // called after each state.update(); persists mid-turn progress
- // MaxSteps is the maximum number of tool-call rounds per turn.
- // When reached, a soft nudge is injected and the loop exits cleanly.
- // 0 means unlimited.
- MaxSteps int
- // ReadPlanStep returns the next unchecked step from the plan file, or "".
- // Used as the primary source for TaskState.PlanStep; falls back to
- // heuristic extraction from assistant text if nil.
- ReadPlanStep func() string
- // IncrToolCallCount increments and returns the session-wide tool call counter.
+// TurnCtx holds per-turn closures and state that vary between turns within
+// the same agent. Everything else comes from *Runtime.
+type TurnCtx struct {
+ Output EventHandler
+ PopInject func() string // returns and clears pending inject, or ""
+ AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place
+ Save func() // called after each state.update(); persists mid-turn progress
+ ReadPlanStep func() string // next unchecked step from plan file, or ""
IncrToolCallCount func() int64
- // ResultCache persists read-safe tool results across turns.
- // Cleared on write operations (file_write, file_edit).
- ResultCache *sync.Map
+ ResultCache *sync.Map
}
-func run(ctx context.Context, cfg agentConfig, state state) error {
+func run(ctx context.Context, rt *Runtime, tc TurnCtx, state state) error {
var step int
var consecutiveErrors int // rounds where every tool call returned an error
var roundsWithoutPlan int // consecutive tool rounds without a PLAN: block
@@ -145,22 +128,22 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
// resultCache stores outputs of read-safe tool calls keyed by name+args.
// Only tools classified as parallel-safe (immutable reads) are cached.
// sync.Map is required because execOne may run in concurrent goroutines.
- // Uses the cross-turn cache from agentConfig if available.
- resultCache := cfg.ResultCache
+ // Uses the cross-turn cache from TurnCtx if available.
+ resultCache := tc.ResultCache
if resultCache == nil {
resultCache = &sync.Map{}
}
for {
- emit(cfg, Event{Role: "state", Content: "thinking"})
+ emit(tc, Event{Role: "state", Content: "thinking"})
// Proactive context gate: strip cold material before calling the backend.
- if budget := contextBudget(ctx, cfg.Backend); budget > 0 && state.estimateTokens() > budget {
- state.stripCold(ctx, cfg.Backend)
+ if budget := contextBudget(ctx, rt.Backend); budget > 0 && state.estimateTokens() > budget {
+ state.stripCold(ctx, rt.Backend)
}
history := state.history()
- if cfg.preamble != "" {
- history = append([]backend.Message{{Role: "system", Content: cfg.preamble}}, history...)
+ if rt.Preamble != "" {
+ history = append([]backend.Message{{Role: "system", Content: rt.Preamble}}, history...)
}
if ts := state.taskState(); ts != nil {
if msg := ts.render(); msg != "" && len(history) > 0 {
@@ -183,7 +166,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
reasoning.Reset()
toolCalls = nil
- ch, err := cfg.Backend.ChatStream(ctx, history, cfg.Tools, cfg.GenerationParams)
+ ch, err := rt.Backend.ChatStream(ctx, history, rt.Tools, rt.GenParams)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
@@ -194,9 +177,9 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
}
var rlErr *backend.RateLimitError
if errors.As(err, &rlErr) {
- emit(cfg, Event{Role: "limitretry"})
+ emit(tc, Event{Role: "limitretry"})
}
- if err := retryCountdown(ctx, cfg, wait); err != nil {
+ if err := retryCountdown(ctx, tc, wait); err != nil {
return fmt.Errorf("step %d: %w", step, err)
}
continue
@@ -207,30 +190,30 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
for ev := range ch {
if ev.Reasoning != "" {
if !hadReasoning {
- emit(cfg, Event{Role: "reasoning", Content: "\n"})
+ emit(tc, Event{Role: "reasoning", Content: "\n"})
hadReasoning = true
}
reasoning.WriteString(ev.Reasoning)
- emit(cfg, Event{Role: "reasoning", Content: ev.Reasoning})
+ emit(tc, Event{Role: "reasoning", Content: ev.Reasoning})
}
if ev.Content != "" {
if hadReasoning {
- emit(cfg, Event{Role: "reasoning", Content: "\n\n"})
+ emit(tc, Event{Role: "reasoning", Content: "\n\n"})
hadReasoning = false
}
content.WriteString(ev.Content)
- emit(cfg, Event{Role: "assistant", Content: ev.Content, ResponseID: responseID})
+ emit(tc, Event{Role: "assistant", Content: ev.Content, ResponseID: responseID})
}
toolCalls = append(toolCalls, ev.ToolCalls...)
if ev.Done {
if hadReasoning {
- emit(cfg, Event{Role: "reasoning", Content: "\n\n"})
+ emit(tc, Event{Role: "reasoning", Content: "\n\n"})
hadReasoning = false
}
stopReason = ev.StopReason
done = true
if ev.Usage.InputTokens > 0 || ev.Usage.OutputTokens > 0 {
- emit(cfg, Event{
+ emit(tc, Event{
Role: "usage",
Content: fmt.Sprintf("%d %d 0 %g %d %d", ev.Usage.InputTokens, ev.Usage.OutputTokens, ev.Usage.CostUSD, ev.Usage.CachedInputTokens, ev.Usage.CacheCreationTokens),
})
@@ -243,7 +226,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
inChars += len(tc.Name) + len(tc.Arguments)
}
}
- emit(cfg, Event{
+ emit(tc, Event{
Role: "usage",
Content: fmt.Sprintf("%d %d 1 0", inChars/4, content.Len()/4),
})
@@ -259,7 +242,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
if ctx.Err() != nil {
// User pause: record partial state and return.
if hadReasoning {
- emit(cfg, Event{Role: "reasoning", Content: "\n\n"})
+ emit(tc, Event{Role: "reasoning", Content: "\n\n"})
}
msg := backend.Message{ID: responseID, Role: "assistant", Content: content.String(), Reasoning: reasoning.String(), ToolCalls: toolCalls}
var results []toolResult
@@ -272,7 +255,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
})
}
state.update(msg, results)
- cfg.Save()
+ tc.Save()
return ctx.Err()
}
@@ -281,10 +264,10 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
return fmt.Errorf("step %d: stream dropped (no more retries)", step)
}
if hadReasoning {
- emit(cfg, Event{Role: "reasoning", Content: "\n\n"})
+ emit(tc, Event{Role: "reasoning", Content: "\n\n"})
}
wait := streamDropBaseDelay << attempt
- if err := retryCountdown(ctx, cfg, wait); err != nil {
+ if err := retryCountdown(ctx, tc, wait); err != nil {
return fmt.Errorf("step %d: %w", step, err)
}
}
@@ -299,8 +282,8 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
// Parse text-based tool calls: models that don't support the function
// calling API emit tool invocations as plain text (e.g.
// "file_read: args=[...]" (text-based tool call syntax)). Parse these into proper ToolCall structs.
- if len(toolCalls) == 0 && len(cfg.Tools) > 0 {
- if parsed := parseTextToolCalls(content.String(), cfg.Tools); len(parsed) > 0 {
+ if len(toolCalls) == 0 && len(rt.Tools) > 0 {
+ if parsed := parseTextToolCalls(content.String(), rt.Tools); len(parsed) > 0 {
toolCalls = parsed
}
}
@@ -310,10 +293,10 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
results := make([]toolResult, 0, len(toolCalls))
interrupted := false
- cancelledResult := func(tc backend.ToolCall) toolResult {
+ cancelledResult := func(call backend.ToolCall) toolResult {
return toolResult{
- ToolCallID: tc.ID,
- Name: tc.Name,
+ ToolCallID: call.ID,
+ Name: call.Name,
Content: `{"status":"cancelled","error":"interrupted"}`,
IsError: true,
}
@@ -321,24 +304,24 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
// execOne runs a single tool call end-to-end and reports whether the
// context was cancelled during execution.
- execOne := func(tc backend.ToolCall) (toolResult, bool) {
- if tc.Name == "" {
- return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: "error: empty tool name", IsError: true}, false
+ execOne := func(call backend.ToolCall) (toolResult, bool) {
+ if call.Name == "" {
+ return toolResult{ToolCallID: call.ID, Name: call.Name, Content: "error: empty tool name", IsError: true}, false
}
if ctx.Err() != nil {
- cr := cancelledResult(tc)
- emit(cfg, Event{Role: "tool", Name: tc.Name, Content: cr.Content, OutputFormat: toolOutputFormat(cfg, tc.Name)})
+ cr := cancelledResult(call)
+ emit(tc, Event{Role: "tool", Name: call.Name, Content: cr.Content, OutputFormat: toolOutputFormat(rt, call.Name)})
return cr, true
}
- emit(cfg, Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)})
- readSafe := cfg.ClassifyTool != nil && cfg.ClassifyTool(tc.Name)
+ emit(tc, Event{Role: "call", Name: call.Name, Content: string(call.Arguments)})
+ readSafe := rt.ClassifyTool != nil && rt.ClassifyTool(call.Name)
if readSafe {
- key := tc.Name + "\x00" + string(tc.Arguments)
+ key := call.Name + "\x00" + string(call.Arguments)
if v, ok := resultCache.Load(key); ok {
entry := v.(cachedResult)
- if cacheValid(entry, tc.Arguments) {
- emit(cfg, Event{Role: "tool", Name: tc.Name, Content: entry.Result, OutputFormat: toolOutputFormat(cfg, tc.Name)})
- return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: entry.Result}, false
+ if cacheValid(entry, call.Arguments) {
+ emit(tc, Event{Role: "tool", Name: call.Name, Content: entry.Result, OutputFormat: toolOutputFormat(rt, call.Name)})
+ return toolResult{ToolCallID: call.ID, Name: call.Name, Content: entry.Result}, false
}
// Stale — remove and re-execute.
resultCache.Delete(key)
@@ -348,14 +331,14 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
var resultBlocks []backend.ContentBlock
var isErr bool
streamed := false
- if cfg.Exec != nil {
+ if rt.Exec != nil {
streamBytes := 0
streamCtx := toolsrv.WithOutputStream(ctx, func(data string) {
if streamBytes >= defaultToolResultMaxBytes {
return // already at ceiling, drop further chunks
}
if !streamed {
- emit(cfg, Event{Role: "tool", Name: tc.Name, OutputFormat: toolOutputFormat(cfg, tc.Name)})
+ emit(tc, Event{Role: "tool", Name: call.Name, OutputFormat: toolOutputFormat(rt, call.Name)})
streamed = true
}
streamBytes += len(data)
@@ -364,20 +347,20 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
excess := streamBytes - defaultToolResultMaxBytes
data = data[:len(data)-excess]
}
- emit(cfg, Event{Role: "tool", Name: tc.Name, Content: data, OutputFormat: toolOutputFormat(cfg, tc.Name)})
+ emit(tc, Event{Role: "tool", Name: call.Name, Content: data, OutputFormat: toolOutputFormat(rt, call.Name)})
})
- out, blocks, err := cfg.Exec(streamCtx, tc.Name, tc.Arguments)
+ out, blocks, err := rt.Exec(streamCtx, call.Name, call.Arguments)
if err != nil {
isErr = true
if ctx.Err() != nil {
result = "error: tool execution interrupted by user"
- if cfg.PopInject != nil {
- if injected := cfg.PopInject(); injected != "" {
+ if tc.PopInject != nil {
+ if injected := tc.PopInject(); injected != "" {
result += "\n\n\n" + injected + "\n"
}
}
- emit(cfg, Event{Role: "tool", Name: tc.Name, Content: result, OutputFormat: toolOutputFormat(cfg, tc.Name)})
- return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: result, IsError: true}, true
+ emit(tc, Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
+ return toolResult{ToolCallID: call.ID, Name: call.Name, Content: result, IsError: true}, true
}
var rlErr *toolsrv.RateLimitedError
if errors.As(err, &rlErr) {
@@ -396,8 +379,8 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
// Accumulate suffix text (user-interruptions, truncation hints)
// that must be emitted after streaming completes.
var suffix string
- if cfg.PopInject != nil {
- if injected := cfg.PopInject(); injected != "" {
+ if tc.PopInject != nil {
+ if injected := tc.PopInject(); injected != "" {
suffix += "\n\n\n" + injected + "\n"
}
}
@@ -407,48 +390,48 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
orig := len(result)
result = strings.ToValidUTF8(result[:defaultToolResultMaxBytes], "")
suffix = fmt.Sprintf("\n\n[HARD LIMIT: %s output truncated — %d of %d bytes shown. This is a safety ceiling, not a semantic boundary.]",
- tc.Name, defaultToolResultMaxBytes, orig)
+ call.Name, defaultToolResultMaxBytes, orig)
result += suffix
}
if readSafe && !isErr {
- path := extractFilePath(tc.Arguments)
+ path := extractFilePath(call.Arguments)
mtime, size := fileStat(path)
- resultCache.Store(tc.Name+"\x00"+string(tc.Arguments), cachedResult{
+ resultCache.Store(call.Name+"\x00"+string(call.Arguments), cachedResult{
Result: result,
ModTime: mtime,
Size: size,
})
}
if !streamed {
- emit(cfg, Event{Role: "tool", Name: tc.Name, Content: result, OutputFormat: toolOutputFormat(cfg, tc.Name)})
+ emit(tc, Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
} else {
// Emit suffixes that were appended after streaming completed
// so they appear at the end of the chat output.
if suffix != "" {
- emit(cfg, Event{Role: "tool", Name: tc.Name, Content: suffix, OutputFormat: toolOutputFormat(cfg, tc.Name)})
+ emit(tc, Event{Role: "tool", Name: call.Name, Content: suffix, OutputFormat: toolOutputFormat(rt, call.Name)})
}
// Signal the log handler to close the source fence and emit
// [[[end]]]. Without this, streamingRole stays set and the
// fence is only closed on the next role transition — or never,
// if the agent turn ends or is interrupted.
- emit(cfg, Event{Role: "tool", Name: tc.Name, OutputFormat: toolOutputFormat(cfg, tc.Name)})
+ emit(tc, Event{Role: "tool", Name: call.Name, OutputFormat: toolOutputFormat(rt, call.Name)})
}
tier := TierHot
- if !isErr && cfg.ClassifyTier != nil {
- tier = cfg.ClassifyTier(tc.Name, tc.Arguments)
+ if !isErr && rt.ClassifyTier != nil {
+ tier = rt.ClassifyTier(call.Name, call.Arguments)
}
- return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: result, ContentBlocks: resultBlocks, IsError: isErr, Tier: tier}, false
+ return toolResult{ToolCallID: call.ID, Name: call.Name, Content: result, ContentBlocks: resultBlocks, IsError: isErr, Tier: tier}, false
}
isParallelSafe := func(name string) bool {
- return name != "" && cfg.ClassifyTool != nil && cfg.ClassifyTool(name)
+ return name != "" && rt.ClassifyTool != nil && rt.ClassifyTool(name)
}
for i := 0; i < len(toolCalls) && !interrupted; {
if ctx.Err() != nil {
for _, remaining := range toolCalls[i:] {
cr := cancelledResult(remaining)
- emit(cfg, Event{Role: "tool", Name: remaining.Name, Content: cr.Content, OutputFormat: toolOutputFormat(cfg, remaining.Name)})
+ emit(tc, Event{Role: "tool", Name: remaining.Name, Content: cr.Content, OutputFormat: toolOutputFormat(rt, remaining.Name)})
results = append(results, cr)
}
interrupted = true
@@ -467,7 +450,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
fillCancelled := func(from int) {
for _, remaining := range toolCalls[from:] {
cr := cancelledResult(remaining)
- emit(cfg, Event{Role: "tool", Name: remaining.Name, Content: cr.Content, OutputFormat: toolOutputFormat(cfg, remaining.Name)})
+ emit(tc, Event{Role: "tool", Name: remaining.Name, Content: cr.Content, OutputFormat: toolOutputFormat(rt, remaining.Name)})
results = append(results, cr)
}
}
@@ -491,8 +474,8 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
batchInt := make([]bool, len(batch))
var wg sync.WaitGroup
uniqueResults := make([]inflightResult, len(batch))
- for k, tc := range batch {
- key := tc.Name + "\x00" + string(tc.Arguments)
+ for k, call := range batch {
+ key := call.Name + "\x00" + string(call.Arguments)
if first, dup := inflight[key]; dup {
// Will copy result from first occurrence after wg.Wait.
batchResults[k] = toolResult{} // placeholder
@@ -501,29 +484,29 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
}
inflight[key] = k
wg.Add(1)
- go func(k int, tc backend.ToolCall) {
+ go func(k int, call backend.ToolCall) {
defer wg.Done()
- uniqueResults[k].tr, uniqueResults[k].wasInt = execOne(tc)
- }(k, tc)
+ uniqueResults[k].tr, uniqueResults[k].wasInt = execOne(call)
+ }(k, call)
}
wg.Wait()
// Fill results: unique calls get their own result, duplicates copy from first.
- for k, tc := range batch {
- key := tc.Name + "\x00" + string(tc.Arguments)
+ for k, call := range batch {
+ key := call.Name + "\x00" + string(call.Arguments)
first := inflight[key]
if k == first {
batchResults[k] = uniqueResults[k].tr
batchInt[k] = uniqueResults[k].wasInt
} else {
batchResults[k] = toolResult{
- ToolCallID: tc.ID,
- Name: tc.Name,
+ ToolCallID: call.ID,
+ Name: call.Name,
Content: uniqueResults[first].tr.Content,
IsError: uniqueResults[first].tr.IsError,
}
batchInt[k] = uniqueResults[first].wasInt
- emit(cfg, Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)})
- emit(cfg, Event{Role: "tool", Name: tc.Name, Content: batchResults[k].Content, OutputFormat: toolOutputFormat(cfg, tc.Name)})
+ emit(tc, Event{Role: "call", Name: call.Name, Content: string(call.Arguments)})
+ emit(tc, Event{Role: "tool", Name: call.Name, Content: batchResults[k].Content, OutputFormat: toolOutputFormat(rt, call.Name)})
}
}
// Add all batch results — model needs a result for every tool call.
@@ -540,11 +523,11 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
}
state.update(msg, results)
- cfg.Save()
+ tc.Save()
// Auto-update TaskState from what actually happened this round.
if ts := state.taskState(); ts != nil {
- inferTaskStateUpdate(ts, msg, results, cfg.ReadPlanStep)
+ inferTaskStateUpdate(ts, msg, results, tc.ReadPlanStep)
state.updateTaskState(*ts)
}
@@ -580,7 +563,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
consecutiveErrors = 0
}
if consecutiveErrors >= consecutiveErrorHardLimit {
- emit(cfg, Event{Role: "error", Content: fmt.Sprintf("%d consecutive tool errors — aborting", consecutiveErrors)})
+ emit(tc, Event{Role: "error", Content: fmt.Sprintf("%d consecutive tool errors — aborting", consecutiveErrors)})
return fmt.Errorf("step %d: %d consecutive tool errors", step, consecutiveErrors)
}
if consecutiveErrors == consecutiveErrorSoftLimit {
@@ -658,9 +641,9 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
// This allows the agent to continue working without hitting the soft
// step-budget guardrail, as long as it's making active progress (writing
// files, renaming symbols, etc.) rather than looping on research.
- if cfg.ResetsCounter != nil {
+ if rt.ResetsCounter != nil {
for _, r := range results {
- if !r.IsError && cfg.ResetsCounter(r.Name) {
+ if !r.IsError && rt.ResetsCounter(r.Name) {
step = 0
break
}
@@ -670,18 +653,18 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
// Step budget warnings: nudge the agent at 50% and when exhausted.
// These only fire for research-heavy loops — action tools (file_write,
// file_edit, shell) reset the counter, so active progress never triggers.
- if cfg.MaxSteps > 0 {
- halfBudget := cfg.MaxSteps / 2
+ if rt.MaxSteps > 0 {
+ halfBudget := rt.MaxSteps / 2
if step == halfBudget {
state.update(backend.Message{
Role: "user",
- Content: fmt.Sprintf("\nYou have used %d/%d research steps without taking action. Consider making progress — write code, edit files, or run commands. Action tools reset this counter.\n", step, cfg.MaxSteps),
+ Content: fmt.Sprintf("\nYou have used %d/%d research steps without taking action. Consider making progress — write code, edit files, or run commands. Action tools reset this counter.\n", step, rt.MaxSteps),
}, nil)
- } else if step >= cfg.MaxSteps-1 {
- emit(cfg, Event{Role: "maxsteps", Content: fmt.Sprintf("%d", step+1)})
+ } else if step >= rt.MaxSteps-1 {
+ emit(tc, Event{Role: "maxsteps", Content: fmt.Sprintf("%d", step+1)})
state.update(backend.Message{
Role: "user",
- Content: fmt.Sprintf("\nStep budget exhausted (%d/%d steps used). Stop calling tools. Summarize what you have done and what remains, then stop.\n", step+1, cfg.MaxSteps),
+ Content: fmt.Sprintf("\nStep budget exhausted (%d/%d steps used). Stop calling tools. Summarize what you have done and what remains, then stop.\n", step+1, rt.MaxSteps),
}, nil)
break
}
@@ -700,8 +683,8 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
}
}
- if cfg.AutoCompact != nil {
- cfg.AutoCompact(ctx)
+ if tc.AutoCompact != nil {
+ tc.AutoCompact(ctx)
}
step++
@@ -710,9 +693,9 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
return nil
}
-func emit(cfg agentConfig, msg Event) {
- if cfg.Output != nil {
- cfg.Output(msg)
+func emit(tc TurnCtx, msg Event) {
+ if tc.Output != nil {
+ tc.Output(msg)
}
}
@@ -1070,7 +1053,7 @@ func transientWait(err error, attempt int) (time.Duration, bool) {
return 0, false
}
-func retryCountdown(ctx context.Context, cfg agentConfig, wait time.Duration) error {
+func retryCountdown(ctx context.Context, tc TurnCtx, wait time.Duration) error {
deadline := time.Now().Add(wait)
for {
remaining := time.Until(deadline)
@@ -1081,7 +1064,7 @@ func retryCountdown(ctx context.Context, cfg agentConfig, wait time.Duration) er
h := totalSecs / 3600
m := (totalSecs % 3600) / 60
s := totalSecs % 60
- emit(cfg, Event{Role: "retry", Content: fmt.Sprintf("%02d:%02d:%02d", h, m, s)})
+ emit(tc, Event{Role: "retry", Content: fmt.Sprintf("%02d:%02d:%02d", h, m, s)})
select {
case <-ctx.Done():
return ctx.Err()
diff --git a/agent/runtime.go b/agent/runtime.go
index bb65354..f2eeb34 100644
--- a/agent/runtime.go
+++ b/agent/runtime.go
@@ -23,11 +23,10 @@ type Runtime struct {
ResetsCounter func(string) bool // true if tool resets step counter
GenParams backend.GenerationParams
MaxSteps int
- // 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
+ // toolRegistryRevision tracks the last-seen tool registry revision. When the
+ // registry revision advances (after tool_load/unload), the next turn
+ // will detect the mismatch and refetch.
+ toolRegistryRevision uint64
// CompactionModel overrides the model used for context compaction.
// Resolved from: agent config > OLLIE_COMPACTION_MODEL env > per-backend default.
CompactionModel string
diff --git a/agent/turn.go b/agent/turn.go
index 952c464..83ff68f 100644
--- a/agent/turn.go
+++ b/agent/turn.go
@@ -100,46 +100,35 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
ag.auditLog.Debug("turn: start input=%s session=%s", auditTruncate(input), ag.sessionID)
- // Build per-turn agentConfig from the current runtime.
- // Refresh tool definitions from the tool server so dynamically loaded
- // tools (via tool_load) are available on the next turn without a rebuild.
- ti, listErr := ag.runtime.ToolServer.ListTools()
- if listErr != nil {
- ag.log.Error("tool server unavailable: %v", listErr)
- ag.emit(Event{Role: "error", Content: fmt.Sprintf("tool server unavailable: %v", listErr)})
- ag.SetState("idle")
- return ""
- }
- tools := toolInfosToBackend(ti)
-
- // Build output format lookup from the fresh tool list. This ensures
- // dynamically loaded tools (via tool_load) have their outputFormat
- // available immediately, not just after a full runtime rebuild.
- outputFormats := make(map[string]string, len(ti))
- for _, info := range ti {
- outputFormats[info.Name] = info.OutputFormat
- }
- outputFormatFn := func(name string) string {
- return outputFormats[name]
+ // Refresh tool definitions only when the registry has changed (tool_load/unload).
+ // This avoids ListTools IPC + allocation on every turn in the common case.
+ if ag.toolsNeedRefresh() {
+ ti, listErr := ag.runtime.ToolServer.ListTools()
+ if listErr != nil {
+ ag.log.Error("tool server unavailable: %v", listErr)
+ ag.emit(Event{Role: "error", Content: fmt.Sprintf("tool server unavailable: %v", listErr)})
+ ag.SetState("idle")
+ return ""
+ }
+ ag.runtime.Tools = toolInfosToBackend(ti)
+ outputFormats := make(map[string]string, len(ti))
+ for _, info := range ti {
+ outputFormats[info.Name] = info.OutputFormat
+ }
+ ag.runtime.OutputFormat = func(name string) string {
+ return outputFormats[name]
+ }
}
- ag.cfg = agentConfig{
- Backend: ag.runtime.Backend,
- preamble: ag.runtime.Preamble,
- Output: ag.output,
- Tools: tools,
- Exec: ag.runtime.Exec,
- ClassifyTool: ag.runtime.ClassifyTool,
- OutputFormat: outputFormatFn,
- ClassifyTier: ag.runtime.ClassifyTier,
- ResetsCounter: ag.runtime.ResetsCounter,
- GenerationParams: ag.runtime.GenParams,
- MaxSteps: ag.runtime.MaxSteps,
- ReadPlanStep: ag.readPlanStep,
+ turnCtx := TurnCtx{
+ Output: ag.output,
+ ReadPlanStep: ag.readPlanStep,
+ Save: ag.save,
+ ResultCache: &ag.resultCache,
}
var replyBuf strings.Builder
- ag.cfg.Output = func(ev Event) {
+ turnCtx.Output = func(ev Event) {
switch ev.Role {
case "assistant":
replyBuf.WriteString(ev.Content)
@@ -176,18 +165,18 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
}
ag.emit(ev)
}
- ag.cfg.PopInject = func() string {
+ turnCtx.PopInject = func() string {
if p := ag.pendingInject.Swap(nil); p != nil {
return *p
}
return ""
}
- ag.cfg.IncrToolCallCount = func() int64 {
+ turnCtx.IncrToolCallCount = func() int64 {
return ag.toolCallCount.Add(1)
}
- ag.cfg.Save = ag.save
- ag.cfg.ResultCache = &ag.resultCache
- ag.cfg.AutoCompact = func(ctx context.Context) {
+ turnCtx.Save = ag.save
+ turnCtx.ResultCache = &ag.resultCache
+ turnCtx.AutoCompact = func(ctx context.Context) {
if ctx.Err() != nil || ag.history == nil {
return
}
@@ -214,7 +203,7 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
}
ag.SetState("thinking")
} else if warnLimit := ag.autoWarnLimit(ctx); warnLimit > 0 && tokens >= warnLimit && !ag.warnedContext {
- ctxLen := ag.cfg.Backend.ContextLength(ctx)
+ ctxLen := ag.runtime.Backend.ContextLength(ctx)
if ctxLen <= 0 {
ctxLen = defaultContextLength
}
@@ -252,7 +241,7 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
err error
)
for {
- err = run(actCtx, ag.cfg, ag.history)
+ err = run(actCtx, ag.runtime, turnCtx, ag.history)
actCancel(nil)
ag.currentAction.CompareAndSwap(handle, nil)
@@ -307,8 +296,8 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
}
if ag.history != nil {
- ag.history.recordTurnCost(ag.cfg.Backend.Model())
- appendUsageLog(ag.sessionID, ag.cfg.Backend.Name(), ag.cfg.Backend.Model(), ag.history)
+ ag.history.recordTurnCost(ag.runtime.Backend.Model())
+ appendUsageLog(ag.sessionID, ag.runtime.Backend.Name(), ag.runtime.Backend.Model(), ag.history)
if ag.history.LastTurnCostUSD > 0 {
ag.emit(Event{Role: "info", Content: fmt.Sprintf("costLast=$%.4f\n", ag.history.LastTurnCostUSD)})
}
diff --git a/session/registry.go b/session/registry.go
index 45be186..15924b1 100644
--- a/session/registry.go
+++ b/session/registry.go
@@ -398,7 +398,7 @@ func CreateAgent(sessName string, args []string) (*agent.Agent, error) {
// Create backend - use override if provided, otherwise use config or env default
beName := backendName
if beName == "" {
- beName = rt.CfgBackend
+ beName = cfg.Backend
}
be, err := backend.NewWithName(beName)
if err != nil {
@@ -409,8 +409,8 @@ func CreateAgent(sessName string, args []string) (*agent.Agent, error) {
// Apply model override if provided
if modelName != "" {
rt.Backend.SetModel(modelName)
- } else if rt.CfgModel != "" {
- rt.Backend.SetModel(rt.CfgModel)
+ } else if cfg.Model != "" {
+ rt.Backend.SetModel(cfg.Model)
}
// Create agent
diff --git a/toolsrv/accessors.go b/toolsrv/accessors.go
index 3bead9c..f06a83b 100644
--- a/toolsrv/accessors.go
+++ b/toolsrv/accessors.go
@@ -33,6 +33,15 @@ func (e *Server) SetOnToolsChanged(fn func(string)) {
e.OnToolsChanged = fn
}
+// ToolRegistryRevision returns the current registry revision for this server's session.
+// Returns 0 if no registry is configured.
+func (e *Server) ToolRegistryRevision() uint64 {
+ if e.toolRegistry == nil || e.sessionID == "" {
+ return 0
+ }
+ return e.toolRegistry.Revision(e.sessionID)
+}
+
// ExecuteInSandbox runs a command in the sandbox. Exported for builtin tools.
func (e *Server) ExecuteInSandbox(ctx context.Context, cmd, language string, timeout int, sandbox string, trusted bool, stdin string, detach bool) (string, error) {
return e.executeWithStdin(ctx, cmd, language, timeout, sandbox, trusted, stdin, detach)