mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Fix middleware terminate flag to exit function calling loop immediately (#2868)
* Fix middleware terminate flag to exit function calling loop immediately * Eliminating duck typing * Improve function exec result handling * Fix race condition * Fix mypy issues
This commit is contained in:
committed by
GitHub
Unverified
parent
3139347526
commit
11d6dcfe80
@@ -1,5 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import (
|
||||
@@ -16,6 +18,7 @@ from agent_framework import (
|
||||
TextContent,
|
||||
ai_function,
|
||||
)
|
||||
from agent_framework._middleware import FunctionInvocationContext, FunctionMiddleware
|
||||
|
||||
|
||||
async def test_base_client_with_function_calling(chat_client_base: ChatClientProtocol):
|
||||
@@ -2206,3 +2209,175 @@ async def test_streaming_error_recovery_resets_counter(chat_client_base: ChatCli
|
||||
assert len(error_results) >= 1
|
||||
assert len(success_results) >= 1
|
||||
assert call_count == 2 # Both calls executed
|
||||
|
||||
|
||||
class TerminateLoopMiddleware(FunctionMiddleware):
|
||||
"""Middleware that sets terminate=True to exit the function calling loop."""
|
||||
|
||||
async def process(
|
||||
self, context: FunctionInvocationContext, next_handler: Callable[[FunctionInvocationContext], Awaitable[None]]
|
||||
) -> None:
|
||||
# Set result to a simple value - the framework will wrap it in FunctionResultContent
|
||||
context.result = "terminated by middleware"
|
||||
context.terminate = True
|
||||
|
||||
|
||||
async def test_terminate_loop_single_function_call(chat_client_base: ChatClientProtocol):
|
||||
"""Test that terminate_loop=True exits the function calling loop after single function call."""
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="test_function")
|
||||
def ai_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
return f"Processed {arg1}"
|
||||
|
||||
# Queue up two responses: function call, then final text
|
||||
# If terminate_loop works, only the first response should be consumed
|
||||
chat_client_base.run_responses = [
|
||||
ChatResponse(
|
||||
messages=ChatMessage(
|
||||
role="assistant",
|
||||
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}')],
|
||||
)
|
||||
),
|
||||
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
|
||||
]
|
||||
|
||||
response = await chat_client_base.get_response(
|
||||
"hello",
|
||||
tool_choice="auto",
|
||||
tools=[ai_func],
|
||||
middleware=[TerminateLoopMiddleware()],
|
||||
)
|
||||
|
||||
# Function should NOT have been executed - middleware intercepted it
|
||||
assert exec_counter == 0
|
||||
|
||||
# There should be 2 messages: assistant with function call, tool result from middleware
|
||||
# The loop should NOT have continued to call the LLM again
|
||||
assert len(response.messages) == 2
|
||||
assert response.messages[0].role == Role.ASSISTANT
|
||||
assert isinstance(response.messages[0].contents[0], FunctionCallContent)
|
||||
assert response.messages[1].role == Role.TOOL
|
||||
assert isinstance(response.messages[1].contents[0], FunctionResultContent)
|
||||
assert response.messages[1].contents[0].result == "terminated by middleware"
|
||||
|
||||
# Verify the second response is still in the queue (wasn't consumed)
|
||||
assert len(chat_client_base.run_responses) == 1
|
||||
|
||||
|
||||
class SelectiveTerminateMiddleware(FunctionMiddleware):
|
||||
"""Only terminates for terminating_function."""
|
||||
|
||||
async def process(
|
||||
self, context: FunctionInvocationContext, next_handler: Callable[[FunctionInvocationContext], Awaitable[None]]
|
||||
) -> None:
|
||||
if context.function.name == "terminating_function":
|
||||
# Set result to a simple value - the framework will wrap it in FunctionResultContent
|
||||
context.result = "terminated by middleware"
|
||||
context.terminate = True
|
||||
else:
|
||||
await next_handler(context)
|
||||
|
||||
|
||||
async def test_terminate_loop_multiple_function_calls_one_terminates(chat_client_base: ChatClientProtocol):
|
||||
"""Test that any(terminate_loop=True) exits loop even with multiple function calls."""
|
||||
normal_call_count = 0
|
||||
terminating_call_count = 0
|
||||
|
||||
@ai_function(name="normal_function")
|
||||
def normal_func(arg1: str) -> str:
|
||||
nonlocal normal_call_count
|
||||
normal_call_count += 1
|
||||
return f"Normal {arg1}"
|
||||
|
||||
@ai_function(name="terminating_function")
|
||||
def terminating_func(arg1: str) -> str:
|
||||
nonlocal terminating_call_count
|
||||
terminating_call_count += 1
|
||||
return f"Terminating {arg1}"
|
||||
|
||||
# Queue up two responses: parallel function calls, then final text
|
||||
chat_client_base.run_responses = [
|
||||
ChatResponse(
|
||||
messages=ChatMessage(
|
||||
role="assistant",
|
||||
contents=[
|
||||
FunctionCallContent(call_id="1", name="normal_function", arguments='{"arg1": "value1"}'),
|
||||
FunctionCallContent(call_id="2", name="terminating_function", arguments='{"arg1": "value2"}'),
|
||||
],
|
||||
)
|
||||
),
|
||||
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
|
||||
]
|
||||
|
||||
response = await chat_client_base.get_response(
|
||||
"hello",
|
||||
tool_choice="auto",
|
||||
tools=[normal_func, terminating_func],
|
||||
middleware=[SelectiveTerminateMiddleware()],
|
||||
)
|
||||
|
||||
# normal_function should have executed (middleware calls next_handler)
|
||||
# terminating_function should NOT have executed (middleware intercepts it)
|
||||
assert normal_call_count == 1
|
||||
assert terminating_call_count == 0
|
||||
|
||||
# There should be 2 messages: assistant with function calls, tool results
|
||||
# The loop should NOT have continued to call the LLM again
|
||||
assert len(response.messages) == 2
|
||||
assert response.messages[0].role == Role.ASSISTANT
|
||||
assert len(response.messages[0].contents) == 2
|
||||
assert response.messages[1].role == Role.TOOL
|
||||
# Both function results should be present
|
||||
assert len(response.messages[1].contents) == 2
|
||||
|
||||
# Verify the second response is still in the queue (wasn't consumed)
|
||||
assert len(chat_client_base.run_responses) == 1
|
||||
|
||||
|
||||
async def test_terminate_loop_streaming_single_function_call(chat_client_base: ChatClientProtocol):
|
||||
"""Test that terminate_loop=True exits the streaming function calling loop."""
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="test_function")
|
||||
def ai_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
return f"Processed {arg1}"
|
||||
|
||||
# Queue up two streaming responses
|
||||
chat_client_base.streaming_responses = [
|
||||
[
|
||||
ChatResponseUpdate(
|
||||
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}')],
|
||||
role="assistant",
|
||||
),
|
||||
],
|
||||
[
|
||||
ChatResponseUpdate(
|
||||
contents=[TextContent(text="done")],
|
||||
role="assistant",
|
||||
)
|
||||
],
|
||||
]
|
||||
|
||||
updates = []
|
||||
async for update in chat_client_base.get_streaming_response(
|
||||
"hello",
|
||||
tool_choice="auto",
|
||||
tools=[ai_func],
|
||||
middleware=[TerminateLoopMiddleware()],
|
||||
):
|
||||
updates.append(update)
|
||||
|
||||
# Function should NOT have been executed - middleware intercepted it
|
||||
assert exec_counter == 0
|
||||
|
||||
# Should have function call update and function result update
|
||||
# The loop should NOT have continued to call the LLM again
|
||||
assert len(updates) == 2
|
||||
|
||||
# Verify the second streaming response is still in the queue (wasn't consumed)
|
||||
assert len(chat_client_base.streaming_responses) == 1
|
||||
|
||||
@@ -193,7 +193,8 @@ class TestChatAgentFunctionBasedMiddleware:
|
||||
# Create a message to start the conversation
|
||||
messages = [ChatMessage(role=Role.USER, text="test message")]
|
||||
|
||||
# Set up chat client to return a function call
|
||||
# Set up chat client to return a function call, then a final response
|
||||
# If terminate works correctly, only the first response should be consumed
|
||||
chat_client.responses = [
|
||||
ChatResponse(
|
||||
messages=[
|
||||
@@ -204,7 +205,8 @@ class TestChatAgentFunctionBasedMiddleware:
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
),
|
||||
ChatResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="this should not be consumed")]),
|
||||
]
|
||||
|
||||
# Create the test function with the expected signature
|
||||
@@ -222,7 +224,11 @@ class TestChatAgentFunctionBasedMiddleware:
|
||||
# Verify that function was not called and only middleware executed
|
||||
assert execution_order == ["middleware_before", "middleware_after"]
|
||||
assert "function_called" not in execution_order
|
||||
assert execution_order == ["middleware_before", "middleware_after"]
|
||||
|
||||
# Verify the chat client was only called once (no extra LLM call after termination)
|
||||
assert chat_client.call_count == 1
|
||||
# Verify the second response is still in the queue (wasn't consumed)
|
||||
assert len(chat_client.responses) == 1
|
||||
|
||||
async def test_function_middleware_with_post_termination(self, chat_client: "MockChatClient") -> None:
|
||||
"""Test that function middleware can terminate execution after calling next()."""
|
||||
@@ -242,7 +248,8 @@ class TestChatAgentFunctionBasedMiddleware:
|
||||
# Create a message to start the conversation
|
||||
messages = [ChatMessage(role=Role.USER, text="test message")]
|
||||
|
||||
# Set up chat client to return a function call
|
||||
# Set up chat client to return a function call, then a final response
|
||||
# If terminate works correctly, only the first response should be consumed
|
||||
chat_client.responses = [
|
||||
ChatResponse(
|
||||
messages=[
|
||||
@@ -253,7 +260,8 @@ class TestChatAgentFunctionBasedMiddleware:
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
),
|
||||
ChatResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="this should not be consumed")]),
|
||||
]
|
||||
|
||||
# Create the test function with the expected signature
|
||||
@@ -273,6 +281,11 @@ class TestChatAgentFunctionBasedMiddleware:
|
||||
assert "function_called" in execution_order
|
||||
assert execution_order == ["middleware_before", "function_called", "middleware_after"]
|
||||
|
||||
# Verify the chat client was only called once (no extra LLM call after termination)
|
||||
assert chat_client.call_count == 1
|
||||
# Verify the second response is still in the queue (wasn't consumed)
|
||||
assert len(chat_client.responses) == 1
|
||||
|
||||
async def test_function_based_agent_middleware_with_chat_agent(self, chat_client: "MockChatClient") -> None:
|
||||
"""Test function-based agent middleware with ChatAgent."""
|
||||
execution_order: list[str] = []
|
||||
|
||||
Reference in New Issue
Block a user