diff --git a/go/adk/pkg/models/ollama_adk.go b/go/adk/pkg/models/ollama_adk.go index 82f4435366..35e01c76e7 100644 --- a/go/adk/pkg/models/ollama_adk.go +++ b/go/adk/pkg/models/ollama_adk.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "iter" + "maps" + "slices" "strings" "github.com/google/uuid" @@ -364,8 +366,11 @@ func convertGenaiToolsToOllama(tools []*genai.Tool) []api.Tool { // Ollama requires typed properties, so we convert to map[string]any first then to api.ToolProperty. if m := parametersJsonSchemaToMap(decl.ParametersJsonSchema); m != nil { if props, ok := m["properties"].(map[string]any); ok { - for name, propAny := range props { - if propMap, ok := propAny.(map[string]any); ok { + // Sort: api.ToolPropertiesMap preserves insertion order, so + // ranging the map directly would emit a different property + // order on every call and break prompt prefix caching. + for _, name := range slices.Sorted(maps.Keys(props)) { + if propMap, ok := props[name].(map[string]any); ok { prop := api.ToolProperty{} if t, ok := propMap["type"].(string); ok { prop.Type = api.PropertyType{t} @@ -389,7 +394,9 @@ func convertGenaiToolsToOllama(tools []*genai.Tool) []api.Tool { } } } else if decl.Parameters != nil { - for name, schema := range decl.Parameters.Properties { + // Sorted for the same reason as above. + for _, name := range slices.Sorted(maps.Keys(decl.Parameters.Properties)) { + schema := decl.Parameters.Properties[name] if schema == nil { continue } diff --git a/go/adk/pkg/models/ollama_test.go b/go/adk/pkg/models/ollama_test.go index 9df42d6c8d..c1174b3c32 100644 --- a/go/adk/pkg/models/ollama_test.go +++ b/go/adk/pkg/models/ollama_test.go @@ -2,6 +2,7 @@ package models import ( "reflect" + "slices" "testing" "google.golang.org/genai" @@ -214,3 +215,50 @@ func TestConvertGenaiContentsToOllamaMessages(t *testing.T) { }) } } + +// TestConvertGenaiToolsToOllamaPropertyOrderIsStable guards prompt prefix +// caching. api.ToolPropertiesMap preserves insertion order, so if the +// conversion ranges over the Go map of properties directly, every call emits a +// different property order. Servers that cache on the prompt prefix then +// re-process the whole prompt on every request. +func TestConvertGenaiToolsToOllamaPropertyOrderIsStable(t *testing.T) { + props := map[string]*genai.Schema{ + "project": {Type: genai.TypeString, Description: "project slug"}, + "family": {Type: genai.TypeString, Description: "change family"}, + "bucket": {Type: genai.TypeString, Description: "severity bucket"}, + "since": {Type: genai.TypeString, Description: "lower bound"}, + "limit": {Type: genai.TypeInteger, Description: "max rows"}, + } + tools := []*genai.Tool{{ + FunctionDeclarations: []*genai.FunctionDeclaration{{ + Name: "list_changes", + Parameters: &genai.Schema{Type: genai.TypeObject, Properties: props}, + }}, + }} + + want := names(t, tools) + for i := range 100 { + if got := names(t, tools); !reflect.DeepEqual(got, want) { + t.Fatalf("property order changed between calls:\n first: %v\n call %d: %v", want, i, got) + } + } + + if !slices.IsSorted(want) { + t.Errorf("property order is not deterministic across processes: %v", want) + } +} + +// names returns the parameter property names of the first converted tool, in +// the order they would be serialized. +func names(t *testing.T, tools []*genai.Tool) []string { + t.Helper() + converted := convertGenaiToolsToOllama(tools) + if len(converted) != 1 { + t.Fatalf("expected 1 tool, got %d", len(converted)) + } + var out []string + for name := range converted[0].Function.Parameters.Properties.All() { + out = append(out, name) + } + return out +}