Skip to content

Commit 6004002

Browse files
foundry-samples-repo-sync[bot]djetchevCopilotmeerakurupDchillakuru
authored
Automated sync from private repo (2026-04-15) (#655)
* Add OpenAPI tool and A2A connector tests for VNet-private resources (#94) * Add OpenAPI tool and A2A connector tests for VNet-private resources Extend the 19-hybrid-private-resources-agent-setup test suite to validate OpenAPI tools and A2A (Agent-to-Agent) connectors through the Data Proxy when services run behind a private VNet. New files: - openapi-server/: Minimal FastAPI calculator service (Dockerfile + app) - tests/calculator_openapi.json: OpenAPI spec for the calculator service - tests/test_openapi_tool_agents_v2.py: Focused OpenAPI tool tests (connectivity + public/private agent tests with --retry support) - tests/test_a2a_connector_agents_v2.py: Focused A2A connector tests using A2APreviewTool from azure.ai.projects.models Updated files: - tests/test_agents_v2.py: Added Tests 6 (OpenAPI) and 7 (A2A) to full suite - tests/TESTING-GUIDE.md: Added Steps 5-6 for OpenAPI/A2A deployment + test commands - README.md: Added OpenAPI and A2A to feature list No Bicep infrastructure changes required — the existing networkInjections with scenario: 'agent' routes all tool types through the VNet universally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix OpenAPI/A2A tests and add A2A server - Fix OpenApiTool constructor: use OpenApiFunctionDefinition wrapper - Fix deprecated 'agent' property: use 'agent_reference' in extra_body - Add A2A protocol server (FastAPI) for testing RemoteA2AConnector - Serves agent card at /.well-known/agent-card.json - Handles JSON-RPC 2.0 message/send with kind discriminator - Dynamic absolute URL in agent card from request headers - Simple calculator logic for testing - All fixes applied across test_agents_v2, test_openapi_tool_agents_v2, test_ai_search_tool_agents_v2, and test_mcp_tools_agents_v2 Test results (WestUS2): OpenAPI public: PASS (connectivity + agent tool call) OpenAPI private: FAIL (Data Proxy can't reach internal Container App) A2A public: PASS (agent card + message/send + response) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Azure Function behind VNet example with three-scenario docs - New azure-function-server/ with calculator API that stores results in private blob storage, proving VNet Integration works (storage.stored field) - Three scenarios documented: (1) no VNet baseline, (2) VNet Integration only (DataProxy-compatible), (3) full lockdown with PE (customer code only) - Key finding: publicNetworkAccess must be Enabled for DataProxy — Disabled causes 403 Ip Forbidden because DataProxy resolves DNS at Foundry level - Storage requires three PEs: Blob + Queue + File (File often forgotten) - deploy-function.bicep with full VNet deployment including all three storage PEs - Dedicated test script with expect_storage validation - TESTING-GUIDE.md rewritten with scenario matrix, deployment order, and updated test results for MCP/OpenAPI/A2A/Function (all passing) - Added Fabric Data Agent test script (test_fabric_data_agent_v2.py) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/README.md Co-authored-by: Meera Kurup <meerakurup@microsoft.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Meera Kurup <meerakurup@microsoft.com> * Serialize project connections in 18-managed-virtual-network-preview azuredeploy.json (#142) Align azuredeploy.json with the serialization already present in ai-project-identity.bicep. Connections now deploy sequentially: 1. CosmosDB - depends on project resource 2. Azure Storage - depends on CosmosDB connection 3. Azure Search - depends on Azure Storage connection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update sync state to e448696a5a61 --------- Co-authored-by: djetchev <dimitar.jetchev@iohk.io> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Meera Kurup <meerakurup@microsoft.com> Co-authored-by: Dchillakuru <167816084+Dchillakuru@users.noreply.github.com> Co-authored-by: foundry-samples-repo-sync[bot] <foundry-samples-repo-sync[bot]@users.noreply.github.com>
1 parent ac7b9a3 commit 6004002

25 files changed

+4108
-27
lines changed

.github/.sync-sha

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
c47f5c67c4e6e732996a4a0d200e2b6026c9cd15
1+
e448696a5a619683d4c940e9bfe666bd23d3f8a5

infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/azuredeploy.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2094,7 +2094,7 @@
20942094
}
20952095
},
20962096
"dependsOn": [
2097-
"[resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName'))]"
2097+
"[resourceId('Microsoft.CognitiveServices/accounts/projects/connections', parameters('accountName'), parameters('projectName'), parameters('cosmosDBName'))]"
20982098
]
20992099
},
21002100
{
@@ -2112,7 +2112,7 @@
21122112
}
21132113
},
21142114
"dependsOn": [
2115-
"[resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName'))]"
2115+
"[resourceId('Microsoft.CognitiveServices/accounts/projects/connections', parameters('accountName'), parameters('projectName'), parameters('azureStorageName'))]"
21162116
]
21172117
},
21182118
{

infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ For detailed setup instructions, see: [Securely connect to Azure AI Foundry](htt
8585
Use this template when you want:
8686
- **Private backend resources** — Keep AI Search, Cosmos DB, and Storage behind private endpoints
8787
- **MCP server integration** — Deploy MCP servers on the VNet that agents can access via Data Proxy
88+
- **OpenAPI tool integration** — Deploy OpenAPI-spec HTTP services on the VNet for agent tool access
89+
- **A2A (Agent-to-Agent)** — Connect agents to remote agents behind the VNet via the A2A protocol
90+
- **Azure Functions** — Deploy an Azure Function behind a VNET for agent tool access.
8891
- **Private Foundry (default)** — Full network isolation with secure access via VPN/ExpressRoute/Bastion
8992
- **Optional public Foundry access** — Switch to public for portal-based development if allowed by your security policy
9093

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
FROM python:3.11-slim
2+
3+
WORKDIR /app
4+
5+
COPY requirements.txt .
6+
RUN pip install --no-cache-dir -r requirements.txt
7+
8+
COPY main.py .
9+
10+
EXPOSE 8080
11+
12+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
"""
2+
Minimal A2A (Agent-to-Agent) Protocol Server
3+
4+
A lightweight implementation of the A2A protocol specification (https://a2a-protocol.org)
5+
for testing Azure AI Foundry's RemoteA2AConnector through the Data Proxy.
6+
7+
Endpoints:
8+
GET /.well-known/agent.json - Agent card (A2A spec standard)
9+
GET /.well-known/agent-card.json - Agent card (Azure SDK default path)
10+
POST / - JSON-RPC 2.0 task endpoint
11+
GET /healthz - Container health check
12+
"""
13+
14+
import json
15+
import logging
16+
import os
17+
import uuid
18+
19+
from fastapi import FastAPI, Request
20+
from fastapi.responses import JSONResponse
21+
22+
logging.basicConfig(level=logging.INFO)
23+
logger = logging.getLogger(__name__)
24+
25+
app = FastAPI(title="A2A Calculator Agent")
26+
27+
# ============================================================================
28+
# Agent Card — describes this agent's identity and capabilities
29+
# ============================================================================
30+
31+
AGENT_CARD = {
32+
"name": "Calculator Agent",
33+
"description": "A simple calculator agent for A2A protocol testing. "
34+
"Performs basic arithmetic (add, subtract, multiply, divide).",
35+
"url": "/",
36+
"version": "1.0.0",
37+
"protocolVersion": "0.2.6",
38+
"preferredTransport": "jsonrpc",
39+
"capabilities": {
40+
"streaming": False,
41+
"pushNotifications": False,
42+
},
43+
"skills": [
44+
{
45+
"id": "calculate",
46+
"name": "Calculate",
47+
"description": "Performs basic arithmetic operations (add, subtract, multiply, divide)",
48+
"tags": ["math", "calculator", "arithmetic"],
49+
"examples": ["add 5 and 3", "multiply 7 by 8"],
50+
}
51+
],
52+
"defaultInputModes": ["text"],
53+
"defaultOutputModes": ["text"],
54+
}
55+
56+
57+
# ============================================================================
58+
# Agent Card Endpoints
59+
# ============================================================================
60+
61+
62+
@app.get("/.well-known/agent.json")
63+
@app.get("/.well-known/agent-card.json")
64+
async def get_agent_card(request: Request):
65+
"""Serves agent card at both A2A spec and Azure SDK default paths.
66+
Dynamically sets the url field to the absolute URL of this server."""
67+
logger.info("Agent card requested")
68+
# Build absolute URL from the incoming request
69+
base_url = os.environ.get("A2A_BASE_URL", "")
70+
if not base_url:
71+
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
72+
host = request.headers.get("x-forwarded-host", request.headers.get("host", ""))
73+
base_url = f"{scheme}://{host}"
74+
card = dict(AGENT_CARD)
75+
card["url"] = base_url + "/"
76+
return card
77+
78+
79+
# ============================================================================
80+
# JSON-RPC 2.0 Task Endpoint
81+
# ============================================================================
82+
83+
84+
def _jsonrpc_error(request_id: str, code: int, message: str, status: int = 200):
85+
return JSONResponse(
86+
status_code=status,
87+
content={
88+
"jsonrpc": "2.0",
89+
"id": request_id,
90+
"error": {"code": code, "message": message},
91+
},
92+
)
93+
94+
95+
@app.get("/")
96+
async def root_get():
97+
"""GET on root — return basic info (some A2A clients probe this)."""
98+
return {"name": "Calculator Agent", "protocol": "a2a", "version": "1.0.0"}
99+
100+
101+
def _extract_user_text(params: dict) -> str:
102+
"""Extract text content from an A2A message's parts."""
103+
message = params.get("message", {})
104+
parts = message.get("parts", [])
105+
for part in parts:
106+
if part.get("kind") == "text" or part.get("type") == "text":
107+
return part.get("text", "")
108+
return ""
109+
110+
111+
def _process_message(text: str) -> str:
112+
"""Simple calculator logic — returns a text response."""
113+
lower = text.lower().strip()
114+
115+
# Try to detect arithmetic from natural language
116+
import re
117+
118+
# Match patterns like "add 5 and 3", "multiply 4 by 7", "15 + 3"
119+
ops = {
120+
"add": "+",
121+
"plus": "+",
122+
"sum": "+",
123+
"subtract": "-",
124+
"minus": "-",
125+
"multiply": "*",
126+
"times": "*",
127+
"divide": "/",
128+
"divided": "/",
129+
}
130+
131+
# Check for "X op Y" patterns
132+
num_pattern = r"(-?\d+(?:\.\d+)?)"
133+
for word, op in ops.items():
134+
pattern = rf"{word}\s+{num_pattern}\s+(?:and|by|from|with)?\s*{num_pattern}"
135+
match = re.search(pattern, lower)
136+
if match:
137+
a, b = float(match.group(1)), float(match.group(2))
138+
return _compute(op, a, b)
139+
140+
# Check for "X + Y" style
141+
arith_match = re.search(
142+
rf"{num_pattern}\s*([+\-*/])\s*{num_pattern}", text
143+
)
144+
if arith_match:
145+
a = float(arith_match.group(1))
146+
op = arith_match.group(2)
147+
b = float(arith_match.group(3))
148+
return _compute(op, a, b)
149+
150+
# Capability question
151+
if "what" in lower and ("do" in lower or "can" in lower or "capabilities" in lower):
152+
return (
153+
"I'm a calculator agent. I can perform basic arithmetic: "
154+
"add, subtract, multiply, and divide. "
155+
"Try asking me something like 'add 5 and 3' or 'multiply 7 by 8'."
156+
)
157+
158+
return f"I received your message: '{text}'. I'm a calculator agent — ask me to do math!"
159+
160+
161+
def _compute(op: str, a: float, b: float) -> str:
162+
if op == "+":
163+
return f"{a} + {b} = {a + b}"
164+
elif op == "-":
165+
return f"{a} - {b} = {a - b}"
166+
elif op == "*":
167+
return f"{a} * {b} = {a * b}"
168+
elif op == "/":
169+
if b == 0:
170+
return "Error: Division by zero"
171+
return f"{a} / {b} = {a / b}"
172+
return "Unknown operation"
173+
174+
175+
@app.post("/")
176+
async def handle_jsonrpc(request: Request):
177+
"""Handle A2A JSON-RPC 2.0 requests."""
178+
try:
179+
body = await request.json()
180+
except json.JSONDecodeError:
181+
return _jsonrpc_error("unknown", -32700, "Parse error", status=400)
182+
183+
request_id = body.get("id", str(uuid.uuid4()))
184+
185+
if body.get("jsonrpc") != "2.0":
186+
return _jsonrpc_error(request_id, -32600, "Invalid Request: jsonrpc must be '2.0'")
187+
188+
method = body.get("method", "")
189+
# Support both A2A v1.0 (message/send) and older (tasks/send)
190+
supported_methods = {"message/send", "tasks/send", "message/stream"}
191+
192+
if method not in supported_methods:
193+
return _jsonrpc_error(request_id, -32601, f"Method not found: {method}")
194+
195+
params = body.get("params", {})
196+
task_id = params.get("id", str(uuid.uuid4()))
197+
user_text = _extract_user_text(params)
198+
199+
logger.info(f"A2A {method}: task={task_id}, text='{user_text[:100]}'")
200+
201+
response_text = _process_message(user_text)
202+
203+
# Build A2A response as a direct Message (with kind discriminator)
204+
# The A2A SDK uses "kind" to distinguish Message vs Task responses
205+
result = {
206+
"kind": "message",
207+
"messageId": str(uuid.uuid4()),
208+
"role": "agent",
209+
"parts": [{"kind": "text", "text": response_text}],
210+
}
211+
212+
return JSONResponse(
213+
content={"jsonrpc": "2.0", "id": request_id, "result": result}
214+
)
215+
216+
217+
# ============================================================================
218+
# Health Check
219+
# ============================================================================
220+
221+
222+
@app.get("/healthz")
223+
async def healthz():
224+
return {"status": "ok"}
225+
226+
227+
if __name__ == "__main__":
228+
import uvicorn
229+
230+
uvicorn.run(app, host="0.0.0.0", port=8080)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
fastapi>=0.110.0
2+
uvicorn>=0.27.0

0 commit comments

Comments
 (0)