agent: simplify configuration/instantiation layers

- Kill agentConfig struct. run() takes (*Runtime, TurnCtx, state) directly.
  TurnCtx holds only 7 per-turn closures; stable config reads come from Runtime.

- Lazy tool refresh: toolsNeedRefresh() checks ToolRegistryRevision() on the
  tool server. Skips ListTools IPC when registry hasn't changed. Remote Conn
  falls back to always-refetch (same as before).

- AgentConfig.GenParams() method replaces the 16-line manual field copy in
  BuildRuntime.

- Remove CfgBackend/CfgModel from Runtime. Callers read cfg.Backend/cfg.Model
  directly since they already have the AgentConfig in scope.
This commit is contained in:
Ollie Agent 2026-08-08 14:19:46 +02:00
parent 8f7d21fc38
commit 32ff54f708
9 changed files with 203 additions and 202 deletions

View File

@ -22,8 +22,7 @@ import (
type Agent struct { type Agent struct {
history *History history *History
runtime *Runtime 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") profile string // config profile name (e.g. "default")
agentsDir string agentsDir string
systemPrompt string // system prompt for /agent reloads 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. // SaveFull persists the full session state (history + metadata) to the given path.
func (ag *Agent) SaveFull(path, sessionID, sessionName, cwd, remote string) error { func (ag *Agent) SaveFull(path, sessionID, sessionName, cwd, remote string) error {
// Switching profiles intentionally clears the conversation history, but // Switching profiles intentionally clears the conversation history, but

View File

@ -3,6 +3,8 @@ package agent
import ( import (
"encoding/json" "encoding/json"
"io" "io"
"ollie/backend"
) )
// Prompt holds the agent prompt. It unmarshals from either a JSON string // 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 { func (c *AgentConfig) ToolsEnabled() bool {
return c.Tools == nil || *c.Tools 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,
}
}

View File

@ -43,24 +43,7 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string
} else { } else {
agentPrompt = resolved agentPrompt = resolved
} }
genParams = backend.GenerationParams{ genParams = cfg.GenParams()
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,
}
maxSteps = cfg.MaxSteps maxSteps = cfg.MaxSteps
if len(cfg.AllowTools) > 0 { if len(cfg.AllowTools) > 0 {
if rs, ok := srv.(interface{ SetAllowTools([]string) }); ok { 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 { if cfg != nil {
backendName = cfg.Backend
modelName = cfg.Model
compactionModel = cfg.CompactionModel compactionModel = cfg.CompactionModel
} }
@ -151,8 +132,6 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string
Preamble: preamble, Preamble: preamble,
GenParams: genParams, GenParams: genParams,
MaxSteps: maxSteps, MaxSteps: maxSteps,
CfgBackend: backendName,
CfgModel: modelName,
CompactionModel: compactionModel, CompactionModel: compactionModel,
Messages: messages, Messages: messages,
} }

View File

@ -183,20 +183,20 @@ func (ag *Agent) cmdAgent(_ context.Context, args []string) {
env := []string{"OLLIE_SESSION_ID=" + ag.sessionID, "OLLIE_UNAME=" + ag.id} env := []string{"OLLIE_SESSION_ID=" + ag.sessionID, "OLLIE_UNAME=" + ag.id}
env = append(env, ag.promptEnvExtra...) env = append(env, ag.promptEnvExtra...)
rt := BuildRuntime(cfg, disp, ag.cwd, env, ag.systemPrompt, ag.envBlock) rt := BuildRuntime(cfg, disp, ag.cwd, env, ag.systemPrompt, ag.envBlock)
if rt.CfgBackend != "" { if cfg.Backend != "" {
newBe, err := ag.newBackend(rt.CfgBackend) newBe, err := ag.newBackend(cfg.Backend)
if err != nil { 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 return
} }
if rt.CfgModel != "" { if cfg.Model != "" {
newBe.SetModel(rt.CfgModel) newBe.SetModel(cfg.Model)
} }
rt.Backend = newBe rt.Backend = newBe
} else { } else {
rt.Backend = ag.runtime.Backend rt.Backend = ag.runtime.Backend
if rt.CfgModel != "" { if cfg.Model != "" {
rt.Backend.SetModel(rt.CfgModel) rt.Backend.SetModel(cfg.Model)
} }
} }
ag.runtime = rt ag.runtime = rt

View File

@ -98,43 +98,26 @@ func fileStat(path string) (int64, int64) {
return info.ModTime().UnixNano(), info.Size() return info.ModTime().UnixNano(), info.Size()
} }
func toolOutputFormat(cfg agentConfig, name string) string { func toolOutputFormat(rt *Runtime, name string) string {
if cfg.OutputFormat == nil { if rt.OutputFormat == nil {
return "" return ""
} }
return cfg.OutputFormat(name) return rt.OutputFormat(name)
} }
type agentConfig struct { // TurnCtx holds per-turn closures and state that vary between turns within
Backend backend.Backend // the same agent. Everything else comes from *Runtime.
Tools []backend.Tool type TurnCtx struct {
Exec toolExecutor Output EventHandler
ClassifyTool func(name string) bool // nil=treat all as serial; true=parallel-read-safe PopInject func() string // returns and clears pending inject, or ""
OutputFormat func(name string) string // empty means plaintext fence AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place
ClassifyTier func(name string, args json.RawMessage) ResultTier // nil=TierHot for all; tools self-classify retention Save func() // called after each state.update(); persists mid-turn progress
ResetsCounter func(name string) bool // nil=false for all; true=reset step counter ReadPlanStep func() string // next unchecked step from plan file, or ""
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.
IncrToolCallCount func() int64 IncrToolCallCount func() int64
// ResultCache persists read-safe tool results across turns. ResultCache *sync.Map
// Cleared on write operations (file_write, file_edit).
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 step int
var consecutiveErrors int // rounds where every tool call returned an error var consecutiveErrors int // rounds where every tool call returned an error
var roundsWithoutPlan int // consecutive tool rounds without a PLAN: block 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. // resultCache stores outputs of read-safe tool calls keyed by name+args.
// Only tools classified as parallel-safe (immutable reads) are cached. // Only tools classified as parallel-safe (immutable reads) are cached.
// sync.Map is required because execOne may run in concurrent goroutines. // sync.Map is required because execOne may run in concurrent goroutines.
// Uses the cross-turn cache from agentConfig if available. // Uses the cross-turn cache from TurnCtx if available.
resultCache := cfg.ResultCache resultCache := tc.ResultCache
if resultCache == nil { if resultCache == nil {
resultCache = &sync.Map{} resultCache = &sync.Map{}
} }
for { 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. // Proactive context gate: strip cold material before calling the backend.
if budget := contextBudget(ctx, cfg.Backend); budget > 0 && state.estimateTokens() > budget { if budget := contextBudget(ctx, rt.Backend); budget > 0 && state.estimateTokens() > budget {
state.stripCold(ctx, cfg.Backend) state.stripCold(ctx, rt.Backend)
} }
history := state.history() history := state.history()
if cfg.preamble != "" { if rt.Preamble != "" {
history = append([]backend.Message{{Role: "system", Content: cfg.preamble}}, history...) history = append([]backend.Message{{Role: "system", Content: rt.Preamble}}, history...)
} }
if ts := state.taskState(); ts != nil { if ts := state.taskState(); ts != nil {
if msg := ts.render(); msg != "" && len(history) > 0 { if msg := ts.render(); msg != "" && len(history) > 0 {
@ -183,7 +166,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
reasoning.Reset() reasoning.Reset()
toolCalls = nil 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 err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() return ctx.Err()
@ -194,9 +177,9 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
} }
var rlErr *backend.RateLimitError var rlErr *backend.RateLimitError
if errors.As(err, &rlErr) { 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) return fmt.Errorf("step %d: %w", step, err)
} }
continue continue
@ -207,30 +190,30 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
for ev := range ch { for ev := range ch {
if ev.Reasoning != "" { if ev.Reasoning != "" {
if !hadReasoning { if !hadReasoning {
emit(cfg, Event{Role: "reasoning", Content: "<think>\n"}) emit(tc, Event{Role: "reasoning", Content: "<think>\n"})
hadReasoning = true hadReasoning = true
} }
reasoning.WriteString(ev.Reasoning) reasoning.WriteString(ev.Reasoning)
emit(cfg, Event{Role: "reasoning", Content: ev.Reasoning}) emit(tc, Event{Role: "reasoning", Content: ev.Reasoning})
} }
if ev.Content != "" { if ev.Content != "" {
if hadReasoning { if hadReasoning {
emit(cfg, Event{Role: "reasoning", Content: "\n</think>\n"}) emit(tc, Event{Role: "reasoning", Content: "\n</think>\n"})
hadReasoning = false hadReasoning = false
} }
content.WriteString(ev.Content) 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...) toolCalls = append(toolCalls, ev.ToolCalls...)
if ev.Done { if ev.Done {
if hadReasoning { if hadReasoning {
emit(cfg, Event{Role: "reasoning", Content: "\n</think>\n"}) emit(tc, Event{Role: "reasoning", Content: "\n</think>\n"})
hadReasoning = false hadReasoning = false
} }
stopReason = ev.StopReason stopReason = ev.StopReason
done = true done = true
if ev.Usage.InputTokens > 0 || ev.Usage.OutputTokens > 0 { if ev.Usage.InputTokens > 0 || ev.Usage.OutputTokens > 0 {
emit(cfg, Event{ emit(tc, Event{
Role: "usage", 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), 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) inChars += len(tc.Name) + len(tc.Arguments)
} }
} }
emit(cfg, Event{ emit(tc, Event{
Role: "usage", Role: "usage",
Content: fmt.Sprintf("%d %d 1 0", inChars/4, content.Len()/4), 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 { if ctx.Err() != nil {
// User pause: record partial state and return. // User pause: record partial state and return.
if hadReasoning { if hadReasoning {
emit(cfg, Event{Role: "reasoning", Content: "\n</think>\n"}) emit(tc, Event{Role: "reasoning", Content: "\n</think>\n"})
} }
msg := backend.Message{ID: responseID, Role: "assistant", Content: content.String(), Reasoning: reasoning.String(), ToolCalls: toolCalls} msg := backend.Message{ID: responseID, Role: "assistant", Content: content.String(), Reasoning: reasoning.String(), ToolCalls: toolCalls}
var results []toolResult var results []toolResult
@ -272,7 +255,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
}) })
} }
state.update(msg, results) state.update(msg, results)
cfg.Save() tc.Save()
return ctx.Err() 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) return fmt.Errorf("step %d: stream dropped (no more retries)", step)
} }
if hadReasoning { if hadReasoning {
emit(cfg, Event{Role: "reasoning", Content: "\n</think>\n"}) emit(tc, Event{Role: "reasoning", Content: "\n</think>\n"})
} }
wait := streamDropBaseDelay << attempt 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) 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 // Parse text-based tool calls: models that don't support the function
// calling API emit tool invocations as plain text (e.g. // calling API emit tool invocations as plain text (e.g.
// "file_read: args=[...]" (text-based tool call syntax)). Parse these into proper ToolCall structs. // "file_read: args=[...]" (text-based tool call syntax)). Parse these into proper ToolCall structs.
if len(toolCalls) == 0 && len(cfg.Tools) > 0 { if len(toolCalls) == 0 && len(rt.Tools) > 0 {
if parsed := parseTextToolCalls(content.String(), cfg.Tools); len(parsed) > 0 { if parsed := parseTextToolCalls(content.String(), rt.Tools); len(parsed) > 0 {
toolCalls = parsed toolCalls = parsed
} }
} }
@ -310,10 +293,10 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
results := make([]toolResult, 0, len(toolCalls)) results := make([]toolResult, 0, len(toolCalls))
interrupted := false interrupted := false
cancelledResult := func(tc backend.ToolCall) toolResult { cancelledResult := func(call backend.ToolCall) toolResult {
return toolResult{ return toolResult{
ToolCallID: tc.ID, ToolCallID: call.ID,
Name: tc.Name, Name: call.Name,
Content: `{"status":"cancelled","error":"interrupted"}`, Content: `{"status":"cancelled","error":"interrupted"}`,
IsError: true, 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 // execOne runs a single tool call end-to-end and reports whether the
// context was cancelled during execution. // context was cancelled during execution.
execOne := func(tc backend.ToolCall) (toolResult, bool) { execOne := func(call backend.ToolCall) (toolResult, bool) {
if tc.Name == "" { if call.Name == "" {
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: "error: empty tool name", IsError: true}, false return toolResult{ToolCallID: call.ID, Name: call.Name, Content: "error: empty tool name", IsError: true}, false
} }
if ctx.Err() != nil { if ctx.Err() != nil {
cr := cancelledResult(tc) cr := cancelledResult(call)
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: cr.Content, OutputFormat: toolOutputFormat(cfg, tc.Name)}) emit(tc, Event{Role: "tool", Name: call.Name, Content: cr.Content, OutputFormat: toolOutputFormat(rt, call.Name)})
return cr, true return cr, true
} }
emit(cfg, Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)}) emit(tc, Event{Role: "call", Name: call.Name, Content: string(call.Arguments)})
readSafe := cfg.ClassifyTool != nil && cfg.ClassifyTool(tc.Name) readSafe := rt.ClassifyTool != nil && rt.ClassifyTool(call.Name)
if readSafe { if readSafe {
key := tc.Name + "\x00" + string(tc.Arguments) key := call.Name + "\x00" + string(call.Arguments)
if v, ok := resultCache.Load(key); ok { if v, ok := resultCache.Load(key); ok {
entry := v.(cachedResult) entry := v.(cachedResult)
if cacheValid(entry, tc.Arguments) { if cacheValid(entry, call.Arguments) {
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: entry.Result, OutputFormat: toolOutputFormat(cfg, tc.Name)}) emit(tc, Event{Role: "tool", Name: call.Name, Content: entry.Result, OutputFormat: toolOutputFormat(rt, call.Name)})
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: entry.Result}, false return toolResult{ToolCallID: call.ID, Name: call.Name, Content: entry.Result}, false
} }
// Stale — remove and re-execute. // Stale — remove and re-execute.
resultCache.Delete(key) resultCache.Delete(key)
@ -348,14 +331,14 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
var resultBlocks []backend.ContentBlock var resultBlocks []backend.ContentBlock
var isErr bool var isErr bool
streamed := false streamed := false
if cfg.Exec != nil { if rt.Exec != nil {
streamBytes := 0 streamBytes := 0
streamCtx := toolsrv.WithOutputStream(ctx, func(data string) { streamCtx := toolsrv.WithOutputStream(ctx, func(data string) {
if streamBytes >= defaultToolResultMaxBytes { if streamBytes >= defaultToolResultMaxBytes {
return // already at ceiling, drop further chunks return // already at ceiling, drop further chunks
} }
if !streamed { 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 streamed = true
} }
streamBytes += len(data) streamBytes += len(data)
@ -364,20 +347,20 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
excess := streamBytes - defaultToolResultMaxBytes excess := streamBytes - defaultToolResultMaxBytes
data = data[:len(data)-excess] 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 { if err != nil {
isErr = true isErr = true
if ctx.Err() != nil { if ctx.Err() != nil {
result = "error: tool execution interrupted by user" result = "error: tool execution interrupted by user"
if cfg.PopInject != nil { if tc.PopInject != nil {
if injected := cfg.PopInject(); injected != "" { if injected := tc.PopInject(); injected != "" {
result += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>" result += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
} }
} }
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)})
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: result, IsError: true}, true return toolResult{ToolCallID: call.ID, Name: call.Name, Content: result, IsError: true}, true
} }
var rlErr *toolsrv.RateLimitedError var rlErr *toolsrv.RateLimitedError
if errors.As(err, &rlErr) { 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) // Accumulate suffix text (user-interruptions, truncation hints)
// that must be emitted after streaming completes. // that must be emitted after streaming completes.
var suffix string var suffix string
if cfg.PopInject != nil { if tc.PopInject != nil {
if injected := cfg.PopInject(); injected != "" { if injected := tc.PopInject(); injected != "" {
suffix += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>" suffix += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
} }
} }
@ -407,48 +390,48 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
orig := len(result) orig := len(result)
result = strings.ToValidUTF8(result[:defaultToolResultMaxBytes], "") 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.]", 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 result += suffix
} }
if readSafe && !isErr { if readSafe && !isErr {
path := extractFilePath(tc.Arguments) path := extractFilePath(call.Arguments)
mtime, size := fileStat(path) mtime, size := fileStat(path)
resultCache.Store(tc.Name+"\x00"+string(tc.Arguments), cachedResult{ resultCache.Store(call.Name+"\x00"+string(call.Arguments), cachedResult{
Result: result, Result: result,
ModTime: mtime, ModTime: mtime,
Size: size, Size: size,
}) })
} }
if !streamed { 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 { } else {
// Emit suffixes that were appended after streaming completed // Emit suffixes that were appended after streaming completed
// so they appear at the end of the chat output. // so they appear at the end of the chat output.
if suffix != "" { 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 // Signal the log handler to close the source fence and emit
// [[[end]]]. Without this, streamingRole stays set and the // [[[end]]]. Without this, streamingRole stays set and the
// fence is only closed on the next role transition — or never, // fence is only closed on the next role transition — or never,
// if the agent turn ends or is interrupted. // 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 tier := TierHot
if !isErr && cfg.ClassifyTier != nil { if !isErr && rt.ClassifyTier != nil {
tier = cfg.ClassifyTier(tc.Name, tc.Arguments) 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 { 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; { for i := 0; i < len(toolCalls) && !interrupted; {
if ctx.Err() != nil { if ctx.Err() != nil {
for _, remaining := range toolCalls[i:] { for _, remaining := range toolCalls[i:] {
cr := cancelledResult(remaining) 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) results = append(results, cr)
} }
interrupted = true interrupted = true
@ -467,7 +450,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
fillCancelled := func(from int) { fillCancelled := func(from int) {
for _, remaining := range toolCalls[from:] { for _, remaining := range toolCalls[from:] {
cr := cancelledResult(remaining) 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) results = append(results, cr)
} }
} }
@ -491,8 +474,8 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
batchInt := make([]bool, len(batch)) batchInt := make([]bool, len(batch))
var wg sync.WaitGroup var wg sync.WaitGroup
uniqueResults := make([]inflightResult, len(batch)) uniqueResults := make([]inflightResult, len(batch))
for k, tc := range batch { for k, call := range batch {
key := tc.Name + "\x00" + string(tc.Arguments) key := call.Name + "\x00" + string(call.Arguments)
if first, dup := inflight[key]; dup { if first, dup := inflight[key]; dup {
// Will copy result from first occurrence after wg.Wait. // Will copy result from first occurrence after wg.Wait.
batchResults[k] = toolResult{} // placeholder batchResults[k] = toolResult{} // placeholder
@ -501,29 +484,29 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
} }
inflight[key] = k inflight[key] = k
wg.Add(1) wg.Add(1)
go func(k int, tc backend.ToolCall) { go func(k int, call backend.ToolCall) {
defer wg.Done() defer wg.Done()
uniqueResults[k].tr, uniqueResults[k].wasInt = execOne(tc) uniqueResults[k].tr, uniqueResults[k].wasInt = execOne(call)
}(k, tc) }(k, call)
} }
wg.Wait() wg.Wait()
// Fill results: unique calls get their own result, duplicates copy from first. // Fill results: unique calls get their own result, duplicates copy from first.
for k, tc := range batch { for k, call := range batch {
key := tc.Name + "\x00" + string(tc.Arguments) key := call.Name + "\x00" + string(call.Arguments)
first := inflight[key] first := inflight[key]
if k == first { if k == first {
batchResults[k] = uniqueResults[k].tr batchResults[k] = uniqueResults[k].tr
batchInt[k] = uniqueResults[k].wasInt batchInt[k] = uniqueResults[k].wasInt
} else { } else {
batchResults[k] = toolResult{ batchResults[k] = toolResult{
ToolCallID: tc.ID, ToolCallID: call.ID,
Name: tc.Name, Name: call.Name,
Content: uniqueResults[first].tr.Content, Content: uniqueResults[first].tr.Content,
IsError: uniqueResults[first].tr.IsError, IsError: uniqueResults[first].tr.IsError,
} }
batchInt[k] = uniqueResults[first].wasInt batchInt[k] = uniqueResults[first].wasInt
emit(cfg, Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)}) emit(tc, Event{Role: "call", Name: call.Name, Content: string(call.Arguments)})
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: batchResults[k].Content, OutputFormat: toolOutputFormat(cfg, tc.Name)}) 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. // 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) state.update(msg, results)
cfg.Save() tc.Save()
// Auto-update TaskState from what actually happened this round. // Auto-update TaskState from what actually happened this round.
if ts := state.taskState(); ts != nil { if ts := state.taskState(); ts != nil {
inferTaskStateUpdate(ts, msg, results, cfg.ReadPlanStep) inferTaskStateUpdate(ts, msg, results, tc.ReadPlanStep)
state.updateTaskState(*ts) state.updateTaskState(*ts)
} }
@ -580,7 +563,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
consecutiveErrors = 0 consecutiveErrors = 0
} }
if consecutiveErrors >= consecutiveErrorHardLimit { 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) return fmt.Errorf("step %d: %d consecutive tool errors", step, consecutiveErrors)
} }
if consecutiveErrors == consecutiveErrorSoftLimit { 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 // This allows the agent to continue working without hitting the soft
// step-budget guardrail, as long as it's making active progress (writing // step-budget guardrail, as long as it's making active progress (writing
// files, renaming symbols, etc.) rather than looping on research. // files, renaming symbols, etc.) rather than looping on research.
if cfg.ResetsCounter != nil { if rt.ResetsCounter != nil {
for _, r := range results { for _, r := range results {
if !r.IsError && cfg.ResetsCounter(r.Name) { if !r.IsError && rt.ResetsCounter(r.Name) {
step = 0 step = 0
break 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. // Step budget warnings: nudge the agent at 50% and when exhausted.
// These only fire for research-heavy loops — action tools (file_write, // These only fire for research-heavy loops — action tools (file_write,
// file_edit, shell) reset the counter, so active progress never triggers. // file_edit, shell) reset the counter, so active progress never triggers.
if cfg.MaxSteps > 0 { if rt.MaxSteps > 0 {
halfBudget := cfg.MaxSteps / 2 halfBudget := rt.MaxSteps / 2
if step == halfBudget { if step == halfBudget {
state.update(backend.Message{ state.update(backend.Message{
Role: "user", Role: "user",
Content: fmt.Sprintf("<system-step-budget-warning>\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</system-step-budget-warning>", step, cfg.MaxSteps), Content: fmt.Sprintf("<system-step-budget-warning>\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</system-step-budget-warning>", step, rt.MaxSteps),
}, nil) }, nil)
} else if step >= cfg.MaxSteps-1 { } else if step >= rt.MaxSteps-1 {
emit(cfg, Event{Role: "maxsteps", Content: fmt.Sprintf("%d", step+1)}) emit(tc, Event{Role: "maxsteps", Content: fmt.Sprintf("%d", step+1)})
state.update(backend.Message{ state.update(backend.Message{
Role: "user", Role: "user",
Content: fmt.Sprintf("<system-step-budget-stop>\nStep budget exhausted (%d/%d steps used). Stop calling tools. Summarize what you have done and what remains, then stop.\n</system-step-budget-stop>", step+1, cfg.MaxSteps), Content: fmt.Sprintf("<system-step-budget-stop>\nStep budget exhausted (%d/%d steps used). Stop calling tools. Summarize what you have done and what remains, then stop.\n</system-step-budget-stop>", step+1, rt.MaxSteps),
}, nil) }, nil)
break break
} }
@ -700,8 +683,8 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
} }
} }
if cfg.AutoCompact != nil { if tc.AutoCompact != nil {
cfg.AutoCompact(ctx) tc.AutoCompact(ctx)
} }
step++ step++
@ -710,9 +693,9 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
return nil return nil
} }
func emit(cfg agentConfig, msg Event) { func emit(tc TurnCtx, msg Event) {
if cfg.Output != nil { if tc.Output != nil {
cfg.Output(msg) tc.Output(msg)
} }
} }
@ -1070,7 +1053,7 @@ func transientWait(err error, attempt int) (time.Duration, bool) {
return 0, false 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) deadline := time.Now().Add(wait)
for { for {
remaining := time.Until(deadline) remaining := time.Until(deadline)
@ -1081,7 +1064,7 @@ func retryCountdown(ctx context.Context, cfg agentConfig, wait time.Duration) er
h := totalSecs / 3600 h := totalSecs / 3600
m := (totalSecs % 3600) / 60 m := (totalSecs % 3600) / 60
s := totalSecs % 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 { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()

View File

@ -23,11 +23,10 @@ type Runtime struct {
ResetsCounter func(string) bool // true if tool resets step counter ResetsCounter func(string) bool // true if tool resets step counter
GenParams backend.GenerationParams GenParams backend.GenerationParams
MaxSteps int MaxSteps int
// CfgBackend and CfgModel are the backend/model overrides from the agent // toolRegistryRevision tracks the last-seen tool registry revision. When the
// config JSON. Empty means no override. Applied by the caller after // registry revision advances (after tool_load/unload), the next turn
// BuildRuntime returns. // will detect the mismatch and refetch.
CfgBackend string toolRegistryRevision uint64
CfgModel string
// CompactionModel overrides the model used for context compaction. // CompactionModel overrides the model used for context compaction.
// Resolved from: agent config > OLLIE_COMPACTION_MODEL env > per-backend default. // Resolved from: agent config > OLLIE_COMPACTION_MODEL env > per-backend default.
CompactionModel string CompactionModel string

View File

@ -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) ag.auditLog.Debug("turn: start input=%s session=%s", auditTruncate(input), ag.sessionID)
// Build per-turn agentConfig from the current runtime. // Refresh tool definitions only when the registry has changed (tool_load/unload).
// Refresh tool definitions from the tool server so dynamically loaded // This avoids ListTools IPC + allocation on every turn in the common case.
// tools (via tool_load) are available on the next turn without a rebuild. if ag.toolsNeedRefresh() {
ti, listErr := ag.runtime.ToolServer.ListTools() ti, listErr := ag.runtime.ToolServer.ListTools()
if listErr != nil { if listErr != nil {
ag.log.Error("tool server unavailable: %v", listErr) ag.log.Error("tool server unavailable: %v", listErr)
ag.emit(Event{Role: "error", Content: fmt.Sprintf("tool server unavailable: %v", listErr)}) ag.emit(Event{Role: "error", Content: fmt.Sprintf("tool server unavailable: %v", listErr)})
ag.SetState("idle") ag.SetState("idle")
return "" return ""
} }
tools := toolInfosToBackend(ti) ag.runtime.Tools = toolInfosToBackend(ti)
outputFormats := make(map[string]string, len(ti))
// Build output format lookup from the fresh tool list. This ensures for _, info := range ti {
// dynamically loaded tools (via tool_load) have their outputFormat outputFormats[info.Name] = info.OutputFormat
// available immediately, not just after a full runtime rebuild. }
outputFormats := make(map[string]string, len(ti)) ag.runtime.OutputFormat = func(name string) string {
for _, info := range ti { return outputFormats[name]
outputFormats[info.Name] = info.OutputFormat }
}
outputFormatFn := func(name string) string {
return outputFormats[name]
} }
ag.cfg = agentConfig{ turnCtx := TurnCtx{
Backend: ag.runtime.Backend, Output: ag.output,
preamble: ag.runtime.Preamble, ReadPlanStep: ag.readPlanStep,
Output: ag.output, Save: ag.save,
Tools: tools, ResultCache: &ag.resultCache,
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,
} }
var replyBuf strings.Builder var replyBuf strings.Builder
ag.cfg.Output = func(ev Event) { turnCtx.Output = func(ev Event) {
switch ev.Role { switch ev.Role {
case "assistant": case "assistant":
replyBuf.WriteString(ev.Content) replyBuf.WriteString(ev.Content)
@ -176,18 +165,18 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
} }
ag.emit(ev) ag.emit(ev)
} }
ag.cfg.PopInject = func() string { turnCtx.PopInject = func() string {
if p := ag.pendingInject.Swap(nil); p != nil { if p := ag.pendingInject.Swap(nil); p != nil {
return *p return *p
} }
return "" return ""
} }
ag.cfg.IncrToolCallCount = func() int64 { turnCtx.IncrToolCallCount = func() int64 {
return ag.toolCallCount.Add(1) return ag.toolCallCount.Add(1)
} }
ag.cfg.Save = ag.save turnCtx.Save = ag.save
ag.cfg.ResultCache = &ag.resultCache turnCtx.ResultCache = &ag.resultCache
ag.cfg.AutoCompact = func(ctx context.Context) { turnCtx.AutoCompact = func(ctx context.Context) {
if ctx.Err() != nil || ag.history == nil { if ctx.Err() != nil || ag.history == nil {
return return
} }
@ -214,7 +203,7 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
} }
ag.SetState("thinking") ag.SetState("thinking")
} else if warnLimit := ag.autoWarnLimit(ctx); warnLimit > 0 && tokens >= warnLimit && !ag.warnedContext { } 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 { if ctxLen <= 0 {
ctxLen = defaultContextLength ctxLen = defaultContextLength
} }
@ -252,7 +241,7 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
err error err error
) )
for { for {
err = run(actCtx, ag.cfg, ag.history) err = run(actCtx, ag.runtime, turnCtx, ag.history)
actCancel(nil) actCancel(nil)
ag.currentAction.CompareAndSwap(handle, nil) ag.currentAction.CompareAndSwap(handle, nil)
@ -307,8 +296,8 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
} }
if ag.history != nil { if ag.history != nil {
ag.history.recordTurnCost(ag.cfg.Backend.Model()) ag.history.recordTurnCost(ag.runtime.Backend.Model())
appendUsageLog(ag.sessionID, ag.cfg.Backend.Name(), ag.cfg.Backend.Model(), ag.history) appendUsageLog(ag.sessionID, ag.runtime.Backend.Name(), ag.runtime.Backend.Model(), ag.history)
if ag.history.LastTurnCostUSD > 0 { if ag.history.LastTurnCostUSD > 0 {
ag.emit(Event{Role: "info", Content: fmt.Sprintf("costLast=$%.4f\n", ag.history.LastTurnCostUSD)}) ag.emit(Event{Role: "info", Content: fmt.Sprintf("costLast=$%.4f\n", ag.history.LastTurnCostUSD)})
} }

View File

@ -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 // Create backend - use override if provided, otherwise use config or env default
beName := backendName beName := backendName
if beName == "" { if beName == "" {
beName = rt.CfgBackend beName = cfg.Backend
} }
be, err := backend.NewWithName(beName) be, err := backend.NewWithName(beName)
if err != nil { if err != nil {
@ -409,8 +409,8 @@ func CreateAgent(sessName string, args []string) (*agent.Agent, error) {
// Apply model override if provided // Apply model override if provided
if modelName != "" { if modelName != "" {
rt.Backend.SetModel(modelName) rt.Backend.SetModel(modelName)
} else if rt.CfgModel != "" { } else if cfg.Model != "" {
rt.Backend.SetModel(rt.CfgModel) rt.Backend.SetModel(cfg.Model)
} }
// Create agent // Create agent

View File

@ -33,6 +33,15 @@ func (e *Server) SetOnToolsChanged(fn func(string)) {
e.OnToolsChanged = fn 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. // 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) { 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) return e.executeWithStdin(ctx, cmd, language, timeout, sandbox, trusted, stdin, detach)