diff --git a/internal/agent/systemprompt_sections.go b/internal/agent/systemprompt_sections.go index 42ae9d8b..d59e881e 100644 --- a/internal/agent/systemprompt_sections.go +++ b/internal/agent/systemprompt_sections.go @@ -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] + "…" } diff --git a/internal/store/pg/agents_context.go b/internal/store/pg/agents_context.go index b1763fc7..7684d3bd 100644 --- a/internal/store/pg/agents_context.go +++ b/internal/store/pg/agents_context.go @@ -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 diff --git a/internal/store/sqlitestore/agents_context.go b/internal/store/sqlitestore/agents_context.go index a3dc2418..cded9c0c 100644 --- a/internal/store/sqlitestore/agents_context.go +++ b/internal/store/sqlitestore/agents_context.go @@ -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 diff --git a/internal/tools/policy.go b/internal/tools/policy.go index e7f70a0e..57df0a84 100644 --- a/internal/tools/policy.go +++ b/internal/tools/policy.go @@ -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 } diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 0325a464..490841f8 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -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 }