diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 93722a8987..1605c5443d 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -636,7 +636,7 @@ async def invoke( parsed_arguments = dict(arguments) if self.input_model is not None and not self._schema_supplied: parsed_arguments = self.input_model.model_validate(parsed_arguments).model_dump( - exclude_none=True + exclude_none=False ) elif isinstance(arguments, BaseModel): if ( @@ -645,7 +645,7 @@ async def invoke( and not isinstance(arguments, self.input_model) ): raise TypeError(f"Expected {self.input_model.__name__}, got {type(arguments).__name__}") - parsed_arguments = arguments.model_dump(exclude_none=True) + parsed_arguments = arguments.model_dump(exclude_none=False) else: raise TypeError( f"Expected mapping-like arguments for tool '{self.name}', got {type(arguments).__name__}" @@ -1492,7 +1492,7 @@ async def _auto_invoke_function( runtime_kwargs["session"] = invocation_session try: if not cast(bool, getattr(tool, "_schema_supplied", False)) and tool.input_model is not None: - args = tool.input_model.model_validate(parsed_args).model_dump(exclude_none=True) + args = tool.input_model.model_validate(parsed_args).model_dump(exclude_none=False) else: args = dict(parsed_args) args = _validate_arguments_against_schema( diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index ce47f5fc8e..048551752b 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -1973,11 +1973,35 @@ def _coalesce_text_content(contents: list[Content], type_str: Literal["text", "t contents.extend(coalesced_contents) +def _coalesce_code_interpreter_content(contents: list[Content]) -> None: + """Coalesce code_interpreter_tool_call items with the same call_id into a single item.""" + if not contents: + return + seen: dict[str, Content] = {} + coalesced: list[Content] = [] + for content in contents: + if content.type == "code_interpreter_tool_call" and content.call_id: + if content.call_id in seen: + existing = seen[content.call_id] + if content.inputs: + if existing.inputs is None: + existing.inputs = [] + existing.inputs.extend(content.inputs) + else: + seen[content.call_id] = content + coalesced.append(content) + else: + coalesced.append(content) + contents.clear() + contents.extend(coalesced) + + def _finalize_response(response: ChatResponse | AgentResponse) -> None: """Finalizes the response by performing any necessary post-processing.""" for msg in response.messages: _coalesce_text_content(msg.contents, "text") _coalesce_text_content(msg.contents, "text_reasoning") + _coalesce_code_interpreter_content(msg.contents) # region ContinuationToken diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index b3762bf4ef..df161a056d 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -1,4 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. +import asyncio from typing import Annotated, Any, Literal, get_args, get_origin from unittest.mock import Mock @@ -1462,4 +1463,20 @@ def test_skip_parsing_is_singleton() -> None: assert repr(SKIP_PARSING) == "SKIP_PARSING" +def test_invoke_preserves_explicit_none_arguments() -> None: + """Optional parameters explicitly set to None must not be stripped before invocation.""" + + @tool + def greet(name: str, greeting: str | None = None) -> str: + return f"{greeting or 'Hello'}, {name}!" + + result = asyncio.run(greet.invoke(arguments={"name": "World", "greeting": None})) + assert isinstance(result, list) + assert result[0].text == "Hello, World!" + + result = asyncio.run(greet.invoke(arguments={"name": "World"})) + assert isinstance(result, list) + assert result[0].text == "Hello, World!" + + # endregion