Skip to content

Commit bbb064e

Browse files
committed
parameterized foundry agent and tests
1 parent c73fb19 commit bbb064e

File tree

13 files changed

+1068
-61
lines changed

13 files changed

+1068
-61
lines changed

conftest.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""
2+
Test configuration for agent tests.
3+
"""
4+
5+
import sys
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
# Add the agents path
11+
agents_path = Path(__file__).parent.parent.parent / "backend" / "v3" / "magentic_agents"
12+
sys.path.insert(0, str(agents_path))
13+
14+
15+
@pytest.fixture(scope="session")
16+
def event_loop():
17+
"""Create an instance of the default event loop for the test session."""
18+
import asyncio
19+
loop = asyncio.get_event_loop_policy().new_event_loop()
20+
yield loop
21+
loop.close()
22+
23+
24+
@pytest.fixture
25+
def agent_env_vars():
26+
"""Common environment variables for agent testing."""
27+
return {
28+
"BING_CONNECTION_NAME": "test_bing_connection",
29+
"MCP_SERVER_ENDPOINT": "http://test-mcp-server",
30+
"MCP_SERVER_NAME": "test_mcp_server",
31+
"MCP_SERVER_DESCRIPTION": "Test MCP server",
32+
"TENANT_ID": "test_tenant_id",
33+
"CLIENT_ID": "test_client_id",
34+
"AZURE_OPENAI_ENDPOINT": "https://test.openai.azure.com/",
35+
"AZURE_OPENAI_API_KEY": "test_key",
36+
"AZURE_OPENAI_DEPLOYMENT_NAME": "test_deployment"
37+
}

pytest.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
[pytest]
2-
addopts = -p pytest_asyncio
2+
addopts = -p pytest_asyncio

src/backend/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,5 @@ dependencies = [
2929
"python-multipart>=0.0.20",
3030
"semantic-kernel>=1.32.2",
3131
"uvicorn>=0.34.2",
32+
"pylint-pydantic>=0.3.5",
3233
]

src/backend/uv.lock

Lines changed: 100 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import os
2+
from contextlib import AsyncExitStack
3+
from dataclasses import dataclass
4+
from typing import Any
5+
6+
from azure.ai.projects.aio import AIProjectClient
7+
from azure.identity import InteractiveBrowserCredential
8+
from azure.identity.aio import DefaultAzureCredential
9+
from semantic_kernel.agents.azure_ai.azure_ai_agent import AzureAIAgent
10+
from semantic_kernel.connectors.mcp import MCPStreamableHttpPlugin
11+
from v3.magentic_agents.models.agent_models import MCPConfig
12+
13+
14+
class MCPEnabledBase:
15+
"""
16+
Base that owns an AsyncExitStack and, if configured, enters the MCP plugin
17+
as an async context. Subclasses build the actual agent in _after_open().
18+
"""
19+
20+
def __init__(self, mcp: MCPConfig | None = None) -> None:
21+
self._stack: AsyncExitStack | None = None
22+
self.mcp_cfg: MCPConfig = mcp or MCPConfig.from_env()
23+
self.mcp_plugin: MCPStreamableHttpPlugin | None = None
24+
self._agent: Any | None = None # delegate target
25+
26+
async def open(self) -> "MCPEnabledBase":
27+
if self._stack is not None:
28+
return self
29+
self._stack = AsyncExitStack()
30+
await self._enter_mcp_if_configured()
31+
await self._after_open()
32+
return self
33+
34+
async def close(self) -> None:
35+
if self._stack is None:
36+
return
37+
try:
38+
self.cred.close()
39+
await self._stack.aclose()
40+
finally:
41+
self._stack = None
42+
self.mcp_plugin = None
43+
self._agent = None
44+
45+
# Context manager
46+
async def __aenter__(self) -> "MCPEnabledBase":
47+
return await self.open()
48+
49+
async def __aexit__(self, exc_type, exc, tb) -> None:
50+
await self.close()
51+
52+
# Delegate attributes to the built agent
53+
def __getattr__(self, name: str) -> Any:
54+
if self._agent is not None:
55+
return getattr(self._agent, name)
56+
raise AttributeError(f"{type(self).__name__} has no attribute '{name}'")
57+
58+
# Hooks
59+
async def _after_open(self) -> None:
60+
"""Subclasses must build self._agent here."""
61+
raise NotImplementedError
62+
63+
# Internals
64+
def _build_mcp_headers(self) -> dict:
65+
if not self.mcp_cfg.client_id:
66+
return {}
67+
self.cred = InteractiveBrowserCredential(
68+
tenant_id=self.mcp_cfg.tenant_id or None,
69+
client_id=self.mcp_cfg.client_id,
70+
)
71+
tok = self.cred.get_token(f"api://{self.mcp_cfg.client_id}/access_as_user")
72+
return {
73+
"Authorization": f"Bearer {tok.token}",
74+
"Content-Type": "application/json",
75+
}
76+
77+
async def _enter_mcp_if_configured(self) -> None:
78+
if not self.mcp_cfg.url:
79+
return
80+
# Note: had this commented out in my testing because I don't have
81+
# access to your resources
82+
headers = self._build_mcp_headers()
83+
plugin = MCPStreamableHttpPlugin(
84+
name=self.mcp_cfg.name,
85+
description=self.mcp_cfg.description,
86+
url=self.mcp_cfg.url,
87+
headers=headers,
88+
)
89+
# Enter MCP async context via the stack to ensure correct LIFO cleanup
90+
if self._stack is None:
91+
self._stack = AsyncExitStack()
92+
self.mcp_plugin = await self._stack.enter_async_context(plugin)
93+
94+
95+
class AzureAgentBase(MCPEnabledBase):
96+
"""
97+
Extends MCPEnabledBase with Azure async contexts that many agents need:
98+
- DefaultAzureCredential (async)
99+
- AzureAIAgent.create_client(...) (async)
100+
Subclasses then create an AzureAIAgent definition and bind plugins.
101+
"""
102+
103+
def __init__(self, mcp: MCPConfig | None = None) -> None:
104+
super().__init__(mcp=mcp)
105+
self.creds: DefaultAzureCredential | None = None
106+
self.client: AIProjectClient | None = None
107+
108+
async def open(self) -> "AzureAgentBase":
109+
if self._stack is not None:
110+
return self
111+
self._stack = AsyncExitStack()
112+
# Azure async contexts
113+
self.creds = DefaultAzureCredential()
114+
await self._stack.enter_async_context(self.creds)
115+
self.client = AzureAIAgent.create_client(credential=self.creds)
116+
await self._stack.enter_async_context(self.client)
117+
118+
# MCP async context if requested
119+
await self._enter_mcp_if_configured()
120+
121+
# Build the agent
122+
await self._after_open()
123+
return self
124+
125+
async def close(self) -> None:
126+
await self.creds.close()
127+
await super().close()

0 commit comments

Comments
 (0)