From 21c9a5656274ac5b0840d44159550c6b0a3a8afb Mon Sep 17 00:00:00 2001 From: venti <1308199824@qq.com> Date: Sat, 30 May 2026 15:22:23 +0800 Subject: [PATCH 1/7] Fix auto function calling stripping explicit null arguments (fixes #5934) --- python/packages/core/agent_framework/_tools.py | 6 +++--- python/packages/core/tests/core/test_tools.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) 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 From 2436b5e34d5bca0ebd5fcad605299220ef2eda0c Mon Sep 17 00:00:00 2001 From: venti <1308199824@qq.com> Date: Sat, 30 May 2026 15:38:52 +0800 Subject: [PATCH 2/7] fix: re-role trailing assistant message to user for Anthropic (fixes #5008) --- .../agent_framework_anthropic/_chat_client.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) 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. From 7cfe88fcf58d06ed82ae232807ae157eb0ba5fb9 Mon Sep 17 00:00:00 2001 From: venti <1308199824@qq.com> Date: Tue, 2 Jun 2026 12:24:18 +0800 Subject: [PATCH 3/7] fix: address Copilot review feedback (exclude_unset, test coverage, synthetic user turn) --- .../agent_framework_anthropic/_chat_client.py | 6 +++--- .../packages/anthropic/tests/test_anthropic_client.py | 4 +++- python/packages/core/agent_framework/_tools.py | 4 ++-- python/packages/core/tests/core/test_tools.py | 10 ++++++---- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 0c6a70e798..46d0b2447a 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -710,10 +710,10 @@ def _prepare_messages_for_anthropic(self, messages: Sequence[Message]) -> list[d 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. + # Append a synthetic user turn so chained agent outputs work as + # valid context for the next agent without rewriting the assistant message. if result and result[-1].get("role") == "assistant": - result[-1] = {**result[-1], "role": "user"} + result.append({"role": "user", "content": "Continue"}) return result diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 0cfec3423c..5fbb750aae 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -618,9 +618,11 @@ def test_prepare_messages_for_anthropic_without_system( result = client._prepare_messages_for_anthropic(messages) - assert len(result) == 2 + assert len(result) == 3 assert result[0]["role"] == "user" assert result[1]["role"] == "assistant" + assert result[2]["role"] == "user" + assert result[2]["content"] == "Continue" # Tool Conversion Tests diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 1605c5443d..271e232b40 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=False + exclude_unset=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=False) + parsed_arguments = arguments.model_dump(exclude_unset=True, exclude_none=False) else: raise TypeError( f"Expected mapping-like arguments for tool '{self.name}', got {type(arguments).__name__}" diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index df161a056d..fe7dfa2810 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -1467,16 +1467,18 @@ 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}!" + def greet(name: str, greeting: str | None = "Hi") -> str: + if greeting is None: + return f"Custom, {name}!" + return f"{greeting}, {name}!" result = asyncio.run(greet.invoke(arguments={"name": "World", "greeting": None})) assert isinstance(result, list) - assert result[0].text == "Hello, World!" + assert result[0].text == "Custom, World!" result = asyncio.run(greet.invoke(arguments={"name": "World"})) assert isinstance(result, list) - assert result[0].text == "Hello, World!" + assert result[0].text == "Hi, World!" # endregion From 01a706eacd8e781bd7cd554c49227ae0b3665eb5 Mon Sep 17 00:00:00 2001 From: venti <1308199824@qq.com> Date: Tue, 2 Jun 2026 21:18:02 +0800 Subject: [PATCH 4/7] fix: update docstring and extend exclude_unset to auto_invoke_function --- .../anthropic/agent_framework_anthropic/_chat_client.py | 4 ++-- python/packages/core/agent_framework/_tools.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 46d0b2447a..dfb35c9dfd 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -698,8 +698,8 @@ def _prepare_messages_for_anthropic(self, messages: Sequence[Message]) -> list[d 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. + If the last message is from the assistant, a synthetic user turn is + appended to satisfy this constraint. """ # first system message is passed as instructions if messages and isinstance(messages[0], Message) and messages[0].role == "system": diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 271e232b40..de2152aea9 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -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=False) + args = tool.input_model.model_validate(parsed_args).model_dump(exclude_unset=True, exclude_none=False) else: args = dict(parsed_args) args = _validate_arguments_against_schema( From 7f68a30856e76c115ed769a24ac604aafdff9e2a Mon Sep 17 00:00:00 2001 From: venti <1308199824@qq.com> Date: Tue, 2 Jun 2026 21:44:46 +0800 Subject: [PATCH 5/7] revert: remove unrelated core _tools.py changes from Anthropic PR The exclude_none/exclude_unset changes in the core package are out of scope for this Anthropic-specific fix. This PR now only contains the Anthropic chat client docstring fix and the synthetic user turn append. --- .../packages/core/agent_framework/_tools.py | 6 +++--- python/packages/core/tests/core/test_tools.py | 19 ------------------- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index de2152aea9..93722a8987 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_unset=True, exclude_none=False + exclude_none=True ) 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_unset=True, exclude_none=False) + parsed_arguments = arguments.model_dump(exclude_none=True) 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_unset=True, exclude_none=False) + args = tool.input_model.model_validate(parsed_args).model_dump(exclude_none=True) 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 fe7dfa2810..b3762bf4ef 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -1,5 +1,4 @@ # Copyright (c) Microsoft. All rights reserved. -import asyncio from typing import Annotated, Any, Literal, get_args, get_origin from unittest.mock import Mock @@ -1463,22 +1462,4 @@ 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 = "Hi") -> str: - if greeting is None: - return f"Custom, {name}!" - return f"{greeting}, {name}!" - - result = asyncio.run(greet.invoke(arguments={"name": "World", "greeting": None})) - assert isinstance(result, list) - assert result[0].text == "Custom, World!" - - result = asyncio.run(greet.invoke(arguments={"name": "World"})) - assert isinstance(result, list) - assert result[0].text == "Hi, World!" - - # endregion From 98cd205f13543336fbf19ab0a807ea37c5205743 Mon Sep 17 00:00:00 2001 From: venti <1308199824@qq.com> Date: Thu, 11 Jun 2026 19:44:00 +0800 Subject: [PATCH 6/7] fix: avoid appending user turn after Anthropic tool use --- .../agent_framework_anthropic/_chat_client.py | 12 +++++++-- .../anthropic/tests/test_anthropic_client.py | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index dfb35c9dfd..02da45b07e 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -699,7 +699,7 @@ def _prepare_messages_for_anthropic(self, messages: Sequence[Message]) -> list[d Anthropic's API requires that the conversation ends with a user message. If the last message is from the assistant, a synthetic user turn is - appended to satisfy this constraint. + appended when it will not break Anthropic tool_use/tool_result pairing. """ # first system message is passed as instructions if messages and isinstance(messages[0], Message) and messages[0].role == "system": @@ -712,11 +712,19 @@ def _prepare_messages_for_anthropic(self, messages: Sequence[Message]) -> list[d # Anthropic requires the conversation to end with a user message. # Append a synthetic user turn so chained agent outputs work as # valid context for the next agent without rewriting the assistant message. - if result and result[-1].get("role") == "assistant": + if result and result[-1].get("role") == "assistant" and not self._message_has_tool_use(result[-1]): result.append({"role": "user", "content": "Continue"}) return result + def _message_has_tool_use(self, message: dict[str, Any]) -> bool: + """Return whether an Anthropic message contains tool_use blocks.""" + content = message.get("content") + return isinstance(content, list) and any( + isinstance(item, dict) and item.get("type") in {"tool_use", "mcp_tool_use", "server_tool_use"} + for item in content + ) + def _prepare_message_for_anthropic(self, message: Message) -> dict[str, Any]: """Prepare a Message for the Anthropic client. diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 5fbb750aae..3e82ebb168 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -625,6 +625,32 @@ def test_prepare_messages_for_anthropic_without_system( assert result[2]["content"] == "Continue" +def test_prepare_messages_for_anthropic_does_not_append_after_tool_use( + mock_anthropic_client: MagicMock, +) -> None: + """Do not append plain user text after assistant tool_use blocks.""" + client = create_test_anthropic_client(mock_anthropic_client) + messages = [ + Message(role="user", contents=["What's the weather?"]), + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_123", + name="get_weather", + arguments={"location": "Seattle"}, + ) + ], + ), + ] + + result = client._prepare_messages_for_anthropic(messages) + + assert len(result) == 2 + assert result[1]["role"] == "assistant" + assert result[1]["content"][0]["type"] == "tool_use" + + # Tool Conversion Tests From 7868bd94777705c79888cd9dd743b2c5c6308fc5 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 19 Jun 2026 14:35:19 +0900 Subject: [PATCH 7/7] Fix Anthropic tool-use type narrowing Use object-typed content narrowing before checking Anthropic tool-use block types so strict Pyright no longer treats dynamic message content as Unknown. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_anthropic/_chat_client.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 02da45b07e..c7d7906c39 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -5,7 +5,7 @@ import logging import sys from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence -from typing import Any, ClassVar, Final, Generic, Literal, TypedDict +from typing import Any, ClassVar, Final, Generic, Literal, TypedDict, cast from agent_framework import ( Annotation, @@ -719,11 +719,19 @@ def _prepare_messages_for_anthropic(self, messages: Sequence[Message]) -> list[d def _message_has_tool_use(self, message: dict[str, Any]) -> bool: """Return whether an Anthropic message contains tool_use blocks.""" - content = message.get("content") - return isinstance(content, list) and any( - isinstance(item, dict) and item.get("type") in {"tool_use", "mcp_tool_use", "server_tool_use"} - for item in content - ) + content: object = message.get("content") + if not isinstance(content, list): + return False + + content_blocks = cast(list[object], content) + for content_block in content_blocks: + match content_block: + case {"type": "tool_use" | "mcp_tool_use" | "server_tool_use"}: + return True + case _: + pass + + return False def _prepare_message_for_anthropic(self, message: Message) -> dict[str, Any]: """Prepare a Message for the Anthropic client.