From 4fb135a1d2c2e21f0f2bf38018b402dcd7fd3a7c Mon Sep 17 00:00:00 2001 From: Yimin Jin Date: Fri, 26 Jun 2026 11:30:33 +0800 Subject: [PATCH] Convert 09 declarative customer-support sample to a code-based routing agent The declarative workflow.yaml routes with Power Fx (=...) conditions, which require the powerfx package plus the .NET runtime. Neither is available in the code/zip hosted runtime (powerfx is not installed for Python >= 3.14, and the managed runtime has no .NET), so the sample fails at runtime with 'PowerFx is not available' unless deployed as a container. Replace the declarative workflow with an equivalent CustomerSupportAgent(BaseAgent) that performs the same triage routing in plain Python, drop the agent-framework-declarative dependency, and simplify the Dockerfile (no .NET). The sample now runs under code deployment with no PowerFx/.NET dependency. --- .../Dockerfile | 17 +-- .../09-declarative-customer-support/README.md | 26 +++-- .../agent.manifest.yaml | 6 +- .../09-declarative-customer-support/main.py | 100 +++++++++++++----- .../requirements.txt | 1 - .../workflow.yaml | 89 ---------------- 6 files changed, 91 insertions(+), 148 deletions(-) delete mode 100644 samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/workflow.yaml diff --git a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/Dockerfile b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/Dockerfile index 66d3b74ec..cfebf5857 100644 --- a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/Dockerfile +++ b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/Dockerfile @@ -1,22 +1,7 @@ -FROM python:3.12-slim +FROM python:3.14-slim WORKDIR /app -# .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. -RUN apt-get update \ - && apt-get install -y --no-install-recommends wget ca-certificates libicu-dev \ - && wget -qO /tmp/dotnet-install.sh https://dot.net/v1/dotnet-install.sh \ - && chmod +x /tmp/dotnet-install.sh \ - && /tmp/dotnet-install.sh --runtime dotnet --channel 10.0 --install-dir /usr/share/dotnet \ - && ln -s /usr/share/dotnet/dotnet /usr/local/bin/dotnet \ - && rm -f /tmp/dotnet-install.sh \ - && rm -rf /var/lib/apt/lists/* - -ENV DOTNET_ROOT=/usr/share/dotnet - COPY . user_agent/ WORKDIR /app/user_agent diff --git a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/README.md b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/README.md index 52c855eff..f3df82230 100644 --- a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/README.md +++ b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/README.md @@ -1,31 +1,29 @@ # 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()`). +A realistic **multi-turn** [Agent Framework](https://github.com/microsoft/agent-framework) customer-support triage agent, hosted using the **Responses protocol**. A small code-based router runs a triage agent on every user turn and dispatches to a technical or billing specialist — reading the prior conversation through the message history the hosting infrastructure replays on each turn. -> 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). - -> This sample currently requires using a Docker based deployment. +> Read more about [Agent Framework agents](https://learn.microsoft.com/en-us/agent-framework/user-guide/agents/?pivots=programming-language-python) and [hosting agents behind the Responses protocol](https://learn.microsoft.com/azure/ai-foundry/agents/concepts/hosted-agents). ## How It Works -### The Workflow +### The routing agent -[`workflow.yaml`](workflow.yaml) describes a customer-support triage flow: +[`main.py`](main.py) defines `CustomerSupportAgent`, a `BaseAgent` subclass that implements the triage flow in plain Python: -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. +1. It runs `TriageAgent`, which looks at the full conversation so far and emits a structured `TriageResponse` (`Category`, `NeedsClarification`, `ClarificationQuestion`, `Reply`). +2. It routes on the triage decision: + - **NeedsClarification** → asks one focused follow-up question and ends the turn. + - **Category = "Technical"** → confirms the handoff, then streams `TechSupportAgent`'s reply 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). + - **else** → 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. +Each user message re-runs the router from the top. The hosting infrastructure replays the prior turns of the conversation as the input messages, so every sub-agent 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. +[`main.py`](main.py) builds three `Agent` instances on top of a shared `FoundryChatClient` (one per role), composes them inside `CustomerSupportAgent`, and hands that 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. +The triage agent is configured with `response_format=TriageResponse` (a Pydantic model) so the router can read its structured fields via `result.value`. The specialist agents are plain text and stream their reply straight to the caller. ## Option 1: Azure Developer CLI (`azd`) diff --git a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/agent.manifest.yaml b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/agent.manifest.yaml index 1fbe741c9..59c44837c 100644 --- a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/agent.manifest.yaml +++ b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/agent.manifest.yaml @@ -1,14 +1,14 @@ name: agent-framework-declarative-customer-support-responses description: > - A multi-turn Agent Framework declarative (YAML-defined) customer-support - triage workflow hosted by Foundry. + A multi-turn Agent Framework customer-support triage agent that routes + between technical and billing specialists, hosted by Foundry. metadata: tags: - Agent Framework - AI Agent Hosting - Azure AI AgentServer - Responses Protocol - - Declarative Workflow + - Multi-agent Routing - Multi-turn template: name: agent-framework-declarative-customer-support-responses diff --git a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/main.py b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/main.py index 84f59a451..78e9a4305 100644 --- a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/main.py +++ b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/main.py @@ -1,12 +1,17 @@ # Copyright (c) Microsoft. All rights reserved. import os -from pathlib import Path from typing import Literal -from agent_framework import Agent +from agent_framework import ( + Agent, + AgentResponse, + AgentResponseUpdate, + BaseAgent, + Content, + Message, +) from agent_framework.foundry import FoundryChatClient -from agent_framework_declarative import WorkflowFactory from agent_framework_foundry_hosting import ResponsesHostServer from azure.identity import DefaultAzureCredential from dotenv import load_dotenv @@ -83,18 +88,71 @@ class TriageResponse(BaseModel): """.strip() +# --- Routing agent --------------------------------------------------------------- + +_TECH_HANDOFF = "Connecting you with technical support..." +_BILLING_HANDOFF = "Connecting you with billing support..." + + +class CustomerSupportAgent(BaseAgent): + """A code-based replacement for the declarative triage workflow. + + On every turn the hosting infrastructure passes the full conversation + history as ``messages``. We run the triage agent for a structured decision, + then route to a specialist or answer directly -- the same branching the + workflow.yaml ``ConditionGroup`` expressed, but in plain Python so no + PowerFx/.NET runtime is required. + """ + + def __init__(self, *, triage_agent: Agent, tech_support_agent: Agent, billing_agent: Agent, **kwargs) -> None: + super().__init__(**kwargs) + self._triage_agent = triage_agent + self._tech_support_agent = tech_support_agent + self._billing_agent = billing_agent + + def run(self, messages=None, *, stream: bool = False, session=None, **kwargs): + return self._run_stream(messages) if stream else self._run(messages) + + async def _route(self, messages) -> "tuple[str, Agent | None]": + """Map the triage decision to (text_to_send, specialist_or_None).""" + decision = (await self._triage_agent.run(messages)).value + if not isinstance(decision, TriageResponse): + return "Sorry, I couldn't process that. Could you rephrase?", None + if decision.NeedsClarification: + return decision.ClarificationQuestion or "Could you tell me a bit more?", None + if decision.Category == "Technical": + return _TECH_HANDOFF, self._tech_support_agent + if decision.Category == "Billing": + return _BILLING_HANDOFF, self._billing_agent + return decision.Reply or "", None + + async def _run(self, messages) -> AgentResponse: + text, specialist = await self._route(messages) + out = [Message(role="assistant", contents=[Content.from_text(text=text)])] + if specialist is not None: + out += (await specialist.run(messages)).messages + return AgentResponse(messages=out) + + async def _run_stream(self, messages): + text, specialist = await self._route(messages) + yield AgentResponseUpdate(role="assistant", contents=[Content.from_text(text=text)]) + if specialist is not None: + async for update in specialist.run(messages, stream=True): + yield update + + # --- 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. + # One agent per role, all sharing the same FoundryChatClient. The triage + # agent emits a structured TriageResponse; the specialists reply in plain + # text. History is managed by the hosting infrastructure, so store=False. triage_agent = Agent( client=client, name="TriageAgent", @@ -114,29 +172,21 @@ def main() -> None: default_options={"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", + # The routing agent re-runs triage on every turn with the full conversation + # history the host provides, then dispatches to a specialist or replies + # directly -- the same logic the workflow.yaml ConditionGroup expressed. + support_agent = CustomerSupportAgent( + triage_agent=triage_agent, + tech_support_agent=tech_support_agent, + billing_agent=billing_agent, + name="customer-support-triage", description=( - "A multi-turn customer-support triage workflow that routes " - "between technical and billing specialists based on the " - "conversation history." + "A multi-turn customer-support triage agent that routes between " + "technical and billing specialists based on the conversation history." ), ) - ResponsesHostServer(workflow_agent).run() + ResponsesHostServer(support_agent).run() if __name__ == "__main__": diff --git a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/requirements.txt b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/requirements.txt index c44de55a7..01f3e7bc9 100644 --- a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/requirements.txt +++ b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/requirements.txt @@ -1,6 +1,5 @@ # Use the narrow Foundry subpackages to keep dependencies light. agent-framework-foundry -agent-framework-declarative agent-framework-foundry-hosting mcp<2,>=1.24.0 diff --git a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/workflow.yaml b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/workflow.yaml deleted file mode 100644 index c4593d98d..000000000 --- a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/workflow.yaml +++ /dev/null @@ -1,89 +0,0 @@ -# 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