From 6bcb0d9af3eab583338217950679eb2c99aeccf6 Mon Sep 17 00:00:00 2001 From: Prognosticate X Date: Fri, 2 Oct 2026 10:45:18 +0800 Subject: [PATCH 1/6] fix(python): sort MCP inputSchema properties deterministically for prompt caching Some MCP servers emit tool inputSchema properties in arbitrary order between restarts or dynamic reload events. When downstream LLM clients construct function-calling tools, unstable schema ordering invalidates the model's prompt cache across restarts. This change ensures that object schema properties are sorted deterministically by key during MCPTool.load_tools, preserving prompt cache hits without altering validation semantics. Aligns with #8245. --- python/packages/core/agent_framework/_mcp.py | 3 ++ python/packages/core/tests/core/test_mcp.py | 32 ++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 242e94cae68..8cee88e1cf0 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -2603,6 +2603,9 @@ async def _load_tools_locked(self) -> None: input_schema = dict(tool.inputSchema or {}) if input_schema.get("type") == "object" and "properties" not in input_schema: input_schema["properties"] = {} + elif isinstance(input_schema.get("properties"), dict): + # Sort property keys deterministically to preserve prompt cache stability + input_schema["properties"] = dict(sorted(input_schema["properties"].items())) # Register declared param names before the existing-tool skip below so that # reloads (e.g. notifications/tools/list_changed) preserve the allowlist for diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 94d646acaa7..e51e2a600f7 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -10178,3 +10178,35 @@ async def connect_then_fail_loading(**_: Any) -> None: # endregion + + +async def test_load_tools_sorts_input_schema_properties_deterministically() -> None: + """Verify that inputSchema properties are sorted deterministically for prompt caching.""" + tool = MCPTool(name="test") # type: ignore[abstract] + tool.session = AsyncMock() + tool.load_tools_flag = True + + page = Mock() + page.tools = [ + types.Tool( + name="demo_tool", + description="A test tool with unsorted schema properties", + inputSchema={ + "type": "object", + "properties": { + "z_param": {"type": "string"}, + "a_param": {"type": "number"}, + "m_param": {"type": "boolean"}, + }, + }, + ), + ] + page.nextCursor = None + tool.session.list_tools = AsyncMock(return_value=page) + + await tool.load_tools() + + func = next(f for f in tool.functions if f.name == "demo_tool") + schema = func.parameters() + assert isinstance(schema, dict) + assert list(schema["properties"].keys()) == ["a_param", "m_param", "z_param"] From 384a1cb52ee34bae268619fd0b4035638535a12e Mon Sep 17 00:00:00 2001 From: Prognosticate X Date: Thu, 8 Oct 2026 23:41:01 +0800 Subject: [PATCH 2/6] fix(python): sort nested MCP inputSchema properties deterministically --- python/packages/core/agent_framework/_mcp.py | 36 ++++++++++++- python/packages/core/tests/core/test_mcp.py | 53 ++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 8cee88e1cf0..ede0b35d673 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -720,6 +720,38 @@ def _mcp_header_identity(headers: Mapping[str, str]) -> _MCPHeaderIdentity: return tuple(sorted(normalized.items())) +def _canonicalize_schema(schema: Any) -> Any: + """Recursively sort schema properties deterministically to preserve prompt cache stability. + + Sorts keys within 'properties', 'patternProperties', and schema definition mappings + ('$defs', 'definitions'), and recursively canonicalizes nested schemas in objects and arrays. + Non-dict/list values and schema structures without these keys are returned unmodified. + """ + if isinstance(schema, dict): + result: dict[str, Any] = {} + for key, value in schema.items(): + if key in {"properties", "patternProperties", "$defs", "definitions"} and isinstance(value, dict): + # Sort mapping keys deterministically, recursively canonicalizing each subschema + result[key] = {k: _canonicalize_schema(v) for k, v in sorted(value.items())} + elif key == "items": + if isinstance(value, dict): + result[key] = _canonicalize_schema(value) + elif isinstance(value, list): + result[key] = [_canonicalize_schema(item) for item in value] + else: + result[key] = value + elif key in {"allOf", "anyOf", "oneOf"} and isinstance(value, list): + result[key] = [_canonicalize_schema(sub) for sub in value] + elif isinstance(value, (dict, list)): + result[key] = _canonicalize_schema(value) + else: + result[key] = value + return result + if isinstance(schema, list): + return [_canonicalize_schema(item) for item in schema] + return schema + + # Internal polling bounds for MCP long-running tasks. Not user-tunable today; # promote to MCPTaskOptions if a concrete need arises. _MCP_TASK_MIN_POLL_INTERVAL = timedelta(milliseconds=500) @@ -2604,8 +2636,8 @@ async def _load_tools_locked(self) -> None: if input_schema.get("type") == "object" and "properties" not in input_schema: input_schema["properties"] = {} elif isinstance(input_schema.get("properties"), dict): - # Sort property keys deterministically to preserve prompt cache stability - input_schema["properties"] = dict(sorted(input_schema["properties"].items())) + # Sort property keys recursively to preserve prompt cache stability + input_schema = _canonicalize_schema(input_schema) # Register declared param names before the existing-tool skip below so that # reloads (e.g. notifications/tools/list_changed) preserve the allowlist for diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index e51e2a600f7..2ef4dd5c693 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -10210,3 +10210,56 @@ async def test_load_tools_sorts_input_schema_properties_deterministically() -> N schema = func.parameters() assert isinstance(schema, dict) assert list(schema["properties"].keys()) == ["a_param", "m_param", "z_param"] + + +async def test_load_tools_sorts_nested_input_schema_deterministically() -> None: + """Verify that nested properties, items, and $defs are recursively sorted deterministically.""" + tool = MCPTool(name="test") # type: ignore[abstract] + tool.session = AsyncMock() + tool.load_tools_flag = True + + page = Mock() + page.tools = [ + types.Tool( + name="nested_tool", + description="Tool with nested objects and defs", + inputSchema={ + "type": "object", + "$defs": { + "ZDef": {"type": "string"}, + "ADef": {"type": "integer"}, + }, + "properties": { + "user": { + "type": "object", + "properties": { + "zip": {"type": "string"}, + "address": {"type": "string"}, + }, + }, + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "key": {"type": "string"}, + }, + }, + }, + }, + }, + ), + ] + page.nextCursor = None + tool.session.list_tools = AsyncMock(return_value=page) + + await tool.load_tools() + + func = next(f for f in tool.functions if f.name == "nested_tool") + schema = func.parameters() + assert isinstance(schema, dict) + assert list(schema["$defs"].keys()) == ["ADef", "ZDef"] + assert list(schema["properties"].keys()) == ["tags", "user"] + assert list(schema["properties"]["user"]["properties"].keys()) == ["address", "zip"] + assert list(schema["properties"]["tags"]["items"]["properties"].keys()) == ["key", "value"] From 6b14a1ade50c80187cbf689eb8c8cfd00a1f9ad1 Mon Sep 17 00:00:00 2001 From: Prognosticate X Date: Fri, 9 Oct 2026 19:06:22 +0800 Subject: [PATCH 3/6] test(core): define TestServer subclass in test_mcp to satisfy type checker --- python/packages/core/tests/core/test_mcp.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 2ef4dd5c693..db7967b438b 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -10182,7 +10182,12 @@ async def connect_then_fail_loading(**_: Any) -> None: async def test_load_tools_sorts_input_schema_properties_deterministically() -> None: """Verify that inputSchema properties are sorted deterministically for prompt caching.""" - tool = MCPTool(name="test") # type: ignore[abstract] + + class TestServer(MCPTool): + def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: + return None # type: ignore[return-value] # pyrefly: ignore[bad-return] # ty: ignore[invalid-return-type] + + tool = TestServer(name="test") tool.session = AsyncMock() tool.load_tools_flag = True @@ -10214,7 +10219,12 @@ async def test_load_tools_sorts_input_schema_properties_deterministically() -> N async def test_load_tools_sorts_nested_input_schema_deterministically() -> None: """Verify that nested properties, items, and $defs are recursively sorted deterministically.""" - tool = MCPTool(name="test") # type: ignore[abstract] + + class TestServer(MCPTool): + def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: + return None # type: ignore[return-value] # pyrefly: ignore[bad-return] # ty: ignore[invalid-return-type] + + tool = TestServer(name="test") tool.session = AsyncMock() tool.load_tools_flag = True From 7eadae7060d8d6fcfe6d739838b769b83d32bc83 Mon Sep 17 00:00:00 2001 From: Prognosticate X Date: Fri, 9 Oct 2026 22:30:41 +0800 Subject: [PATCH 4/6] fix(mcp): add explicit type casts to _canonicalize_schema for pyright --- python/packages/core/agent_framework/_mcp.py | 34 ++++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index ede0b35d673..7f440c250d5 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -729,26 +729,32 @@ def _canonicalize_schema(schema: Any) -> Any: """ if isinstance(schema, dict): result: dict[str, Any] = {} - for key, value in schema.items(): - if key in {"properties", "patternProperties", "$defs", "definitions"} and isinstance(value, dict): + schema_dict = cast(dict[str, Any], schema) + for key, val in schema_dict.items(): + if key in {"properties", "patternProperties", "$defs", "definitions"} and isinstance(val, dict): # Sort mapping keys deterministically, recursively canonicalizing each subschema - result[key] = {k: _canonicalize_schema(v) for k, v in sorted(value.items())} + sub_dict = cast(dict[str, Any], val) + sorted_items: list[tuple[str, Any]] = sorted(sub_dict.items(), key=lambda pair: pair[0]) + result[key] = {k: _canonicalize_schema(v) for k, v in sorted_items} elif key == "items": - if isinstance(value, dict): - result[key] = _canonicalize_schema(value) - elif isinstance(value, list): - result[key] = [_canonicalize_schema(item) for item in value] + if isinstance(val, dict): + result[key] = _canonicalize_schema(val) + elif isinstance(val, list): + items_list = cast(list[Any], val) + result[key] = [_canonicalize_schema(item) for item in items_list] else: - result[key] = value - elif key in {"allOf", "anyOf", "oneOf"} and isinstance(value, list): - result[key] = [_canonicalize_schema(sub) for sub in value] - elif isinstance(value, (dict, list)): - result[key] = _canonicalize_schema(value) + result[key] = val + elif key in {"allOf", "anyOf", "oneOf"} and isinstance(val, list): + branches_list = cast(list[Any], val) + result[key] = [_canonicalize_schema(sub) for sub in branches_list] + elif isinstance(val, (dict, list)): + result[key] = _canonicalize_schema(val) else: - result[key] = value + result[key] = val return result if isinstance(schema, list): - return [_canonicalize_schema(item) for item in schema] + schema_list = cast(list[Any], schema) + return [_canonicalize_schema(item) for item in schema_list] return schema From 614940bdeab640b4ff967ac5e0380d0b675cc975 Mon Sep 17 00:00:00 2001 From: Prognosticate X Date: Tue, 13 Oct 2026 12:32:37 +0800 Subject: [PATCH 5/6] refactor(core): centralize deterministic schema canonicalization at FunctionTool boundary --- python/packages/core/agent_framework/_mcp.py | 41 -------- .../packages/core/agent_framework/_tools.py | 31 +++++- python/packages/core/tests/core/test_mcp.py | 95 ------------------ python/packages/core/tests/core/test_tools.py | 96 +++++++++++++++++++ 4 files changed, 125 insertions(+), 138 deletions(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 7f440c250d5..242e94cae68 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -720,44 +720,6 @@ def _mcp_header_identity(headers: Mapping[str, str]) -> _MCPHeaderIdentity: return tuple(sorted(normalized.items())) -def _canonicalize_schema(schema: Any) -> Any: - """Recursively sort schema properties deterministically to preserve prompt cache stability. - - Sorts keys within 'properties', 'patternProperties', and schema definition mappings - ('$defs', 'definitions'), and recursively canonicalizes nested schemas in objects and arrays. - Non-dict/list values and schema structures without these keys are returned unmodified. - """ - if isinstance(schema, dict): - result: dict[str, Any] = {} - schema_dict = cast(dict[str, Any], schema) - for key, val in schema_dict.items(): - if key in {"properties", "patternProperties", "$defs", "definitions"} and isinstance(val, dict): - # Sort mapping keys deterministically, recursively canonicalizing each subschema - sub_dict = cast(dict[str, Any], val) - sorted_items: list[tuple[str, Any]] = sorted(sub_dict.items(), key=lambda pair: pair[0]) - result[key] = {k: _canonicalize_schema(v) for k, v in sorted_items} - elif key == "items": - if isinstance(val, dict): - result[key] = _canonicalize_schema(val) - elif isinstance(val, list): - items_list = cast(list[Any], val) - result[key] = [_canonicalize_schema(item) for item in items_list] - else: - result[key] = val - elif key in {"allOf", "anyOf", "oneOf"} and isinstance(val, list): - branches_list = cast(list[Any], val) - result[key] = [_canonicalize_schema(sub) for sub in branches_list] - elif isinstance(val, (dict, list)): - result[key] = _canonicalize_schema(val) - else: - result[key] = val - return result - if isinstance(schema, list): - schema_list = cast(list[Any], schema) - return [_canonicalize_schema(item) for item in schema_list] - return schema - - # Internal polling bounds for MCP long-running tasks. Not user-tunable today; # promote to MCPTaskOptions if a concrete need arises. _MCP_TASK_MIN_POLL_INTERVAL = timedelta(milliseconds=500) @@ -2641,9 +2603,6 @@ async def _load_tools_locked(self) -> None: input_schema = dict(tool.inputSchema or {}) if input_schema.get("type") == "object" and "properties" not in input_schema: input_schema["properties"] = {} - elif isinstance(input_schema.get("properties"), dict): - # Sort property keys recursively to preserve prompt cache stability - input_schema = _canonicalize_schema(input_schema) # Register declared param names before the existing-tool skip below so that # reloads (e.g. notifications/tools/list_changed) preserve the allowlist for diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 602da9dc4cb..4f7375b6b08 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -436,6 +436,28 @@ def _normalize_tool_description_format( # pyright: ignore[reportUnusedFunction] return normalized +def _canonicalize_schema(schema: Any) -> Any: + """Recursively sort schema dictionary keys deterministically to preserve prompt cache stability. + + Sorts all object-level keyword keys alphabetically, and recursively canonicalizes nested + schemas in mappings (properties, patternProperties, $defs, definitions, dependentSchemas) + and sequences (items, allOf, anyOf, oneOf). Non-dict/list values are returned unmodified. + """ + if isinstance(schema, dict): + schema_dict = cast(dict[str, Any], schema) + result: dict[str, Any] = {} + for key, val in sorted(schema_dict.items(), key=lambda pair: pair[0]): + if isinstance(val, (dict, list)): + result[key] = _canonicalize_schema(val) + else: + result[key] = val + return result + if isinstance(schema, list): + schema_list = cast(list[Any], schema) + return [_canonicalize_schema(item) for item in schema_list] + return schema + + def _format_tool_parameters( # pyright: ignore[reportUnusedFunction] parameters: dict[str, Any], *, @@ -1309,14 +1331,19 @@ def to_json_schema_spec(self) -> dict[str, Any]: """Convert a FunctionTool to the JSON Schema function specification format. Returns: - A dictionary containing the function specification in JSON Schema format. + A dictionary containing the function specification in JSON Schema format, + with canonicalized deterministic schema sorting to preserve LLM prompt caching. """ + raw_parameters = self.parameters() + canonical_parameters = ( + _canonicalize_schema(raw_parameters) if isinstance(raw_parameters, dict) else raw_parameters + ) return { "type": "function", "function": { "name": self.name, "description": self.description, - "parameters": self.parameters(), + "parameters": canonical_parameters, }, } diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index db7967b438b..94d646acaa7 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -10178,98 +10178,3 @@ async def connect_then_fail_loading(**_: Any) -> None: # endregion - - -async def test_load_tools_sorts_input_schema_properties_deterministically() -> None: - """Verify that inputSchema properties are sorted deterministically for prompt caching.""" - - class TestServer(MCPTool): - def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: - return None # type: ignore[return-value] # pyrefly: ignore[bad-return] # ty: ignore[invalid-return-type] - - tool = TestServer(name="test") - tool.session = AsyncMock() - tool.load_tools_flag = True - - page = Mock() - page.tools = [ - types.Tool( - name="demo_tool", - description="A test tool with unsorted schema properties", - inputSchema={ - "type": "object", - "properties": { - "z_param": {"type": "string"}, - "a_param": {"type": "number"}, - "m_param": {"type": "boolean"}, - }, - }, - ), - ] - page.nextCursor = None - tool.session.list_tools = AsyncMock(return_value=page) - - await tool.load_tools() - - func = next(f for f in tool.functions if f.name == "demo_tool") - schema = func.parameters() - assert isinstance(schema, dict) - assert list(schema["properties"].keys()) == ["a_param", "m_param", "z_param"] - - -async def test_load_tools_sorts_nested_input_schema_deterministically() -> None: - """Verify that nested properties, items, and $defs are recursively sorted deterministically.""" - - class TestServer(MCPTool): - def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: - return None # type: ignore[return-value] # pyrefly: ignore[bad-return] # ty: ignore[invalid-return-type] - - tool = TestServer(name="test") - tool.session = AsyncMock() - tool.load_tools_flag = True - - page = Mock() - page.tools = [ - types.Tool( - name="nested_tool", - description="Tool with nested objects and defs", - inputSchema={ - "type": "object", - "$defs": { - "ZDef": {"type": "string"}, - "ADef": {"type": "integer"}, - }, - "properties": { - "user": { - "type": "object", - "properties": { - "zip": {"type": "string"}, - "address": {"type": "string"}, - }, - }, - "tags": { - "type": "array", - "items": { - "type": "object", - "properties": { - "value": {"type": "string"}, - "key": {"type": "string"}, - }, - }, - }, - }, - }, - ), - ] - page.nextCursor = None - tool.session.list_tools = AsyncMock(return_value=page) - - await tool.load_tools() - - func = next(f for f in tool.functions if f.name == "nested_tool") - schema = func.parameters() - assert isinstance(schema, dict) - assert list(schema["$defs"].keys()) == ["ADef", "ZDef"] - assert list(schema["properties"].keys()) == ["tags", "user"] - assert list(schema["properties"]["user"]["properties"].keys()) == ["address", "zip"] - assert list(schema["properties"]["tags"]["items"]["properties"].keys()) == ["key", "value"] diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 3071e191513..4bae58cfeca 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio import copy +import json import logging import threading from contextvars import ContextVar @@ -2029,4 +2030,99 @@ def test_skip_parsing_is_singleton() -> None: assert repr(SKIP_PARSING) == "SKIP_PARSING" +def test_to_json_schema_spec_canonicalizes_reordered_top_level_keywords() -> None: + """Verify that to_json_schema_spec canonicalizes top-level and nested keyword order for prompt caching.""" + # Two tools with differently ordered top-level keywords and properties + schema_1 = { + "title": "SearchTool", + "type": "object", + "description": "Searches items", + "properties": { + "z_query": {"description": "Query string", "type": "string"}, + "a_limit": {"default": 10, "type": "integer"}, + }, + "required": ["z_query"], + } + schema_2 = { + "description": "Searches items", + "properties": { + "a_limit": {"type": "integer", "default": 10}, + "z_query": {"type": "string", "description": "Query string"}, + }, + "type": "object", + "required": ["z_query"], + "title": "SearchTool", + } + + tool_1 = FunctionTool(name="search", description="Search tool", input_model=schema_1) + tool_2 = FunctionTool(name="search", description="Search tool", input_model=schema_2) + + spec_1 = tool_1.to_json_schema_spec() + spec_2 = tool_2.to_json_schema_spec() + + # Top-level parameters dictionary keys must be sorted deterministically + assert list(spec_1["function"]["parameters"].keys()) == ["description", "properties", "required", "title", "type"] + assert list(spec_2["function"]["parameters"].keys()) == ["description", "properties", "required", "title", "type"] + + # Properties dictionary keys must be sorted deterministically + assert list(spec_1["function"]["parameters"]["properties"].keys()) == ["a_limit", "z_query"] + assert list(spec_2["function"]["parameters"]["properties"].keys()) == ["a_limit", "z_query"] + + # Wire-level serialization must be identical + wire_1 = json.dumps(spec_1) + wire_2 = json.dumps(spec_2) + assert wire_1 == wire_2 + + +def test_to_json_schema_spec_canonicalizes_root_schema_without_properties() -> None: + """Regression: root schemas without 'properties' are canonicalized deterministically.""" + schema_1 = { + "description": "A scalar or empty tool schema", + "type": "string", + "title": "ScalarInput", + } + schema_2 = { + "title": "ScalarInput", + "description": "A scalar or empty tool schema", + "type": "string", + } + + tool_1 = FunctionTool(name="scalar_tool", description="Scalar tool", input_model=schema_1) + tool_2 = FunctionTool(name="scalar_tool", description="Scalar tool", input_model=schema_2) + + spec_1 = tool_1.to_json_schema_spec() + spec_2 = tool_2.to_json_schema_spec() + + assert list(spec_1["function"]["parameters"].keys()) == ["description", "title", "type"] + assert list(spec_2["function"]["parameters"].keys()) == ["description", "title", "type"] + assert json.dumps(spec_1) == json.dumps(spec_2) + + +def test_to_json_schema_spec_canonicalizes_nested_and_defs_deterministically() -> None: + """Verify that nested objects, $defs, and arrays are recursively sorted deterministically.""" + schema = { + "type": "object", + "$defs": { + "ZItem": {"type": "string"}, + "AItem": {"type": "integer"}, + }, + "properties": { + "metadata": { + "type": "object", + "properties": { + "tag_z": {"type": "string"}, + "tag_a": {"type": "string"}, + }, + }, + }, + } + tool = FunctionTool(name="complex_tool", description="Complex tool", input_model=schema) + spec = tool.to_json_schema_spec() + params = spec["function"]["parameters"] + + assert list(params.keys()) == ["$defs", "properties", "type"] + assert list(params["$defs"].keys()) == ["AItem", "ZItem"] + assert list(params["properties"]["metadata"]["properties"].keys()) == ["tag_a", "tag_z"] + + # endregion From e132c1daad8b7e6c897851d2d97cfd1f0e0b334c Mon Sep 17 00:00:00 2001 From: Prognosticate X Date: Fri, 16 Oct 2026 16:53:35 +0800 Subject: [PATCH 6/6] fix(core): canonicalize schema inside FunctionTool.parameters() for all providers --- .../packages/core/agent_framework/_tools.py | 16 +++--- python/packages/core/tests/core/test_tools.py | 53 +++++++++++++++++++ 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 4f7375b6b08..522fce98b1b 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1257,11 +1257,14 @@ def parameters(self) -> dict[str, Any]: """Create the JSON schema of the parameters. Returns: - A dictionary containing the JSON schema for the function's parameters. + A dictionary containing the JSON schema for the function's parameters, + with deterministic recursive sorting to preserve prompt cache stability. The result is cached after the first call for performance. """ if self._cached_parameters is None: - self._cached_parameters = self._input_schema + raw_schema = self._input_schema + canonical = _canonicalize_schema(raw_schema) if isinstance(raw_schema, dict) else raw_schema + self._cached_parameters = canonical if isinstance(canonical, dict) else {} return self._cached_parameters @staticmethod @@ -1331,19 +1334,14 @@ def to_json_schema_spec(self) -> dict[str, Any]: """Convert a FunctionTool to the JSON Schema function specification format. Returns: - A dictionary containing the function specification in JSON Schema format, - with canonicalized deterministic schema sorting to preserve LLM prompt caching. + A dictionary containing the function specification in JSON Schema format. """ - raw_parameters = self.parameters() - canonical_parameters = ( - _canonicalize_schema(raw_parameters) if isinstance(raw_parameters, dict) else raw_parameters - ) return { "type": "function", "function": { "name": self.name, "description": self.description, - "parameters": canonical_parameters, + "parameters": self.parameters(), }, } diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 4bae58cfeca..d7f6ecec852 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -2125,4 +2125,57 @@ def test_to_json_schema_spec_canonicalizes_nested_and_defs_deterministically() - assert list(params["properties"]["metadata"]["properties"].keys()) == ["tag_a", "tag_z"] +def test_parameters_canonicalizes_schema_deterministically() -> None: + """Verify that FunctionTool.parameters() directly canonicalizes schemas deterministically. + + This ensures providers that serialize tool.parameters() directly (such as Anthropic, + Bedrock, Gemini, and Mistral) obtain deterministic wire-level outputs without cache churn. + """ + schema_1 = { + "title": "Config", + "description": "Provider tool", + "type": "object", + "properties": { + "z_param": {"type": "string"}, + "a_param": {"type": "integer"}, + }, + } + schema_2 = { + "properties": { + "a_param": {"type": "integer"}, + "z_param": {"type": "string"}, + }, + "type": "object", + "description": "Provider tool", + "title": "Config", + } + + tool_1 = FunctionTool(name="config_tool", description="tool", input_model=schema_1) + tool_2 = FunctionTool(name="config_tool", description="tool", input_model=schema_2) + + params_1 = tool_1.parameters() + params_2 = tool_2.parameters() + + # Direct parameters() must be sorted deterministically + assert list(params_1.keys()) == ["description", "properties", "title", "type"] + assert list(params_2.keys()) == ["description", "properties", "title", "type"] + assert list(params_1["properties"].keys()) == ["a_param", "z_param"] + assert list(params_2["properties"].keys()) == ["a_param", "z_param"] + + # Provider wire simulation: Anthropic tool definition shape + anthropic_wire_1 = json.dumps({ + "type": "custom", + "name": tool_1.name, + "description": tool_1.description, + "input_schema": tool_1.parameters(), + }) + anthropic_wire_2 = json.dumps({ + "type": "custom", + "name": tool_2.name, + "description": tool_2.description, + "input_schema": tool_2.parameters(), + }) + assert anthropic_wire_1 == anthropic_wire_2 + + # endregion