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)