fix: deterministic prompt ordering for LLM cache hit (#719)

Sort all non-deterministic map iterations that affect system prompt and
tool definitions sent to LLM APIs. Go map iteration order is random,
causing prompt prefix to change every turn — breaking Anthropic/OpenAI
prompt caching (cache by exact prefix match).

Fixed 5 sources of non-deterministic ordering:
- Registry.List(): sort canonical tool names
- Registry.ProviderDefs(): sort tools + aliases before building defs
- PolicyEngine.FilterTools(): sort alias iteration (single Aliases() call)
- buildMCPToolsInlineSection(): sort MCP tool names in system prompt
- GetAgentContextFiles/GetUserContextFiles: ORDER BY file_name (PG+SQLite)

Based on PR #718 by @therichardngai-code with additional fixes:
- Context files from DB now deterministic (ORDER BY file_name)
- FilterTools() calls registry.Aliases() once instead of 3 times
This commit is contained in:
Viet Tran authored and GitHub committed 2026-04-06 12:54:44 +07:00
1 parent 2801f0a978
commit e88686b13a
5 files changed
+48 -13

No files matched your search

+8 -1
View File
@@ -55,7 +55,14 @@ func buildMCPToolsInlineSection(descs map[string]string) []string {
mcpOptionalParamInstruction,
"",
}
for name, desc := range descs {
// Sort MCP tool names for deterministic ordering — critical for prompt caching.
sortedNames := make([]string, 0, len(descs))
for name := range descs {
sortedNames = append(sortedNames, name)
}
slices.Sort(sortedNames)
for _, name := range sortedNames {
desc := descs[name]
if len(desc) > mcpToolDescMaxLen {
desc = desc[:mcpToolDescMaxLen] + "…"
}
+2 -2
View File
@@ -25,7 +25,7 @@ func (s *PGAgentStore) GetAgentContextFiles(ctx context.Context, agentID uuid.UU
return nil, err
}
rows, err := s.db.QueryContext(ctx,
"SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = $1"+tClause,
"SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = $1"+tClause+" ORDER BY file_name",
append([]any{agentID}, tArgs...)...)
if err != nil {
return nil, err
@@ -87,7 +87,7 @@ func (s *PGAgentStore) GetUserContextFiles(ctx context.Context, agentID uuid.UUI
return nil, err
}
rows, err := s.db.QueryContext(ctx,
"SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = $1 AND user_id = $2"+tClause,
"SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = $1 AND user_id = $2"+tClause+" ORDER BY file_name",
append([]any{agentID, userID}, tArgs...)...)
if err != nil {
return nil, err
+2 -2
View File
@@ -27,7 +27,7 @@ func (s *SQLiteAgentStore) GetAgentContextFiles(ctx context.Context, agentID uui
return nil, err
}
rows, err := s.db.QueryContext(ctx,
"SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = ?"+tClause,
"SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = ?"+tClause+" ORDER BY file_name",
append([]any{agentID}, tArgs...)...)
if err != nil {
return nil, err
@@ -97,7 +97,7 @@ func (s *SQLiteAgentStore) GetUserContextFiles(ctx context.Context, agentID uuid
return nil, err
}
rows, err := s.db.QueryContext(ctx,
"SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = ? AND user_id = ?"+tClause,
"SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = ? AND user_id = ?"+tClause+" ORDER BY file_name",
append([]any{agentID, userID}, tArgs...)...)
if err != nil {
return nil, err
+10 -2
View File
@@ -130,8 +130,16 @@ func (pe *PolicyEngine) FilterTools(
}
}
// Add registry aliases for allowed canonical tools
for alias, canonical := range registry.Aliases() {
// Add registry aliases for allowed canonical tools.
// Sort alias names for deterministic ordering (prompt caching).
aliasMap := registry.Aliases()
aliasList := make([]string, 0, len(aliasMap))
for alias := range aliasMap {
aliasList = append(aliasList, alias)
}
slices.Sort(aliasList)
for _, alias := range aliasList {
canonical := aliasMap[alias]
if !allowedSet[canonical] {
continue
}
+26 -6
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"maps"
"slices"
"strings"
"sync"
"time"
@@ -222,18 +223,34 @@ func safeExecute(tool Tool, ctx context.Context, args map[string]any) (result *R
// ProviderDefs returns tool definitions for LLM provider APIs.
// Includes alias definitions (same params/description, alias name).
// Results are sorted by tool name for deterministic ordering (prompt caching).
func (r *Registry) ProviderDefs() []providers.ToolDefinition {
r.mu.RLock()
defer r.mu.RUnlock()
defs := make([]providers.ToolDefinition, 0, len(r.tools)+len(r.aliases))
for name, tool := range r.tools {
if r.disabled[name] {
continue
// Sort canonical tool names for deterministic ordering.
sortedNames := make([]string, 0, len(r.tools))
for name := range r.tools {
if !r.disabled[name] {
sortedNames = append(sortedNames, name)
}
defs = append(defs, ToProviderDef(tool))
}
for alias, canonical := range r.aliases {
slices.Sort(sortedNames)
defs := make([]providers.ToolDefinition, 0, len(sortedNames)+len(r.aliases))
for _, name := range sortedNames {
defs = append(defs, ToProviderDef(r.tools[name]))
}
// Sort alias names for deterministic ordering.
sortedAliases := make([]string, 0, len(r.aliases))
for alias := range r.aliases {
sortedAliases = append(sortedAliases, alias)
}
slices.Sort(sortedAliases)
for _, alias := range sortedAliases {
canonical := r.aliases[alias]
if r.disabled[canonical] {
continue
}
@@ -254,6 +271,8 @@ func (r *Registry) ProviderDefs() []providers.ToolDefinition {
}
// List returns all registered canonical tool names (excludes aliases).
// Results are sorted lexicographically for deterministic ordering — critical
// for LLM prompt caching (Anthropic/OpenAI cache by exact prefix match).
func (r *Registry) List() []string {
r.mu.RLock()
defer r.mu.RUnlock()
@@ -263,6 +282,7 @@ func (r *Registry) List() []string {
names = append(names, name)
}
}
slices.Sort(names)
return names
}