Files
agent-framework/python/samples/04-hosting/af-hosting/foundry_hosted_agent/app.py
T
Eduard van Valkenburg 6b822853eb 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>
2026-05-28 14:57:46 +02:00

186 lines
7.7 KiB
Python

# 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]