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

View File

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

View File

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

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 = 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

View File

@ -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: "<think>\n"})
emit(tc, Event{Role: "reasoning", Content: "<think>\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</think>\n"})
emit(tc, Event{Role: "reasoning", Content: "\n</think>\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</think>\n"})
emit(tc, Event{Role: "reasoning", Content: "\n</think>\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</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}
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</think>\n"})
emit(tc, Event{Role: "reasoning", Content: "\n</think>\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<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
}
}
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<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)
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("<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)
} 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("<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)
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()

View File

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

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

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

View File

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