Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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`)

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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__":
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down

This file was deleted.

Loading