Python: Introducing UserInputRequest and Response types and HostedMcpTool (#405)

* initial work on User Approval (and hosted mcp to validate)

* small update to the comments in the sample

* enable local MCP tools in chatClient get methods

* working streaming and improved setup

* fix for pyright

* updated create_approval -> create_response method

* added tests

* updated HostedMcpTool and addressed feedback

* update type name

* naming updates

* small docstring update

* mypy fix

* fixes and updates

* fixes for responses

* fix int tests

* removed broken tests

* updated test running

* removed specific content check on websearch

* increased timeout

* split slow foundry test

* don't parallel run samples

* add dist load to unit tests

---------

Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2025-09-10 15:37:34 +02:00
committed by GitHub
Unverified
parent 947f2bf642
commit 6aa746d891
21 changed files with 1186 additions and 447 deletions
@@ -22,7 +22,7 @@ def test_get_logger_custom_name():
def test_get_logger_invalid_name():
"""Test that an exception is raised for an invalid logger name."""
with pytest.raises(AgentFrameworkException, match="Logger name must start with 'agent_framework'."):
with pytest.raises(AgentFrameworkException):
get_logger("invalid_name")
+113 -2
View File
@@ -1,14 +1,24 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from unittest.mock import Mock, patch
import pytest
from pydantic import BaseModel
from agent_framework import AIFunction, HostedCodeInterpreterTool, ToolProtocol, ai_function
from agent_framework import (
AIFunction,
HostedCodeInterpreterTool,
HostedMCPTool,
ToolProtocol,
ai_function,
)
from agent_framework._tools import _parse_inputs
from agent_framework.exceptions import ToolException
from agent_framework.telemetry import GenAIAttributes
# region AIFunction and ai_function decorator tests
def test_ai_function_decorator():
"""Test the ai_function decorator."""
@@ -291,7 +301,7 @@ async def test_ai_function_invoke_invalid_pydantic_args():
await invalid_args_test.invoke(arguments=wrong_args)
# Tests for HostedCodeInterpreterTool and _parse_inputs
# region HostedCodeInterpreterTool and _parse_inputs
def test_hosted_code_interpreter_tool_default():
@@ -507,3 +517,104 @@ def test_hosted_code_interpreter_tool_with_unknown_input():
"""Test HostedCodeInterpreterTool with single unknown input."""
with pytest.raises(ValueError, match="Unsupported input type"):
HostedCodeInterpreterTool(inputs={"hosted_file": "file-single"})
# region HostedMCPTool tests
def test_hosted_mcp_tool_with_other_fields():
"""Test creating a HostedMCPTool with a specific approval dict, headers and additional properties."""
tool = HostedMCPTool(
name="mcp-tool",
url="https://mcp.example",
description="A test MCP tool",
headers={"x": "y"},
additional_properties={"p": 1},
)
assert tool.name == "mcp-tool"
# pydantic AnyUrl preserves as string-like
assert str(tool.url).startswith("https://")
assert tool.headers == {"x": "y"}
assert tool.additional_properties == {"p": 1}
assert tool.description == "A test MCP tool"
@pytest.mark.parametrize(
"approval_mode",
[
"always_require",
"never_require",
{
"always_require_approval": {"toolA"},
"never_require_approval": {"toolB"},
},
{
"always_require_approval": ["toolA"],
"never_require_approval": ("toolB",),
},
],
ids=["always_require", "never_require", "specific", "specific_with_parsing"],
)
def test_hosted_mcp_tool_with_approval_mode(approval_mode: str | dict[str, Any]):
"""Test creating a HostedMCPTool with a specific approval dict, headers and additional properties."""
tool = HostedMCPTool(name="mcp-tool", url="https://mcp.example", approval_mode=approval_mode)
assert tool.name == "mcp-tool"
# pydantic AnyUrl preserves as string-like
assert str(tool.url).startswith("https://")
if not isinstance(approval_mode, dict):
assert tool.approval_mode == approval_mode
else:
# approval_mode parsed to sets
assert isinstance(tool.approval_mode["always_require_approval"], set)
assert isinstance(tool.approval_mode["never_require_approval"], set)
assert "toolA" in tool.approval_mode["always_require_approval"]
assert "toolB" in tool.approval_mode["never_require_approval"]
def test_hosted_mcp_tool_invalid_approval_mode_raises():
"""Invalid approval_mode string should raise ServiceInitializationError."""
with pytest.raises(ToolException):
HostedMCPTool(name="bad", url="https://x", approval_mode="invalid_mode")
@pytest.mark.parametrize(
"tools",
[
{"toolA", "toolB"},
("toolA", "toolB"),
["toolA", "toolB"],
["toolA", "toolB", "toolA"],
],
ids=[
"set",
"tuple",
"list",
"list_with_duplicates",
],
)
def test_hosted_mcp_tool_with_allowed_tools(tools: list[str] | tuple[str, ...] | set[str]):
"""Test creating a HostedMCPTool with a list of allowed tools."""
tool = HostedMCPTool(
name="mcp-tool",
url="https://mcp.example",
allowed_tools=tools,
)
assert tool.name == "mcp-tool"
# pydantic AnyUrl preserves as string-like
assert str(tool.url).startswith("https://")
# approval_mode parsed to set
assert isinstance(tool.allowed_tools, set)
assert tool.allowed_tools == {"toolA", "toolB"}
def test_hosted_mcp_tool_with_dict_of_allowed_tools():
"""Test creating a HostedMCPTool with a dict of allowed tools."""
with pytest.raises(ToolException):
HostedMCPTool(
name="mcp-tool",
url="https://mcp.example",
allowed_tools={"toolA": "Tool A", "toolC": "Tool C"},
)
+40 -2
View File
@@ -21,6 +21,8 @@ from agent_framework import (
DataContent,
ErrorContent,
FinishReason,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
GeneratedEmbeddings,
@@ -38,6 +40,7 @@ from agent_framework import (
UsageDetails,
ai_function,
)
from agent_framework.exceptions import AdditionItemMismatch
@fixture
@@ -296,9 +299,8 @@ def test_function_call_content_add_merging_and_errors():
# incompatible call ids
a = FunctionCallContent(call_id="1", name="f", arguments="abc")
b = FunctionCallContent(call_id="2", name="f", arguments="def")
from agent_framework.exceptions import AgentFrameworkException
with raises(AgentFrameworkException):
with raises(AdditionItemMismatch):
_ = a + b
@@ -379,6 +381,42 @@ def test_usage_details_add_with_none_and_type_errors():
u += 42 # type: ignore[arg-type]
# region UserInputRequest and Response
def test_function_approval_request_and_response_creation():
"""Test creating a FunctionApprovalRequestContent and producing a response."""
fc = FunctionCallContent(call_id="call-1", name="do_something", arguments={"a": 1})
req = FunctionApprovalRequestContent(id="req-1", function_call=fc)
assert req.type == "function_approval_request"
assert req.function_call == fc
assert req.id == "req-1"
assert isinstance(req, BaseContent)
resp = req.create_response(True)
assert isinstance(resp, FunctionApprovalResponseContent)
assert resp.approved is True
assert resp.function_call == fc
assert resp.id == "req-1"
def test_function_approval_serialization_roundtrip():
fc = FunctionCallContent(call_id="c2", name="f", arguments='{"x":1}')
req = FunctionApprovalRequestContent(id="id-2", function_call=fc, additional_properties={"meta": 1})
dumped = req.model_dump()
loaded = FunctionApprovalRequestContent.model_validate(dumped)
assert loaded == req
class TestModel(BaseModel):
content: Contents
test_item = TestModel.model_validate({"content": dumped})
assert isinstance(test_item.content, FunctionApprovalRequestContent)
# region BaseContent Serialization