diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/.dockerignore b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/.dockerignore new file mode 100644 index 0000000000..31ed562a7e --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/.dockerignore @@ -0,0 +1,7 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +.env diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/.env.example b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/.env.example new file mode 100644 index 0000000000..2a38d9c9b8 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/.env.example @@ -0,0 +1,2 @@ +FOUNDRY_PROJECT_ENDPOINT="..." +AZURE_AI_MODEL_DEPLOYMENT_NAME="..." diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/Dockerfile b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/Dockerfile new file mode 100644 index 0000000000..82316e2210 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/Dockerfile @@ -0,0 +1,33 @@ +# .NET 10 runtime is required by the `powerfx` package, which +# agent-framework-declarative uses to evaluate `=...` expressions in the +# workflow YAML (e.g. =Local.Triage.NeedsClarification). Without it, +# ConditionGroup evaluation raises and the workflow produces no output. +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS dotnet + +FROM python:3.12-slim + +WORKDIR /app + +# libicu is required by the .NET runtime for globalization support. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates libicu-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy the pinned .NET runtime from the official image. +COPY --from=dotnet /usr/share/dotnet /usr/share/dotnet +RUN ln -s /usr/share/dotnet/dotnet /usr/local/bin/dotnet + +ENV DOTNET_ROOT=/usr/share/dotnet + +COPY . user_agent/ +WORKDIR /app/user_agent + +RUN if [ -f requirements.txt ]; then \ + pip install -r requirements.txt; \ + else \ + echo "No requirements.txt found"; \ + fi + +EXPOSE 8088 + +CMD ["python", "main.py"] diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/README.md new file mode 100644 index 0000000000..be3ff9a393 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/README.md @@ -0,0 +1,55 @@ +# What this sample demonstrates + +A realistic **multi-turn** [Agent Framework](https://github.com/microsoft/agent-framework) **declarative workflow** — defined entirely in YAML — hosted using the **Responses protocol**. It shows how a declarative workflow that invokes multiple Foundry-hosted agents can run end-to-end on every user turn while reading the prior conversation through `Conversation.messages` (populated automatically by `Workflow.as_agent()`). + +> Read more about declarative workflows in the [Agent Framework documentation](https://learn.microsoft.com/en-us/agent-framework/workflows/declarative/?pivots=programming-language-python) and about workflow-as-an-agent in the [Workflow as an Agent documentation](https://learn.microsoft.com/en-us/agent-framework/workflows/as-agents?pivots=programming-language-python). + +## How It Works + +### The Workflow + +[`workflow.yaml`](workflow.yaml) describes a customer-support triage flow: + +1. `InvokeAzureAgent: TriageAgent` — looks at the full conversation so far and emits a structured `TriageResponse` (`Category`, `NeedsClarification`, `ClarificationQuestion`, `Reply`). +2. `ConditionGroup` routes on the triage decision: + - **NeedsClarification** → `SendActivity` asks one focused follow-up question and ends the turn. + - **Category = "Technical"** → `SendActivity` confirms the handoff, then `InvokeAzureAgent: TechSupportAgent` answers with `autoSend: true` so its reply streams directly to the caller. + - **Category = "Billing"** → same pattern, routed to `BillingAgent`. + - **else** → `SendActivity` returns the triage agent's `Reply` directly (good for greetings or general questions). + +Each user message re-runs the workflow from the trigger. Because `Workflow.as_agent()` populates `Conversation.messages` with the prior turns of the conversation, every `InvokeAzureAgent` call sees the full history — which is what makes the triage decision and the specialist follow-ups coherent across turns. + +### Agent Hosting + +[`main.py`](main.py) builds three `Agent` instances on top of a shared `FoundryChatClient` (one per workflow role), registers them with the `WorkflowFactory` so the YAML's `InvokeAzureAgent` actions can resolve them by name, loads the workflow, wraps it with `.as_agent(...)`, and hands the agent to `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol. + +The triage agent is configured with `response_format=TriageResponse` (a Pydantic model) so the workflow can read its structured fields via `Local.Triage.*`. The specialist agents are plain text and use `autoSend: true` to deliver their reply straight to the caller. + +## Running the Agent Host + +Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host. + +## Interacting with the agent + +> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to the agent. + +Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. For example: + +```bash +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "I have a problem"}' +``` + +Invoke with `azd`: + +```bash +azd ai agent invoke --local "I was double-charged this month" +# → "Connecting you with billing support..." +# → BillingAgent: "I'm sorry about that. Can you share the last 4 digits of the card on file?" +``` + +## Deploying the Agent to Foundry + +To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory. + +> [!IMPORTANT] +> Deploy this sample as a **container** (not Code/ZIP). Its declarative workflow uses Power Fx, which needs the .NET runtime included in the `Dockerfile`. Choose **Container** in every deploy flow. \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/agent.manifest.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/agent.manifest.yaml new file mode 100644 index 0000000000..584120c871 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/agent.manifest.yaml @@ -0,0 +1,25 @@ +name: agent-framework-declarative-customer-support-responses +description: > + A multi-turn Agent Framework declarative (YAML-defined) customer-support + triage workflow hosted by Foundry. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Declarative Workflow + - Multi-turn +template: + name: agent-framework-declarative-customer-support-responses + kind: hosted + protocols: + - protocol: responses + version: 2.0.0 + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" +resources: + - kind: model + id: gpt-5.4-mini + name: AZURE_AI_MODEL_DEPLOYMENT_NAME diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/agent.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/agent.yaml new file mode 100644 index 0000000000..44ad3ede5b --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/agent.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: agent-framework-declarative-customer-support-responses +protocols: + - protocol: responses + version: 2.0.0 +resources: + cpu: '0.25' + memory: '0.5Gi' +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/main.py new file mode 100644 index 0000000000..6c6eba1e2a --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/main.py @@ -0,0 +1,145 @@ +# Copyright (c) Microsoft. All rights reserved. + +import os +from pathlib import Path +from typing import Any, Literal + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_declarative import WorkflowFactory +from agent_framework_foundry_hosting import ResponsesHostServer +from agent_framework_openai import OpenAIChatOptions +from azure.identity import DefaultAzureCredential +from dotenv import load_dotenv +from pydantic import BaseModel, Field + +# Load environment variables from .env file +load_dotenv() + + +# --- Structured triage response -------------------------------------------------- + + +class TriageResponse(BaseModel): + """Triage decision produced from the conversation so far.""" + + Category: Literal["Technical", "Billing", "General"] = Field( + description=( + "The best category for the user's request. " + "Use 'Technical' for hardware/software/network issues, " + "'Billing' for invoices/subscriptions/refunds, and " + "'General' for anything else (greetings, FAQs, small talk)." + ), + ) + NeedsClarification: bool = Field( + description=( + "True if you cannot confidently classify the request yet and " + "need to ask the user one focused follow-up question." + ), + ) + ClarificationQuestion: str = Field( + default="", + description=( + "A single, polite follow-up question to ask the user. " + "Required when NeedsClarification is true; otherwise empty." + ), + ) + Reply: str = Field( + default="", + description=( + "A natural-language reply to the user. Used when Category is 'General'; otherwise may be left empty." + ), + ) + + +# --- Agent instructions ---------------------------------------------------------- + +TRIAGE_INSTRUCTIONS = """ +You are the front-line triage agent for a customer support workflow. + +You will see the full conversation so far. Decide whether to: +- Ask the user one focused follow-up question (set NeedsClarification = true), or +- Route the conversation to the right specialist by setting Category, or +- Answer directly for general/small-talk requests via Reply. + +Be efficient: do not ask a clarification if a category is already clear. +""".strip() + +TECH_SUPPORT_INSTRUCTIONS = """ +You are a senior technical support specialist. The conversation history shows +what the user has told you so far and which steps were already attempted. + +Provide one concrete next troubleshooting step at a time, then wait for the +user's response. Be concise and friendly. If the issue appears resolved, +congratulate the user and ask if there's anything else. +""".strip() + +BILLING_INSTRUCTIONS = """ +You are a customer billing specialist. The conversation history shows what +the user has asked. + +Help the user with invoice, subscription, refund, and payment-method +questions. If you need account details (e.g., last 4 of card, account email), +ask for them one at a time. Keep responses short and polite. +""".strip() + + +# --- Host setup ------------------------------------------------------------------ + + +def main() -> None: + workflow_path = Path(__file__).parent / "workflow.yaml" + + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=DefaultAzureCredential(), + ) + + # The workflow's InvokeAzureAgent actions reference these agents by name. + triage_agent = Agent( + client=client, + name="TriageAgent", + instructions=TRIAGE_INSTRUCTIONS, + default_options=OpenAIChatOptions[Any](response_format=TriageResponse, store=False), + ) + tech_support_agent = Agent( + client=client, + name="TechSupportAgent", + instructions=TECH_SUPPORT_INSTRUCTIONS, + default_options=OpenAIChatOptions(store=False), + ) + billing_agent = Agent( + client=client, + name="BillingAgent", + instructions=BILLING_INSTRUCTIONS, + default_options=OpenAIChatOptions(store=False), + ) + + factory = WorkflowFactory( + agents={ + "TriageAgent": triage_agent, + "TechSupportAgent": tech_support_agent, + "BillingAgent": billing_agent, + }, + ) + + workflow = factory.create_workflow_from_yaml_path(str(workflow_path)) + + # Wrap the declarative workflow as an AIAgent so it can be served behind + # the Responses protocol. Each user turn re-runs the workflow with the + # full conversation history available via Conversation.messages. + workflow_agent = workflow.as_agent( + name="declarative-customer-support", + description=( + "A multi-turn customer-support triage workflow that routes " + "between technical and billing specialists based on the " + "conversation history." + ), + ) + + ResponsesHostServer(workflow_agent).run() + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/requirements.txt b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/requirements.txt new file mode 100644 index 0000000000..c4220da654 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/requirements.txt @@ -0,0 +1,7 @@ +agent-framework-declarative +agent-framework-foundry +agent-framework-foundry-hosting>=1.0.0a260630 +agent-framework-openai + +# debugpy enables local debugging of this agent with the Foundry Toolkit VS Code extension. +debugpy \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/workflow.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/workflow.yaml new file mode 100644 index 0000000000..c4593d98d3 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/13_declarative_customer_support/workflow.yaml @@ -0,0 +1,89 @@ +# A multi-turn customer-support triage workflow defined declaratively. +# +# Each user message re-runs the workflow with the full conversation history +# available to the invoked agents via Conversation.messages. The TriageAgent +# decides on every turn whether to ask a follow-up question, route to a +# technical specialist, route to a billing specialist, or close the ticket +# with a general response. + +kind: Workflow +trigger: + kind: OnConversationStart + id: customer_support_triage + actions: + + # Look at the full conversation so far and decide what to do. + # The agent's structured response drives the routing below. + # autoSend=false keeps the raw structured JSON out of the user-facing + # output stream — the ConditionGroup below is what produces the reply. + - kind: InvokeAzureAgent + id: triage + agent: + name: TriageAgent + output: + autoSend: false + responseObject: Local.Triage + + # Branch on the triage decision. + - kind: ConditionGroup + id: route + conditions: + + # Not enough information yet — ask the user a targeted follow-up. + - condition: =Local.Triage.NeedsClarification + id: ask_followup + actions: + - kind: SendActivity + id: send_followup + activity: + text: =Local.Triage.ClarificationQuestion + - kind: GotoAction + id: end_after_followup + actionId: all_done + + # Technical issue — hand off to the technical specialist. + # autoSend streams the agent's reply directly to the caller. + - condition: =Local.Triage.Category = "Technical" + id: route_technical + actions: + - kind: SendActivity + id: log_technical + activity: + text: "Connecting you with technical support..." + - kind: InvokeAzureAgent + id: tech_support + agent: + name: TechSupportAgent + output: + autoSend: true + - kind: GotoAction + id: end_after_technical + actionId: all_done + + # Billing issue — hand off to the billing specialist. + - condition: =Local.Triage.Category = "Billing" + id: route_billing + actions: + - kind: SendActivity + id: log_billing + activity: + text: "Connecting you with billing support..." + - kind: InvokeAzureAgent + id: billing_support + agent: + name: BillingAgent + output: + autoSend: true + - kind: GotoAction + id: end_after_billing + actionId: all_done + + # Default: a general question or chit-chat — send the triage reply. + elseActions: + - kind: SendActivity + id: send_general + activity: + text: =Local.Triage.Reply + + - kind: EndWorkflow + id: all_done