Skip to content

Commit 982dcb4

Browse files
committed
Add agent lifecycle, models, and refactor agents
Introduces common lifecycle management for agents, new agent configuration models, and refactors ProxyAgent and ReasoningAgentTemplate to use agent_framework primitives. Removes Semantic Kernel dependencies, adds Azure AI Search integration, and streamlines agent creation and invocation logic for improved maintainability and extensibility.
1 parent bdb1bbf commit 982dcb4

File tree

5 files changed

+822
-428
lines changed

5 files changed

+822
-428
lines changed
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from contextlib import AsyncExitStack
5+
from typing import Any, Optional
6+
7+
from azure.ai.projects.aio import AIProjectClient
8+
from azure.identity.aio import DefaultAzureCredential
9+
10+
from agent_framework.azure import AzureAIAgentClient
11+
from agent_framework import HostedMCPTool
12+
13+
from af.magentic_agents.models.agent_models import MCPConfig
14+
from af.config.agent_registry import agent_registry
15+
16+
17+
class MCPEnabledBase:
18+
"""
19+
Base that owns an AsyncExitStack and (optionally) prepares an MCP tool
20+
for subclasses to attach to ChatOptions (agent_framework style).
21+
Subclasses must implement _after_open() and assign self._agent.
22+
"""
23+
24+
def __init__(self, mcp: MCPConfig | None = None) -> None:
25+
self._stack: AsyncExitStack | None = None
26+
self.mcp_cfg: MCPConfig | None = mcp
27+
self.mcp_tool: HostedMCPTool | None = None
28+
self._agent: Any | None = None # delegate target (e.g., AzureAIAgentClient)
29+
30+
async def open(self) -> "MCPEnabledBase":
31+
if self._stack is not None:
32+
return self
33+
self._stack = AsyncExitStack()
34+
self._prepare_mcp_tool()
35+
await self._after_open()
36+
return self
37+
38+
async def close(self) -> None:
39+
if self._stack is None:
40+
return
41+
try:
42+
# Attempt to close the underlying agent/client if it exposes close()
43+
if self._agent and hasattr(self._agent, "close"):
44+
try:
45+
await self._agent.close() # AzureAIAgentClient has async close
46+
except Exception: # noqa: BLE001
47+
pass
48+
# Unregister from registry if present
49+
try:
50+
agent_registry.unregister_agent(self)
51+
except Exception: # noqa: BLE001
52+
pass
53+
await self._stack.aclose()
54+
finally:
55+
self._stack = None
56+
self.mcp_tool = None
57+
self._agent = None
58+
59+
# Context manager
60+
async def __aenter__(self) -> "MCPEnabledBase":
61+
return await self.open()
62+
63+
async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: D401
64+
await self.close()
65+
66+
# Delegate to underlying agent
67+
def __getattr__(self, name: str) -> Any:
68+
if self._agent is not None:
69+
return getattr(self._agent, name)
70+
raise AttributeError(f"{type(self).__name__} has no attribute '{name}'")
71+
72+
async def _after_open(self) -> None:
73+
"""Subclasses must build self._agent here."""
74+
raise NotImplementedError
75+
76+
def _prepare_mcp_tool(self) -> None:
77+
"""Translate MCPConfig to a HostedMCPTool (agent_framework construct)."""
78+
if not self.mcp_cfg:
79+
return
80+
try:
81+
self.mcp_tool = HostedMCPTool(
82+
name=self.mcp_cfg.name,
83+
description=self.mcp_cfg.description,
84+
server_label=self.mcp_cfg.name.replace(" ", "_"),
85+
url="", # URL will be resolved via MCPConfig in HostedMCPTool
86+
)
87+
except Exception: # noqa: BLE001
88+
self.mcp_tool = None
89+
90+
91+
class AzureAgentBase(MCPEnabledBase):
92+
"""
93+
Extends MCPEnabledBase with Azure credential + AIProjectClient contexts.
94+
Subclasses:
95+
- create or attach an Azure AI Agent definition
96+
- instantiate an AzureAIAgentClient and assign to self._agent
97+
- optionally register themselves via agent_registry
98+
"""
99+
100+
def __init__(self, mcp: MCPConfig | None = None) -> None:
101+
super().__init__(mcp=mcp)
102+
self.creds: Optional[DefaultAzureCredential] = None
103+
self.client: Optional[AIProjectClient] = None
104+
self.project_endpoint: Optional[str] = None
105+
self._created_ephemeral: bool = False # reserved if you add ephemeral agent cleanup
106+
107+
async def open(self) -> "AzureAgentBase":
108+
if self._stack is not None:
109+
return self
110+
self._stack = AsyncExitStack()
111+
112+
# Resolve Azure AI Project endpoint (mirrors old SK env var usage)
113+
self.project_endpoint = os.getenv("AZURE_AI_PROJECT_ENDPOINT")
114+
if not self.project_endpoint:
115+
raise RuntimeError(
116+
"AZURE_AI_PROJECT_ENDPOINT environment variable is required for AzureAgentBase."
117+
)
118+
119+
# Acquire credential
120+
self.creds = DefaultAzureCredential()
121+
await self._stack.enter_async_context(self.creds)
122+
123+
# Create AIProjectClient
124+
self.client = AIProjectClient(
125+
endpoint=self.project_endpoint,
126+
credential=self.creds,
127+
)
128+
await self._stack.enter_async_context(self.client)
129+
130+
# Prepare MCP
131+
self._prepare_mcp_tool()
132+
133+
# Let subclass build agent client
134+
await self._after_open()
135+
136+
# Register agent (best effort)
137+
try:
138+
agent_registry.register_agent(self)
139+
except Exception: # noqa: BLE001
140+
pass
141+
142+
return self
143+
144+
async def close(self) -> None:
145+
"""
146+
Close agent client and Azure resources.
147+
If you implement ephemeral agent creation in subclasses, you can
148+
optionally delete the agent definition here.
149+
"""
150+
try:
151+
# Example optional clean up of an agent id:
152+
# if self._agent and isinstance(self._agent, AzureAIAgentClient) and self._agent._should_delete_agent:
153+
# try:
154+
# if self.client and self._agent.agent_id:
155+
# await self.client.agents.delete_agent(self._agent.agent_id)
156+
# except Exception:
157+
# pass
158+
159+
# Close underlying client via base close
160+
if self._agent and hasattr(self._agent, "close"):
161+
try:
162+
await self._agent.close()
163+
except Exception: # noqa: BLE001
164+
pass
165+
166+
# Unregister from registry
167+
try:
168+
agent_registry.unregister_agent(self)
169+
except Exception: # noqa: BLE001
170+
pass
171+
172+
# Close credential and project client
173+
if self.client:
174+
try:
175+
await self.client.close()
176+
except Exception: # noqa: BLE001
177+
pass
178+
if self.creds:
179+
try:
180+
await self.creds.close()
181+
except Exception: # noqa: BLE001
182+
pass
183+
184+
finally:
185+
await super().close()
186+
self.client = None
187+
self.creds = None
188+
self.project_endpoint = None
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Models for agent configurations."""
2+
3+
from dataclasses import dataclass
4+
5+
from common.config.app_config import config
6+
7+
8+
@dataclass(slots=True)
9+
class MCPConfig:
10+
"""Configuration for connecting to an MCP server."""
11+
12+
url: str = ""
13+
name: str = "MCP"
14+
description: str = ""
15+
tenant_id: str = ""
16+
client_id: str = ""
17+
18+
@classmethod
19+
def from_env(cls) -> "MCPConfig":
20+
url = config.MCP_SERVER_ENDPOINT
21+
name = config.MCP_SERVER_NAME
22+
description = config.MCP_SERVER_DESCRIPTION
23+
tenant_id = config.AZURE_TENANT_ID
24+
client_id = config.AZURE_CLIENT_ID
25+
26+
# Raise exception if any required environment variable is missing
27+
if not all([url, name, description, tenant_id, client_id]):
28+
raise ValueError(f"{cls.__name__} Missing required environment variables")
29+
30+
return cls(
31+
url=url,
32+
name=name,
33+
description=description,
34+
tenant_id=tenant_id,
35+
client_id=client_id,
36+
)
37+
38+
39+
# @dataclass(slots=True)
40+
# class BingConfig:
41+
# """Configuration for connecting to Bing Search."""
42+
# connection_name: str = "Bing"
43+
44+
# @classmethod
45+
# def from_env(cls) -> "BingConfig":
46+
# connection_name = config.BING_CONNECTION_NAME
47+
48+
# # Raise exception if required environment variable is missing
49+
# if not connection_name:
50+
# raise ValueError(f"{cls.__name__} Missing required environment variables")
51+
52+
# return cls(
53+
# connection_name=connection_name,
54+
# )
55+
56+
57+
@dataclass(slots=True)
58+
class SearchConfig:
59+
"""Configuration for connecting to Azure AI Search."""
60+
61+
connection_name: str | None = None
62+
endpoint: str | None = None
63+
index_name: str | None = None
64+
api_key: str | None = None # API key for Azure AI Search
65+
66+
@classmethod
67+
def from_env(cls) -> "SearchConfig":
68+
connection_name = config.AZURE_AI_SEARCH_CONNECTION_NAME
69+
index_name = config.AZURE_AI_SEARCH_INDEX_NAME
70+
endpoint = config.AZURE_AI_SEARCH_ENDPOINT
71+
api_key = config.AZURE_AI_SEARCH_API_KEY
72+
73+
# Raise exception if any required environment variable is missing
74+
if not all([connection_name, index_name, endpoint]):
75+
raise ValueError(
76+
f"{cls.__name__} Missing required Azure Search environment variables"
77+
)
78+
79+
return cls(
80+
connection_name=connection_name,
81+
index_name=index_name,
82+
endpoint=endpoint,
83+
api_key=api_key,
84+
)

0 commit comments

Comments
 (0)