From 6b62488ee9156820644e55dff7c83d7a8dbb371a Mon Sep 17 00:00:00 2001 From: Alan Mak <108719967+meaqua9420@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:58:06 +0800 Subject: [PATCH] fix: resolve semantic kernel tool call names --- .../_sk_chat_completion_adapter.py | 61 +++++++-- .../models/test_sk_chat_completion_adapter.py | 124 ++++++++++++++++++ 2 files changed, 175 insertions(+), 10 deletions(-) diff --git a/python/packages/autogen-ext/src/autogen_ext/models/semantic_kernel/_sk_chat_completion_adapter.py b/python/packages/autogen-ext/src/autogen_ext/models/semantic_kernel/_sk_chat_completion_adapter.py index b9267057cd49..599d6fb4ba83 100644 --- a/python/packages/autogen-ext/src/autogen_ext/models/semantic_kernel/_sk_chat_completion_adapter.py +++ b/python/packages/autogen-ext/src/autogen_ext/models/semantic_kernel/_sk_chat_completion_adapter.py @@ -402,7 +402,52 @@ def _sync_tools_with_kernel(self, kernel: Kernel, tools: Sequence[Tool | ToolSch kernel.add_plugin(self._tools_plugin) - def _process_tool_calls(self, result: ChatMessageContent) -> list[FunctionCall]: + @staticmethod + def _get_tool_name(tool: Tool | ToolSchema) -> str: + return tool.schema["name"] if isinstance(tool, Tool) else tool["name"] + + @staticmethod + def _normalized_tool_lookup(tool_names: set[str]) -> dict[str, str | None]: + lookup: dict[str, str | None] = {} + for tool_name in tool_names: + normalized_name = tool_name.replace("-", "_") + if normalized_name in lookup: + lookup[normalized_name] = None + else: + lookup[normalized_name] = tool_name + return lookup + + def _resolve_tool_call_name(self, plugin_name: str, function_name: str, tool_names: set[str]) -> str: + if plugin_name: + full_name = f"{plugin_name}-{function_name}" + candidates = [function_name, full_name, f"{plugin_name}_{function_name}"] + else: + full_name = function_name + candidates = [function_name] + + if not tool_names: + return full_name + + for candidate in candidates: + if candidate in tool_names: + return candidate + + normalized_tool_lookup = self._normalized_tool_lookup(tool_names) + autogen_plugin_prefix = f"{self._tools_plugin.name}_" + for candidate in candidates: + normalized_candidate = candidate.replace("-", "_") + normalized_candidates = [normalized_candidate] + if normalized_candidate.startswith(autogen_plugin_prefix): + normalized_candidates.append(normalized_candidate[len(autogen_plugin_prefix) :]) + + for normalized_name in normalized_candidates: + resolved_name = normalized_tool_lookup.get(normalized_name) + if resolved_name is not None: + return resolved_name + + return full_name + + def _process_tool_calls(self, result: ChatMessageContent, tool_names: set[str]) -> list[FunctionCall]: """Process tool calls from SK ChatMessageContent""" function_calls: list[FunctionCall] = [] for item in result.items: @@ -410,10 +455,7 @@ def _process_tool_calls(self, result: ChatMessageContent) -> list[FunctionCall]: # Extract plugin name and function name plugin_name = item.plugin_name or "" function_name = item.function_name - if plugin_name: - full_name = f"{plugin_name}-{function_name}" - else: - full_name = function_name + full_name = self._resolve_tool_call_name(plugin_name, function_name, tool_names) if item.id is None: raise ValueError("Function call ID is required") @@ -486,6 +528,7 @@ async def create( chat_history = self._convert_to_chat_history(messages) user_settings = self._get_prompt_settings(extra_create_args) settings = self._build_execution_settings(user_settings, tools) + tool_names = {self._get_tool_name(tool) for tool in tools} # Sync tools with kernel self._sync_tools_with_kernel(kernel, tools) @@ -515,7 +558,7 @@ async def create( # Process content based on whether there are tool calls content: Union[str, list[FunctionCall]] if any(isinstance(item, FunctionCallContent) for item in result[0].items): - content = self._process_tool_calls(result[0]) + content = self._process_tool_calls(result[0], tool_names) finish_reason: Literal["function_calls", "stop"] = "function_calls" else: content = result[0].content @@ -605,6 +648,7 @@ async def create_stream( chat_history = self._convert_to_chat_history(messages) user_settings = self._get_prompt_settings(extra_create_args) settings = self._build_execution_settings(user_settings, tools) + tool_names = {self._get_tool_name(tool) for tool in tools} self._sync_tools_with_kernel(kernel, tools) prompt_tokens = 0 @@ -671,10 +715,7 @@ async def create_stream( for _, call_content in function_calls_in_progress.items(): plugin_name = call_content.plugin_name or "" function_name = call_content.function_name - if plugin_name: - full_name = f"{plugin_name}-{function_name}" - else: - full_name = function_name + full_name = self._resolve_tool_call_name(plugin_name, function_name, tool_names) if isinstance(call_content.arguments, dict): arguments = json.dumps(call_content.arguments) diff --git a/python/packages/autogen-ext/tests/models/test_sk_chat_completion_adapter.py b/python/packages/autogen-ext/tests/models/test_sk_chat_completion_adapter.py index 300ae0982904..0e096274a424 100644 --- a/python/packages/autogen-ext/tests/models/test_sk_chat_completion_adapter.py +++ b/python/packages/autogen-ext/tests/models/test_sk_chat_completion_adapter.py @@ -374,6 +374,130 @@ async def test_sk_chat_completion_with_prompt_tools(sk_client: AzureChatCompleti assert not result.cached +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("plugin_name", "function_name"), + [ + ("autogen_tools", "get_weather"), + ("autogen_tools", "get-weather"), + (None, "get-weather"), + (None, "autogen-tools_get_weather"), + ], +) +async def test_sk_chat_completion_resolves_autogen_tool_names(plugin_name: str | None, function_name: str) -> None: + async def mock_get_chat_message_contents( + chat_history: ChatHistory, + settings: PromptExecutionSettings, + **kwargs: Any, + ) -> list[ChatMessageContent]: + return [ + ChatMessageContent( + ai_model_id="test-model", + role=AuthorRole.ASSISTANT, + items=[ + FunctionCallContent( + id="call_1", + plugin_name=plugin_name, + function_name=function_name, + arguments='{"city": "London"}', + ) + ], + finish_reason=FinishReason.TOOL_CALLS, + ) + ] + + mock_client = AsyncMock(spec=AzureChatCompletion) + mock_client.get_chat_message_contents = mock_get_chat_message_contents + + tool = ToolSchema( + name="get_weather", + description="Get the current weather for a city", + parameters=ParametersSchema( + type="object", + properties={"city": {"type": "string", "description": "City name"}}, + required=["city"], + ), + ) + adapter = SKChatCompletionAdapter(mock_client, kernel=Kernel(memory=NullMemory())) + + result = await adapter.create( + messages=[UserMessage(content="What is the weather in London?", source="user")], + tools=[tool], + ) + + assert isinstance(result.content, list) + assert result.content[0].name == "get_weather" + assert result.content[0].arguments == '{"city": "London"}' + + +@pytest.mark.asyncio +async def test_sk_chat_completion_stream_resolves_autogen_tool_names() -> None: + async def mock_get_streaming_chat_message_contents( + chat_history: ChatHistory, + settings: PromptExecutionSettings, + **kwargs: Any, + ) -> AsyncGenerator[list["StreamingChatMessageContent"], Any]: + yield [ + StreamingChatMessageContent( + choice_index=0, + ai_model_id="test-model", + role=AuthorRole.ASSISTANT, + items=[ + FunctionCallContent( + id="call_1", + plugin_name="autogen_tools", + function_name="get-weather", + arguments='{"city": ', + ) + ], + ) + ] + yield [ + StreamingChatMessageContent( + choice_index=0, + ai_model_id="test-model", + role=AuthorRole.ASSISTANT, + items=[FunctionCallContent(function_name="get-weather", arguments='"London"}')], + ) + ] + yield [ + StreamingChatMessageContent( # type: ignore + choice_index=0, + ai_model_id="test-model", + role=AuthorRole.ASSISTANT, + finish_reason=FinishReason.TOOL_CALLS, + metadata={"usage": {"prompt_tokens": 10, "completion_tokens": 5}}, + ) + ] + + mock_client = AsyncMock(spec=AzureChatCompletion) + mock_client.get_streaming_chat_message_contents = mock_get_streaming_chat_message_contents + + tool = ToolSchema( + name="get_weather", + description="Get the current weather for a city", + parameters=ParametersSchema( + type="object", + properties={"city": {"type": "string", "description": "City name"}}, + required=["city"], + ), + ) + adapter = SKChatCompletionAdapter(mock_client, kernel=Kernel(memory=NullMemory())) + + response_chunks: list[CreateResult | str] = [] + async for chunk in adapter.create_stream( + messages=[UserMessage(content="What is the weather in London?", source="user")], + tools=[tool], + ): + response_chunks.append(chunk) + + final_chunk = response_chunks[-1] + assert isinstance(final_chunk, CreateResult) + assert isinstance(final_chunk.content, list) + assert final_chunk.content[0].name == "get_weather" + assert final_chunk.content[0].arguments == '{"city": "London"}' + + @pytest.mark.asyncio async def test_sk_chat_completion_without_tools( sk_client: AzureChatCompletion, caplog: pytest.LogCaptureFixture