Skip to content
Open
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
@@ -0,0 +1,7 @@
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.Python
.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
FOUNDRY_PROJECT_ENDPOINT="..."
AZURE_AI_MODEL_DEPLOYMENT_NAME="..."
Original file line number Diff line number Diff line change
@@ -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/*
Comment thread
TaoChenOSU marked this conversation as resolved.

# 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"]
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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}
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading