diff --git a/src/app/tools/singleAgentExample.py b/src/app/tools/singleAgentExample.py
index d072ddb..1bb6f23 100644
--- a/src/app/tools/singleAgentExample.py
+++ b/src/app/tools/singleAgentExample.py
@@ -1,5 +1,6 @@
import os
import time
+import logging
from azure.ai.inference import ChatCompletionsClient
from azure.core.credentials import AzureKeyCredential
from azure.identity import DefaultAzureCredential
@@ -29,6 +30,9 @@
# Global client instance
client = None
+logger = logging.getLogger("single_agent_example")
+if os.getenv("A2A_DEBUG", "").lower() in {"1", "true", "yes"}:
+ logging.basicConfig(level=logging.DEBUG)
def get_client():
"""Lazily initialize and return the MSFT Foundry client"""
@@ -115,7 +119,8 @@ def generate_response(text_input):
]
# Call MSFT Foundry chat API
- response = client.complete(
+ try:
+ response = client.complete(
model=deployment,
messages=messages,
max_tokens=10000,
@@ -123,7 +128,10 @@ def generate_response(text_input):
top_p=1.0,
frequency_penalty=0,
presence_penalty=0
- )
+ )
+ except Exception:
+ logger.exception("singleAgentExample GPT call failed (endpoint=%s, deployment=%s)", getattr(client, "_endpoint", None), deployment)
+ raise
end_sum = time.time()
print(f"generate_response Execution Time: {end_sum - start_time} seconds")
diff --git a/src/chat_app_multi_agent.py b/src/chat_app_multi_agent.py
index b09a4d5..38628da 100644
--- a/src/chat_app_multi_agent.py
+++ b/src/chat_app_multi_agent.py
@@ -1,6 +1,8 @@
import os
import logging
import json
+import uuid
+import traceback
from typing import Any, Dict
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
from fastapi.responses import HTMLResponse
@@ -28,6 +30,17 @@
)
logger = logging.getLogger(__name__)
+
+def _debug_enabled() -> bool:
+ return os.getenv("A2A_DEBUG", "").lower() in {"1", "true", "yes"}
+
+
+def _format_exception_for_client(error_id: str, exc: Exception) -> str:
+ parts: list[str] = [f"error_id={error_id}", f"exception_type={type(exc).__name__}", f"exception={str(exc)}"]
+ if _debug_enabled():
+ parts.append("traceback=\n" + traceback.format_exc())
+ return "\n".join(parts)
+
# Initialize FastAPI app
app = FastAPI(title="Zava AI Shopping Assistant")
@@ -67,8 +80,58 @@ def _extract_plain_answer(raw: str) -> str:
return inner.strip()
except Exception:
pass
+
+ # Support legacy non-JSON "answer: ...\nimage_output: ...\nproducts: ..." blocks.
+ legacy = _parse_legacy_kv_block(text)
+ if legacy and isinstance(legacy.get("answer"), str):
+ return legacy["answer"].strip()
return text
+
+def _parse_legacy_kv_block(text: str) -> Dict[str, Any] | None:
+ """Parse legacy key-value blocks emitted by older prompts.
+
+ Example input:
+ answer: hello there
+ image_output: []
+ products: []
+
+ Returns a dict with keys when recognized, else None.
+ """
+ if not text:
+ return None
+
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
+ if not lines:
+ return None
+
+ # Quick reject: must contain at least an answer line.
+ has_answer = any(ln.lower().startswith("answer:") for ln in lines)
+ if not has_answer:
+ return None
+
+ parsed: Dict[str, Any] = {}
+ for line in lines:
+ # Only parse simple "key: value" lines.
+ if ":" not in line:
+ continue
+ key, value = line.split(":", 1)
+ key = key.strip().lower()
+ value = value.strip().rstrip(",")
+
+ if key in {"answer", "image_output", "products"}:
+ if key == "answer":
+ parsed["answer"] = value
+ continue
+
+ # Try to JSON-decode arrays/objects, otherwise keep as string.
+ try:
+ parsed[key] = json.loads(value)
+ except Exception:
+ parsed[key] = value
+
+ return parsed or None
+
def _flatten_response_json(response_json: Dict[str, Any]) -> str:
"""Derive a single natural language answer from structured fields."""
base = response_json.get('answer') or ''
@@ -100,6 +163,46 @@ def _get_env_any(*names: str) -> str | None:
return None
+def _plan_handoff_sequence(domain: str, user_message: str) -> list[str]:
+ """Plan a minimal agent handoff sequence.
+
+ This is the missing piece for "agents talking to each other" in the chat UI.
+ The UI still sends a single user message, but the server can delegate to
+ multiple specialist agents and pass context forward.
+ """
+ msg = (user_message or "").lower()
+
+ # Always start with the classified domain.
+ sequence: list[str] = [domain]
+
+ # Product discovery and comparison are best handled by product management.
+ if any(k in msg for k in ["recommend", "recommendation", "find", "search", "compare", "best", "popular", "spec", "specification"]):
+ if "product_management" not in sequence:
+ sequence.append("product_management")
+
+ # Design requests often benefit from product discovery afterwards.
+ if any(k in msg for k in ["design", "interior", "room", "layout", "style", "color", "paint", "decor", "furniture"]):
+ if "interior_design" not in sequence:
+ sequence.append("interior_design")
+ if "product_management" not in sequence:
+ sequence.append("product_management")
+
+ # Purchase intent: check inventory then cart.
+ if any(k in msg for k in ["buy", "purchase", "checkout", "order", "add to cart", "add this", "add it", "remove from cart"]):
+ if "inventory" not in sequence:
+ sequence.append("inventory")
+ if "cart_management" not in sequence:
+ sequence.append("cart_management")
+
+ # Discounts: consult loyalty.
+ if any(k in msg for k in ["discount", "loyalty", "points", "member", "reward", "promo", "promotion", "deal"]):
+ if "customer_loyalty" not in sequence:
+ sequence.append("customer_loyalty")
+
+ # Cap to avoid runaway chains.
+ return sequence[:4]
+
+
def get_agent_processor(domain: str):
"""Return a processor (remote if available, else local) for the domain."""
agent_id_map = {
@@ -107,7 +210,8 @@ def get_agent_processor(domain: str):
"inventory": _get_env_any("inventory_agent", "AGENT_INVENTORY_AGENT_ID"),
"customer_loyalty": _get_env_any("customer_loyalty", "AGENT_CUSTOMER_LOYALTY_ID"),
"cart_management": _get_env_any("cart_manager", "AGENT_CART_MANAGER_ID"),
- "cora": _get_env_any("cora", "AGENT_CORA_ID")
+ "cora": _get_env_any("cora", "AGENT_CORA_ID"),
+ "product_management": _get_env_any("product_management", "AGENT_PRODUCT_MANAGEMENT_ID"),
}
agent_id = agent_id_map.get(domain)
@@ -117,7 +221,9 @@ def get_agent_processor(domain: str):
# Prefer remote only if endpoint exists and agent id looks like a remote id
remote_endpoint = os.getenv("AZURE_AI_AGENT_ENDPOINT") or os.getenv("AZURE_AI_PROJECT_ENDPOINT")
- if remote_endpoint and agent_id.startswith("asst_") and not agent_id.startswith("asst_local_"):
+ # Real Foundry agent IDs are not guaranteed to start with "asst_" (some SDKs/services use
+ # a name-based ID). Treat only explicit "asst_local_*" as local simulation.
+ if remote_endpoint and not agent_id.startswith("asst_local_"):
try:
return AgentProcessor(agent_id=agent_id, project_endpoint=remote_endpoint)
except Exception as e:
@@ -194,84 +300,97 @@ async def websocket_endpoint(websocket: WebSocket):
domain = classification["domain"]
logger.info(f"Classified as domain: {domain} (confidence: {classification['confidence']})")
-
- # Step 2: Get appropriate agent
- agent_processor = get_agent_processor(domain)
-
- if not agent_processor:
- # Instead of reverting to single-agent (which may lack config),
- # emit a message explaining the missing processor.
- warning = "Multi-agent processor unavailable; please verify configuration."
- await websocket.send_text(fast_json_dumps({
- "answer": warning,
- "agent": "unassigned",
- "cart": persistent_cart
- }))
- conversation_history.append({"role": "assistant", "content": warning})
- continue
-
- # Step 3: Prepare context for agent
- additional_context = {
- "cart": persistent_cart,
- "discount": customer_discount
- }
-
- if domain == "cart_management":
- # Cart manager needs full history
- additional_context["conversation_history"] = conversation_history
-
- # Step 4: Call agent and stream response
- response_text = ""
- for chunk in agent_processor.run_conversation_with_text_stream(
- user_message=user_message,
- conversation_history=conversation_history[-5:], # Last 5 messages
- additional_context=additional_context
- ):
- response_text += chunk
-
- # Step 5: Parse response and flatten to a human answer
- parsed_json: Dict[str, Any] | None = None
- try:
- parsed_json = json.loads(response_text)
- except Exception:
- # Try secondary parse if nested JSON inside 'answer'
- if response_text.strip().startswith('{'):
- try:
- parsed_json = json.loads(response_text.strip())
- except Exception:
- parsed_json = None
-
- if parsed_json:
- if "cart" in parsed_json and isinstance(parsed_json["cart"], list):
- persistent_cart = parsed_json["cart"]
- if "discount_percentage" in parsed_json and parsed_json["discount_percentage"]:
- customer_discount = parsed_json["discount_percentage"]
- flattened = _flatten_response_json(parsed_json)
+
+ # Step 2: Plan handoffs and execute sequence
+ sequence = _plan_handoff_sequence(domain, user_message)
+ logger.info(f"Multi-agent handoff sequence: {sequence}")
+
+ agent_outputs: list[dict[str, Any]] = []
+ last_parsed_json: Dict[str, Any] | None = None
+ last_domain = domain
+
+ for step_idx, step_domain in enumerate(sequence):
+ agent_processor = get_agent_processor(step_domain)
+ if not agent_processor:
+ raise RuntimeError(f"No agent processor available for domain={step_domain}")
+
+ additional_context = {
+ "cart": persistent_cart,
+ "discount": customer_discount,
+ "handoff": {
+ "step": step_idx + 1,
+ "sequence": sequence,
+ "previous_outputs": agent_outputs[-3:],
+ },
+ }
+ if step_domain == "cart_management":
+ additional_context["conversation_history"] = conversation_history
+
+ response_text = ""
+ for chunk in agent_processor.run_conversation_with_text_stream(
+ user_message=user_message,
+ conversation_history=conversation_history[-5:],
+ additional_context=additional_context,
+ ):
+ response_text += chunk
+
+ parsed_json: Dict[str, Any] | None = None
+ try:
+ parsed_json = json.loads(response_text)
+ except Exception:
+ parsed_json = None
+
+ if parsed_json and isinstance(parsed_json, dict):
+ # Lift nested JSON if present.
+ if isinstance(parsed_json.get("answer"), str):
+ legacy = _parse_legacy_kv_block(parsed_json["answer"])
+ if legacy:
+ parsed_json.update({k: v for k, v in legacy.items() if v is not None})
+ if "cart" in parsed_json and isinstance(parsed_json["cart"], list):
+ persistent_cart = parsed_json["cart"]
+ if "discount_percentage" in parsed_json and parsed_json["discount_percentage"]:
+ customer_discount = parsed_json["discount_percentage"]
+ last_parsed_json = parsed_json
+
+ agent_outputs.append({
+ "agent": step_domain,
+ "raw": response_text,
+ "parsed": parsed_json,
+ })
+ last_domain = step_domain
+
+ # Step 3: Build final response from last agent result
+ if last_parsed_json:
+ flattened = _flatten_response_json(last_parsed_json)
answer_text = _extract_plain_answer(flattened)
-
- # Extract image URL if present
- image_url = parsed_json.get("image_url")
+ image_url = last_parsed_json.get("image_url")
else:
- answer_text = _extract_plain_answer(response_text)
+ answer_text = _extract_plain_answer(agent_outputs[-1]["raw"] if agent_outputs else "")
image_url = None
- # Send natural language answer with metadata
response_data = {
"answer": answer_text,
- "agent": domain,
+ "agent": last_domain,
"cart": persistent_cart,
- "discount": customer_discount
+ "discount": customer_discount,
}
-
- # Include image URL if available
+
+ # Forward structured fields if present.
+ if last_parsed_json:
+ if "products" in last_parsed_json:
+ response_data["products"] = last_parsed_json.get("products")
+ if "image_output" in last_parsed_json:
+ response_data["image_output"] = last_parsed_json.get("image_output")
+ if isinstance(last_parsed_json.get("error"), str):
+ response_data["error"] = last_parsed_json["error"]
+ if last_parsed_json.get("error_id") is not None:
+ response_data["error_id"] = last_parsed_json.get("error_id")
if image_url:
response_data["image_url"] = image_url
-
- await websocket.send_text(fast_json_dumps(response_data))
+ await websocket.send_text(fast_json_dumps(response_data))
conversation_history.append({"role": "assistant", "content": answer_text})
-
- logger.info(f"Response sent successfully from {domain} agent")
+ logger.info(f"Response sent successfully (final agent={last_domain})")
else:
# === SINGLE-AGENT MODE (Legacy) ===
@@ -285,10 +404,11 @@ async def websocket_endpoint(websocket: WebSocket):
logger.info("Response sent successfully from single agent")
except Exception as e:
- logger.error("Error during response generation", exc_info=True)
+ error_id = uuid.uuid4().hex
+ logger.error("Error during response generation (error_id=%s)", error_id, exc_info=True)
await websocket.send_text(fast_json_dumps({
- "answer": "I'm sorry, I encountered an error processing your request. Please try again.",
- "error": str(e),
+ "answer": f"I'm sorry, I encountered an error processing your request. Please try again. (error_id={error_id})",
+ "error": _format_exception_for_client(error_id, e),
"cart": persistent_cart
}))
diff --git a/src/prompts/ShopperAgentPrompt.txt b/src/prompts/ShopperAgentPrompt.txt
index 1e00cb4..2fd5192 100644
--- a/src/prompts/ShopperAgentPrompt.txt
+++ b/src/prompts/ShopperAgentPrompt.txt
@@ -2,11 +2,15 @@ Shopper Agent Guidelines
========================================
- You are the public facing assistant of Zava
- Greet people and help them as needed
-- Return response in following json format (image_output and products empty)
+- Return response as a valid JSON object with the following shape:
-answer: your answer,
-image_output: []
-products: []
+{
+ "answer": "
",
+ "image_output": [],
+ "products": []
+}
+
+IMPORTANT: Your entire response must be valid JSON only (no extra text).
Shopper Agent Tool
diff --git a/src/requirements.txt b/src/requirements.txt
index 0ecf281..e6c378e 100644
--- a/src/requirements.txt
+++ b/src/requirements.txt
@@ -6,6 +6,7 @@ azure-identity==1.19.0
azure-search-documents==11.6.0
azure-ai-inference==1.0.0b6
azure-ai-projects>=2.0.0b1
+azure-ai-agents>=1.1.0
azure-storage-blob==12.19.0
openai==1.54.0
fastapi==0.115.0
diff --git a/src/services/azure_auth.py b/src/services/azure_auth.py
index 5bc00f2..1d4a7b6 100644
--- a/src/services/azure_auth.py
+++ b/src/services/azure_auth.py
@@ -2,6 +2,7 @@
import os
from dataclasses import dataclass
+import logging
from typing import Sequence
from azure.core.credentials import AccessToken, TokenCredential
@@ -11,6 +12,8 @@
COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default"
AI_FOUNDRY_SCOPE = "https://ai.azure.com/.default"
+logger = logging.getLogger("azure_auth")
+
@dataclass(frozen=True)
class FixedScopeTokenCredential(TokenCredential):
@@ -49,6 +52,7 @@ def get_inference_credential(
scope = COGNITIVE_SERVICES_SCOPE
if endpoint and "services.ai.azure.com" in endpoint:
scope = AI_FOUNDRY_SCOPE
+ logger.debug("Using token scope %s for endpoint %s", scope, endpoint)
return FixedScopeTokenCredential(default_credential, scope=scope)
diff --git a/src/services/handoff_service.py b/src/services/handoff_service.py
index 4393e2f..0ed0b47 100644
--- a/src/services/handoff_service.py
+++ b/src/services/handoff_service.py
@@ -4,6 +4,7 @@
"""
import os
import json
+import logging
from typing import Dict, Any, Optional
from pydantic import BaseModel
from azure.ai.inference import ChatCompletionsClient
@@ -37,6 +38,9 @@ class HandoffService:
def __init__(self):
"""Initialize the handoff service with GPT client"""
+ self.logger = logging.getLogger("handoff_service")
+ if os.getenv("A2A_DEBUG", "").lower() in {"1", "true", "yes"}:
+ logging.basicConfig(level=logging.DEBUG)
endpoint = (
os.getenv("gpt_endpoint")
or os.getenv("AZURE_OPENAI_ENDPOINT")
@@ -63,6 +67,7 @@ def __init__(self):
foundry_endpoint = foundry_endpoint.replace('.services.azure.com', '.services.ai.azure.com')
if not foundry_endpoint.endswith('/models'):
foundry_endpoint = f"{foundry_endpoint.rstrip('/')}/models"
+ self.foundry_endpoint = foundry_endpoint
if api_key:
credential = AzureKeyCredential(api_key)
@@ -177,6 +182,7 @@ def classify_intent(
}
except Exception as e:
+ self.logger.exception("Intent classification failed (endpoint=%s, deployment=%s)", self.foundry_endpoint, self.deployment)
# Default to cora on error
return {
"domain": "cora",
diff --git a/terraform-infrastructure/README.md b/terraform-infrastructure/README.md
index 9cb3228..9b19cf5 100644
--- a/terraform-infrastructure/README.md
+++ b/terraform-infrastructure/README.md
@@ -119,7 +119,7 @@ graph TD;
-

-
Refresh Date: 2026-01-30
+

+
Refresh Date: 2026-02-02
diff --git a/terraform-infrastructure/main.tf b/terraform-infrastructure/main.tf
index 5111645..c04c393 100644
--- a/terraform-infrastructure/main.tf
+++ b/terraform-infrastructure/main.tf
@@ -13,6 +13,9 @@ resource "random_id" "suffix" {
byte_length = 4
}
+# Stable GUID for custom role definitions
+resource "random_uuid" "maas_inference_role_id" {}
+
locals {
# Use provided user_principal_id or default to current Azure CLI user
principal_id = var.user_principal_id != null ? var.user_principal_id : data.azurerm_client_config.current.object_id
@@ -46,6 +49,30 @@ locals {
deploy_to_container_apps = var.deployment_target == "containerapps"
}
+# Custom role to allow Azure AI Foundry MaaS inference endpoints via AAD.
+# Some built-in roles don't include the MaaS dataActions required by the
+# /models/chat/completions endpoint.
+resource "azurerm_role_definition" "maas_inference_user" {
+ name = "${var.name_prefix}-${local.suffix}-maas-inference-user"
+ role_definition_id = random_uuid.maas_inference_role_id.result
+ scope = "/subscriptions/${data.azurerm_client_config.current.subscription_id}"
+ description = "Allows calling Azure AI Foundry MaaS chat/embeddings inference endpoints."
+
+ permissions {
+ actions = []
+ data_actions = [
+ "Microsoft.CognitiveServices/accounts/MaaS/chat/completions/action",
+ "Microsoft.CognitiveServices/accounts/MaaS/embeddings/action",
+ ]
+ not_actions = []
+ not_data_actions = []
+ }
+
+ assignable_scopes = [
+ "/subscriptions/${data.azurerm_client_config.current.subscription_id}",
+ ]
+}
+
resource "azurerm_cosmosdb_account" "cosmos" {
name = local.cosmos_account_name
location = azurerm_resource_group.rg.location
@@ -108,7 +135,8 @@ resource "azapi_resource" "storage" {
}
# AI Foundry account (preview) using AzAPI provider.
-# Using managed identity authentication (disableLocalAuth = true for better security)
+# Managed identity is used by the app, but local auth must remain enabled for some
+# automation steps (e.g., index/vectorizer configuration that requires an API key).
resource "azapi_resource" "ai_foundry" {
type = "Microsoft.CognitiveServices/accounts@2024-10-01"
name = local.ai_foundry_name
@@ -122,7 +150,8 @@ resource "azapi_resource" "ai_foundry" {
properties = {
allowProjectManagement = true
customSubDomainName = local.ai_foundry_name
- disableLocalAuth = true
+ disableLocalAuth = false
+ publicNetworkAccess = "Enabled"
}
})
}
@@ -348,6 +377,15 @@ resource "azurerm_container_app" "app" {
value = "8000"
}
+ env {
+ name = "A2A_DEBUG"
+ value = "true"
+ }
+ env {
+ name = "APP_BUILD_ID"
+ value = local.app_source_hash
+ }
+
env {
name = "USE_MULTI_AGENT"
value = var.enable_multi_agent ? "true" : "false"
@@ -362,12 +400,12 @@ resource "azurerm_container_app" "app" {
value = local.ai_project_name
}
env {
- name = "AZURE_AI_PROJECT_ENDPOINT"
- value = "https://${local.ai_foundry_name}.services.ai.azure.com/api/projects/${local.ai_project_name}"
+ name = "AZURE_AI_PROJECT_ENDPOINT"
+ secret_name = "agent-endpoint"
}
env {
- name = "AZURE_AI_AGENT_ENDPOINT"
- value = "https://${local.ai_foundry_name}.services.ai.azure.com/api/projects/${local.ai_project_name}"
+ name = "AZURE_AI_AGENT_ENDPOINT"
+ secret_name = "agent-endpoint"
}
env {
name = "AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME"
@@ -463,11 +501,24 @@ resource "azurerm_container_app" "app" {
name = "SEARCH_SERVICE_KEY"
secret_name = "search-service-key"
}
+ env {
+ name = "AZURE_OPENAI_API_KEY"
+ secret_name = "ai-foundry-key"
+ }
+ env {
+ name = "gpt_api_key"
+ secret_name = "ai-foundry-key"
+ }
env {
name = "STORAGE_CONNECTION_STRING"
secret_name = "storage-connection-string"
}
+ env {
+ name = "A2A_DEBUG"
+ value = "true"
+ }
+
# Cosmos auth: use AAD when local auth disabled
dynamic "env" {
for_each = var.enable_cosmos_local_auth ? [1] : []
@@ -480,7 +531,7 @@ resource "azurerm_container_app" "app" {
for_each = var.enable_cosmos_local_auth ? [] : [1]
content {
name = "COSMOS_DB_KEY"
- value = "AAD_AUTH"
+ value = ""
}
}
@@ -505,6 +556,10 @@ resource "azurerm_container_app" "app" {
name = "AGENT_CART_MANAGER_ID"
value = data.external.agents_state.result["agent_cart_manager_id"]
}
+ env {
+ name = "AGENT_PRODUCT_MANAGEMENT_ID"
+ value = data.external.agents_state.result["agent_product_management_id"]
+ }
env {
name = "cora"
value = data.external.agents_state.result["agent_cora_id"]
@@ -525,7 +580,14 @@ resource "azurerm_container_app" "app" {
name = "cart_manager"
value = data.external.agents_state.result["agent_cart_manager_id"]
}
+ env {
+ name = "product_management"
+ value = data.external.agents_state.result["agent_product_management_id"]
+ }
}
+
+ min_replicas = 1
+ max_replicas = 2
}
ingress {
@@ -553,6 +615,16 @@ resource "azurerm_container_app" "app" {
key_vault_secret_id = "${azurerm_key_vault.kv.vault_uri}secrets/storage-connection-string"
identity = azurerm_user_assigned_identity.containerapp_identity[0].id
}
+ secret {
+ name = "agent-endpoint"
+ key_vault_secret_id = "${azurerm_key_vault.kv.vault_uri}secrets/agent-endpoint"
+ identity = azurerm_user_assigned_identity.containerapp_identity[0].id
+ }
+ secret {
+ name = "ai-foundry-key"
+ key_vault_secret_id = "${azurerm_key_vault.kv.vault_uri}secrets/ai-foundry-key"
+ identity = azurerm_user_assigned_identity.containerapp_identity[0].id
+ }
dynamic "secret" {
for_each = var.enable_cosmos_local_auth ? [1] : []
content {
@@ -567,6 +639,10 @@ resource "azurerm_container_app" "app" {
azurerm_user_assigned_identity.containerapp_identity,
azurerm_role_assignment.kv_secrets_user_containerapp,
azurerm_role_assignment.containerapp_acr_pull,
+ azapi_resource.containerapp_cosmos_data_contributor,
+ azurerm_role_assignment.containerapp_foundry_openai_user,
+ azurerm_role_assignment.containerapp_project_openai_user,
+ azurerm_role_assignment.containerapp_project_ai_user,
null_resource.docker_image_build,
null_resource.set_kv_secrets,
null_resource.set_agent_kv_secrets
@@ -729,6 +805,7 @@ resource "azurerm_linux_web_app" "app" {
site_config {
always_on = true
http2_enabled = true
+ websockets_enabled = true
minimum_tls_version = "1.2"
# Ensure App Service waits for container readiness
health_check_path = "/health"
@@ -746,6 +823,8 @@ resource "azurerm_linux_web_app" "app" {
WEBSITES_ENABLE_APP_SERVICE_STORAGE = "false"
DOCKER_ENABLE_CI = "true"
WEBSITES_PORT = "8000"
+ A2A_DEBUG = "true"
+ APP_BUILD_ID = local.app_source_hash
# GPT Configuration (using managed identity)
gpt_endpoint = "https://${local.ai_foundry_name}.cognitiveservices.azure.com/"
@@ -766,7 +845,7 @@ resource "azurerm_linux_web_app" "app" {
# External Service Keys via Key Vault
SEARCH_SERVICE_KEY = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/search-admin-key)"
- COSMOS_DB_KEY = var.enable_cosmos_local_auth ? "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/cosmos-primary-key)" : "AAD_AUTH"
+ COSMOS_DB_KEY = var.enable_cosmos_local_auth ? "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/cosmos-primary-key)" : ""
STORAGE_CONNECTION_STRING = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/storage-connection-string)"
# Multi-Agent Configuration - Agent IDs from Key Vault
@@ -905,12 +984,43 @@ resource "null_resource" "set_kv_secrets" {
command = <<-EOT
$kv = "${azurerm_key_vault.kv.name}"
Write-Host "Setting Key Vault secrets (search/storage/cosmos/agent-endpoint)..."
+
+ # Foundry endpoint (authoritative) -> also derive Agents API endpoint
+ $rawAiFoundryEndpoint = az cognitiveservices account show `
+ --resource-group "${azurerm_resource_group.rg.name}" `
+ --name "${local.ai_foundry_name}" `
+ --query "properties.endpoint" `
+ --output tsv
+ $agentEndpointBase = $rawAiFoundryEndpoint -replace "cognitiveservices\.azure\.com", "services.ai.azure.com"
+ $agentEndpointBase = $agentEndpointBase.TrimEnd("/")
+ $agentsProjectEndpoint = "$agentEndpointBase/api/projects/${local.ai_project_name}"
+
az keyvault secret set --vault-name $kv --name "search-admin-key" --value "${jsondecode(data.azapi_resource_action.search_admin_keys[0].output).primaryKey}" | Out-Null
az keyvault secret set --vault-name $kv --name "storage-connection-string" --value "DefaultEndpointsProtocol=https;AccountName=${local.storage_account};AccountKey=${jsondecode(data.azapi_resource_action.storage_keys_unconditional.output).keys[0].value};EndpointSuffix=core.windows.net" | Out-Null
+ # Required for local automation/pipelines (some SDKs require an API key)
+ # The Foundry account can take a while to become terminal; retry key fetch.
+ $aiFoundryKey = $null
+ for ($i = 0; $i -lt 90; $i++) {
+ try {
+ $aiFoundryKey = az cognitiveservices account keys list `
+ --resource-group "${azurerm_resource_group.rg.name}" `
+ --name "${local.ai_foundry_name}" `
+ --query "key1" `
+ --output tsv
+ if ($aiFoundryKey) { break }
+ } catch {
+ # ignore and retry
+ }
+ Start-Sleep -Seconds 10
+ }
+ if (-not $aiFoundryKey) {
+ throw "Timed out waiting for AI Foundry keys. Try re-running terraform apply after the account finishes provisioning."
+ }
+ az keyvault secret set --vault-name $kv --name "ai-foundry-key" --value $aiFoundryKey | Out-Null
if (${var.enable_cosmos_local_auth ? "$true" : "$false"}) {
az keyvault secret set --vault-name $kv --name "cosmos-primary-key" --value "${jsondecode(data.azapi_resource_action.cosmos_keys[0].output).primaryMasterKey}" | Out-Null
}
- az keyvault secret set --vault-name $kv --name "agent-endpoint" --value "https://${local.ai_foundry_name}.services.ai.azure.com/api/projects/${local.ai_project_name}" | Out-Null
+ az keyvault secret set --vault-name $kv --name "agent-endpoint" --value $agentsProjectEndpoint | Out-Null
EOT
interpreter = ["PowerShell", "-Command"]
working_dir = path.module
@@ -1189,13 +1299,13 @@ locals {
# Assign Cosmos DB Built-in Data Contributor role to specified user principal
resource "azapi_resource" "cosmos_user_data_contributor" {
type = "Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-04-15"
- name = md5("${azurerm_cosmosdb_account.cosmos.id}-${local.principal_id}-${local.cosmos_db_data_contributor_role_id}")
+ name = md5("${azurerm_cosmosdb_account.cosmos.id}-${local.principal_id}-${local.cosmos_db_data_contributor_role_id}-${azurerm_cosmosdb_sql_database.cosmosdb.name}")
parent_id = azurerm_cosmosdb_account.cosmos.id
body = jsonencode({
properties = {
roleDefinitionId = "${azurerm_cosmosdb_account.cosmos.id}/sqlRoleDefinitions/${local.cosmos_db_data_contributor_role_id}"
principalId = local.principal_id
- scope = azurerm_cosmosdb_account.cosmos.id
+ scope = "${azurerm_cosmosdb_account.cosmos.id}/dbs/${azurerm_cosmosdb_sql_database.cosmosdb.name}"
}
})
}
@@ -1204,16 +1314,19 @@ resource "azapi_resource" "cosmos_user_data_contributor" {
resource "azapi_resource" "containerapp_cosmos_data_contributor" {
count = local.deploy_to_container_apps ? 1 : 0
type = "Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-04-15"
- name = md5("${azurerm_cosmosdb_account.cosmos.id}-${azurerm_user_assigned_identity.containerapp_identity[0].principal_id}-${local.cosmos_db_data_contributor_role_id}")
+ name = md5("${azurerm_cosmosdb_account.cosmos.id}-${azurerm_user_assigned_identity.containerapp_identity[0].principal_id}-${local.cosmos_db_data_contributor_role_id}-${azurerm_cosmosdb_sql_database.cosmosdb.name}")
parent_id = azurerm_cosmosdb_account.cosmos.id
body = jsonencode({
properties = {
roleDefinitionId = "${azurerm_cosmosdb_account.cosmos.id}/sqlRoleDefinitions/${local.cosmos_db_data_contributor_role_id}"
principalId = azurerm_user_assigned_identity.containerapp_identity[0].principal_id
- scope = azurerm_cosmosdb_account.cosmos.id
+ scope = "${azurerm_cosmosdb_account.cosmos.id}/dbs/${azurerm_cosmosdb_sql_database.cosmosdb.name}"
}
})
- depends_on = [azurerm_container_app.app]
+ depends_on = [
+ azurerm_user_assigned_identity.containerapp_identity,
+ azurerm_cosmosdb_sql_database.cosmosdb
+ ]
}
# Role assignments for Search managed identity
@@ -1226,26 +1339,26 @@ resource "azurerm_role_assignment" "search_cosmos_account_reader" {
resource "azapi_resource" "search_cosmos_data_reader" {
type = "Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-04-15"
- name = md5("${azurerm_cosmosdb_account.cosmos.id}-${azurerm_search_service.search.identity[0].principal_id}-${local.cosmos_db_data_reader_role_id}")
+ name = md5("${azurerm_cosmosdb_account.cosmos.id}-${azurerm_search_service.search.identity[0].principal_id}-${local.cosmos_db_data_reader_role_id}-${azurerm_cosmosdb_sql_database.cosmosdb.name}")
parent_id = azurerm_cosmosdb_account.cosmos.id
body = jsonencode({
properties = {
roleDefinitionId = "${azurerm_cosmosdb_account.cosmos.id}/sqlRoleDefinitions/${local.cosmos_db_data_reader_role_id}"
principalId = azurerm_search_service.search.identity[0].principal_id
- scope = azurerm_cosmosdb_account.cosmos.id
+ scope = "${azurerm_cosmosdb_account.cosmos.id}/dbs/${azurerm_cosmosdb_sql_database.cosmosdb.name}"
}
})
}
resource "azapi_resource" "search_cosmos_data_contributor" {
type = "Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-04-15"
- name = md5("${azurerm_cosmosdb_account.cosmos.id}-${azurerm_search_service.search.identity[0].principal_id}-${local.cosmos_db_data_contributor_role_id}")
+ name = md5("${azurerm_cosmosdb_account.cosmos.id}-${azurerm_search_service.search.identity[0].principal_id}-${local.cosmos_db_data_contributor_role_id}-${azurerm_cosmosdb_sql_database.cosmosdb.name}")
parent_id = azurerm_cosmosdb_account.cosmos.id
body = jsonencode({
properties = {
roleDefinitionId = "${azurerm_cosmosdb_account.cosmos.id}/sqlRoleDefinitions/${local.cosmos_db_data_contributor_role_id}"
principalId = azurerm_search_service.search.identity[0].principal_id
- scope = azurerm_cosmosdb_account.cosmos.id
+ scope = "${azurerm_cosmosdb_account.cosmos.id}/dbs/${azurerm_cosmosdb_sql_database.cosmosdb.name}"
}
})
}
@@ -1282,6 +1395,15 @@ resource "azurerm_role_assignment" "webapp_foundry_openai_user" {
depends_on = [azurerm_linux_web_app.app]
}
+resource "azurerm_role_assignment" "webapp_foundry_maas_inference_user" {
+ count = local.deploy_to_appservice ? 1 : 0
+ scope = azapi_resource.ai_foundry.id
+ role_definition_id = azurerm_role_definition.maas_inference_user.role_definition_resource_id
+ principal_id = data.azurerm_linux_web_app.app_identity[0].identity[0].principal_id
+ principal_type = "ServicePrincipal"
+ depends_on = [azurerm_linux_web_app.app, azapi_resource.ai_foundry, azurerm_role_definition.maas_inference_user]
+}
+
resource "azurerm_role_assignment" "webapp_project_openai_user" {
count = local.deploy_to_appservice ? 1 : 0
scope = azapi_resource.ai_project.id
@@ -1298,7 +1420,16 @@ resource "azurerm_role_assignment" "containerapp_foundry_openai_user" {
role_definition_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/providers/Microsoft.Authorization/roleDefinitions/${local.cognitive_openai_user_role_id}"
principal_id = azurerm_user_assigned_identity.containerapp_identity[0].principal_id
principal_type = "ServicePrincipal"
- depends_on = [azurerm_container_app.app]
+ depends_on = [azurerm_user_assigned_identity.containerapp_identity, azapi_resource.ai_foundry]
+}
+
+resource "azurerm_role_assignment" "containerapp_foundry_maas_inference_user" {
+ count = local.deploy_to_container_apps ? 1 : 0
+ scope = azapi_resource.ai_foundry.id
+ role_definition_id = azurerm_role_definition.maas_inference_user.role_definition_resource_id
+ principal_id = azurerm_user_assigned_identity.containerapp_identity[0].principal_id
+ principal_type = "ServicePrincipal"
+ depends_on = [azurerm_user_assigned_identity.containerapp_identity, azapi_resource.ai_foundry, azurerm_role_definition.maas_inference_user]
}
resource "azurerm_role_assignment" "containerapp_project_openai_user" {
@@ -1307,7 +1438,16 @@ resource "azurerm_role_assignment" "containerapp_project_openai_user" {
role_definition_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/providers/Microsoft.Authorization/roleDefinitions/${local.cognitive_openai_user_role_id}"
principal_id = azurerm_user_assigned_identity.containerapp_identity[0].principal_id
principal_type = "ServicePrincipal"
- depends_on = [azurerm_container_app.app]
+ depends_on = [azurerm_user_assigned_identity.containerapp_identity, azapi_resource.ai_project]
+}
+
+resource "azurerm_role_assignment" "containerapp_project_ai_user" {
+ count = local.deploy_to_container_apps ? 1 : 0
+ scope = azapi_resource.ai_project.id
+ role_definition_name = "Azure AI User"
+ principal_id = azurerm_user_assigned_identity.containerapp_identity[0].principal_id
+ principal_type = "ServicePrincipal"
+ depends_on = [azurerm_user_assigned_identity.containerapp_identity, azapi_resource.ai_project]
}
# Grant AcrPull role to Container App managed identity for ACR pulls
@@ -1381,7 +1521,7 @@ resource "null_resource" "ai_model_deployments" {
--model-name "gpt-4o-mini" `
--model-version "2024-07-18" `
--model-format "OpenAI" `
- --sku-capacity 10 `
+ --sku-capacity 1 `
--sku-name "GlobalStandard"
if ($LASTEXITCODE -eq 0) {
@@ -1463,7 +1603,7 @@ data "azapi_resource_action" "cosmos_keys" {
depends_on = [azurerm_cosmosdb_account.cosmos]
}
-# AI Foundry now uses managed identity authentication - no keys needed
+# AI Foundry uses managed identity for the app, but automation may still require keys
# Connect resources to MSFT Foundry project using ARM templates
resource "azapi_resource" "storage_connection" {
@@ -1679,7 +1819,7 @@ resource "null_resource" "create_env_file" {
$searchKey = az keyvault secret show --vault-name $kv --name search-admin-key --query value -o tsv
if (${var.enable_cosmos_local_auth ? "$true" : "$false"}) {
$cosmosKey = az keyvault secret show --vault-name $kv --name cosmos-primary-key --query value -o tsv
- } else { $cosmosKey = "AAD_AUTH" }
+ } else { $cosmosKey = "" }
$storageConnectionString = az keyvault secret show --vault-name $kv --name storage-connection-string --query value -o tsv
# Create .env file content
@@ -1691,7 +1831,7 @@ AZURE_AI_PROJECT_NAME=${local.ai_project_name}
AZURE_AI_AGENT_ENDPOINT=$agentsProjectEndpoint
# Azure OpenAI Model Deployments
- AZURE_OPENAI_CHAT_DEPLOYMENT=${var.chat_model_deployment}
+AZURE_OPENAI_CHAT_DEPLOYMENT=${var.chat_model_deployment}
AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-3-small
AZURE_OPENAI_ENDPOINT=$openAiEndpoint
AZURE_OPENAI_API_KEY=$aiFoundryKey
@@ -1699,7 +1839,7 @@ AZURE_OPENAI_API_VERSION=2024-02-01
# GPT Model Configuration (for single-agent chat)
gpt_endpoint=$openAiEndpoint
- gpt_deployment=${var.chat_model_deployment}
+gpt_deployment=${var.chat_model_deployment}
gpt_api_key=$aiFoundryKey
gpt_api_version=2024-02-01
@@ -1729,7 +1869,7 @@ AZURE_RESOURCE_GROUP=${azurerm_resource_group.rg.name}
AZURE_LOCATION=${var.location}
# Multi-Agent Configuration
-USE_MULTI_AGENT=true
+USE_MULTI_AGENT=${var.enable_multi_agent ? "true" : "false"}
AZURE_AI_PROJECT_ENDPOINT=$agentsProjectEndpoint
AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME=${var.chat_model_deployment}
@@ -2002,7 +2142,7 @@ resource "null_resource" "deploy_multi_agents" {
}
Write-Host "Installing required Azure SDK packages..."
- & $pythonCmd -m pip install -q --pre 'azure-ai-projects>=2.0.0b1' azure-identity python-dotenv
+ & $pythonCmd -m pip install -q --pre 'azure-ai-projects>=2.0.0b1' 'azure-ai-agents>=1.1.0' azure-identity python-dotenv
if ($LASTEXITCODE -ne 0) {
Write-Host "ERROR: Failed to install required packages"
@@ -2471,6 +2611,7 @@ aiosignal>=1.3.0
A2A_HOST=${var.a2a_host}
A2A_PORT=${var.a2a_port}
A2A_LOG_LEVEL=INFO
+A2A_DEBUG=true
# Base application URL for monitoring
BASE_APP_URL=https://${local.web_app_name}.azurewebsites.net
diff --git a/terraform-infrastructure/terraform.tfvars b/terraform-infrastructure/terraform.tfvars
index 1ed8bf8..381ba1a 100644
--- a/terraform-infrastructure/terraform.tfvars
+++ b/terraform-infrastructure/terraform.tfvars
@@ -1,4 +1,4 @@
-resource_group_name = "RG-AI-Retail-DemoX0"
+resource_group_name = "RG-AI-Retail-DemoX34"
location = "eastus2"
name_prefix = "zava"