diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 98c181f152..0c6a70e798 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -696,11 +696,26 @@ def _prepare_messages_for_anthropic(self, messages: Sequence[Message]) -> list[d This skips the first message if it is a system message, as Anthropic expects system instructions as a separate parameter. + + Anthropic's API requires that the conversation ends with a user message. + If the last message is from the assistant, its role is changed to user + to satisfy this constraint. """ # first system message is passed as instructions if messages and isinstance(messages[0], Message) and messages[0].role == "system": - return [self._prepare_message_for_anthropic(msg) for msg in messages[1:]] - return [self._prepare_message_for_anthropic(msg) for msg in messages] + msgs = list(messages[1:]) + else: + msgs = list(messages) + + result = [self._prepare_message_for_anthropic(msg) for msg in msgs] + + # Anthropic requires the conversation to end with a user message. + # Re-role a trailing assistant message as user so chained agent + # outputs work as valid context for the next agent. + if result and result[-1].get("role") == "assistant": + result[-1] = {**result[-1], "role": "user"} + + return result def _prepare_message_for_anthropic(self, message: Message) -> dict[str, Any]: """Prepare a Message for the Anthropic client. 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/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