Skip to content

Commit bdb1bbf

Browse files
committed
Add agent framework API router and service skeletons
Introduces the main FastAPI router for agent framework v3, including endpoints for team management, plan creation, approval, agent messaging, and configuration upload. Adds callback handlers for agent responses and streaming, global debug access, and service skeletons for agents, base API, foundry, MCP, and team management. These files establish the backend structure for multi-agent orchestration and extensible service integration.
1 parent 6533e61 commit bdb1bbf

27 files changed

+5469
-0
lines changed

src/backend/af/__init__.py

Whitespace-only changes.

src/backend/af/api/router.py

Lines changed: 1338 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Callbacks package for handling agent responses and streaming
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
class DebugGlobalAccess:
2+
"""Class to manage global access to the Magentic orchestration manager."""
3+
4+
_managers = []
5+
6+
@classmethod
7+
def add_manager(cls, manager):
8+
"""Add a new manager to the global list."""
9+
cls._managers.append(manager)
10+
11+
@classmethod
12+
def get_managers(cls):
13+
"""Get the list of all managers."""
14+
return cls._managers
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"""
2+
Agent Framework response callbacks for employee onboarding / multi-agent system.
3+
Replaces Semantic Kernel message types with agent_framework ChatResponseUpdate handling.
4+
"""
5+
6+
import asyncio
7+
import json
8+
import logging
9+
import re
10+
import time
11+
from typing import Optional
12+
13+
from agent_framework import (
14+
ChatResponseUpdate,
15+
FunctionCallContent,
16+
UsageContent,
17+
Role,
18+
TextContent,
19+
)
20+
21+
from af.config.settings import connection_config
22+
from af.models.messages import (
23+
AgentMessage,
24+
AgentMessageStreaming,
25+
AgentToolCall,
26+
AgentToolMessage,
27+
WebsocketMessageType,
28+
)
29+
30+
logger = logging.getLogger(__name__)
31+
32+
33+
# ---------------------------------------------------------------------------
34+
# Utility
35+
# ---------------------------------------------------------------------------
36+
37+
_CITATION_PATTERNS = [
38+
(r"\[\d+:\d+\|source\]", ""), # [9:0|source]
39+
(r"\[\s*source\s*\]", ""), # [source]
40+
(r"\[\d+\]", ""), # [12]
41+
(r"【[^】]*】", ""), # Unicode bracket citations
42+
(r"\(source:[^)]*\)", ""), # (source: xyz)
43+
(r"\[source:[^\]]*\]", ""), # [source: xyz]
44+
]
45+
46+
47+
def clean_citations(text: str) -> str:
48+
"""Remove citation markers from agent responses while preserving formatting."""
49+
if not text:
50+
return text
51+
for pattern, repl in _CITATION_PATTERNS:
52+
text = re.sub(pattern, repl, text, flags=re.IGNORECASE)
53+
return text
54+
55+
56+
def _parse_function_arguments(arg_value: Optional[str | dict]) -> dict:
57+
"""Best-effort parse for function call arguments (stringified JSON or dict)."""
58+
if arg_value is None:
59+
return {}
60+
if isinstance(arg_value, dict):
61+
return arg_value
62+
if isinstance(arg_value, str):
63+
try:
64+
return json.loads(arg_value)
65+
except Exception: # noqa: BLE001
66+
return {"raw": arg_value}
67+
return {"raw": str(arg_value)}
68+
69+
70+
# ---------------------------------------------------------------------------
71+
# Core handlers
72+
# ---------------------------------------------------------------------------
73+
74+
def agent_framework_update_callback(
75+
update: ChatResponseUpdate,
76+
user_id: Optional[str] = None,
77+
) -> None:
78+
"""
79+
Handle a non-streaming perspective of updates (tool calls, intermediate steps, final usage).
80+
This can be called for each ChatResponseUpdate; it will route tool calls and standard text
81+
messages to WebSocket.
82+
"""
83+
agent_name = getattr(update, "model_id", None) or "Agent"
84+
# Use Role or fallback
85+
role = getattr(update, "role", Role.ASSISTANT)
86+
87+
# Detect tool/function calls
88+
function_call_contents = [
89+
c for c in (update.contents or [])
90+
if isinstance(c, FunctionCallContent)
91+
]
92+
93+
if user_id is None:
94+
return
95+
96+
try:
97+
if function_call_contents:
98+
# Build tool message
99+
tool_message = AgentToolMessage(agent_name=agent_name)
100+
for fc in function_call_contents:
101+
args = _parse_function_arguments(getattr(fc, "arguments", None))
102+
tool_message.tool_calls.append(
103+
AgentToolCall(
104+
tool_name=getattr(fc, "name", "unknown_tool"),
105+
arguments=args,
106+
)
107+
)
108+
asyncio.create_task(
109+
connection_config.send_status_update_async(
110+
tool_message,
111+
user_id,
112+
message_type=WebsocketMessageType.AGENT_TOOL_MESSAGE,
113+
)
114+
)
115+
logger.info("Function call(s) dispatched: %s", tool_message)
116+
return
117+
118+
# Ignore pure usage or empty updates (handled as final in streaming handler)
119+
if any(isinstance(c, UsageContent) for c in (update.contents or [])):
120+
# We'll treat this as a final token accounting event; no standard message needed.
121+
logger.debug("UsageContent received (final accounting); skipping text dispatch.")
122+
return
123+
124+
# Standard assistant/user message (non-stream delta)
125+
if update.text:
126+
final_message = AgentMessage(
127+
agent_name=agent_name,
128+
timestamp=str(time.time()),
129+
content=clean_citations(update.text),
130+
)
131+
asyncio.create_task(
132+
connection_config.send_status_update_async(
133+
final_message,
134+
user_id,
135+
message_type=WebsocketMessageType.AGENT_MESSAGE,
136+
)
137+
)
138+
logger.info("%s message: %s", role.name.capitalize(), final_message)
139+
140+
except Exception as e: # noqa: BLE001
141+
logger.error("agent_framework_update_callback: Error sending WebSocket message: %s", e)
142+
143+
144+
async def streaming_agent_framework_callback(
145+
update: ChatResponseUpdate,
146+
user_id: Optional[str] = None,
147+
) -> None:
148+
"""
149+
Handle streaming deltas. For each update with text, forward a streaming message.
150+
Mark is_final=True when a UsageContent is observed (end of run).
151+
"""
152+
if user_id is None:
153+
return
154+
155+
try:
156+
# Determine if this update marks the end
157+
is_final = any(isinstance(c, UsageContent) for c in (update.contents or []))
158+
159+
# Streaming text can appear either in update.text or inside TextContent entries.
160+
pieces: list[str] = []
161+
if update.text:
162+
pieces.append(update.text)
163+
# Some events may provide TextContent objects without setting update.text
164+
for c in (update.contents or []):
165+
if isinstance(c, TextContent) and getattr(c, "text", None):
166+
pieces.append(c.text)
167+
168+
if not pieces:
169+
return
170+
171+
streaming_message = AgentMessageStreaming(
172+
agent_name=getattr(update, "model_id", None) or "Agent",
173+
content=clean_citations("".join(pieces)),
174+
is_final=is_final,
175+
)
176+
177+
await connection_config.send_status_update_async(
178+
streaming_message,
179+
user_id,
180+
message_type=WebsocketMessageType.AGENT_MESSAGE_STREAMING,
181+
)
182+
183+
if is_final:
184+
logger.info("Final streaming chunk sent for agent '%s'", streaming_message.agent_name)
185+
186+
except Exception as e: # noqa: BLE001
187+
logger.error("streaming_agent_framework_callback: Error sending streaming WebSocket message: %s", e)
188+
189+
190+
# ---------------------------------------------------------------------------
191+
# Convenience wrappers (optional)
192+
# ---------------------------------------------------------------------------
193+
194+
def handle_update(update: ChatResponseUpdate, user_id: Optional[str]) -> None:
195+
"""
196+
Unified entry point if caller doesn't distinguish streaming vs non-streaming.
197+
You can call this once per update. It will:
198+
- Forward streaming text increments
199+
- Forward tool calls
200+
- Skip purely usage-only events (except marking final in streaming)
201+
"""
202+
# Send streaming chunk first (async context)
203+
asyncio.create_task(streaming_agent_framework_callback(update, user_id))
204+
# Then send non-stream items (tool calls or discrete messages)
205+
agent_framework_update_callback(update, user_id)
206+
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Service abstractions for v3.
2+
3+
Exports:
4+
- BaseAPIService: minimal async HTTP wrapper using endpoints from AppConfig
5+
- MCPService: service targeting a local/remote MCP server
6+
- FoundryService: helper around Azure AI Foundry (AIProjectClient)
7+
"""
8+
9+
from .agents_service import AgentsService
10+
from .base_api_service import BaseAPIService
11+
from .foundry_service import FoundryService
12+
from .mcp_service import MCPService
13+
14+
__all__ = [
15+
"BaseAPIService",
16+
"MCPService",
17+
"FoundryService",
18+
"AgentsService",
19+
]
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""
2+
AgentsService (skeleton)
3+
4+
Lightweight service that receives a TeamService instance and exposes helper
5+
methods to convert a TeamConfiguration into a list/array of agent descriptors.
6+
7+
This is intentionally a simple skeleton — the user will later provide the
8+
implementation that wires these descriptors into Semantic Kernel / Foundry
9+
agent instances.
10+
"""
11+
12+
import logging
13+
from typing import Any, Dict, List, Union
14+
15+
from common.models.messages_kernel import TeamAgent, TeamConfiguration
16+
from v3.common.services.team_service import TeamService
17+
18+
19+
class AgentsService:
20+
"""Service for building agent descriptors from a team configuration.
21+
22+
Responsibilities (skeleton):
23+
- Receive a TeamService instance on construction (can be used for validation
24+
or lookups when needed).
25+
- Expose a method that accepts a TeamConfiguration (or raw dict) and
26+
returns a list of agent descriptors. Descriptors are plain dicts that
27+
contain the fields required to later instantiate runtime agents.
28+
29+
The concrete instantiation logic (semantic kernel / foundry) is intentionally
30+
left out and should be implemented by the user later (see
31+
`instantiate_agents` placeholder).
32+
"""
33+
34+
def __init__(self, team_service: TeamService):
35+
self.team_service = team_service
36+
self.logger = logging.getLogger(__name__)
37+
38+
async def get_agents_from_team_config(
39+
self, team_config: Union[TeamConfiguration, Dict[str, Any]]
40+
) -> List[Dict[str, Any]]:
41+
"""Return a list of lightweight agent descriptors derived from a
42+
TeamConfiguration or a raw dict.
43+
44+
Each descriptor contains the basic fields from the team config and a
45+
placeholder where a future runtime/agent object can be attached.
46+
47+
Args:
48+
team_config: TeamConfiguration model instance or a raw dict
49+
50+
Returns:
51+
List[dict] -- each dict contains keys like:
52+
- input_key, type, name, system_message, description, icon,
53+
index_name, agent_obj (placeholder)
54+
"""
55+
if not team_config:
56+
return []
57+
58+
# Accept either the pydantic TeamConfiguration or a raw dictionary
59+
if hasattr(team_config, "agents"):
60+
agents_raw = team_config.agents or []
61+
elif isinstance(team_config, dict):
62+
agents_raw = team_config.get("agents", [])
63+
else:
64+
# Unknown type; try to coerce to a list
65+
try:
66+
agents_raw = list(team_config)
67+
except Exception:
68+
agents_raw = []
69+
70+
descriptors: List[Dict[str, Any]] = []
71+
for a in agents_raw:
72+
if isinstance(a, TeamAgent):
73+
desc = {
74+
"input_key": a.input_key,
75+
"type": a.type,
76+
"name": a.name,
77+
"system_message": getattr(a, "system_message", ""),
78+
"description": getattr(a, "description", ""),
79+
"icon": getattr(a, "icon", ""),
80+
"index_name": getattr(a, "index_name", ""),
81+
"use_rag": getattr(a, "use_rag", False),
82+
"use_mcp": getattr(a, "use_mcp", False),
83+
"coding_tools": getattr(a, "coding_tools", False),
84+
# Placeholder for later wiring to a runtime/agent instance
85+
"agent_obj": None,
86+
}
87+
elif isinstance(a, dict):
88+
desc = {
89+
"input_key": a.get("input_key"),
90+
"type": a.get("type"),
91+
"name": a.get("name"),
92+
"system_message": a.get("system_message") or a.get("instructions"),
93+
"description": a.get("description"),
94+
"icon": a.get("icon"),
95+
"index_name": a.get("index_name"),
96+
"use_rag": a.get("use_rag", False),
97+
"use_mcp": a.get("use_mcp", False),
98+
"coding_tools": a.get("coding_tools", False),
99+
"agent_obj": None,
100+
}
101+
else:
102+
# Fallback: keep raw object for later introspection
103+
desc = {"raw": a, "agent_obj": None}
104+
105+
descriptors.append(desc)
106+
107+
return descriptors
108+
109+
async def instantiate_agents(self, agent_descriptors: List[Dict[str, Any]]):
110+
"""Placeholder for instantiating runtime agent objects from descriptors.
111+
112+
The real implementation should create Semantic Kernel / Foundry agents
113+
and attach them to each descriptor under the key `agent_obj` or return a
114+
list of instantiated agents.
115+
116+
Raises:
117+
NotImplementedError -- this is only a skeleton.
118+
"""
119+
raise NotImplementedError(
120+
"Agent instantiation is not implemented in the skeleton"
121+
)

0 commit comments

Comments
 (0)