Python: Improve python sample validation workflow (#7350)

* Add skill to replace hardcoded foundry project endpoint and model

* Include more samples and fix migration samples part 1

* Fix migration samples

* Replace Foundry hosted agent validation skill

* Fix hosted agent file sample

* Fix agent result format

* Reorganize jobs

* Update discovery heuristic for apps

* Split agents into even more jobs

* Add toolbox endpoint

* Add more pre configured resources

* Fix using deployed agent sample

* Add sample status

* Add playbook

* Exclude hidden folder in sample discovery

* Install autogen dependencies

* Grant azure search RBAC role

* Increase timeout for magentic

* Build search resouce id deterministically

* Remove grant in the workflow

* Move azure cli login closer to when the sample actually runs

* Refactor playbook

* Fix using deployed agent sample

* Actually save the playbooks

* Fix action syntax error

* Fix magentic sample

* Address copilot comments

* Fix link inspection

* Address comments

* Correct README

* Fix playbook path

* Remove trailing space
This commit is contained in:
Tao Chen
2026-08-03 15:28:53 -07:00
committed by GitHub
parent 18997c2fde
commit 8d379168b2
50 changed files with 1481 additions and 774 deletions
@@ -14,6 +14,7 @@ from agent_framework import (
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import GroupChatRequestSentEvent, MagenticBuilder, MagenticProgressLedger
from agent_framework_orchestrations import MagenticOrchestrator
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
@@ -113,17 +114,21 @@ async def main() -> None:
print("\nStarting workflow execution...")
# Keep track of the last executor to format output nicely in streaming mode
last_response_id: str | None = None
last_message_id: str | None = None
output_event: WorkflowEvent | None = None
async for event in workflow.run(task, stream=True):
if event.type in ("intermediate", "output") and isinstance(event.data, AgentResponseUpdate):
response_id = event.data.response_id
if response_id != last_response_id:
if last_response_id is not None:
print("\n")
print(f"- {event.executor_id}:", end=" ", flush=True)
last_response_id = response_id
print(event.data, end="", flush=True)
if event.type in ("intermediate", "output"):
if event.executor_id == MagenticOrchestrator.MANAGER_NAME:
output_event = event
else:
event_data = cast(AgentResponseUpdate, event.data)
message_id = event_data.message_id
if message_id != last_message_id:
if last_message_id is not None:
print("\n")
print(f"- {event.executor_id}:", end=" ", flush=True)
last_message_id = message_id
print(event_data, end="", flush=True)
elif event.type == "magentic_orchestrator":
print(f"\n[Magentic Orchestrator Event] Type: {event.data.event_type.name}")
@@ -137,21 +142,20 @@ async def main() -> None:
# Block to allow user to read the plan/progress before continuing
# Note: this is for demonstration only and is not the recommended way to handle human interaction.
# Please refer to `with_plan_review` for proper human interaction during planning phases.
await asyncio.get_event_loop().run_in_executor(None, input, "Press Enter to continue...")
# await asyncio.get_event_loop().run_in_executor(None, input, "Press Enter to continue...")
elif event.type == "group_chat" and isinstance(event.data, GroupChatRequestSentEvent):
print(f"\n[REQUEST SENT ({event.data.round_index})] to agent: {event.data.participant_name}")
elif event.type == "output":
output_event = event
if not output_event:
raise RuntimeError("Workflow did not produce a final output event.")
if output_event:
# The output of the magentic workflow is a collection of chat messages from all participants
outputs = cast(list[Message], output_event.data)
print("\n" + "=" * 80)
print("\nFinal Conversation Transcript:\n")
for message in outputs:
print(f"{message.author_name or message.role}: {message.text}\n")
print("\n\nWorkflow completed!")
print("Final Output:")
# The output of the Magentic workflow is an AgentResponse or AgentResponseUpdate,
# which contains the final message text.
output_message = cast(AgentResponseUpdate, output_event.data)
print(output_message.text)
if __name__ == "__main__":
@@ -19,11 +19,10 @@ This directory contains samples that demonstrate how to use hosted [Agent Framew
| 6 | [Files](responses/files/) | An agent demonstrating how to work with files in a hosted agent session, including uploading files to a hosted agent session and having the agent read and manipulate those files at runtime. |
| 7 | [Observability](responses/observability/) | A sample demonstrating how to enable observability for the agent deployed to Foundry. |
| 8 | [Azure AI Search RAG](responses/azure_search_rag/) | An agent with Retrieval Augmented Generation (RAG) capabilities backed by Azure AI Search, grounding answers in documents indexed in a pre-provisioned search index. |
| 9 | [Foundry Skills](responses/foundry_skills/) | An agent that uploads `SKILL.md` files to the Foundry Skills REST API and downloads them at startup, decoupling tone/policy guidelines from agent code. |
| 10 | [Foundry Memory](responses/foundry_memory/) | An agent with persistent semantic memory backed by a Microsoft Foundry Memory Store, using `FoundryMemoryProvider` to remember user facts across sessions. |
| 11 | [Monty CodeAct](responses/monty_codeact/) | An agent with a Monty-backed CodeAct context provider, exposing a single `execute_code` tool that runs Python in a [pydantic-monty](https://github.com/pydantic/monty) interpreter and invokes typed host tools (`compute`, `fetch_data`) from inside the sandbox. Uses the beta `agent-framework-monty` package. |
| 12 | [Foundry Toolbox MCP Skills](responses/foundry_toolbox_mcp_skills/) | An agent that discovers MCP-based skills attached to a Foundry Toolbox and serves them via `SkillsProvider(MCPSkillsSource(...))`, fetching `SKILL.md` bodies and supplementary resources on demand. |
| 13 | [Using deployed agent](responses/using_deployed_agent.py) | A sample demonstrating how to invoke an agent that has already been deployed to Foundry, showing how to interact with a hosted agent in code. |
| 9 | [Foundry Memory](responses/foundry_memory/) | An agent with persistent semantic memory backed by a Microsoft Foundry Memory Store, using `FoundryMemoryProvider` to remember user facts across sessions. |
| 10 | [Monty CodeAct](responses/monty_codeact/) | An agent with a Monty-backed CodeAct context provider, exposing a single `execute_code` tool that runs Python in a [pydantic-monty](https://github.com/pydantic/monty) interpreter and invokes typed host tools (`compute`, `fetch_data`) from inside the sandbox. Uses the beta `agent-framework-monty` package. |
| 11 | [Foundry Toolbox MCP Skills](responses/foundry_toolbox_mcp_skills/) | An agent that discovers MCP-based skills attached to a Foundry Toolbox and serves them via `SkillsProvider(MCPSkillsSource(...))`, fetching `SKILL.md` bodies and supplementary resources on demand. |
| 12 | [Using deployed agent](responses/using_deployed_agent.py) | A sample demonstrating how to invoke an agent that has already been deployed to Foundry, showing how to interact with a hosted agent in code. |
### Invocations API
@@ -2,69 +2,16 @@
import asyncio
import os
from collections.abc import Callable
from urllib.parse import urlsplit
import httpx
from agent_framework import Agent, MCPStreamableHTTPTool, tool
from agent_framework.foundry import FoundryChatClient, ResponsesHostServer
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
from agent_framework import Agent, tool
from agent_framework.foundry import FoundryChatClient, FoundryToolbox, ResponsesHostServer
from azure.identity import DefaultAzureCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
def resolve_toolbox_endpoint() -> str:
"""Resolve the toolbox MCP endpoint URL.
Prefers the explicit ``TOOLBOX_ENDPOINT`` env var (set in ``agent.yaml`` or
``agent.manifest.yaml`` and via ``azd env set TOOLBOX_ENDPOINT`` after the toolbox
is created); falls back to constructing the URL from ``FOUNDRY_PROJECT_ENDPOINT``
and ``TOOLBOX_NAME``.
"""
if (endpoint := os.environ.get("TOOLBOX_ENDPOINT")) is not None:
if not endpoint:
raise ValueError("TOOLBOX_ENDPOINT is set but empty")
return endpoint
try:
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/")
toolbox_name = os.environ["TOOLBOX_NAME"]
except KeyError as e:
raise ValueError(
"Either set TOOLBOX_ENDPOINT, or set both FOUNDRY_PROJECT_ENDPOINT "
"and TOOLBOX_NAME to build the toolbox MCP endpoint."
) from e
return f"{project_endpoint}/toolboxes/{toolbox_name}/mcp?api-version=v1"
def _toolbox_name_from_endpoint(endpoint: str) -> str:
"""Extract the toolbox name from a toolbox MCP endpoint URL.
Handles both the versioned (``.../toolboxes/<name>/versions/<n>/mcp``) and
unversioned (``.../toolboxes/<name>/mcp``) endpoint shapes that Foundry
produces. Falls back to ``"toolbox"`` when the path has no ``toolboxes``
segment.
"""
segments = urlsplit(endpoint).path.split("/")
if "toolboxes" in segments:
idx = segments.index("toolboxes")
if idx + 1 < len(segments) and segments[idx + 1]:
return segments[idx + 1]
return "toolbox"
class ToolboxAuth(httpx.Auth):
"""Injects a fresh bearer token on every request."""
def __init__(self, token_provider: Callable[[], str]):
self._get_token = token_provider
def auth_flow(self, request: httpx.Request):
request.headers["Authorization"] = f"Bearer {self._get_token()}"
yield request
@tool(description="Get the current working directory.", approval_mode="never_require")
def get_cwd() -> str:
"""Get the current working directory."""
@@ -96,48 +43,35 @@ def read_file(file_path: str) -> str:
async def main():
credential = DefaultAzureCredential()
# Create the toolbox
token_provider = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
# FoundryToolbox resolves the toolbox endpoint from the environment
# (TOOLBOX_ENDPOINT, or FOUNDRY_PROJECT_ENDPOINT + TOOLBOX_NAME), authenticates
# every request with the credential, and transparently forwards the platform
# per-request call-id to the toolbox. The hosting server enters the agent, which
# connects the toolbox on first use and closes it at shutdown.
toolbox = FoundryToolbox(credential)
# Resolve the endpoint once and derive a friendly tool name from it. When
# ``TOOLBOX_NAME`` isn't set, extract the toolbox name from the URL path so
# the tool's local name matches the upstream toolbox.
toolbox_endpoint = resolve_toolbox_endpoint()
toolbox_name = os.environ.get("TOOLBOX_NAME") or _toolbox_name_from_endpoint(toolbox_endpoint)
# Create the chat client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=credential,
)
async with httpx.AsyncClient(
auth=ToolboxAuth(token_provider),
timeout=120.0,
) as http_client:
toolbox = MCPStreamableHTTPTool(
name=toolbox_name,
url=toolbox_endpoint,
http_client=http_client,
load_prompts=False,
)
# Create the chat client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=credential,
)
agent = Agent(
client=client,
instructions=(
"You are a friendly assistant. Keep your answers brief. "
"Make sure all mathematical calculations are performed using the code interpreter "
"instead of mental arithmetic."
),
tools=[get_cwd, list_files, read_file, toolbox],
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
server = ResponsesHostServer(agent)
await server.run_async()
agent = Agent(
client=client,
instructions=(
"You are a friendly assistant. Keep your answers brief. "
"Make sure all mathematical calculations are performed using the code interpreter "
"instead of mental arithmetic."
),
tools=[get_cwd, list_files, read_file, toolbox],
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
server = ResponsesHostServer(agent)
await server.run_async()
if __name__ == "__main__":
@@ -1,10 +0,0 @@
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.Python
.env
provision_skills.py
skills
downloaded_skills
@@ -1,6 +0,0 @@
FOUNDRY_PROJECT_ENDPOINT="..."
AZURE_AI_MODEL_DEPLOYMENT_NAME="..."
# Comma-separated list of Foundry skill names to download at startup.
SKILL_NAMES="support-style,escalation-policy"
# Optional writable directory for downloaded skills. Defaults to the system temp directory.
# DOWNLOADED_SKILLS_DIR="/tmp/maf_downloaded_skills"
@@ -1 +0,0 @@
downloaded_skills/
@@ -1,16 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
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"]
@@ -1,139 +0,0 @@
# What this sample demonstrates
An [Agent Framework](https://github.com/microsoft/agent-framework) agent that loads its behavioral guidelines from [**Foundry Skills**](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/skills?view=foundry&pivots=python) at startup, hosted using the **Responses protocol**. Skills are authored once as `SKILL.md` files, uploaded to your Foundry project through `AIProjectClient.beta.skills`, and downloaded by the agent on boot so updates ship without code changes.
## How It Works
### Authoring skills
Each skill is a Markdown file with a YAML front matter block. This sample ships two source skills under [`skills/`](skills/):
| Skill | Purpose |
|---|---|
| [`support-style`](skills/support-style/SKILL.md) | Voice, formatting, and signature rules for Contoso Outdoors support replies. |
| [`escalation-policy`](skills/escalation-policy/SKILL.md) | When and how to escalate a customer ticket. |
Each `SKILL.md` includes a unique `*-CANARY-*` token that the model is asked to echo, so you can prove the skill was loaded from Foundry (not hallucinated) by checking the response.
> The `name` and `description` values in the YAML front matter must be **unquoted** — quoting them causes the Skills REST API to return HTTP 500 on import.
### Uploading skills with `AIProjectClient`
[`provision_skills.py`](provision_skills.py) walks `skills/*/SKILL.md`, packages each file as an in-memory ZIP (with `SKILL.md` at the archive root), and imports it through [`AIProjectClient.beta.skills.create_from_package`](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/skills?view=foundry&pivots=python#option-2-import-from-a-skillmd-zip). The client is constructed with `allow_preview=True` (Skills is a preview feature) and authenticates with `DefaultAzureCredential`. Existing skills are deleted first via `beta.skills.delete` so the script is safe to re-run after editing a `SKILL.md`, and `beta.skills.list` is called at the end to verify each skill round-trips.
### Downloading skills at agent startup
[`main.py`](main.py) reads the comma-separated `SKILL_NAMES` env var, opens an `AIProjectClient` (also with `allow_preview=True`), and for each skill name streams the ZIP archive from `beta.skills.download(name)` and unpacks it into a **separate writable runtime directory**. By default this directory is created under the system temp folder as `maf_downloaded_skills/<name>/`, which works in hosted containers where the application directory may be read-only. Set `DOWNLOADED_SKILLS_DIR` to override the location.
A [`SkillsProvider`](../../../../../packages/core/agent_framework/_skills.py) is then built over the downloaded skills directory and attached to the `Agent` as a context provider. The provider follows the [Agent Skills](https://agentskills.io/) progressive-disclosure pattern:
1. **Advertise** — skill names and descriptions are injected into the system prompt at session start (~100 tokens per skill).
2. **Load** — the model calls the `load_skill` tool when it decides a skill is relevant to the user's turn, and the full `SKILL.md` body is returned.
This means the model only pays the token cost for a skill's full body when it actually needs it, and updating a skill in Foundry + restarting the agent is enough to pick up the change — no code redeploy required.
### Agent Hosting
The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol.
## Prerequisites
- A Microsoft Foundry project with a deployed model (e.g., `gpt-4.1-mini`)
- Azure CLI logged in (`az login`)
### Required RBAC
Your identity (or the Managed Identity running the container in production) needs **Azure AI User** on the Foundry project scope. This single role covers both authoring skills with `provision_skills.py` and downloading them from `main.py`.
## Provisioning the skills (one time)
From this directory, with the venv activated and `az login` done:
```bash
export FOUNDRY_PROJECT_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>"
python provision_skills.py
```
Or in PowerShell:
```powershell
$env:FOUNDRY_PROJECT_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>"
python provision_skills.py
```
Expected output:
```text
Provisioning skill 'escalation-policy' from skills/escalation-policy/SKILL.md...
Imported skill 'escalation-policy' (id=skill_..., has_blob=True).
Provisioning skill 'support-style' from skills/support-style/SKILL.md...
Imported skill 'support-style' (id=skill_..., has_blob=True).
Done.
```
Re-running the script after editing a `SKILL.md` re-imports the skill, replacing the previous version.
> To remove a skill manually, call `project.beta.skills.delete("<name>")` on an `AIProjectClient` constructed with `allow_preview=True`.
## 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.
In addition to the standard environment variables, this sample requires:
```bash
export SKILL_NAMES="support-style,escalation-policy"
```
Or in PowerShell:
```powershell
$env:SKILL_NAMES="support-style,escalation-policy"
```
You can also place these in a `.env` file next to `main.py` — see [`.env.example`](.env.example).
On startup you should see:
```text
Downloading skill 'support-style' from Foundry...
Downloading skill 'escalation-policy' from Foundry...
```
The downloaded `SKILL.md` files land under `DOWNLOADED_SKILLS_DIR/<name>/SKILL.md`. The directory is recreated from scratch on every run, so deleting it manually is never necessary.
By default, the sample uses the system temp directory, for example `/tmp/maf_downloaded_skills` on Linux. To choose a different writable location, set `DOWNLOADED_SKILLS_DIR` before startup.
## 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": "Hi, I am Alex. I just want to confirm I can return my tent within 30 days."}'
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "I want a $750 refund on Order #A-1042 right now or I am calling my lawyer."}'
```
| Prompt mentions | Skill that should drive the response |
|---|---|
| Routine return / shipping / care question | Model loads `support-style` (canary `STYLE-CANARY-3318`) — no escalation. |
| Injury, legal threat, press, or refund > $500 | Model loads `escalation-policy` (canary `ESC-CANARY-7742`) **and** `support-style`. |
Because skills are loaded on demand, the canary token in a response also proves the model actually invoked `load_skill` for the matching skill (not just saw its name in the advertised list).
## 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.
When deploying, make sure `SKILL_NAMES` is set in your `azd` environment so it gets injected into the hosted container per [`agent.manifest.yaml`](agent.manifest.yaml):
```bash
azd env set SKILL_NAMES "support-style,escalation-policy"
```
If it is not set, running `azd ai agent init -m <agent.manifest.yaml>` will prompt you to enter it interactively.
The deployed agent's Managed Identity needs **Azure AI User** on the Foundry project to download skills at startup. Make sure you have run `provision_skills.py` against the same Foundry project before deploying — otherwise the agent will fail to start with HTTP 404 on the skill download.
> The `skills/` source folder is **not** deployed to Foundry — only the downloaded skills are used at runtime. The `provision_skills.py` step is required to upload the skills to Foundry before the agent can download them.
@@ -1,32 +0,0 @@
name: agent-framework-agent-foundry-skills-responses
description: >
An Agent Framework agent that downloads its instructions from the Foundry
Skills REST API at startup, demonstrating how to decouple behavioral
guidelines (tone, escalation policy, etc.) from agent code.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Foundry Skills
template:
name: agent-framework-agent-foundry-skills-responses
kind: hosted
protocols:
- protocol: responses
version: 2.0.0
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
- name: SKILL_NAMES
value: "{{SKILL_NAMES}}"
parameters:
properties:
- name: SKILL_NAMES
secret: false
description: Comma-separated list of Foundry skill names to download at startup (e.g., support-style,escalation-policy)
resources:
- kind: model
id: gpt-4.1-mini
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
@@ -1,14 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: agent-framework-agent-foundry-skills-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}
- name: SKILL_NAMES
value: ${SKILL_NAMES}
@@ -1,115 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Foundry Skills hosted agent sample.
At startup, this agent downloads each Foundry Skill named in
``SKILL_NAMES`` from the project's ``beta.skills`` API, unpacks each
one into a separate writable runtime directory and wires
that directory into a :class:`SkillsProvider` so the agent advertises the
skills to the model and loads them on demand (progressive disclosure).
Upload the skills to Foundry once with ``provision_skills.py`` before running
this sample.
"""
import asyncio
import io
import logging
import os
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import Final
from agent_framework import Agent, SkillsProvider
from agent_framework.foundry import FoundryChatClient, ResponsesHostServer
from azure.ai.projects.aio import AIProjectClient
from azure.identity.aio import DefaultAzureCredential
from dotenv import load_dotenv
load_dotenv()
# Runtime directory where skills downloaded from Foundry are unpacked.
# Kept separate from the static ``skills/`` source folder so the two never
# get confused: the source folder is the input to ``provision_skills.py``
# and the runtime folder is the output of this script's bootstrap step.
# Defaults to a system temp location because hosted containers may mount the
# application directory read-only. Set DOWNLOADED_SKILLS_DIR to override it.
_DEFAULT_DOWNLOADED_SKILLS_DIR: Final = Path(tempfile.gettempdir()) / "maf_downloaded_skills"
_DOWNLOADED_SKILLS_DIR_ENV = os.environ.get("DOWNLOADED_SKILLS_DIR")
DOWNLOADED_SKILLS_DIR: Final = Path((_DOWNLOADED_SKILLS_DIR_ENV or "").strip() or _DEFAULT_DOWNLOADED_SKILLS_DIR)
logger = logging.getLogger(__name__)
def _safe_extract_zip(zf: zipfile.ZipFile, dest_dir: Path) -> None:
"""Extract ``zf`` into ``dest_dir``, rejecting entries that escape it (zip-slip guard)."""
dest_root = dest_dir.resolve()
for member in zf.infolist():
member_path = (dest_root / member.filename).resolve()
if dest_root != member_path and dest_root not in member_path.parents:
raise RuntimeError(f"Refusing to extract unsafe path '{member.filename}' outside of '{dest_root}'.")
zf.extractall(dest_dir)
async def _bootstrap_skills(endpoint: str, skill_names: list[str], target_dir: Path) -> None:
"""Download each named skill via ``project.beta.skills`` and unpack it as ``<target_dir>/<name>/SKILL.md``."""
if target_dir.exists(): # noqa: ASYNC240
shutil.rmtree(target_dir)
target_dir.mkdir(parents=True) # noqa: ASYNC240
async with (
DefaultAzureCredential() as credential,
AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project,
):
for name in skill_names:
logger.info(f"Downloading skill '{name}' from Foundry...")
stream = await project.beta.skills.download(name)
zip_bytes = b"".join([chunk async for chunk in stream])
skill_dir = target_dir / name
skill_dir.mkdir()
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
_safe_extract_zip(zf, skill_dir)
if not (skill_dir / "SKILL.md").is_file():
raise RuntimeError(f"Downloaded archive for '{name}' did not contain a SKILL.md at the root.")
async def main() -> None:
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
skill_names = [name.strip() for name in os.environ["SKILL_NAMES"].split(",") if name.strip()]
if not skill_names:
raise RuntimeError("SKILL_NAMES must list at least one skill name.")
# Pull the latest copy of each skill from Foundry into a runtime-only folder.
await _bootstrap_skills(project_endpoint, skill_names, DOWNLOADED_SKILLS_DIR)
# Build a SkillsProvider over the unpacked folder. The provider advertises
# each skill's name + description to the model and exposes the ``load_skill``
# tool the model uses to retrieve the full SKILL.md body on demand. No
# script_runner is configured because the skills in this sample are
# instruction-only.
skills_provider = SkillsProvider.from_paths(skill_paths=str(DOWNLOADED_SKILLS_DIR))
async with DefaultAzureCredential() as credential:
client = FoundryChatClient(
project_endpoint=project_endpoint,
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=credential,
)
agent = Agent(
client=client,
instructions="You are a customer-support assistant for Contoso Outdoors.",
context_providers=[skills_provider],
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
server = ResponsesHostServer(agent)
await server.run_async()
if __name__ == "__main__":
asyncio.run(main())
@@ -1,96 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Provision Foundry Skills used by this sample.
For each ``skills/<name>/SKILL.md`` file in this directory, this script packages
the file as an in-memory ZIP and imports it through the Foundry project's
:class:`~azure.ai.projects.aio.AIProjectClient` so the skill becomes downloadable
by any hosted agent in the project.
If a skill with the same name already exists in Foundry, it is deleted first
so the script is safe to re-run after editing a ``SKILL.md`` file.
Usage (from this directory, with the venv activated and ``az login`` done):
python provision_skills.py
Required env vars (also read from a local ``.env`` file if present):
FOUNDRY_PROJECT_ENDPOINT e.g. https://<account>.services.ai.azure.com/api/projects/<project>
Your identity needs the ``Azure AI User`` role on the Foundry project.
"""
import asyncio
import io
import os
import zipfile
from pathlib import Path
from azure.ai.projects.aio import AIProjectClient
from azure.ai.projects.models import CreateSkillVersionFromFilesBody
from azure.core.exceptions import ResourceNotFoundError
from azure.identity.aio import DefaultAzureCredential
from dotenv import load_dotenv
SKILLS_DIR = Path(__file__).parent / "skills"
def _zip_skill_md(skill_md: Path) -> bytes:
"""Return the bytes of a ZIP archive containing ``SKILL.md`` at the root."""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("SKILL.md", skill_md.read_text(encoding="utf-8"))
return buffer.getvalue()
async def _delete_skill_if_exists(project: AIProjectClient, name: str) -> None:
try:
await project.beta.skills.delete(name)
except ResourceNotFoundError:
return
print(f" Deleted existing skill '{name}'.")
async def main() -> None:
load_dotenv()
endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
skill_files = sorted(SKILLS_DIR.glob("*/SKILL.md"))
if not skill_files:
raise RuntimeError(f"No SKILL.md files found under {SKILLS_DIR}.")
async with (
DefaultAzureCredential() as credential,
AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project,
):
for skill_md in skill_files:
name = skill_md.parent.name
print(f"Provisioning skill '{name}' from {skill_md.relative_to(SKILLS_DIR.parent)}...")
await _delete_skill_if_exists(project, name)
imported = await project.beta.skills.create_from_files(
name,
content=CreateSkillVersionFromFilesBody(
files=[(f"{name}.zip", _zip_skill_md(skill_md), "application/zip")]
),
)
print(f" Imported skill '{imported.name}' (id={imported.skill_id}, version={imported.version}).")
print("Verifying skills via project.beta.skills.list()...")
listed = {skill.name: skill async for skill in project.beta.skills.list()}
for skill_md in skill_files:
name = skill_md.parent.name
skill = listed.get(name)
if skill is None:
raise RuntimeError(f"Skill '{name}' was imported but is not present in the project listing.")
print(
f" OK '{skill.name}': id={skill.id}, "
f"description={skill.description!r}, default_version={skill.default_version}"
)
print("Done.")
if __name__ == "__main__":
asyncio.run(main())
@@ -1,3 +0,0 @@
agent-framework-foundry
agent-framework-foundry-hosting>=1.0.0a260630
azure-ai-projects
@@ -1,30 +0,0 @@
---
name: escalation-policy
description: When and how to escalate Contoso Outdoors customer-support tickets.
---
# Contoso Outdoors Escalation Policy
You must follow this escalation policy on every conversation.
## Escalate immediately when the customer
- Reports an injury, allergic reaction, or other safety incident.
- Mentions legal action, regulators, or the press.
- Has waited more than 14 days for a refund that was already approved.
- Requests a refund larger than $500.
## How to escalate
1. Acknowledge the issue in one sentence.
2. Tell the customer you are escalating to a senior specialist.
3. Provide the escalation reference `ESC-CANARY-7742` and the SLA: a senior
specialist will reply within 1 business day.
4. Do not promise a specific outcome (refund, replacement, compensation) on
escalated tickets — only the senior specialist can commit to one.
## Do not escalate
- Routine returns within the standard 30-day window.
- Shipping status questions.
- Product care and usage questions.
@@ -1,25 +0,0 @@
---
name: support-style
description: Contoso Outdoors customer-support tone and formatting guidelines.
---
# Contoso Outdoors Support Style
You are speaking on behalf of Contoso Outdoors customer support.
## Voice
- Warm, concise, and confident — never apologetic in a hand-wringing way.
- Use the customer's name when it is known.
- Sign every response with `— Contoso Outdoors Support`.
## Formatting
- Keep replies to 13 short paragraphs unless the customer asks for detail.
- Use bullet lists only when enumerating concrete steps or options.
- Always reference order numbers as `Order #<id>` (e.g. `Order #A-1042`).
## Canary
To prove this skill was loaded, include the token `STYLE-CANARY-3318` on a
separate line at the bottom of every response, prefixed with `# `.
@@ -11,13 +11,9 @@ The `FoundryToolbox` is attached to the agent and its skills are exposed through
1. **Advertise** — each skill's name and description are injected into the system prompt so the model knows what is available (~100 tokens per skill).
2. **Load** — when the model decides a skill is relevant, it retrieves the full `SKILL.md` body on demand via `resources/read`.
> The Agent Skills spec defines a third stage — **read resources** — where a skill fetches supplementary files (reference documents, assets) on demand. That stage requires a skill to bundle sibling resources, which Foundry serves as a `type: archive` (ZIP) skill. To keep this sample focused on the advertise + load flow, both skills are single-file `SKILL.md` skills (no bundled resources). See the [`foundry_skills`](../foundry_skills/README.md) sample for the same instruction-only pattern via direct download.
> The Agent Skills spec defines a third stage — **read resources** — where a skill fetches supplementary files (reference documents, assets) on demand. That stage requires a skill to bundle sibling resources, which Foundry serves as a `type: archive` (ZIP) skill. To keep this sample focused on the advertise + load flow, both skills are single-file `SKILL.md` skills (no bundled resources).
## Toolbox MCP skills vs. Foundry Skills
Foundry exposes skills in two ways, and this sample uses the second one.
**Foundry Skills** are downloaded directly into an agent: the agent pulls each `SKILL.md` from the Skills API at startup and serves the bodies from local files. See the [`foundry_skills`](../foundry_skills/README.md) sample.
## Toolbox MCP skills
**Toolbox MCP skills** are accessed through a toolbox over the MCP protocol. A toolbox bundles a curated set of skills (and optionally tools) behind one MCP endpoint, and any MCP client discovers them automatically. Skill bodies are fetched on demand. The same `SKILL.md` files power both modes — the difference is only in delivery.
@@ -37,8 +33,6 @@ The agent is hosted with the `ResponsesHostServer`, which provisions a REST API
## The bundled skills
This sample ships two source skills under [`skills/`](skills/), reused from the [`foundry_skills`](../foundry_skills/README.md) sample so you can compare the two delivery modes side by side:
| Skill | Purpose |
|---|---|
| [`support-style`](skills/support-style/SKILL.md) | Voice, formatting, and signature rules for Contoso Outdoors support replies. |
@@ -4,8 +4,7 @@ from __future__ import annotations
import asyncio
import os
from collections.abc import Mapping
from typing import Any, cast
from typing import cast
from agent_framework import AgentSession
from agent_framework.foundry import FoundryAgent
@@ -35,47 +34,37 @@ agents, as this is a preview feature in Foundry.
"""
def get_hosted_session_agents(project_client: AIProjectClient) -> Any:
"""Return the hosted-session operations for azure-ai-projects 2.2 or 2.3."""
session_agents = cast(Any, project_client.agents)
if hasattr(session_agents, "create_session"):
return session_agents
return cast(Any, project_client.beta.agents)
async def create_hosted_agent_session(
*,
agent: FoundryAgent,
project_client: AIProjectClient,
agent_name: str,
agent_version: str | None,
isolation_key: str,
) -> AgentSession:
"""Create a hosted-agent service session and wrap it in an AgentSession."""
create_session_kwargs: dict[str, Any] = {
"agent_name": agent_name,
"isolation_key": isolation_key,
}
resolved_agent_version = agent_version
if resolved_agent_version is None:
agent_details = await cast(Any, project_client.beta.agents).get( # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
agent_name=agent_name
)
versions = getattr(agent_details, "versions", None)
if not isinstance(versions, Mapping):
raise ValueError("Hosted agent details did not include a versions mapping.")
latest_version = getattr(cast(Any, versions.get("latest")), "version", None)
if not isinstance(latest_version, str) or not latest_version:
raise ValueError("Hosted agent details did not include a latest version string.")
resolved_agent_version = latest_version
agent_details = await project_client.agents.get(agent_name)
resolved_agent_version = agent_details.versions.latest.version
create_session_kwargs["version_indicator"] = VersionRefIndicator(agent_version=resolved_agent_version)
service_session = await get_hosted_session_agents(project_client).create_session(**create_session_kwargs)
agent_session_id = getattr(service_session, "agent_session_id", None)
if not isinstance(agent_session_id, str) or not agent_session_id:
raise ValueError("Hosted agent session creation did not return a non-empty agent_session_id.")
service_session = await project_client.agents.create_session(
agent_name,
version_indicator=VersionRefIndicator(agent_version=resolved_agent_version),
)
return agent.get_session(service_session.agent_session_id)
return agent.get_session(agent_session_id)
async def delete_hosted_agent_session(
*,
project_client: AIProjectClient,
agent_name: str,
session: AgentSession,
) -> None:
"""Delete a hosted-agent service session."""
await project_client.agents.delete_session(
agent_name,
cast(str, session.service_session_id),
)
async def main() -> None:
@@ -83,7 +72,6 @@ async def main() -> None:
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
agent_name = os.environ["FOUNDRY_AGENT_NAME"]
agent_version = os.getenv("FOUNDRY_AGENT_VERSION")
isolation_key = "my-isolation-key"
project_client = AIProjectClient(
endpoint=project_endpoint,
@@ -104,7 +92,6 @@ async def main() -> None:
project_client=project_client,
agent_name=agent_name,
agent_version=agent_version,
isolation_key=isolation_key,
)
try:
@@ -132,12 +119,11 @@ async def main() -> None:
if chunk.text:
print(chunk.text, end="", flush=True)
finally:
if isinstance(session.service_session_id, str):
await get_hosted_session_agents(project_client).delete_session(
agent_name=agent_name,
session_id=session.service_session_id,
isolation_key=isolation_key,
)
await delete_hosted_agent_session(
project_client=project_client,
agent_name=agent_name,
session=session,
)
if __name__ == "__main__":
@@ -10,7 +10,7 @@ from agent_framework import (
Message,
WorkflowEvent,
)
from agent_framework.orchestrations import MagenticProgressLedger
from agent_framework.orchestrations import MagenticOrchestrator, MagenticProgressLedger
from dotenv import load_dotenv
"""AutoGen MagenticOneGroupChat vs Agent Framework MagenticBuilder.
@@ -25,7 +25,6 @@ load_dotenv()
async def run_autogen() -> None:
"""AutoGen's MagenticOneGroupChat for orchestrated collaboration."""
from autogen_agentchat.agents import AssistantAgent
from autogen_agentchat.teams import MagenticOneGroupChat
from autogen_agentchat.ui import Console
@@ -62,7 +61,7 @@ async def run_autogen() -> None:
team = MagenticOneGroupChat(
participants=[researcher, coder, reviewer],
model_client=client, # Coordinator uses this client
max_turns=20,
max_turns=10,
max_stalls=3,
)
@@ -109,7 +108,7 @@ async def run_agent_framework() -> None:
instructions="You coordinate a team to complete complex tasks efficiently.",
description="Orchestrator for team coordination",
),
max_round_count=20,
max_round_count=10,
max_stall_count=3,
max_reset_count=1,
).build()
@@ -119,14 +118,18 @@ async def run_agent_framework() -> None:
output_event: WorkflowEvent | None = None
print("[Agent Framework] Magentic conversation:")
async for event in workflow.run("Research Python async patterns and write a simple example", stream=True):
if event.type == "output" and isinstance(event.data, AgentResponseUpdate):
message_id = event.data.message_id
if message_id != last_message_id:
if last_message_id is not None:
print("\n")
print(f"- {event.executor_id}:", end=" ", flush=True)
last_message_id = message_id
print(event.data, end="", flush=True)
if event.type == "output":
if event.executor_id == MagenticOrchestrator.MANAGER_NAME:
output_event = event
else:
event_data = cast(AgentResponseUpdate, event.data)
message_id = event_data.message_id
if message_id != last_message_id:
if last_message_id is not None:
print("\n")
print(f"- {event.executor_id}:", end=" ", flush=True)
last_message_id = message_id
print(event_data, end="", flush=True)
elif event.type == "magentic_orchestrator":
print(f"\n[Magentic Orchestrator Event] Type: {event.data.event_type.name}")
@@ -142,19 +145,15 @@ async def run_agent_framework() -> None:
# Please refer to `with_plan_review` for proper human interaction during planning phases.
await asyncio.get_event_loop().run_in_executor(None, input, "Press Enter to continue...")
elif event.type == "output":
output_event = event
if not output_event:
raise RuntimeError("Workflow did not produce a final output event.")
print("\n\nWorkflow completed!")
print("Final Output:")
# The output of the Magentic workflow is a list of ChatMessages with only one final message
# generated by the orchestrator.
output_messages = cast(list[Message], output_event.data)
if output_messages:
output = output_messages[-1].text
print(output)
# The output of the Magentic workflow is an AgentResponse or AgentResponseUpdate,
# which contains the final message text.
output_message = cast(AgentResponseUpdate, output_event.data)
print(output_message.text)
async def main() -> None:
@@ -4,10 +4,11 @@
# "agent-framework-openai",
# "autogen-agentchat",
# "autogen-ext[openai]",
# "python-dotenv",
# ]
# ///
# Run with any PEP 723 compatible runner, e.g.:
# uv run samples/autogen-migration/single_agent/01_basic_assistant_agent.py
# uv run samples/autogen-migration/single_agent/01_basic_agent.py
# Copyright (c) Microsoft. All rights reserved.
@@ -1,3 +1,15 @@
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-openai",
# "autogen-agentchat",
# "autogen-ext[openai]",
# "python-dotenv",
# ]
# ///
# Run with any PEP 723 compatible runner, e.g.:
# uv run samples/autogen-migration/single_agent/02_agent_with_tool.py
# Copyright (c) Microsoft. All rights reserved.
import asyncio
@@ -2,6 +2,7 @@
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-openai",
# "python-dotenv",
# "semantic-kernel",
# ]
# ///
@@ -2,6 +2,7 @@
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-openai",
# "python-dotenv",
# "semantic-kernel",
# ]
# ///
@@ -2,6 +2,7 @@
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-openai",
# "python-dotenv",
# "semantic-kernel",
# ]
# ///
@@ -2,11 +2,21 @@
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-copilotstudio",
# "python-dotenv",
# "semantic-kernel",
# ]
# ///
# Run with any PEP 723 compatible runner, e.g.:
# uv run samples/semantic-kernel-migration/copilot_studio/01_basic_copilot_studio_agent.py
#
# NOTE: The metadata above resolves the Agent Framework half only.
# The Semantic Kernel half (run_semantic_kernel) requires the older
# dot-namespace Microsoft Agents SDK (microsoft.agents.copilotstudio.client and
# microsoft.agents.core, from microsoft-agents-copilotstudio-client<0.3), while
# Agent Framework requires the newer underscore-namespace SDK
# (microsoft_agents.copilotstudio.client, from
# microsoft-agents-copilotstudio-client>=0.3.1). These two generations cannot be
# installed in the same environment, so run each half in its own isolated env.
# Copyright (c) Microsoft. All rights reserved.
"""Call a Copilot Studio agent with SK and Agent Framework."""
@@ -2,11 +2,21 @@
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-copilotstudio",
# "python-dotenv",
# "semantic-kernel",
# ]
# ///
# Run with any PEP 723 compatible runner, e.g.:
# uv run samples/semantic-kernel-migration/copilot_studio/02_copilot_studio_streaming.py
#
# NOTE: The metadata above resolves the Agent Framework half only.
# The Semantic Kernel half (run_semantic_kernel) requires the older
# dot-namespace Microsoft Agents SDK (microsoft.agents.copilotstudio.client and
# microsoft.agents.core, from microsoft-agents-copilotstudio-client<0.3), while
# Agent Framework requires the newer underscore-namespace SDK
# (microsoft_agents.copilotstudio.client, from
# microsoft-agents-copilotstudio-client>=0.3.1). These two generations cannot be
# installed in the same environment, so run each half in its own isolated env.
# Copyright (c) Microsoft. All rights reserved.
"""Stream responses from Copilot Studio agents in SK and AF."""
@@ -2,6 +2,7 @@
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-openai",
# "python-dotenv",
# "semantic-kernel",
# ]
# ///
@@ -2,6 +2,7 @@
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-openai",
# "python-dotenv",
# "semantic-kernel",
# ]
# ///
@@ -2,6 +2,7 @@
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-openai",
# "python-dotenv",
# "semantic-kernel",
# ]
# ///
@@ -3,6 +3,7 @@
# dependencies = [
# "agent-framework-openai",
# "agent-framework-orchestrations",
# "python-dotenv",
# "semantic-kernel",
# ]
# ///
@@ -3,6 +3,7 @@
# dependencies = [
# "agent-framework-openai",
# "agent-framework-orchestrations",
# "python-dotenv",
# "semantic-kernel",
# ]
# ///
@@ -3,6 +3,7 @@
# dependencies = [
# "agent-framework-openai",
# "agent-framework-orchestrations",
# "python-dotenv",
# "semantic-kernel",
# ]
# ///
@@ -3,6 +3,7 @@
# dependencies = [
# "agent-framework-openai",
# "agent-framework-orchestrations",
# "python-dotenv",
# "semantic-kernel",
# ]
# ///
@@ -3,6 +3,7 @@
# dependencies = [
# "agent-framework-openai",
# "agent-framework-orchestrations",
# "python-dotenv",
# "semantic-kernel",
# ]
# ///
@@ -2,6 +2,7 @@
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-core",
# "python-dotenv",
# "semantic-kernel",
# ]
# ///
@@ -2,6 +2,7 @@
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-core",
# "python-dotenv",
# "semantic-kernel",
# ]
# ///