diff --git a/ms_agent/tools/arg_coercion.py b/ms_agent/tools/arg_coercion.py index 674350ef0..6cf57e095 100644 --- a/ms_agent/tools/arg_coercion.py +++ b/ms_agent/tools/arg_coercion.py @@ -99,8 +99,8 @@ def _coerce_value(value: Any, schema: Any) -> Any: dict) else None if not isinstance(properties, dict): for key in ('anyOf', 'oneOf', 'allOf'): - for branch in (schema.get(key) or ()) if isinstance( - schema, dict) else (): + for branch in (schema.get(key) or + ()) if isinstance(schema, dict) else (): if isinstance(branch, dict) and isinstance( branch.get('properties'), dict): properties = branch['properties'] @@ -108,16 +108,20 @@ def _coerce_value(value: Any, schema: Any) -> Any: if properties: break if isinstance(properties, dict): - return { + coerced = { k: (_coerce_value(v, properties[k]) if k in properties else v) for k, v in value.items() } + return (coerced if any(coerced[k] is not v + for k, v in value.items()) else value) return value if isinstance(value, list) and 'array' in types: items = schema.get('items') if isinstance(schema, dict) else None if isinstance(items, dict): - return [_coerce_value(item, items) for item in value] + coerced = [_coerce_value(item, items) for item in value] + return (coerced if any( + new is not old for new, old in zip(coerced, value)) else value) return value # An int where the schema wants a float is already valid JSON-schema-wise, @@ -152,9 +156,9 @@ def coerce_arguments( for key, value in arguments.items(): if key in properties: new_value = _coerce_value(value, properties[key]) - if new_value is not value and new_value != value: - changed = True - elif type(new_value) is not type(value): + # Recursive coercion preserves identity when unchanged. Equality + # would discard nested type changes because 3 == 3.0 in Python. + if new_value is not value: changed = True out[key] = new_value else: diff --git a/tests/tools/test_arg_coercion_nested.py b/tests/tools/test_arg_coercion_nested.py new file mode 100644 index 000000000..5df12d343 --- /dev/null +++ b/tests/tools/test_arg_coercion_nested.py @@ -0,0 +1,103 @@ +"""Nested conversions must not disappear because int and float compare equal.""" +import pytest + +from ms_agent.tools.arg_coercion import coerce_arguments + + +@pytest.mark.parametrize('containers', [ + ('object',), + ('array',), + ('object', 'array', 'object'), + ('array', 'object', 'array'), +]) +def test_nested_number_widening_preserves_types_without_mutating_input(containers): + value = 3 + schema = {'type': 'number'} + for container in containers: + if container == 'object': + value = {'value': value} + schema = {'type': 'object', 'properties': {'value': schema}} + else: + value = [value] + schema = {'type': 'array', 'items': schema} + arguments = {'payload': value} + schema = {'type': 'object', 'properties': {'payload': schema}} + + result = coerce_arguments(arguments, schema) + + assert result is not arguments + actual, original = result['payload'], arguments['payload'] + for container in reversed(containers): + assert actual is not original + key = 'value' if container == 'object' else 0 + actual, original = actual[key], original[key] + assert type(actual) is float + assert actual == 3.0 + assert type(original) is int + assert coerce_arguments(result, schema) is result + + +@pytest.mark.parametrize('value', [3.0, True, None, 'not a number']) +def test_unchanged_nested_values_preserve_argument_identity(value): + arguments = {'payload': {'values': [value]}, 'unknown': {'n': 3}} + schema = { + 'type': 'object', + 'properties': { + 'payload': { + 'type': 'object', + 'properties': { + 'values': {'type': 'array', 'items': {'type': 'number'}}, + }, + }, + }, + } + assert coerce_arguments(arguments, schema) is arguments + + +def test_nested_integer_union_remains_integer(): + arguments = {'payload': [3]} + schema = {'properties': {'payload': { + 'type': 'array', 'items': {'type': ['integer', 'number']}, + }}} + assert coerce_arguments(arguments, schema) is arguments + assert type(arguments['payload'][0]) is int + + +@pytest.mark.asyncio +async def test_manager_dispatches_nested_numbers_to_strict_tool(): + from omegaconf import OmegaConf + from pydantic.v1 import BaseModel, StrictFloat + + from ms_agent.tools.base import ToolBase + from ms_agent.tools.tool_manager import ToolManager + + class Payload(BaseModel): + values: list[StrictFloat] + + class Arguments(BaseModel): + payload: Payload + + class StrictTool(ToolBase): + async def call_tool(self, server_name, *, tool_name, tool_args): + # Pydantic v1 StrictFloat rejects ints, although JSON Schema + # considers them valid numbers. Coercion supports such tools. + Arguments.parse_obj(tool_args) + self.received = tool_args + return 'validated' + + manager = ToolManager(OmegaConf.create({})) + tool = StrictTool(OmegaConf.create({})) + schema = {'type': 'object', 'properties': {'payload': { + 'type': 'object', 'properties': {'values': { + 'type': 'array', 'items': {'type': 'number'}, + }}, + }}} + manager._tool_index['strict---validate'] = ( + tool, 'strict', {'parameters': schema}) + arguments = {'payload': {'values': [3]}} + call = {'tool_name': 'strict---validate', 'arguments': arguments} + + assert await manager.single_call_tool(call) == 'validated' + assert type(tool.received['payload']['values'][0]) is float + assert type(arguments['payload']['values'][0]) is int + assert call['arguments'] is not arguments