mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: add hosting Channels sample apps (#5645)
* samples(hosting): add hosting Channels sample apps under samples/04-hosting/af-hosting Adds five end-to-end sample apps under ``python/samples/04-hosting/af-hosting/`` that exercise the ``agent-framework-hosting`` Channels stack from the simplest single-channel case up to a multi-channel deployment with cross-channel identity linking. Samples (ordered by complexity) ------------------------------- * ``foundry_hosted_agent/`` — minimal Responses + Invocations host with a Foundry-backed agent and ``FoundryHostedAgentHistoryProvider``. ``agd``-deployable; bundles a ``Dockerfile`` and ``scripts/vendor-packages.sh`` that copies workspace packages into ``_vendor/`` for self-contained builds. ``_vendor/`` is gitignored. * ``local_responses/`` — single-channel Responses host with a ``run_hook`` that strips caller-supplied options and forces a reasoning preset. Demonstrates the hook seam over the uniform ``ChannelRequest`` envelope. * ``local_responses_workflow/`` — Responses + Invocations exposing a three-agent workflow with per-conversation checkpoint storage. * ``local_telegram/`` — Responses + Telegram with a ``@tool``, ``FileHistoryProvider``, hooks, and a ``ResponseTarget`` multicast variant (``call_server_multicast.py``) that pushes a single Responses reply to a separate Telegram chat. * ``local_identity_link/`` — full surface: Responses + Invocations + Telegram + Activity Protocol (Teams) + the ``EntraIdentityLinkChannel`` sidecar. Resolves per-channel ids onto a single Entra object id so a user's history follows them across surfaces. Notes ----- * Samples that use Telegram/Teams via Activity Protocol depend on the renamed ``agent-framework-hosting-activity-protocol`` package (see the PR-5 series). * All samples use ``[tool.uv.sources]`` editable workspace deps, except ``foundry_hosted_agent/`` which uses the ``./_vendor/`` self-contained layout for ``azd`` Docker builds. * Each sample includes a ``README.md`` with run instructions and an ``app.py`` ASGI entrypoint plus a ``call_server.py`` client harness. Depends on the prior hosting PRs (foundry-hosted-agent refactor + hosting-core + the per-channel packages). After those merge, this branch can be rebased onto ``main`` cleanly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * samples(hosting): point sample deps at the feature/python-hosting GitHub branch Switches every sample's ``[tool.uv.sources]`` from in-monorepo editable path deps (which only resolve when running inside the agent-framework workspace) to git refs targeting the ``feature/python-hosting`` branch on ``microsoft/agent-framework``. Samples now install standalone outside the monorepo while the ``agent-framework-hosting*`` packages are still pre-PyPI; once they publish, the ``[tool.uv.sources]`` block can be dropped and the declared deps resolve from PyPI. Cleanup ------- * Drops ``foundry_hosted_agent/scripts/vendor-packages.sh``, ``_vendor/`` from ``.gitignore``, the ``hooks.prepackage`` block in ``azure.yaml`` and the ``COPY _vendor/`` step in the Dockerfile — vendoring is no longer needed because git refs make the deps network-resolvable from any context. * Drops obsolete ``workspace.pyproject.toml`` reference and ``scripts/`` / ``workspace.pyproject.toml`` entries from ``Dockerfile.dockerignore``. * Updates the foundry sample's Dockerfile to ``uv sync --no-dev`` (no ``--frozen``) so it locks fresh against the GitHub-hosted deps at build time. * Drops every committed ``uv.lock`` because the resolver needs network access to ``feature/python-hosting`` to lock — they regenerate the first time a user runs ``uv sync`` after the branch lands. * Refreshes the per-sample READMEs to mention the GitHub install path instead of "in-tree workspace packages". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * samples(hosting): address PR #5645 review comments - foundry_hosted_agent/call_server.py: replace hard-coded project_endpoint and service_session_id with FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_HOSTED_AGENT_NAME, and optional FOUNDRY_HOSTED_SESSION_ID environment variables. Session-id is now optional so the sample exercises the new-conversation path by default. - local_identity_link/app.py: * make_telegram_hook: apply the reasoning bump regardless of identity-link state (the previous early-return on linked chats silently dropped the high-effort preset for the very flow the sample exists to demonstrate). * make_responses_hook: add a prominent DEV-ONLY warning that the client-supplied entra_oid shortcut bypasses identity verification and must be replaced by a JWT validator in production. * /link command: early-return when chat_id is missing instead of minting an authorize URL keyed on "telegram:None" (which would poison the link store with a binding any future chat_id-less update would collapse onto). * Switch ENTRA_CERT_PATH / ENTRA_CERT_PASSWORD env vars to the longer ENTRA_CERTIFICATE_PATH / ENTRA_CERTIFICATE_PASSWORD names that the README already documents. * channels: Sequence[Channel] -> list[Channel] (the next line appends, which a Sequence type doesn't expose). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(hosting-samples): apply sample formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(hosting-samples): guard command input text Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
fe89da15b6
commit
6b822853eb
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.<svc>.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"]
|
||||
@@ -0,0 +1,28 @@
|
||||
# BuildKit per-Dockerfile ignore (sibling file: <Dockerfile>.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/
|
||||
@@ -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://<your-project>.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 <https://aka.ms/FoundryPermissions> 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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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_<uuid>`` 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]
|
||||
@@ -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
|
||||
@@ -0,0 +1,126 @@
|
||||
# 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://<project>.services.ai.azure.com
|
||||
FOUNDRY_HOSTED_AGENT_NAME=<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://<project>.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()
|
||||
@@ -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" }
|
||||
@@ -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:<oid>` 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://<your-project>.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://<public-host> # 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://<public-host>/auth/callback` as the redirect URI on your
|
||||
Entra app, then visit (replace ``<chat_id>`` with the Telegram numeric
|
||||
chat id):
|
||||
|
||||
```
|
||||
https://<public-host>/auth/start?channel=telegram&id=<chat_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:<chat_id>):
|
||||
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).
|
||||
@@ -0,0 +1,395 @@
|
||||
# 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:<oid>`` 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:<chat_id> → entra:<oid>`` 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:<safety_id> → entra:<oid>``). Otherwise we fall back
|
||||
to ``responses:<safety_id>``.
|
||||
|
||||
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:<oid>``.
|
||||
|
||||
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:<id>``.
|
||||
3. Fallback ``responses:<safety_id>``.
|
||||
|
||||
.. 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 <city>, /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 <city> — 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:
|
||||
command_text = ctx.request.input if isinstance(ctx.request.input, str) else ""
|
||||
_, _, location = command_text.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 <city>", 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")))
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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:<chat_id>`` 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()
|
||||
@@ -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" }
|
||||
@@ -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://<your-project>.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 <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).
|
||||
@@ -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://<your-project>.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")))
|
||||
@@ -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 <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()
|
||||
@@ -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" }
|
||||
@@ -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://<your-project>.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 <response-id> \
|
||||
'{"topic": "electric SUV", "style": "retro", "audience": "boomers"}'
|
||||
```
|
||||
|
||||
After a few turns, inspect `storage/checkpoints/<isolation_key>/` —
|
||||
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).
|
||||
@@ -0,0 +1,225 @@
|
||||
# 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://<your-project>.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 / <isolation_key>`` 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")))
|
||||
@@ -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 <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()
|
||||
@@ -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 <RESPONSE_ID> 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": "<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
|
||||
@@ -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" }
|
||||
@@ -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:<chat_id>"]}`.
|
||||
|
||||
`app:app` is a module-level Starlette ASGI app, so this sample runs under
|
||||
Hypercorn (multi-process).
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.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).
|
||||
@@ -0,0 +1,227 @@
|
||||
# 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:<chat_id>"`` (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:<id>``).
|
||||
"""
|
||||
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 <city>, /help.")
|
||||
|
||||
async def handle_help(ctx: ChannelCommandContext) -> None:
|
||||
await ctx.reply(
|
||||
"/new — start a fresh conversation\n"
|
||||
"/whoami — show your isolation key\n"
|
||||
"/weather <city> — 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.
|
||||
command_text = ctx.request.input if isinstance(ctx.request.input, str) else ""
|
||||
_, _, location = command_text.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 <city>", 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")))
|
||||
@@ -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 <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()
|
||||
@@ -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:<chat_id>"]}``.
|
||||
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()
|
||||
@@ -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" }
|
||||
Reference in New Issue
Block a user