diff --git a/python/samples/04-hosting/af-hosting/README.md b/python/samples/04-hosting/af-hosting/README.md new file mode 100644 index 0000000000..369c1ae73f --- /dev/null +++ b/python/samples/04-hosting/af-hosting/README.md @@ -0,0 +1,51 @@ +# Multi-channel hosting samples + +End-to-end samples for serving an `agent-framework` agent (or workflow) +through one or more **channels** with `agent-framework-hosting`. + +The general hosting plumbing lives in +[`agent-framework-hosting`](../../../packages/hosting); each channel is +its own package (`agent-framework-hosting-responses`, +`agent-framework-hosting-invocations`, +`agent-framework-hosting-telegram`, `agent-framework-hosting-activity-protocol`, +`agent-framework-hosting-entra`). + +| Sample | What it shows | Packaging | +|---|---|---| +| [`local_responses/`](./local_responses) | The minimal shape: one agent + one `@tool` + `ResponsesChannel` + a single `run_hook` that strips caller-supplied options and forces a `reasoning` preset. | **Local only.** Start here to learn the run-hook seam. | +| [`local_responses_workflow/`](./local_responses_workflow) | A 4-step `Workflow` (typed `SloganBrief` intake → writer → legal → formatter) hosted behind **both** the Responses and Invocations channels via a shared `run_hook` that parses inbound text/JSON into the workflow's typed input. The host writes per-conversation checkpoints via `checkpoint_location=…`. Demonstrates workflow targets + structured input adaptation + multi-channel + resume-across-turns. Includes a `call_server.rest` file with REST examples for both endpoints. | **Local only.** | +| [`foundry_hosted_agent/`](./foundry_hosted_agent) | One Foundry agent, **Responses + Invocations only** — the minimal shape that is **runtime-compatible with the Foundry Hosted Agents platform**. | Ships with `Dockerfile` + `agent.yaml` + `agent.manifest.yaml` + `azure.yaml` so the same image runs locally **or** as a Foundry Hosted Agent (`azd up`). | +| [`local_telegram/`](./local_telegram) | Adds Telegram, a `@tool`, `FileHistoryProvider`, run hooks (per-user / per-chat session keying), extra Telegram commands, and `ResponseTarget` multicast. Runs under Hypercorn with multiple workers. | **Local only.** No Dockerfile / Foundry packaging. | +| [`local_identity_link/`](./local_identity_link) | Everything in `local_telegram/` plus Teams and the Entra identity-link sidecar (`/auth/start` + `/auth/callback`). Demonstrates linking a Telegram chat to an Entra user so multiple non-Entra channels can share one isolation key. | **Local only.** No Dockerfile / Foundry packaging. | + +Each sample is fully self-contained — its own `pyproject.toml`, `uv.lock`, +server `app.py`, calling script(s), and `storage/` directory. Every +sample uses `[tool.uv.sources]` to wire its `agent-framework-hosting*` +dependencies to the +[`feature/python-hosting`](https://github.com/microsoft/agent-framework/tree/feature/python-hosting) +branch of the upstream repo via git refs, so they install cleanly outside +the monorepo while the hosting packages are still pre-PyPI. Once those +packages publish, drop the `[tool.uv.sources]` block and let the +declared deps resolve from PyPI. + +## Relationship to `../foundry-hosted-agents/` + +The sibling [`../foundry-hosted-agents/`](../foundry-hosted-agents) directory +contains samples for the **`agent-framework-hosted`** stack — agents +that run **inside** the Foundry Hosted Agents platform using its +built-in protocol surface (Responses, Invocations, conversation store, +isolation, identity), with **no `agent-framework-hosting` package +involved**. + +| Aspect | `af-hosting/` (this directory) | `foundry-hosted-agents/` | +|---|---|---| +| Server stack | `agent-framework-hosting` + per-channel packages (`-responses`, `-invocations`, `-telegram`, `-activity-protocol`, `-entra`) | `agent-framework-hosted` only — the Foundry Hosted Agents runtime owns the HTTP surface | +| Channels other than Responses / Invocations | Yes — Telegram, Activity Protocol (Teams), Entra identity-linking | No — the platform exposes Responses + Invocations only | +| Run target | Local Hypercorn (`local_responses/`, `local_telegram/`, `local_identity_link/`); Hosted Agents *or* local (`foundry_hosted_agent/`) | Hosted Agents *or* local container; targets the Hosted Agents platform contract | +| When to pick this | You need extra channels (Telegram/Teams via Activity Protocol/…), custom hosting middleware, or want to run outside the Foundry runtime | You only need Responses/Invocations and want zero hosting boilerplate, leveraging the Foundry-managed surface | + +`foundry_hosted_agent/` is the bridge sample: it uses the +`agent-framework-hosting` stack but is packaged so the Foundry Hosted +Agents platform can run it as one of its own. + +See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the cross-sample story. diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/.gitignore b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/.gitignore new file mode 100644 index 0000000000..ea567ea359 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/.gitignore @@ -0,0 +1,419 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates +*.env + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.trx + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Approval Tests result files +*.received.* + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.idb +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +**/.paket/paket.exe +paket-files/ + +# FAKE - F# Make +**/.fake/ + +# CodeRush personal settings +**/.cr/personal + +# Python Tools for Visual Studio (PTVS) +**/__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +#tools/** +#!tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +**/.mfractor/ + +# Local History for Visual Studio +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +**/.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp +.azure diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/Dockerfile b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/Dockerfile new file mode 100644 index 0000000000..9e30a2c3a6 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/Dockerfile @@ -0,0 +1,25 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +# Build context for this Dockerfile is THIS folder (see ``azure.yaml`` -> +# ``services..project: .``). The workspace packages this sample +# depends on are fetched from GitHub by ``uv sync`` (see the ``[tool.uv.sources]`` +# git refs in ``pyproject.toml``). The build needs network access to GitHub +# during ``uv sync`` — no local vendoring step is required. +# +# ``Dockerfile.dockerignore`` (adjacent file, BuildKit) trims the upload to +# just the files COPYed below. + +WORKDIR /app + +COPY pyproject.toml ./ +COPY app.py ./ + +# ``--no-dev`` skips the dev group (which only contains ``openai`` for +# ``call_server.py``). Locks fresh against the GitHub-hosted hosting +# packages declared in ``[tool.uv.sources]``. +RUN uv sync --no-dev + +ENV PORT=8000 +EXPOSE 8000 + +CMD ["uv", "run", "python", "app.py"] diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/Dockerfile.dockerignore b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/Dockerfile.dockerignore new file mode 100644 index 0000000000..87a3e83667 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/Dockerfile.dockerignore @@ -0,0 +1,28 @@ +# BuildKit per-Dockerfile ignore (sibling file: .dockerignore). +# Build context for this image is THIS folder. Trim everything except the +# files the Dockerfile actually COPYs. + +# Local virtualenv & python caches. +.venv/ +**/.venv/ +**/__pycache__/ +**/*.pyc +**/*.pyo +**/.pytest_cache/ +**/.mypy_cache/ +**/.ruff_cache/ + +# azd / git / IDE. +.azure/ +.git/ +.gitignore +.vscode/ +.idea/ + +# Sample-specific files not needed at runtime. +README.md +call_server.py +agent.yaml +agent.manifest.yaml +azure.yaml +infra/ diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/README.md b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/README.md new file mode 100644 index 0000000000..ca57acc89e --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/README.md @@ -0,0 +1,136 @@ +# foundry_hosted_agent — Responses + Invocations (Foundry Hosted Agents compatible) + +Smallest end-to-end hosting sample. One Foundry-backed agent, two +channels, no human-chat surface — and that minimal shape is the whole +point: a host configured with at least the **Responses** and +**Invocations** channels under their default mount roots is +**runtime-compatible with the Foundry Hosted Agents platform**. The +same container image runs locally, behind any ASGI server, or as a +Hosted Agent — no protocol shim, no extra adapter. + +| Route | Channel | Used by | +| ------------------------------ | -------------------- | ------------------------------------------- | +| `POST /responses` | `ResponsesChannel` | OpenAI Responses clients (`call_server.py`) | +| `POST /invocations/invoke` | `InvocationsChannel` | Host-native JSON envelope (Hosted Agents) | + +## Conversation history + +The agent is wired with `FoundryHostedAgentHistoryProvider` (from +`agent-framework-foundry-hosting`). When a Responses request supplies +`previous_response_id`, the channel uses it as the session id and the +provider fetches the prior turn chain directly from +`{FOUNDRY_PROJECT_ENDPOINT}/storage/...` using the same managed-identity +credential as the chat client. Locally (when `FOUNDRY_HOSTING_ENVIRONMENT` +is unset) it transparently falls back to an in-memory store, so the same +code runs in dev. Writes are a no-op — Foundry persists Responses turns +authoritatively as the runtime executes them. + +For richer scenarios (custom tools, history providers, run hooks, +multicast, Telegram, Teams, identity linking) see +[`../local_telegram`](../local_telegram) and +[`../local_identity_link`](../local_identity_link). + +## Layout + +``` +foundry_hosted_agent/ +├── app.py # the host (ResponsesChannel + InvocationsChannel) +├── call_server.py # client: openai SDK / agent framework / FoundryAgent +├── agent.yaml # Foundry Hosted Agents minimal definition +├── agent.manifest.yaml # Foundry Hosted Agents full deployment manifest +├── azure.yaml # azd service config (build context = this folder) +├── Dockerfile # built from this folder; uv fetches deps from GitHub +├── Dockerfile.dockerignore # BuildKit allowlist that trims the context +├── pyproject.toml # depends on the hosting packages via GitHub git refs +└── README.md # this file +``` + +## Run locally + +```bash +export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com +export MODEL_DEPLOYMENT_NAME=gpt-4.1-mini +az login # any DefaultAzureCredential source + +uv sync +uv run python app.py # binds 0.0.0.0:8000 +``` + +The env var names match `agent.manifest.yaml` so the same shell +environment works for both local runs and Hosted Agent deployments. + +## Call locally + +```bash +uv sync --group dev + +# OpenAI SDK pointed at the local /responses endpoint. +uv run python call_server.py --via openai "hello there" + +# The same call via the Agent Framework Agent + OpenAIChatClient stack. +uv run python call_server.py --via af "hello there" + +# Once deployed as a Hosted Agent: target the Foundry-managed endpoint. +export FOUNDRY_HOSTED_AGENT_NAME=agent-framework-hosting-sample +uv run python call_server.py --via foundry "hello there" +``` + +## Docker + +The Docker build context is **this sample folder**. `pyproject.toml` +declares the in-tree `agent-framework-hosting*` packages via +[`[tool.uv.sources]` git refs](./pyproject.toml) pointing at the +``feature/python-hosting`` branch of +[microsoft/agent-framework](https://github.com/microsoft/agent-framework), +so `uv sync` inside the image fetches them directly. No vendoring step is +required — the build just needs network access to GitHub. Once the +hosting packages publish to PyPI you can drop the `[tool.uv.sources]` +overrides and rely on PyPI resolution. + +```bash +# From this folder — context = `.` (sample folder). +DOCKER_BUILDKIT=1 docker build -t hosting-sample-hosted-agent . + +docker run -p 8000:8000 \ + -e FOUNDRY_PROJECT_ENDPOINT -e MODEL_DEPLOYMENT_NAME \ + -e AZURE_CLIENT_ID -e AZURE_TENANT_ID -e AZURE_CLIENT_SECRET \ + hosting-sample-hosted-agent +``` + +## Hosted Agent deployment + +`azure.yaml` keeps `project: .` and uses `docker.remoteBuild: true` — +the remote builder receives only this sample folder and runs +`uv sync` to pull the hosting packages from GitHub. + +The two YAMLs follow the same convention as the +[`foundry-hosted-agents/`](../../foundry-hosted-agents/) reference +samples — `agent.yaml` is the minimal kind/protocols/resources card, +`agent.manifest.yaml` is the full template + environment-variable + +model-resource binding used during deployment. + +```bash +azd up # provisions infra/ + builds + pushes + deploys +azd deploy # rebuild + redeploy only +``` + +### Required Foundry RBAC + +The container runs as the Hosted Agent's managed identity. That identity +needs permission to call the Foundry project's agent/Responses endpoints +— without it the call returns 401 ``PermissionDenied``. Grant the +**Azure AI Project Manager** role (or the more granular +``Microsoft.CognitiveServices/accounts/AIServices/agents/*`` data +actions) on the Foundry project to the Hosted Agent's managed identity. +See for the full role list. + +### Health probe + +The Foundry Hosted Agents runtime probes ``GET /readiness``; +``AgentFrameworkHost`` exposes that route automatically (returns +``200 ok``). No extra wiring needed. + +The host code never imports anything Foundry-specific beyond the chat +client itself — swapping `FoundryChatClient` for `OpenAIChatClient` (or +any other client) flips this sample from a Hosted Agent target to a +non-Foundry deployment without touching the channels. diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/agent.manifest.yaml b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/agent.manifest.yaml new file mode 100644 index 0000000000..1e16c51d7a --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/agent.manifest.yaml @@ -0,0 +1,31 @@ +name: agent-framework-hosting-sample +description: > + Minimal Agent Framework multi-channel hosting sample (Responses + Invocations) + packaged for the Foundry Hosted Agents runtime. Demonstrates that an + ``AgentFrameworkHost`` configured with the Responses and Invocations channels + under their default mounts is a drop-in Hosted Agent image — no protocol + shim, no Foundry-specific server. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Invocations Protocol + - Streaming + - Multi-Channel +template: + name: agent-framework-hosting-sample + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + - protocol: invocations + version: 1.0.0 + environment_variables: + - name: MODEL_DEPLOYMENT_NAME + value: "{{MODEL_DEPLOYMENT_NAME}}" +resources: + - kind: model + id: gpt-5.4-nano + name: MODEL_DEPLOYMENT_NAME diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/agent.yaml b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/agent.yaml new file mode 100644 index 0000000000..efef14b2c4 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/agent.yaml @@ -0,0 +1,26 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml + +kind: hosted +name: agent-framework-hosting-sample +description: | + Minimal Agent Framework multi-channel hosting sample (Responses + Invocations) packaged for the Foundry Hosted Agents runtime. Demonstrates that an ``AgentFrameworkHost`` configured with the Responses and Invocations channels under their default mounts is a drop-in Hosted Agent image — no protocol shim, no Foundry-specific server. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Invocations Protocol + - Streaming + - Multi-Channel +protocols: + - protocol: responses + version: 1.0.0 + - protocol: invocations + version: 1.0.0 +resources: + cpu: "1" + memory: 2Gi +environment_variables: + - name: MODEL_DEPLOYMENT_NAME + value: gpt-5.4-nano diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/app.py b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/app.py new file mode 100644 index 0000000000..67bf14fdd0 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/app.py @@ -0,0 +1,185 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Smallest hosting sample — Responses + Invocations only. + +This sample is intentionally minimal and is **runtime-compatible with the +Foundry Hosted Agents platform**: a host that exposes the Responses and +Invocations channels under their default mount roots can be packaged as a +container image and deployed to Foundry Hosted Agents without any protocol +shim. The same image runs locally, behind any ASGI server, or as a Hosted +Agent. + +History +------- +The agent uses :class:`FoundryHostedAgentHistoryProvider` so that conversation +history is loaded from the Foundry Hosted Agent storage backend when the +container runs inside Foundry. When ``previous_response_id`` is supplied on +an incoming Responses request, the channel routes it through to the +provider as the ``session_id``, and the provider fetches the prior turn +chain from ``{FOUNDRY_PROJECT_ENDPOINT}/storage/...``. Locally +(``FOUNDRY_HOSTING_ENVIRONMENT`` unset) the provider falls back to an +in-memory store so the same code runs in dev. + +Setup +----- +- ``FOUNDRY_PROJECT_ENDPOINT`` — Foundry project endpoint URL. +- ``MODEL_DEPLOYMENT_NAME`` — model deployment name (the same env var + the Foundry Hosted Agents manifest binds via the ``model`` resource — + see ``agent.manifest.yaml``). +- ``FOUNDRY_HOSTING_ENVIRONMENT`` — set automatically by the Hosted Agents + runtime; signals the history provider to talk to the Foundry storage API + instead of the local in-memory fallback. +- ``APPLICATIONINSIGHTS_CONNECTION_STRING`` — when present, the sample + wires Azure Monitor OpenTelemetry export at import time. Foundry Hosted + Agents inject this when an Application Insights resource is bound to + the project; locally it's optional. + +Auth uses ``DefaultAzureCredential`` so any standard Azure auth chain +works (``az login`` locally, managed identity in Hosted Agents, +``AZURE_*`` env vars in CI, ...). + +Run +--- +- Local: ``python app.py`` (binds ``0.0.0.0:8000``) +- ASGI: ``hypercorn app:app --bind 0.0.0.0:8000`` +- Docker: ``docker build -t hosting-sample-hosted-agent . && \\ + docker run -p 8000:8000 \\ + -e FOUNDRY_PROJECT_ENDPOINT -e MODEL_DEPLOYMENT_NAME \\ + hosting-sample-hosted-agent`` +- Hosted Agent: build & push the image, then deploy via ``agent.yaml`` / + ``agent.manifest.yaml`` in this folder. + +Routes +------ +- ``POST /responses`` — OpenAI Responses-shaped surface. +- ``POST /invocations/invoke`` — host-native JSON envelope. +""" + +from __future__ import annotations + +import logging +import os + +from agent_framework import Agent +from agent_framework.observability import enable_instrumentation +from agent_framework_foundry import FoundryChatClient +from agent_framework_foundry_hosting import ( + FoundryHostedAgentHistoryProvider, + foundry_response_id, +) +from agent_framework_hosting import AgentFrameworkHost +from agent_framework_hosting_invocations import InvocationsChannel +from agent_framework_hosting_responses import ResponsesChannel +from azure.identity.aio import DefaultAzureCredential + +# Configure root logging early so library log records (in particular +# ``agent_framework_foundry_hosting._history_provider``) are captured by +# the container's stderr stream and surfaced in the Foundry portal / +# Azure Monitor. ``LOG_LEVEL`` overrides this for production tightening. +logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +# Quiet noisy transports unless explicitly cranked up. +for _noisy in ( + "httpx", + "httpcore", + "azure.core.pipeline.policies.http_logging_policy", + "urllib3", +): + logging.getLogger(_noisy).setLevel(logging.WARNING) + +logger = logging.getLogger(__name__) + + +def _configure_observability() -> None: + """Wire Azure Monitor OpenTelemetry when a connection string is present. + + Foundry Hosted Agents inject ``APPLICATIONINSIGHTS_CONNECTION_STRING`` + into the container at runtime when an Application Insights resource is + bound to the project. We honor the same env var locally so the same + code path lights up in both environments. When the var is absent + (typical local dev without an AI binding) we silently skip — the host + still serves traffic, just without OTel export. + """ + conn_str = os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING") + if not conn_str: + logger.info( + "APPLICATIONINSIGHTS_CONNECTION_STRING not set — skipping Azure Monitor OpenTelemetry configuration.", + ) + return + # Imported lazily so the sample still starts when the optional + # ``azure-monitor-opentelemetry`` dependency isn't installed (e.g. an + # ultra-thin local dev image stripped of observability extras). + from azure.monitor.opentelemetry import configure_azure_monitor + + configure_azure_monitor(connection_string=conn_str) + logger.info("Azure Monitor OpenTelemetry configured.") + + +def build_host() -> AgentFrameworkHost: + # Single credential is shared by the chat client and the history + # provider so we only authenticate (and refresh tokens) once. + credential = DefaultAzureCredential() + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + + agent = Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=os.environ["MODEL_DEPLOYMENT_NAME"], + credential=credential, + ), + name="HostedAgentSample", + instructions="You are called Jarvis, a friendly assistant. Keep answers brief.", + # Loads history from Foundry storage when running inside a Hosted + # Agent (FOUNDRY_HOSTING_ENVIRONMENT set); falls back to an in- + # memory store for local dev. + context_providers=[ + FoundryHostedAgentHistoryProvider( + credential=credential, + endpoint=project_endpoint, + ), + ], + ) + + return AgentFrameworkHost( + target=agent, + channels=[ + # Mint Foundry-storage-compatible response ids + # (``caresp_{18charPartitionKey}{32charEntropy}``). The + # Foundry storage backend partitions records by extracting + # this segment from the id; free-form ``resp_`` ids + # are rejected with an opaque ``HTTP 500 server_error``. + ResponsesChannel(response_id_factory=foundry_response_id), + InvocationsChannel(), + ], + ) + + +# `app` is the canonical ASGI surface — hand it to any ASGI server, or let +# the Foundry Hosted Agents runtime pick it up via the standard entry point. +# Observability is configured at import time so trace/log export is wired +# before the host starts handling requests. Per-request Foundry isolation +# (the platform-injected ``x-agent-{user,chat}-isolation-key`` headers) +# is read by the host's installed ASGI middleware off every inbound HTTP +# request and lifted into a contextvar that +# :class:`FoundryHostedAgentHistoryProvider` consults on each storage call. +# Multi-turn persistence works out of the box in both local dev and the +# Hosted Agents container — no manual middleware wiring needed. +_configure_observability() +enable_instrumentation(enable_sensitive_data=True) +app = build_host().app + + +if __name__ == "__main__": + # Serve the host's ASGI app directly. The Foundry isolation headers + # are read by the host's installed ASGI middleware and threaded + # through the storage provider via a contextvar; nothing extra to wire. + import asyncio + + import hypercorn.asyncio + import hypercorn.config + + config = hypercorn.config.Config() + config.bind = [f"0.0.0.0:{int(os.environ.get('PORT', '8000'))}"] + asyncio.run(hypercorn.asyncio.serve(app, config)) # type: ignore[arg-type] diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/azure.yaml b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/azure.yaml new file mode 100644 index 0000000000..2dddbe9656 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/azure.yaml @@ -0,0 +1,32 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +requiredVersions: + extensions: + azure.ai.agents: '>=0.1.0-preview' +name: ai-foundry-starter-basic +services: + agent-framework-hosting-sample: + project: . + host: azure.ai.agent + language: docker + docker: + remoteBuild: true + config: + container: + resources: + cpu: "1" + memory: 2Gi + scale: + maxReplicas: 1 + deployments: + - model: + format: OpenAI + name: gpt-5.4-nano + version: "2026-03-17" + name: gpt-5.4-nano + sku: + capacity: 250 + name: GlobalStandard +infra: + provider: bicep + path: ./infra diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/call_server.py b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/call_server.py new file mode 100644 index 0000000000..fd01dd9cb2 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/call_server.py @@ -0,0 +1,127 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Call the foundry_hosted_agent server three ways. + +The foundry_hosted_agent host exposes ``POST /responses`` (OpenAI Responses-shaped) and +``POST /invocations/invoke`` (host-native), and that minimal contract is +**runtime-compatible with the Foundry Hosted Agents platform** — so the same +agent code that calls the local server also calls the same image deployed +as a Hosted Agent. + +Modes +----- +``--via openai`` (default) + Plain ``openai`` SDK against the local ``/responses``. Uses + ``api_key="not-needed"`` because the local sample has no auth. + +``--via af`` + Agent Framework ``Agent`` wrapping ``OpenAIChatClient`` pointed at the + local ``BASE_URL``. ``OpenAIChatClient`` already speaks the Responses + surface natively. + +``--via foundry`` + Agent Framework ``FoundryAgent`` against a Hosted Agent that this image + has been deployed as. Requires:: + + FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com + FOUNDRY_HOSTED_AGENT_NAME= + + Auth uses ``AzureCliCredential`` (run ``az login`` first). + +Start the server first (in another shell):: + + uv run python app.py + +Then:: + + uv run python call_server.py "Who are you?" + uv run python call_server.py --via af "What's the weather in Seattle?" + FOUNDRY_PROJECT_ENDPOINT=... FOUNDRY_HOSTED_AGENT_NAME=... \\ + uv run python call_server.py --via foundry "Who are you?" +""" + +from __future__ import annotations + +import argparse +import asyncio +import os + +from agent_framework import Agent +from agent_framework_foundry import FoundryAgent +from agent_framework_openai import OpenAIChatClient +from azure.identity.aio import AzureCliCredential +from openai import OpenAI + +# Bare server origin — the OpenAI SDK / OpenAIChatClient append ``/responses`` themselves. +BASE_URL = "http://127.0.0.1:8000" + + +def call_via_openai_sdk(prompt: str) -> None: + client = OpenAI(base_url=BASE_URL, api_key="not-needed") + response = client.responses.create(model="agent", input=prompt) + print(f"User: {prompt}") + print(f"Agent: {response.output_text}") + + +async def call_via_agent_framework(prompt: str) -> None: + # Agent + OpenAIChatClient(base_url=...) is the Agent Framework way to + # talk to any Responses-shaped endpoint — including foundry_hosted_agent's `/responses`. + chat_client = OpenAIChatClient(base_url=BASE_URL, api_key="not-needed", model_id="agent") + agent = Agent(client=chat_client) + result = await agent.run(prompt) + print(f"User: {prompt}") + print(f"Agent: {result.text}") + + +async def call_via_foundry_hosted_agent(prompt: str) -> None: + # Once foundry_hosted_agent's image is deployed as a Foundry Hosted Agent, FoundryAgent + # keyed on ``agent_name`` is the AF-native client. The agent's runtime is + # the very same Responses + Invocations contract — Foundry just hosts it. + project_endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") + if not project_endpoint: + raise SystemExit( + "FOUNDRY_PROJECT_ENDPOINT must be set; e.g. " + "https://.services.ai.azure.com/api/projects/agents" + ) + agent_name = os.environ.get("FOUNDRY_HOSTED_AGENT_NAME", "agent-framework-hosting-sample") + # Optional: continue a prior conversation by passing FOUNDRY_HOSTED_SESSION_ID. + session_id = os.environ.get("FOUNDRY_HOSTED_SESSION_ID") + async with AzureCliCredential() as credential: + agent = FoundryAgent( + project_endpoint=project_endpoint, + agent_name=agent_name, + credential=credential, + allow_preview=True, + ) + if session_id: + session = agent.get_session(service_session_id=session_id) + result = await agent.run(prompt, session=session) + else: + result = await agent.run(prompt) + print(f"User: {prompt}") + print(f"Agent: {result.text}") + print(f"Session ID (for history continuity): {result.response_id}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + "--via", + choices=("openai", "af", "foundry"), + default="openai", + help="Calling client to use.", + ) + parser.add_argument("prompt", nargs="*") + args = parser.parse_args() + prompt = " ".join(args.prompt) or "Who are you?" + + if args.via == "openai": + call_via_openai_sdk(prompt) + elif args.via == "af": + asyncio.run(call_via_agent_framework(prompt)) + else: + asyncio.run(call_via_foundry_hosted_agent(prompt)) + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/pyproject.toml b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/pyproject.toml new file mode 100644 index 0000000000..685c07ef78 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "agent-framework-hosting-sample-hosted-agent" +version = "0.0.1" +description = "Hosted-Agent-compatible hosting sample (Responses + Invocations)." +requires-python = ">=3.10" +dependencies = [ + "agent-framework-foundry", + "agent-framework-foundry-hosting", + "agent-framework-hosting", + "agent-framework-hosting-invocations", + "agent-framework-hosting-responses", + "azure-identity", + "aiohttp>=3.13.5", + "hypercorn>=0.17", + "azure-monitor-opentelemetry>=1.6", +] + +[dependency-groups] +dev = ["openai>=1.99"] + +[tool.uv] +package = false + +[tool.uv.sources] +agent-framework-foundry-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/foundry_hosting" } +agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" } +agent-framework-hosting-invocations = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-invocations" } +agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" } diff --git a/python/samples/04-hosting/af-hosting/local_identity_link/README.md b/python/samples/04-hosting/af-hosting/local_identity_link/README.md new file mode 100644 index 0000000000..33a706386d --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_identity_link/README.md @@ -0,0 +1,67 @@ +# local_identity_link — every channel, plus identity linking + +The full surface: Responses + Invocations + Telegram + Activity Protocol (Teams) + the Entra +identity-link sidecar. The Entra channel exposes +`/auth/start` + `/auth/callback` so users on Telegram (or any non-Entra +channel) can bind their per-channel id to a stable `entra:` isolation +key. Channel run-hooks then rewrite incoming requests to use the linked +key, so a chat started on Telegram and a chat started on Teams that both +resolve to the same Entra user share one history. + +## Run + +```bash +export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com +export FOUNDRY_MODEL=gpt-4o +export TELEGRAM_BOT_TOKEN=... +# Entra app registration (confidential client): +export ENTRA_TENANT_ID=... +export ENTRA_CLIENT_ID=... +export ENTRA_CLIENT_SECRET=... # or: +# export ENTRA_CERTIFICATE_PATH=./teams-bot.pem +export PUBLIC_BASE_URL=https:// # used to mint redirect_uri +# Teams (optional — same tenant): +export TEAMS_APP_ID=... +export TEAMS_APP_PASSWORD=... + +az login + +uv sync +uv run hypercorn app:app \ + --bind 0.0.0.0:8000 \ + --workers 4 +``` + +## Identity link + +Register `https:///auth/callback` as the redirect URI on your +Entra app, then visit (replace ```` with the Telegram numeric +chat id): + +``` +https:///auth/start?channel=telegram&id= +``` + +After sign-in, subsequent Telegram messages from that chat resolve to the +linked Entra user. + +## Call locally + +```bash +uv sync --group dev + +# Default: post a Responses request as `local-dev`. +uv run python call_server.py "What is the weather in Tokyo?" + +# Resume any session by id, including a Telegram one (works because +# the Telegram run-hook writes sessions under telegram:): +uv run python call_server.py --previous-response-id telegram:8741188429 "What did we discuss?" + +# Multicast to a Telegram chat in parallel with the local response: +uv run python call_server.py --telegram-chat-id 8741188429 "Heads up." +``` + +> This sample is **local-only** — it shows the `agent-framework-hosting` +> server stack as a standalone process. For a Foundry-Hosted-Agents-compatible +> packaging (Dockerfile + `agent.yaml` + `azure.yaml`), see +> [`foundry_hosted_agent/`](../foundry_hosted_agent). diff --git a/python/samples/04-hosting/af-hosting/local_identity_link/app.py b/python/samples/04-hosting/af-hosting/local_identity_link/app.py new file mode 100644 index 0000000000..676d4897ab --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_identity_link/app.py @@ -0,0 +1,397 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Complete multi-channel hosting sample with unified Entra ID identity. + +Wires every built-in channel onto a single ``AgentFrameworkHost`` and +demonstrates a pattern for collapsing per-channel identifiers into a single +**Microsoft Entra ID** (object id) key so a user's history follows them +across surfaces. + +Identity resolution +------------------- +Each request is bucketed under one ``isolation_key`` for ``FileHistoryProvider``: + +- **Teams** is the source of truth. Inbound activities carry the user's + ``aadObjectId``; we promote it to ``entra:`` in the Teams ``run_hook``. +- **Telegram** has no built-in OAuth identity. Users link their chat to + their Entra ID by sending ``/link``; the bot replies with a one-shot + authorize URL served by the host's ``EntraIdentityLinkChannel``. After the + OAuth callback the mapping ``telegram: → entra:`` is + persisted to ``identity_links.json`` and every later Telegram turn is + bucketed under the user's Entra key. +- **Responses API** callers can pass ``entra_oid`` directly (top-level or + in ``metadata``), or pass ``safety_identifier`` and rely on the same + store (``responses: → entra:``). Otherwise we fall back + to ``responses:``. + +Required environment +-------------------- +- ``FOUNDRY_PROJECT_ENDPOINT`` / ``FOUNDRY_MODEL`` — agent backing. +- ``TELEGRAM_BOT_TOKEN`` — required to enable the Telegram channel. +- ``TEAMS_APP_ID`` / ``TEAMS_APP_PASSWORD`` — optional; without them the + Teams channel runs in dev mode (Bot Framework Emulator only). +- ``ENTRA_TENANT_ID`` / ``ENTRA_CLIENT_ID`` plus **either** + ``ENTRA_CLIENT_SECRET`` **or** ``ENTRA_CERT_PATH`` + (+ optional ``ENTRA_CERT_PASSWORD``) — required to enable the ``/link`` + flow. The app's redirect URI must be registered as + ``{PUBLIC_BASE_URL}/auth/callback`` in your Entra app. +- ``PUBLIC_BASE_URL`` — externally reachable base of this host (e.g. + ``https://my-host.example.com``). Defaults to ``http://localhost:8000``. + +Run +--- +This module exposes ``app`` as the canonical ASGI surface. Recommended +production launch is **Hypercorn**:: + + hypercorn app:app --bind 0.0.0.0:8000 --workers 4 + +The ``__main__`` block below uses ``host.serve(...)`` (single-process +Hypercorn) as a local-dev fallback. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Mapping +from dataclasses import replace +from pathlib import Path +from typing import Annotated, Any + +from agent_framework import Agent, FileHistoryProvider, tool +from agent_framework_foundry import FoundryChatClient +from agent_framework_hosting import ( + AgentFrameworkHost, + Channel, + ChannelCommand, + ChannelCommandContext, + ChannelRequest, + ChannelSession, +) +from agent_framework_hosting_activity_protocol import ActivityProtocolChannel +from agent_framework_hosting_entra import ( + EntraIdentityLinkChannel, + EntraIdentityStore, + entra_isolation_key, +) +from agent_framework_hosting_invocations import InvocationsChannel +from agent_framework_hosting_responses import ResponsesChannel +from agent_framework_hosting_telegram import TelegramChannel +from azure.identity.aio import DefaultAzureCredential + +logger = logging.getLogger("agent_framework.hosting.complete_app") + +SESSIONS_DIR = Path(__file__).resolve().parent / "storage" / "sessions" +SESSIONS_DIR.mkdir(parents=True, exist_ok=True) +IDENTITY_STORE_PATH = Path(__file__).resolve().parent / "storage" / "identity_links.json" + + +# --------------------------------------------------------------------------- # +# Tools +# --------------------------------------------------------------------------- # + + +@tool(approval_mode="never_require") +def lookup_weather( + location: Annotated[str, "The city to look up weather for."], +) -> str: + """Return a deterministic weather report for a city.""" + reports = { + "Seattle": "Seattle is rainy with a high of 13°C.", + "Amsterdam": "Amsterdam is cloudy with a high of 16°C.", + "Tokyo": "Tokyo is clear with a high of 22°C.", + } + return reports.get(location, f"{location} is sunny with a high of 20°C.") + + +# --------------------------------------------------------------------------- # +# Run hooks: collapse per-channel identifiers down to a single Entra ID key +# --------------------------------------------------------------------------- # + + +def _replace_session(request: ChannelRequest, isolation_key: str) -> ChannelRequest: + return replace(request, session=ChannelSession(isolation_key=isolation_key)) + + +def make_activity_hook() -> Any: + """Promote ``aadObjectId`` from the inbound Activity to ``entra:``. + + The Activity Protocol channel is treated as the **primary** identity + source for Teams traffic: every authenticated Teams user has an Entra + object id, and we trust it directly without consulting the link store. + """ + + def _hook( + request: ChannelRequest, + *, + protocol_request: Mapping[str, Any] | None = None, + **_: object, + ) -> ChannelRequest: + activity = protocol_request or {} + from_ = activity.get("from") if isinstance(activity, Mapping) else None + oid = from_.get("aadObjectId") if isinstance(from_, Mapping) else None + if oid: + return _replace_session(request, entra_isolation_key(oid)) + # Unauthenticated channels (web chat, emulator) — fall back to the + # per-conversation key the channel already set. + return request + + return _hook + + +def make_telegram_hook(store: EntraIdentityStore) -> Any: + """Resolve identity then bump reasoning effort. + + The reasoning bump applies to **every** Telegram request — linked or + not — so the high-effort preset isn't silently lost the moment a + user runs ``/link`` (which is the headline feature of this sample). + Identity resolution and option mutation are separate concerns: we + swap the session if a link exists, then upgrade the options on the + way out either way. + """ + + def _hook(request: ChannelRequest, **_: object) -> ChannelRequest: + chat_id = request.attributes.get("chat_id") + if chat_id is not None: + linked = store.lookup(f"telegram:{chat_id}") + if linked is not None: + request = _replace_session(request, linked) + # Bump reasoning effort regardless of identity (linked or not). + options = dict(request.options or {}) + options["reasoning"] = {"effort": "high", "summary": "detailed"} + return replace(request, options=options) + + return _hook + + +def make_responses_hook(store: EntraIdentityStore) -> Any: + """Same identity resolution as Telegram/Teams, plus the usual option scrub. + + Resolution order: + 1. Body ``entra_oid`` (top-level or in ``metadata``) — a caller already + knows the user's Entra id. + 2. ``safety_identifier`` (or legacy ``user``) looked up in the link + store as ``responses:``. + 3. Fallback ``responses:``. + + .. WARNING:: + DEV ONLY. The ``entra_oid`` shortcut treats a client-supplied + identity claim as authoritative with **no token verification**: + any Responses caller can claim to be any user and read that + user's history bucket. Production deployments must either: + + - Drop this shortcut entirely and rely on ``safety_identifier`` + + the link store (i.e. force every caller through the OAuth + identity-link flow), or + - Add a JWT validator that verifies an inbound Authorization + header, extracts the verified ``oid`` claim, and feeds *that* + into ``entra_isolation_key`` — never trust a body field for + identity in a multi-tenant deployment. + + This shortcut exists only so the sample's smoke tests can pin + an isolation key without spinning up an Entra app registration. + """ + + def _hook( + request: ChannelRequest, + *, + protocol_request: Mapping[str, Any] | None = None, + **_: object, + ) -> ChannelRequest: + options = dict(request.options or {}) + options.pop("temperature", None) + options.pop("store", None) + + body = protocol_request or {} + metadata = body.get("metadata") if isinstance(body.get("metadata"), dict) else {} + + # WARNING (DEV ONLY): client-supplied entra_oid is trusted with + # NO verification. Production code must verify a JWT instead. + explicit_oid = body.get("entra_oid") or metadata.get("entra_oid") + safety_id = body.get("safety_identifier") or body.get("user") or "anonymous" + + if explicit_oid: + isolation_key = entra_isolation_key(explicit_oid) + else: + isolation_key = store.lookup(f"responses:{safety_id}") or f"responses:{safety_id}" + + return replace( + request, + session=ChannelSession(isolation_key=isolation_key), + options=options or None, + ) + + return _hook + + +# --------------------------------------------------------------------------- # +# Telegram commands +# --------------------------------------------------------------------------- # + + +def make_commands( + host_ref: dict[str, AgentFrameworkHost], + store: EntraIdentityStore, + linker_ref: dict[str, EntraIdentityLinkChannel | None], +) -> list[ChannelCommand]: + def _telegram_key(ctx: ChannelCommandContext) -> str: + chat_id = ctx.request.attributes.get("chat_id") + return f"telegram:{chat_id}" + + def _isolation_for(ctx: ChannelCommandContext) -> str: + # Honour any existing link so /new resets the right bucket. + return store.lookup(_telegram_key(ctx)) or _telegram_key(ctx) + + async def handle_start(ctx: ChannelCommandContext) -> None: + await ctx.reply( + "Hi! I'm a multi-channel agent.\nCommands: /link, /unlink, /new, /whoami, /weather , /help." + ) + + async def handle_help(ctx: ChannelCommandContext) -> None: + await ctx.reply( + "/link — bind this chat to your Entra ID for shared history\n" + "/unlink — unbind this chat\n" + "/new — start a fresh conversation\n" + "/whoami — show your isolation key\n" + "/weather — call the weather tool directly\n" + "/help — this message" + ) + + async def handle_link(ctx: ChannelCommandContext) -> None: + linker = linker_ref.get("linker") + if linker is None: + await ctx.reply( + "Identity linking is not configured on this host. " + "Set ENTRA_TENANT_ID, ENTRA_CLIENT_ID, and either " + "ENTRA_CLIENT_SECRET or ENTRA_CERTIFICATE_PATH." + ) + return + chat_id = ctx.request.attributes.get("chat_id") + if chat_id is None: + # Without a chat_id we'd format "telegram:None" into the + # authorize URL, OAuth would complete, and the store would + # gain a poisoned `telegram:None` entry that any later + # chat_id-less message would collapse onto. Refuse instead. + await ctx.reply( + "Couldn't determine your Telegram chat id; please retry " + "from a 1:1 chat with the bot." + ) + return + url = linker.authorize_url_for("telegram", str(chat_id)) + await ctx.reply("Open this link to bind this chat to your Microsoft account:\n" + url) + + async def handle_unlink(ctx: ChannelCommandContext) -> None: + await store.unlink(_telegram_key(ctx)) + await ctx.reply("This chat is no longer linked. New messages will use the chat-only key.") + + async def handle_new(ctx: ChannelCommandContext) -> None: + host_ref["host"].reset_session(_isolation_for(ctx)) + await ctx.reply("New session started. Previous history is cleared.") + + async def handle_whoami(ctx: ChannelCommandContext) -> None: + key = _isolation_for(ctx) + if key.startswith("entra:"): + await ctx.reply(f"This chat is linked. Isolation key: {key}") + else: + await ctx.reply(f"This chat is not linked to an Entra ID. Isolation key: {key}\nSend /link to bind it.") + + async def handle_weather(ctx: ChannelCommandContext) -> None: + _, _, location = ctx.request.input.partition(" ") + location = location.strip() or "Seattle" + await ctx.reply(lookup_weather(location=location)) + + return [ + ChannelCommand("start", "Introduce the bot", handle_start), + ChannelCommand("help", "List available commands", handle_help), + ChannelCommand("link", "Bind this chat to your Microsoft account", handle_link), + ChannelCommand("unlink", "Unbind this chat from any Microsoft account", handle_unlink), + ChannelCommand("new", "Start a new session for this chat", handle_new), + ChannelCommand("whoami", "Show the isolation key for this chat", handle_whoami), + ChannelCommand("weather", "Call the weather tool: /weather ", handle_weather), + ] + + +# --------------------------------------------------------------------------- # +# Host wiring +# --------------------------------------------------------------------------- # + + +def build_host() -> AgentFrameworkHost: + agent = Agent( + client=FoundryChatClient(credential=DefaultAzureCredential()), + name="WeatherAgent", + instructions=( + "You are a friendly weather assistant. Use the lookup_weather tool " + "for any weather question and answer in one short sentence." + ), + tools=[lookup_weather], + context_providers=[FileHistoryProvider(SESSIONS_DIR)], + default_options={"store": False}, + ) + + store = EntraIdentityStore(IDENTITY_STORE_PATH) + + # Optional Entra-OAuth identity linker. Pick exactly one credential mode: + # ENTRA_CLIENT_SECRET *or* ENTRA_CERT_PATH (+ optional ENTRA_CERT_PASSWORD). + # When unconfigured, /link tells the user the feature is disabled and the + # host runs without a linker. + tenant_id = os.environ.get("ENTRA_TENANT_ID") + client_id = os.environ.get("ENTRA_CLIENT_ID") + client_secret = os.environ.get("ENTRA_CLIENT_SECRET") + cert_path = os.environ.get("ENTRA_CERTIFICATE_PATH") + cert_password_env = os.environ.get("ENTRA_CERTIFICATE_PASSWORD") + public_base_url = os.environ.get("PUBLIC_BASE_URL", "http://localhost:8000") + + linker: EntraIdentityLinkChannel | None = None + if tenant_id and client_id and (client_secret or cert_path): + linker = EntraIdentityLinkChannel( + store=store, + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret, + certificate_path=cert_path, + certificate_password=cert_password_env.encode() if cert_password_env else None, + public_base_url=public_base_url, + ) + + host_ref: dict[str, AgentFrameworkHost] = {} + linker_ref: dict[str, EntraIdentityLinkChannel | None] = {"linker": linker} + + channels: list[Channel] = [ + ResponsesChannel(run_hook=make_responses_hook(store)), + InvocationsChannel(), + ActivityProtocolChannel( + app_id=os.environ.get("TEAMS_APP_ID"), + tenant_id=os.environ.get("TEAMS_TENANT_ID", "botframework.com"), + # Use either a client secret OR a certificate. Cert is required + # for tenants that disallow secrets — see the package README for + # an `openssl` recipe to generate one. + app_password=os.environ.get("TEAMS_APP_PASSWORD"), + certificate_path=os.environ.get("TEAMS_CERT_PATH"), + certificate_password=( + os.environ["TEAMS_CERT_PASSWORD"].encode() if os.environ.get("TEAMS_CERT_PASSWORD") else None + ), + run_hook=make_activity_hook(), + ), + TelegramChannel( + bot_token=os.environ["TELEGRAM_BOT_TOKEN"], + webhook_url=os.environ.get("TELEGRAM_WEBHOOK_URL"), + secret_token=os.environ.get("TELEGRAM_WEBHOOK_SECRET"), + parse_mode="Markdown", + commands=make_commands(host_ref, store, linker_ref), + run_hook=make_telegram_hook(store), + ), + ] + if linker is not None: + channels.append(linker) + + host = AgentFrameworkHost(target=agent, channels=channels, debug=True) + host_ref["host"] = host + return host + + +app = build_host().app + + +if __name__ == "__main__": + build_host().serve(host="0.0.0.0", port=int(os.environ.get("PORT", "8000"))) diff --git a/python/samples/04-hosting/af-hosting/local_identity_link/call_server.py b/python/samples/04-hosting/af-hosting/local_identity_link/call_server.py new file mode 100644 index 0000000000..93c5655c1f --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_identity_link/call_server.py @@ -0,0 +1,74 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Local client for the **complete** server (``app.py`` in this folder). + +Demonstrates the two most distinctive flows the complete sample adds on top +of the advanced sample: + +1. **Identity-linked Telegram resume.** Pass ``--previous-response-id + telegram:`` to resume a Telegram chat's history through the + Responses endpoint — this only works once the user has linked their + Telegram chat to their Entra account via the + ``EntraIdentityLinkChannel`` (visit ``/auth/start?channel=telegram&id=...`` + in the browser first). +2. **Multicast via ``response_target``.** Pass ``--telegram-chat-id`` to + have the host fan out the agent reply to a Telegram chat in addition + to returning it on the local wire. Drop ``--include-originating`` to + send only to Telegram and have the local response reduced to a small + acknowledgement. + +Start the server first (in another shell):: + + cd local_identity_link && uv run python app.py + +Then:: + + python call_server.py "What is the weather in Tokyo?" + python call_server.py --previous-response-id telegram:8741188429 "What did we discuss?" + python call_server.py --telegram-chat-id 8741188429 "Heads up, sending to your phone too." +""" + +from __future__ import annotations + +import argparse + +from openai import OpenAI + +BASE_URL = "http://127.0.0.1:8000" + + +def main() -> None: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("--safety-identifier", default="local-dev") + parser.add_argument("--previous-response-id", default=None) + parser.add_argument("--telegram-chat-id", default=None) + parser.add_argument("--include-originating", action="store_true", default=True) + parser.add_argument("prompt", nargs="*") + args = parser.parse_args() + + prompt = " ".join(args.prompt) or "What is the weather in Seattle?" + + extra_body: dict[str, object] = {} + if args.telegram_chat_id is not None: + targets: list[str] = [] + if args.include_originating: + targets.append("originating") + targets.append(f"telegram:{args.telegram_chat_id}") + extra_body["response_target"] = targets + + client = OpenAI(base_url=BASE_URL, api_key="not-needed") + response = client.responses.create( + model="agent", + input=prompt, + safety_identifier=args.safety_identifier, + previous_response_id=args.previous_response_id, + extra_body=extra_body or None, + ) + print(f"User: {prompt}") + print(f"Agent: {response.output_text}") + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/af-hosting/local_identity_link/pyproject.toml b/python/samples/04-hosting/af-hosting/local_identity_link/pyproject.toml new file mode 100644 index 0000000000..6e8a4d5505 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_identity_link/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "agent-framework-hosting-sample-complete" +version = "0.0.1" +description = "Complete multi-channel hosting sample (Responses + Invocations + Telegram + Activity Protocol + Entra identity-link)." +requires-python = ">=3.10" +dependencies = [ + "agent-framework-foundry", + "agent-framework-hosting", + "agent-framework-hosting-activity-protocol", + "agent-framework-hosting-entra", + "agent-framework-hosting-invocations", + "agent-framework-hosting-responses", + "agent-framework-hosting-telegram", + "azure-identity", + "hypercorn>=0.17", + "httpx>=0.27", + "aiohttp>=3.13.5", +] + +[dependency-groups] +dev = ["openai>=1.99"] + +[tool.uv] +package = false + +[tool.uv.sources] +agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" } +agent-framework-hosting-activity-protocol = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-activity-protocol" } +agent-framework-hosting-entra = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-entra" } +agent-framework-hosting-invocations = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-invocations" } +agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" } +agent-framework-hosting-telegram = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-telegram" } diff --git a/python/samples/04-hosting/af-hosting/local_responses/README.md b/python/samples/04-hosting/af-hosting/local_responses/README.md new file mode 100644 index 0000000000..0e836470ba --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses/README.md @@ -0,0 +1,49 @@ +# local_responses — Responses-only with a settings-altering hook + +The smallest end-to-end `agent-framework-hosting` shape: one Foundry +agent with a `@tool`, one `ResponsesChannel`, one `run_hook`. Useful as +the entry-point sample for understanding the **channel run-hook** seam +without any multi-channel or identity-link concerns. + +What the run hook demonstrates: + +- **Strips** caller-supplied `temperature` / `store` so the host owns + those settings. +- **Forces** a `reasoning` preset (`effort=medium`, `summary=auto`) on + every turn — caller-side overrides are ignored. + +`app:app` is a module-level Starlette ASGI app; recommended local launch +is Hypercorn. + +## Run + +```bash +export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com +export FOUNDRY_MODEL=gpt-5.4-nano +az login + +uv sync +uv run hypercorn app:app --bind 0.0.0.0:8000 +``` + +Single-process for quick iteration: + +```bash +uv run python app.py +``` + +## Call locally + +```bash +uv sync --group dev + +# Plain call: +uv run python call_server.py "What is the weather in Tokyo?" + +# Continue an existing conversation by its `response.id`: +uv run python call_server.py --previous-response-id "And in Seattle?" +``` + +> This sample is **local-only** — no Dockerfile, no Foundry packaging. +> For a Foundry-Hosted-Agents-compatible packaging see +> [`../foundry_hosted_agent`](../foundry_hosted_agent). diff --git a/python/samples/04-hosting/af-hosting/local_responses/app.py b/python/samples/04-hosting/af-hosting/local_responses/app.py new file mode 100644 index 0000000000..1731278611 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses/app.py @@ -0,0 +1,113 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Minimal Responses-only hosting sample. + +Single agent with one ``@tool`` (``lookup_weather``), single channel +(``ResponsesChannel``), one ``run_hook`` that demonstrates the +settings-mutation seam over caller-supplied options. + +What the hook does +------------------ +On every Responses request the hook receives the ``ChannelRequest`` that +the channel built from the inbound HTTP body. It: + +- strips ``store`` (this agent owns persistence) and ``temperature`` + (the configured model may not honor it), +- forces a ``reasoning`` effort + summary preset so the deployed surface + is consistent regardless of what the caller sent. + +The hook is the documented escape hatch over the uniform +``ChannelRequest`` envelope. + +Run +--- +``app`` is a module-level Starlette ASGI app. Recommended local launch:: + + uv sync + az login + export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com + export FOUNDRY_MODEL=gpt-5.4-nano + uv run hypercorn app:app --bind 0.0.0.0:8000 + +Or use the ``__main__`` block (single-process Hypercorn) for quick +iteration:: + + uv run python app.py + +Then call it:: + + uv run python call_server.py "What is the weather in Tokyo?" +""" + +from __future__ import annotations + +import os +from dataclasses import replace +from pathlib import Path +from random import randint +from typing import Annotated + +from agent_framework import Agent, FileHistoryProvider, tool +from agent_framework_foundry import FoundryChatClient +from agent_framework_hosting import AgentFrameworkHost, ChannelRequest +from agent_framework_hosting_responses import ResponsesChannel +from azure.identity.aio import DefaultAzureCredential + +SESSIONS_DIR = Path(__file__).resolve().parent / "storage" / "sessions" +SESSIONS_DIR.mkdir(parents=True, exist_ok=True) + + +@tool(approval_mode="never_require") +def lookup_weather( + location: Annotated[str, "The city to look up weather for."], +) -> str: + """Return a deterministic weather report for a city.""" + high_temp = randint(5, 25) + reports = { + "Seattle": f"Seattle is rainy with a high of {high_temp}°C.", + "Amsterdam": f"Amsterdam is cloudy with a high of {high_temp}°C.", + "Tokyo": f"Tokyo is clear with a high of {high_temp}°C.", + } + return reports.get(location, f"{location} is sunny with a high of {high_temp}°C.") + + +def responses_hook(request: ChannelRequest, **_: object) -> ChannelRequest: + """Strip caller-supplied options the host should own and force a + reasoning preset.""" + options = dict(request.options or {}) + + # The agent's default_options own ``store``; the model may not honor + # ``temperature``. Strip both so the caller can't override. + options.pop("temperature", None) + options.pop("store", None) + + # Force a consistent reasoning preset on every turn. + options["reasoning"] = {"effort": "medium", "summary": "auto"} + + return replace(request, options=options or None) + + +def build_host() -> AgentFrameworkHost: + agent = Agent( + client=FoundryChatClient(credential=DefaultAzureCredential()), + name="WeatherAgent", + instructions=( + "You are a friendly weather assistant. Use the lookup_weather tool " + "for any weather question and answer in one short sentence." + ), + tools=[lookup_weather], + context_providers=[FileHistoryProvider(SESSIONS_DIR)], + default_options={"store": False}, + ) + return AgentFrameworkHost( + target=agent, + channels=[ResponsesChannel(run_hook=responses_hook)], + debug=True, + ) + + +app = build_host().app + + +if __name__ == "__main__": + build_host().serve(host="0.0.0.0", port=int(os.environ.get("PORT", "8000"))) diff --git a/python/samples/04-hosting/af-hosting/local_responses/call_server.py b/python/samples/04-hosting/af-hosting/local_responses/call_server.py new file mode 100644 index 0000000000..aeeaa39479 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses/call_server.py @@ -0,0 +1,47 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Local client for the local_responses sample. + +Posts to ``/responses`` using the standard ``openai`` SDK. + +Pass ``--previous-response-id `` to continue a conversation by its +``response.id`` (returned in the prior response). + +Start the server first (in another shell):: + + uv run python app.py + +Then:: + + uv run python call_server.py "What is the weather in Tokyo?" +""" + +from __future__ import annotations + +import sys + +from openai import OpenAI + +BASE_URL = "http://127.0.0.1:8000" + + +def main() -> None: + args = sys.argv[1:] + previous_response_id: str | None = None + if len(args) >= 2 and args[0] == "--previous-response-id": + previous_response_id = args[1] + args = args[2:] + print(f"Resuming response: {previous_response_id}") + prompt = " ".join(args) or "What is the weather in Tokyo?" + client = OpenAI(base_url=BASE_URL, api_key="not-needed") + response = client.responses.create( + model="agent", + input=prompt, + previous_response_id=previous_response_id, + ) + print(f"User: {prompt}") + print(f"Agent: {response.output_text}") + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml b/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml new file mode 100644 index 0000000000..fb96b95e07 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "agent-framework-hosting-sample-local-responses" +version = "0.0.1" +description = "Minimal Responses-only local hosting sample with a settings-altering run hook." +requires-python = ">=3.10" +dependencies = [ + "agent-framework-foundry", + "agent-framework-hosting", + "agent-framework-hosting-responses", + "azure-identity", + "aiohttp>=3.13.5", + "hypercorn>=0.17", +] + +[dependency-groups] +dev = ["openai>=1.99"] + +[tool.uv] +package = false + +[tool.uv.sources] +agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" } +agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" } diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/README.md b/python/samples/04-hosting/af-hosting/local_responses_workflow/README.md new file mode 100644 index 0000000000..a168ef482e --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/README.md @@ -0,0 +1,86 @@ +# local_responses_workflow — workflow target with structured intake + checkpoints + +A `Workflow` (intake → writer → legal reviewer → formatter) hosted +behind **both the Responses API and the Invocations API**, with the +host configured to **persist per-conversation checkpoints**. Mirrors +[`../../foundry-hosted-agents/responses/04_workflows/`](../../foundry-hosted-agents/responses/04_workflows/) +but uses the `agent-framework-hosting` stack instead of the +Foundry-Hosted-Agents runtime, and adds a structured intake step +(`SloganBrief` with `topic` / `style` / `audience` fields) at the front +of the workflow. + +## What's interesting + +- `AgentFrameworkHost(target=workflow, …)` — the host detects a + `Workflow` target and dispatches to `workflow.run(...)` (no + `Agent.create_session(...)`). +- Two channels are mounted side-by-side (`ResponsesChannel` at + `/responses`, `InvocationsChannel` at `/invocations/invoke`). Both + share the **same `brief_hook`** that **adapts the channel-native + input into the workflow start executor's typed input** — Responses + delivers a `list[Message]`, Invocations delivers a `str`, but the + hook normalises both to text and produces a `SloganBrief`. +- The hook parses the inbound text as JSON + (`{"topic": ..., "style": ..., "audience": ...}`); if parsing fails + it uses the whole text as `topic` with defaults. +- The workflow's first executor (`BriefIntakeExecutor`) accepts + `SloganBrief` directly — that's what gets sent into `workflow.run(...)` + by the host. +- `checkpoint_location=storage/checkpoints/` — the host scopes a + `FileCheckpointStorage` per conversation (Responses keys it on + `previous_response_id` / `conversation_id`; Invocations keys it on + `session_id`) and **restores from the latest checkpoint at the start + of every turn** before applying the new input. Without an isolation + key the host skips checkpointing for that request. +- No `HistoryProvider` — the workflow owns its own state via the + checkpoint store. + +## Run + +```bash +export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com +export FOUNDRY_MODEL=gpt-5.4-nano +az login + +uv sync +uv run hypercorn app:app --bind 0.0.0.0:8000 +``` + +Single-process for quick iteration: + +```bash +uv run python app.py +``` + +## Call locally + +Two clients are provided next to `app.py`: + +- **`call_server.py`** — Python client using the OpenAI SDK (Responses + API only). +- **`call_server.rest`** — raw REST examples for **both** the Responses + and Invocations endpoints (open in VS Code with the REST Client + extension or any compatible HTTP-file runner). + +```bash +uv sync --group dev + +# Structured brief via the OpenAI SDK (Responses API): +uv run python call_server.py \ + '{"topic": "electric SUV", "style": "playful", "audience": "young families"}' + +# Plain topic (style/audience default to "modern" / "general"): +uv run python call_server.py "electric SUV" + +# Continue an existing conversation by its `response.id`: +uv run python call_server.py --previous-response-id \ + '{"topic": "electric SUV", "style": "retro", "audience": "boomers"}' +``` + +After a few turns, inspect `storage/checkpoints//` — +each conversation has its own subdirectory of checkpoint files written +by the host. + +> This sample is **local-only** — no Dockerfile, no Foundry packaging. +> For a Foundry-Hosted-Agents-compatible packaging see +> [`../foundry_hosted_agent`](../foundry_hosted_agent). diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/app.py b/python/samples/04-hosting/af-hosting/local_responses_workflow/app.py new file mode 100644 index 0000000000..a975e81d1f --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/app.py @@ -0,0 +1,227 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Hosted workflow sample with a structured intake step + checkpoint location. + +Same three-agent slogan workflow as +``../../foundry-hosted-agents/responses/04_workflows/main.py`` (writer → +legal reviewer → formatter), but with an extra **structured intake** +step at the front and driven through the ``agent-framework-hosting`` +stack instead of the Foundry-Hosted-Agents runtime. + +Workflow shape +-------------- +``BriefIntakeExecutor`` (typed :class:`SloganBrief` input) → ``writer`` +→ ``legal_reviewer`` → ``formatter``. The intake step formats the +structured brief into a prompt the writer agent understands. + +What this sample shows +---------------------- +- A :class:`~agent_framework.Workflow` is a valid hosting target — the + host detects it and dispatches to ``workflow.run(...)`` instead of + ``agent.run(...)``. +- ``ResponsesChannel(run_hook=...)`` (and the same hook on + ``InvocationsChannel``) is the seam for **adapting the channel-native + input into the workflow start executor's typed input**. The hook here + parses the inbound text as JSON + (``{"topic": ..., "style": ..., "audience": ...}``) — if parsing + fails it falls back to using the whole text as ``topic`` with + defaults — and replaces ``ChannelRequest.input`` with a + :class:`SloganBrief`. +- ``AgentFrameworkHost(checkpoint_location=...)`` enables + per-conversation workflow checkpointing. The host scopes the + checkpoint storage by ``ChannelRequest.session.isolation_key`` + (Responses uses ``previous_response_id`` / ``conversation_id`` as the + isolation key), and restores from the latest checkpoint before each + new turn — so a multi-turn workflow can resume across requests. +- No ``HistoryProvider`` is configured: the workflow owns its own state + via the checkpoint store; the agent-history seam is for plain + ``SupportsAgentRun`` agents. + +Run +--- +``app`` is a module-level Starlette ASGI app:: + + uv sync + az login + export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com + export FOUNDRY_MODEL=gpt-5.4-nano + uv run hypercorn app:app --bind 0.0.0.0:8000 + +Or for quick iteration:: + + uv run python app.py + +Then call it with a structured brief:: + + uv run python call_server.py \\ + '{"topic": "electric SUV", "style": "playful", "audience": "young families"}' + +Or with just a topic — the hook fills in defaults:: + + uv run python call_server.py "Create a slogan for an electric SUV." +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, replace +from pathlib import Path + +from agent_framework import ( + Agent, + AgentExecutor, + Executor, + Message, + WorkflowBuilder, + WorkflowContext, + handler, +) +from agent_framework_foundry import FoundryChatClient +from agent_framework_hosting import AgentFrameworkHost, ChannelRequest +from agent_framework_hosting_invocations import InvocationsChannel +from agent_framework_hosting_responses import ResponsesChannel +from azure.identity.aio import DefaultAzureCredential + +CHECKPOINTS_DIR = Path(__file__).resolve().parent / "storage" / "checkpoints" +CHECKPOINTS_DIR.mkdir(parents=True, exist_ok=True) + + +@dataclass +class SloganBrief: + """Typed input for the workflow's first executor.""" + + topic: str + style: str = "modern" + audience: str = "general" + + +class BriefIntakeExecutor(Executor): + """Format a :class:`SloganBrief` into a prompt for the writer agent.""" + + @handler + async def handle(self, brief: SloganBrief, ctx: WorkflowContext[str]) -> None: + prompt = ( + f"Topic: {brief.topic}\n" + f"Style: {brief.style}\n" + f"Audience: {brief.audience}\n\n" + "Write a single short slogan that fits the topic, style, and audience." + ) + await ctx.send_message(prompt) + + +def _extract_text(value: object) -> str: + """Pull plain text out of whatever the Responses channel produced. + + The channel hands the host either a ``str`` (rare on the Responses + surface) or a list of :class:`Message`. The hook collapses both to + a single concatenated string before attempting to parse a brief. + """ + if isinstance(value, str): + return value + if isinstance(value, Message): + return value.text + if isinstance(value, list): + return "\n".join(_extract_text(item) for item in value) + return "" + + +def _parse_brief(text: str) -> SloganBrief: + """Parse user text into a :class:`SloganBrief`. + + Accepts a JSON object with ``topic`` / ``style`` / ``audience`` + keys; falls back to using the whole text as ``topic`` with the + other fields defaulted. + """ + text = text.strip() + if text.startswith("{"): + try: + data = json.loads(text) + except json.JSONDecodeError: + data = None + if isinstance(data, dict) and "topic" in data: + return SloganBrief( + topic=str(data["topic"]), + style=str(data.get("style", "modern")), + audience=str(data.get("audience", "general")), + ) + return SloganBrief(topic=text or "a generic product") + + +def brief_hook(request: ChannelRequest, **_: object) -> ChannelRequest: + """Adapt the channel's free-form text into the workflow's typed input. + + This is the canonical seam for shaping ``ChannelRequest.input`` into + the workflow start executor's input type — here :class:`SloganBrief` + instead of ``str`` / ``list[Message]``. Shared between the Responses + channel (which delivers a list of :class:`Message`) and the + Invocations channel (which delivers a plain ``str``). + """ + brief = _parse_brief(_extract_text(request.input)) + return replace(request, input=brief) + + +def build_host() -> AgentFrameworkHost: + client = FoundryChatClient(credential=DefaultAzureCredential()) + + writer = Agent( + client=client, + name="writer", + instructions=( + "You are an excellent slogan writer. You create new slogans based on the given topic." + ), + ) + legal = Agent( + client=client, + name="legal_reviewer", + instructions=( + "You are an excellent legal reviewer. " + "Make necessary corrections to the slogan so that it is legally compliant." + ), + ) + formatter = Agent( + client=client, + name="formatter", + instructions=( + "You are an excellent content formatter. " + "You take the slogan and format it in a cool retro style when printing to a terminal." + ), + ) + + intake_ex = BriefIntakeExecutor(id="intake") + # ``context_mode="last_agent"`` ensures each agent only sees the + # previous executor's output — matching the Foundry sample. + writer_ex = AgentExecutor(writer, context_mode="last_agent") + legal_ex = AgentExecutor(legal, context_mode="last_agent") + format_ex = AgentExecutor(formatter, context_mode="last_agent") + + workflow = ( + WorkflowBuilder( + start_executor=intake_ex, + output_executors=[format_ex], + ) + .add_edge(intake_ex, writer_ex) + .add_edge(writer_ex, legal_ex) + .add_edge(legal_ex, format_ex) + .build() + ) + + return AgentFrameworkHost( + target=workflow, + channels=[ + ResponsesChannel(run_hook=brief_hook), + InvocationsChannel(run_hook=brief_hook), + ], + # The host writes a per-conversation FileCheckpointStorage rooted + # at ``CHECKPOINTS_DIR / `` and restores from the + # latest checkpoint at the start of every turn. + checkpoint_location=CHECKPOINTS_DIR, + debug=True, + ) + + +app = build_host().app + + +if __name__ == "__main__": + build_host().serve(host="0.0.0.0", port=int(os.environ.get("PORT", "8000"))) diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.py b/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.py new file mode 100644 index 0000000000..238de564ed --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Local client for the local_responses_workflow sample. + +The server expects a structured slogan brief. You can either pass a +JSON object or a plain topic string (the server's run hook fills the +other fields with defaults). + +Pass ``--previous-response-id `` to continue a conversation by its +``response.id`` — the host uses that as the workflow checkpoint scope +key, so the workflow resumes from where it left off. + +Start the server first (in another shell):: + + uv run python app.py + +Then:: + + uv run python call_server.py \\ + '{"topic": "electric SUV", "style": "playful", "audience": "young families"}' + + uv run python call_server.py "electric SUV" # uses default style/audience +""" + +from __future__ import annotations + +import sys + +from openai import OpenAI + +BASE_URL = "http://127.0.0.1:8000" + + +def main() -> None: + args = sys.argv[1:] + previous_response_id: str | None = None + if len(args) >= 2 and args[0] == "--previous-response-id": + previous_response_id = args[1] + args = args[2:] + print(f"Resuming response: {previous_response_id}") + prompt = " ".join(args) or '{"topic": "electric SUV", "style": "playful", "audience": "young families"}' + client = OpenAI(base_url=BASE_URL, api_key="not-needed") + response = client.responses.create( + model="agent", + input=prompt, + previous_response_id=previous_response_id, + ) + print(f"User: {prompt}") + print(f"Agent: {response.output_text}") + print(f"response.id: {response.id}") + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.rest b/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.rest new file mode 100644 index 0000000000..75f5b78c96 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.rest @@ -0,0 +1,92 @@ +# local_responses_workflow — REST examples +# +# Use with the VS Code "REST Client" extension (humao.rest-client) or +# JetBrains HTTP Client. Each `###` block is one request. +# +# Start the server in another shell first: +# uv run python app.py + +@host = http://127.0.0.1:8000 + +### +# 1. Responses API — structured brief +POST {{host}}/responses +Content-Type: application/json + +{ + "model": "agent", + "input": "{\"topic\": \"electric SUV\", \"style\": \"playful\", \"audience\": \"young families\"}" +} + +### +# 2. Responses API — plain topic, defaults applied by the run hook +POST {{host}}/responses +Content-Type: application/json + +{ + "model": "agent", + "input": "vintage espresso machine" +} + +### +# 3. Responses API — continue the conversation by previous_response_id +# Replace with `id` from one of the responses above — +# the host uses it as the workflow checkpoint scope key, so the +# workflow resumes from its latest checkpoint before applying the +# new input. +POST {{host}}/responses +Content-Type: application/json + +{ + "model": "agent", + "previous_response_id": "", + "input": "{\"topic\": \"electric SUV\", \"style\": \"retro\", \"audience\": \"boomers\"}" +} + +### +# 4. Invocations API — structured brief +POST {{host}}/invocations/invoke +Content-Type: application/json + +{ + "message": "{\"topic\": \"electric SUV\", \"style\": \"playful\", \"audience\": \"young families\"}", + "session_id": "demo-1" +} + +### +# 5. Invocations API — plain topic +POST {{host}}/invocations/invoke +Content-Type: application/json + +{ + "message": "noise-cancelling headphones", + "session_id": "demo-2" +} + +### +# 6. Invocations API — resume the same session_id to reuse the +# workflow's per-conversation checkpoint store. +POST {{host}}/invocations/invoke +Content-Type: application/json + +{ + "message": "{\"topic\": \"noise-cancelling headphones\", \"style\": \"minimalist\", \"audience\": \"developers\"}", + "session_id": "demo-2" +} + +### +# 7. Invocations API — streaming (SSE; one `data:` line per chunk, +# terminated by `data: [DONE]`). +POST {{host}}/invocations/invoke +Content-Type: application/json +Accept: text/event-stream + +{ + "message": "{\"topic\": \"reusable water bottle\", \"style\": \"bold\", \"audience\": \"college students\"}", + "session_id": "demo-3", + "stream": true +} + +### +# 8. Readiness probe +GET {{host}}/readiness diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml b/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml new file mode 100644 index 0000000000..81f91e7eae --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "agent-framework-hosting-sample-local-responses-workflow" +version = "0.0.1" +description = "Local hosting sample exposing a 3-agent workflow over the Responses API with per-conversation checkpoint storage." +requires-python = ">=3.10" +dependencies = [ + "agent-framework-foundry", + "agent-framework-hosting", + "agent-framework-hosting-invocations", + "agent-framework-hosting-responses", + "azure-identity", + "aiohttp>=3.13.5", + "hypercorn>=0.17", +] + +[dependency-groups] +dev = ["openai>=1.99"] + +[tool.uv] +package = false + +[tool.uv.sources] +agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" } +agent-framework-hosting-invocations = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-invocations" } +agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" } diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/storage/checkpoints/.gitkeep b/python/samples/04-hosting/af-hosting/local_responses_workflow/storage/checkpoints/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/samples/04-hosting/af-hosting/local_telegram/README.md b/python/samples/04-hosting/af-hosting/local_telegram/README.md new file mode 100644 index 0000000000..939c905561 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_telegram/README.md @@ -0,0 +1,59 @@ +# local_telegram — `@tool`, file-backed history, hooks, multicast + +Builds on `foundry_hosted_agent/` with the hooks and config most real apps need: + +- A `@tool`-decorated function call (`get_weather`) so streaming and tool + invocation are exercised end-to-end. +- `FileHistoryProvider(./storage/sessions)` so per-user/per-chat history + survives restarts. +- A `responses_hook` that keys each session off the OpenAI + `safety_identifier` field, so multiple users on the Responses endpoint + do not share history. +- A `telegram_hook` that keys per-chat sessions via `telegram_isolation_key`. +- Two extra Telegram commands (`/new`, `/whoami`). +- `ResponseTarget` multicast: a Responses request can fan out the agent + reply to a Telegram chat by passing + `extra_body={"response_target": ["originating", "telegram:"]}`. + +`app:app` is a module-level Starlette ASGI app, so this sample runs under +Hypercorn (multi-process). + +## Run + +```bash +export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com +export FOUNDRY_MODEL=gpt-4o +export TELEGRAM_BOT_TOKEN=... +az login + +uv sync +uv run hypercorn app:app \ + --bind 0.0.0.0:8000 \ + --workers 4 +``` + +Single-process for quick iteration: + +```bash +uv run python app.py +``` + +## Call locally + +```bash +uv sync --group dev + +# Plain call: +uv run python call_server.py "What is the weather in Tokyo?" + +# Resume an existing session by AgentSession id (works across channels): +uv run python call_server.py --previous-response-id telegram:8741188429 "What did we discuss?" + +# Multicast: keep the reply on the local wire AND push it to Telegram. +uv run python call_server_multicast.py --telegram-chat-id 8741188429 "Heads up." +``` + +> This sample is **local-only** — it shows the `agent-framework-hosting` +> server stack as a standalone process. For a Foundry-Hosted-Agents-compatible +> packaging (Dockerfile + `agent.yaml` + `azure.yaml`), see +> [`foundry_hosted_agent/`](../foundry_hosted_agent). diff --git a/python/samples/04-hosting/af-hosting/local_telegram/app.py b/python/samples/04-hosting/af-hosting/local_telegram/app.py new file mode 100644 index 0000000000..48577eee92 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_telegram/app.py @@ -0,0 +1,226 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Advanced multi-channel hosting sample. + +Builds on ``app.py`` to demonstrate: + +- a function ``@tool`` on the agent (``lookup_weather``), +- per-isolation-key history persisted via ``FileHistoryProvider``, +- a ``ResponsesChannel`` ``run_hook`` that clamps caller-supplied + ``ChatOptions`` and honours the OpenAI ``previous_response_id`` field as + the ``AgentSession`` id — so a Responses caller can resume a Telegram + chat by passing ``previous_response_id="telegram:"`` (or any + other isolation key written by another channel), +- a ``TelegramChannel`` ``run_hook`` that bumps ``temperature`` for a + chattier Telegram persona, +- a richer Telegram command catalog including a ``/new`` command that resets + the cached session for the chat. + +Required env: ``FOUNDRY_PROJECT_ENDPOINT``, ``FOUNDRY_MODEL``, +``TELEGRAM_BOT_TOKEN``. Auth uses ``DefaultAzureCredential``. + +Run +--- +This module exposes ``app`` as the canonical ASGI surface. Recommended +production launch is **Hypercorn**:: + + hypercorn app:app --bind 0.0.0.0:8000 --workers 4 + +The ``__main__`` block below uses ``host.serve(...)`` (single-process +Hypercorn) as a local-dev fallback. + +Note +---- +``FileHistoryProvider`` provides only in-process file-write locking. Running +multiple Hypercorn workers against the same ``./sessions`` directory is fine +for this sample, but a production deployment should swap it for a store with +cross-process consistency. +""" + +from __future__ import annotations + +import os +from dataclasses import replace +from pathlib import Path +from random import randint +from typing import Annotated + +from agent_framework import Agent, FileHistoryProvider, tool +from agent_framework_foundry import FoundryChatClient +from agent_framework_hosting import ( + AgentFrameworkHost, + ChannelCommand, + ChannelCommandContext, + ChannelRequest, + ChannelSession, +) +from agent_framework_hosting_responses import ResponsesChannel +from agent_framework_hosting_telegram import TelegramChannel, telegram_isolation_key +from azure.identity.aio import DefaultAzureCredential + +# import logging +# logging.basicConfig(level=logging.DEBUG) + +SESSIONS_DIR = Path(__file__).resolve().parent / "storage" / "sessions" +SESSIONS_DIR.mkdir(parents=True, exist_ok=True) + + +# --------------------------------------------------------------------------- # +# Tools the agent can call +# --------------------------------------------------------------------------- # + + +@tool(approval_mode="never_require") +def lookup_weather( + location: Annotated[str, "The city to look up weather for."], +) -> str: + """Return a deterministic weather report for a city.""" + high_temp = randint(5, 25) + reports = { + "Seattle": f"Seattle is rainy with a high of {high_temp}°C.", + "Amsterdam": f"Amsterdam is cloudy with a high of {high_temp}°C.", + "Tokyo": f"Tokyo is clear with a high of {high_temp}°C.", + } + return reports.get(location, f"{location} is sunny with a high of {high_temp}°C.") + + +# --------------------------------------------------------------------------- # +# Responses channel run hook +# --------------------------------------------------------------------------- # + + +def responses_hook(request: ChannelRequest, *, protocol_request: dict | None = None, **_: object) -> ChannelRequest: + """Validate, rewrite, and key the channel-built ChannelRequest before invocation. + + The spec calls this out as the developer's runtime escape hatch over the + uniform ``ChannelRequest`` envelope. Things this hook does: + + - **strip** ``store`` and ``temperature`` (the agent owns persistence via ``FileHistoryProvider``), + - **inject a session** keyed on the request body. The OpenAI Responses + ``previous_response_id`` field doubles as our isolation key — the + ``ResponsesChannel`` already lifts it onto ``request.session``, so any + caller can resume an arbitrary AgentSession (including one written by + another channel, e.g. ``telegram:8741188429``) by passing it as + ``previous_response_id``. When the caller doesn't pass one, fall back + to a key derived from the OpenAI ``safety_identifier`` field + (``responses:``). + """ + options = dict(request.options or {}) + + # this agent will only run with models that do not support Temperature, so removing it. + options.pop("temperature", None) + options.pop("store", None) + + body = protocol_request or {} + + if request.session is not None and request.session.isolation_key: + # Caller supplied ``previous_response_id`` — the channel already + # used it as the AgentSession id. Keep it as-is. + session = request.session + else: + safety_id = body.get("safety_identifier") or "anonymous" + session = ChannelSession(isolation_key=f"responses:{safety_id}") + + return replace( + request, + session=session, + options=options or None, + ) + + +def telegram_hook(request: ChannelRequest, **_: object) -> ChannelRequest: + """Telegram users get a chattier model — bump temperature on every turn.""" + options = dict(request.options or {}) + options["reasoning"] = {"effort": "high", "summary": "detailed"} + return replace(request, options=options) + + +# --------------------------------------------------------------------------- # +# Telegram commands +# --------------------------------------------------------------------------- # + + +def _isolation_key(ctx: ChannelCommandContext) -> str: + return telegram_isolation_key(ctx.request.attributes.get("chat_id")) + + +def make_commands(host_ref: dict[str, AgentFrameworkHost]) -> list[ChannelCommand]: + """Build commands that close over the host so ``/new`` can reset state.""" + + async def handle_start(ctx: ChannelCommandContext) -> None: + await ctx.reply("Hi! I'm a multi-channel agent.\nCommands: /new, /whoami, /weather , /help.") + + async def handle_help(ctx: ChannelCommandContext) -> None: + await ctx.reply( + "/new — start a fresh conversation\n" + "/whoami — show your isolation key\n" + "/weather — call the weather tool directly\n" + "/help — this message" + ) + + async def handle_new(ctx: ChannelCommandContext) -> None: + host_ref["host"].reset_session(_isolation_key(ctx)) + await ctx.reply("New session started. Previous history is cleared for this chat.") + + async def handle_whoami(ctx: ChannelCommandContext) -> None: + await ctx.reply(f"Your isolation key on this host is: {_isolation_key(ctx)}") + + async def handle_weather(ctx: ChannelCommandContext) -> None: + # Bypass the agent and call the tool directly to demonstrate that + # commands have full control over how they reply. + _, _, location = ctx.request.input.partition(" ") + location = location.strip() or "Seattle" + await ctx.reply(lookup_weather(location=location)) + + return [ + ChannelCommand("start", "Introduce the bot", handle_start), + ChannelCommand("help", "List available commands", handle_help), + ChannelCommand("new", "Start a new session for this chat", handle_new), + ChannelCommand("whoami", "Show the isolation key for this chat", handle_whoami), + ChannelCommand("weather", "Call the weather tool: /weather ", handle_weather), + ] + + +# --------------------------------------------------------------------------- # +# Host wiring +# --------------------------------------------------------------------------- # + + +def build_host() -> AgentFrameworkHost: + agent = Agent( + client=FoundryChatClient(credential=DefaultAzureCredential()), + name="WeatherAgent", + instructions=( + "You are a friendly weather assistant. Use the lookup_weather tool " + "for any weather question and answer in one short sentence." + ), + tools=[lookup_weather], + context_providers=[FileHistoryProvider(SESSIONS_DIR)], + default_options={"store": False}, + ) + + host_ref: dict[str, AgentFrameworkHost] = {} + host = AgentFrameworkHost( + target=agent, + channels=[ + ResponsesChannel(run_hook=responses_hook), + TelegramChannel( + bot_token=os.environ["TELEGRAM_BOT_TOKEN"], + webhook_url=os.environ.get("TELEGRAM_WEBHOOK_URL"), + secret_token=os.environ.get("TELEGRAM_WEBHOOK_SECRET"), + parse_mode="Markdown", + commands=make_commands(host_ref), + run_hook=telegram_hook, + ), + ], + debug=True, + ) + host_ref["host"] = host + return host + + +app = build_host().app + + +if __name__ == "__main__": + build_host().serve(host="0.0.0.0", port=int(os.environ.get("PORT", "8000"))) diff --git a/python/samples/04-hosting/af-hosting/local_telegram/call_server.py b/python/samples/04-hosting/af-hosting/local_telegram/call_server.py new file mode 100644 index 0000000000..1ae01be65b --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_telegram/call_server.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Local client for the advanced agent — POSTs to the ``/responses`` endpoint +exposed by ``server/advanced_app.py`` using the standard ``openai`` SDK. + +The advanced server's ``responses_hook`` keys per-user history off the +OpenAI ``safety_identifier`` field, so we pass ``safety_identifier=`` here. + +Pass ``--previous-response-id `` to resume an existing AgentSession by +its isolation key. Because the server uses ``previous_response_id`` directly +as the ``AgentSession`` id, you can resume any session written by any +channel — for example a Telegram chat at +``--previous-response-id telegram:8741188429``. + +Start the server first (in another shell):: + + cd server && uv run python advanced_app.py + +Then:: + + python call_server.py "What is the weather in Tokyo?" + python call_server.py --previous-response-id telegram:8741188429 "What did we discuss?" +""" + +from __future__ import annotations + +import sys + +from openai import OpenAI + +BASE_URL = "http://127.0.0.1:8000" + + +def main() -> None: + args = sys.argv[1:] + previous_response_id: str | None = None + if len(args) >= 2 and args[0] == "--previous-response-id": + previous_response_id = args[1] + args = args[2:] + print(f"Resuming AgentSession: {previous_response_id}") + prompt = " ".join(args) or "What is the weather in Seattle?" + client = OpenAI(base_url=BASE_URL, api_key="not-needed") + response = client.responses.create( + model="agent", + input=prompt, + safety_identifier="local-dev", + previous_response_id=previous_response_id, + ) + print(f"User: {prompt}") + print(f"Agent: {response.output_text}") + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/af-hosting/local_telegram/call_server_multicast.py b/python/samples/04-hosting/af-hosting/local_telegram/call_server_multicast.py new file mode 100644 index 0000000000..123b16502e --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_telegram/call_server_multicast.py @@ -0,0 +1,92 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Local client demonstrating server-side ``ResponseTarget`` fan-out. + +Posts one request to ``/responses`` with +``extra_body={"response_target": ["originating", "telegram:"]}``. +The server invokes the agent once and the host's +``ChannelContext.deliver_response`` resolves the target list against the +configured channels, calling :class:`host.ChannelPush` ``push`` on each +non-originating destination — here, the operator's Telegram chat. The +``"originating"`` pseudo-name keeps the agent reply on this script's wire +too, so the local terminal sees the reply alongside Telegram. + +Drop ``--include-originating`` to deliver only to Telegram (the local +response becomes a small acknowledgement string referencing the push +targets). + +The ``--previous-response-id`` flag (the AgentSession id) is independent +of ``--telegram-chat-id`` (the push destination). They were conflated in +an earlier iteration; in general one Entra user may have several Telegram +chat ids, and the session id is usually their Entra/responses isolation +key, not the chat id. Pass them both to resume a specific session and +fan-out to a specific chat:: + + python call_server_multicast.py \\ + --previous-response-id telegram:8741188429 \\ + --telegram-chat-id 8741188429 \\ + "What did we discuss?" + +Start the server first (in another shell):: + + cd server && uv run python advanced_app.py +""" + +from __future__ import annotations + +import argparse + +from openai import OpenAI + +BASE_URL = "http://127.0.0.1:8000" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + "--telegram-chat-id", + required=True, + help="Native Telegram chat id to push the agent reply to.", + ) + parser.add_argument( + "--previous-response-id", + default=None, + help=( + "Existing AgentSession id (e.g. 'telegram:8741188429' or " + "'responses:local-dev'). Defaults to no resume — the server " + "creates a fresh session keyed by safety_identifier." + ), + ) + parser.add_argument( + "--no-originating", + action="store_true", + help="Skip 'originating' in response_target; only Telegram receives the reply.", + ) + parser.add_argument("prompt", nargs="*", help="Prompt to send to the agent.") + args = parser.parse_args() + + prompt = " ".join(args.prompt) or "What is the weather in Seattle?" + + response_target: list[str] = [] + if not args.no_originating: + response_target.append("originating") + response_target.append(f"telegram:{args.telegram_chat_id}") + + if args.previous_response_id: + print(f"Resuming AgentSession: {args.previous_response_id}") + print(f"response_target: {response_target}") + + client = OpenAI(base_url=BASE_URL, api_key="not-needed") + response = client.responses.create( + model="agent", + input=prompt, + safety_identifier="local-dev", + previous_response_id=args.previous_response_id, + extra_body={"response_target": response_target}, + ) + print(f"User: {prompt}") + print(f"Agent: {response.output_text}") + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml b/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml new file mode 100644 index 0000000000..52a5f966ad --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "agent-framework-hosting-sample-advanced" +version = "0.0.1" +description = "Advanced multi-channel hosting sample (Responses + Telegram with @tool, FileHistoryProvider, hooks, ResponseTarget multicast)." +requires-python = ">=3.10" +dependencies = [ + "agent-framework-foundry", + "agent-framework-hosting", + "agent-framework-hosting-responses", + "agent-framework-hosting-telegram", + "azure-identity", + "aiohttp>=3.13.5", + "hypercorn>=0.17", + "httpx>=0.27", +] + +[dependency-groups] +dev = ["openai>=1.99"] + +[tool.uv] +package = false + +[tool.uv.sources] +agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" } +agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" } +agent-framework-hosting-telegram = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-telegram" }