From 669eed6990942caa7d9a8f43996b6dcdfa553506 Mon Sep 17 00:00:00 2001 From: Thanh_Dang Date: Thu, 25 Jun 2026 22:17:17 +0700 Subject: [PATCH] perf(agent): cache BuildFilteredTools result per run (#1282) * fix(pipeline): defer non-tool messages until all tool results are emitted Multi-tool responses could interleave synthetic user messages (loop warnings, nudges) between tool results, breaking OpenAI-compatible providers that require contiguous tool_result grouping. Fix both sequential and parallel tool execution paths in tool_stage.go to defer non-tool messages until after all tool results are appended. Fix sanitizeHistory lookahead in loop_history_sanitize.go to reorder interleaved non-tool messages in persisted session history. Closes #1177 * perf(agent): cache BuildFilteredTools result per run All filtering inputs (policy, disabled tools, bootstrap, channel, orchestration mode, user MCP tools) are fixed for the lifetime of a run. Only the final iteration differs (strips all tools). Cache the tool definitions after the first call and reuse for iterations 0..maxIter-1, eliminating N-1 redundant 7-step policy evaluations per agent run. --- internal/agent/loop_pipeline_callbacks.go | 29 +++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/internal/agent/loop_pipeline_callbacks.go b/internal/agent/loop_pipeline_callbacks.go index 9c36b755..e2d2accc 100644 --- a/internal/agent/loop_pipeline_callbacks.go +++ b/internal/agent/loop_pipeline_callbacks.go @@ -199,7 +199,25 @@ func (l *Loop) makeInjectReminders(req *RunRequest) func(ctx context.Context, in } func (l *Loop) makeBuildFilteredTools(req *RunRequest) func(state *pipeline.RunState) ([]providers.ToolDefinition, error) { + // Per-run cache: all filtering inputs (policy, disabled tools, bootstrap, + // channel, orchestration mode, user MCP tools) are fixed for the lifetime + // of a run. Only the final iteration differs (strips all tools). Cache the + // result after the first call and reuse for iterations 0..maxIter-1. + var ( + cachedToolDefs []providers.ToolDefinition + cacheValid bool + ) return func(state *pipeline.RunState) ([]providers.ToolDefinition, error) { + maxIter := l.maxIterations + if req.MaxIterations > 0 && req.MaxIterations < maxIter { + maxIter = req.MaxIterations + } + + // Cache hit: reuse tool defs from first call for non-final iterations. + if cacheValid && state.Iteration != maxIter { + return cachedToolDefs, nil + } + // Load per-user MCP tools (Notion, etc.) into registry before filtering. // Servers with require_user_credentials are deferred at startup and // connected per-request here with the actual user's credentials. @@ -222,10 +240,6 @@ func (l *Loop) makeBuildFilteredTools(req *RunRequest) func(state *pipeline.RunS "sender_id", state.Input.SenderID, "actor_user_id", actorUserID, "user_tools_count", len(userTools)) - maxIter := l.maxIterations - if req.MaxIterations > 0 && req.MaxIterations < maxIter { - maxIter = req.MaxIterations - } allMsgs := state.Messages.All() toolDefs, _, returnedMsgs := l.buildFilteredTools(req, state.Context.HadBootstrap, state.Iteration, maxIter, allMsgs, userTools) @@ -237,6 +251,13 @@ func (l *Loop) makeBuildFilteredTools(req *RunRequest) func(state *pipeline.RunS state.Messages.AppendPending(msg) } } + + // Cache store after first successful non-final call. + if !cacheValid && state.Iteration != maxIter { + cachedToolDefs = toolDefs + cacheValid = true + } + mcpDefs := 0 for _, td := range toolDefs { if td.Function != nil && strings.HasPrefix(strings.TrimSpace(td.Function.Name), "mcp_") {