Python: fix: @ai_function doesn't properly handle 'self' param (#2266)

* Fixes Python: @ai_function doesn't properly handle 'self' param
Fixes #1343

* fix for declaration only funcs

* fix mypy
This commit is contained in:
Eduard van Valkenburg
2025-11-19 15:49:50 +00:00
committed by GitHub
parent d5165e2532
commit 34a00f1b8a
3 changed files with 194 additions and 19 deletions
@@ -1305,26 +1305,20 @@ async def test_approved_function_call_successful_execution(chat_client_base: Cha
assert success_result.result == "Success value1"
async def test_declaration_only_tool_not_executed(chat_client_base: ChatClientProtocol):
"""Test that declaration_only tools are not executed."""
exec_counter = 0
@ai_function(name="declaration_func")
def declaration_func_inner(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Result {arg1}"
# Create a new AIFunction with declaration_only set
async def test_declaration_only_tool(chat_client_base: ChatClientProtocol):
"""Test that declaration_only tools without implementation (func=None) are not executed."""
from agent_framework import AIFunction
# Create a truly declaration-only function with no implementation
declaration_func = AIFunction(
name="declaration_func",
func=declaration_func_inner,
additional_properties={"declaration_only": True},
func=None,
description="A declaration-only function for testing",
input_model={"type": "object", "properties": {"arg1": {"type": "string"}}, "required": ["arg1"]},
)
# Set declaration_only on the instance
object.__setattr__(declaration_func, "_declaration_only", True)
# Verify it's marked as declaration_only
assert declaration_func.declaration_only is True
chat_client_base.run_responses = [
ChatResponse(
@@ -1338,8 +1332,6 @@ async def test_declaration_only_tool_not_executed(chat_client_base: ChatClientPr
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[declaration_func])
# Function should NOT be executed
assert exec_counter == 0
# Should have the function call in messages but not a result
function_calls = [
content
@@ -1349,6 +1341,15 @@ async def test_declaration_only_tool_not_executed(chat_client_base: ChatClientPr
]
assert len(function_calls) >= 1
# Should not have a function result
function_results = [
content
for msg in response.messages
for content in msg.contents
if isinstance(content, FunctionResultContent) and content.call_id == "1"
]
assert len(function_results) == 0
async def test_multiple_function_calls_parallel_execution(chat_client_base: ChatClientProtocol):
"""Test that multiple function calls are executed in parallel."""
@@ -104,6 +104,136 @@ async def test_ai_function_decorator_with_async():
assert (await async_test_tool(1, 2)) == 3
def test_ai_function_decorator_in_class():
"""Test the ai_function decorator."""
class my_tools:
@ai_function(name="test_tool", description="A test tool")
def test_tool(self, x: int, y: int) -> int:
"""A simple function that adds two numbers."""
return x + y
test_tool = my_tools().test_tool
assert isinstance(test_tool, ToolProtocol)
assert isinstance(test_tool, AIFunction)
assert test_tool.name == "test_tool"
assert test_tool.description == "A test tool"
assert test_tool.parameters() == {
"properties": {"x": {"title": "X", "type": "integer"}, "y": {"title": "Y", "type": "integer"}},
"required": ["x", "y"],
"title": "test_tool_input",
"type": "object",
}
assert test_tool(1, 2) == 3
async def test_ai_function_decorator_shared_state():
"""Test that decorated methods maintain shared state across multiple calls and tool usage."""
class StatefulCounter:
"""A class that maintains a counter and provides decorated methods to interact with it."""
def __init__(self, initial_value: int = 0):
self.counter = initial_value
self.operation_log: list[str] = []
@ai_function(name="increment", description="Increment the counter")
def increment(self, amount: int) -> str:
"""Increment the counter by the given amount."""
self.counter += amount
self.operation_log.append(f"increment({amount})")
return f"Counter incremented by {amount}. New value: {self.counter}"
@ai_function(name="get_value", description="Get the current counter value")
def get_value(self) -> str:
"""Get the current counter value."""
self.operation_log.append("get_value()")
return f"Current counter value: {self.counter}"
@ai_function(name="multiply", description="Multiply the counter")
def multiply(self, factor: int) -> str:
"""Multiply the counter by the given factor."""
self.counter *= factor
self.operation_log.append(f"multiply({factor})")
return f"Counter multiplied by {factor}. New value: {self.counter}"
# Create a single instance with shared state
counter_instance = StatefulCounter(initial_value=10)
# Get the decorated methods - these will be used by different "agents" or tools
increment_tool = counter_instance.increment
get_value_tool = counter_instance.get_value
multiply_tool = counter_instance.multiply
# Verify they are AIFunction instances
assert isinstance(increment_tool, AIFunction)
assert isinstance(get_value_tool, AIFunction)
assert isinstance(multiply_tool, AIFunction)
# Tool 1 (increment) is used
result1 = increment_tool(5)
assert result1 == "Counter incremented by 5. New value: 15"
assert counter_instance.counter == 15
# Tool 2 (get_value) sees the state change from tool 1
result2 = get_value_tool()
assert result2 == "Current counter value: 15"
assert counter_instance.counter == 15
# Tool 3 (multiply) modifies the shared state
result3 = multiply_tool(3)
assert result3 == "Counter multiplied by 3. New value: 45"
assert counter_instance.counter == 45
# Tool 2 (get_value) sees the state change from tool 3
result4 = get_value_tool()
assert result4 == "Current counter value: 45"
assert counter_instance.counter == 45
# Tool 1 (increment) sees the current state and modifies it
result5 = increment_tool(10)
assert result5 == "Counter incremented by 10. New value: 55"
assert counter_instance.counter == 55
# Verify the operation log shows all operations in order
assert counter_instance.operation_log == [
"increment(5)",
"get_value()",
"multiply(3)",
"get_value()",
"increment(10)",
]
# Verify the parameters don't include 'self'
assert increment_tool.parameters() == {
"properties": {"amount": {"title": "Amount", "type": "integer"}},
"required": ["amount"],
"title": "increment_input",
"type": "object",
}
assert multiply_tool.parameters() == {
"properties": {"factor": {"title": "Factor", "type": "integer"}},
"required": ["factor"],
"title": "multiply_input",
"type": "object",
}
assert get_value_tool.parameters() == {
"properties": {},
"title": "get_value_input",
"type": "object",
}
# Test with invoke method as well (simulating agent execution)
result6 = await increment_tool.invoke(amount=5)
assert result6 == "Counter incremented by 5. New value: 60"
assert counter_instance.counter == 60
result7 = await get_value_tool.invoke()
assert result7 == "Current counter value: 60"
assert counter_instance.counter == 60
async def test_ai_function_invoke_telemetry_enabled(span_exporter: InMemorySpanExporter):
"""Test the ai_function invoke method with telemetry enabled."""