Files
agent-framework/python/samples/04-hosting/af-hosting/foundry_hosted_agent/app.py
T
Eduard van Valkenburg e5a6e35843 Python: feat(python): cross-channel hosting improvements (endpoint paths, Activity push, Telegram/Teams fixes) (#6307)
* Update hosting channel endpoint paths

Treat channel paths as concrete endpoint paths so built-in channels can be mounted at their defaults or at the app root without sample-specific subclasses. Update docs, tests, and the Foundry Telegram Invocations sample accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add push support to ActivityProtocolChannel

Implement the ChannelPush protocol so the Activity Protocol channel can
receive cross-channel fan-out (ResponseTarget.all_linked) and echo_input
replay as a non-originating destination:

- Add push() that reconstructs a proactive Bot Framework activity (bot/user
  swap) from the stored conversation reference and POSTs it to
  /v3/conversations/{id}/activities.
- Record a ChannelIdentity (service_url, conversation, bot, user, channel_id,
  locale) on ChannelRequest.identity so the host registers the channel under
  its isolation key for fan-out resolution.
- Route the streaming path through deliver_response so Activity-originated
  turns broadcast like Telegram/Discord.
- Add tests for push delivery, service_url validation, ChannelPush instance
  check, and inbound identity recording.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Don't delete Telegram webhook on shutdown by default

The TelegramChannel deleted its webhook on shutdown in webhook mode. During
a rolling redeploy the new revision registers the webhook on startup, then
the old revision's shutdown deletes it, silently breaking inbound delivery
until the next boot. setWebhook is overwriting/idempotent, so startup
re-asserts the webhook every boot and no teardown is needed.

Add a delete_webhook_on_shutdown flag (default False) so teardown is opt-in
for ephemeral deployments, and leave the webhook in place otherwise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix Activity channel streaming on non-Teams channels (405 on updateActivity)

The Activity Protocol channel streamed replies the Teams way: POST a
placeholder, then PUT-edit it as tokens arrive. Only Teams supports the
updateActivity REST op; Web Chat, Direct Line and the Emulator return
405 Method Not Allowed on the PUT, so the user saw only the placeholder.

Gate the placeholder+edit flow on edit-capable channels (msteams). Other
channels now buffer the stream and POST a single final message, mirroring
the non-streaming path's fan-out and response-hook semantics. Also add a
defensive 405 fallback inside the Teams edit loop so an unexpected 405
can never strand the user on the placeholder.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hosting-activity-protocol): don't parse Teams inline attachment content as a URI

Teams message activities include a text/html attachment whose inline
`content` is raw HTML (not a URL). _parse_activity fell back to
`attachment["content"]` and passed it to Content.from_uri, raising
ContentError ("URI must contain a scheme") and failing the whole turn,
so Teams users got no response.

Only treat `contentUrl` as a URI, require an absolute scheme, and skip
unparseable attachments defensively instead of failing the message.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(hosting-activity-protocol): native slash-command dispatch for Teams/Activity

Add a commands= parameter to ActivityProtocolChannel that intercepts a
leading /command (after stripping the bot's own @mention) and dispatches
to ChannelCommand handlers, mirroring the Telegram channel. Unknown
commands fall through to the agent. The channel run_hook is applied to
command requests so handlers observe the same resolved isolation key as
ordinary messages, and handler errors are swallowed (200, no Bot Service
retry of non-idempotent commands).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(hosting): silent attributed Telegram echoes + Teams markdown rendering

- hosting-telegram: send cross-channel input echoes with disable_notification
  (silent) and detect echo payloads so they aren't re-broadcast.
- hosting-activity-protocol: render outbound + push activities as textFormat
  'markdown' so Teams shows formatted replies (enables per-channel variants).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hosting-activity-protocol): address PR #6307 review feedback

Consult the host delivery pipeline even for empty streamed replies so
ResponseTarget.none is honoured and non-originating fan-out is consulted
instead of always emitting an originating "(no response)" message. Applies
to both the progressive-edit (Teams) and buffered (Web Chat/Direct Line)
streaming paths.

Re-validate service_url against the allow-list in push(): the identity is
read from a persisted store and push runs out-of-band, so the captured
service_url must be re-checked before a bearer token is sent.

Adds tests for empty-stream host consultation/suppression on both streaming
paths and for push rejecting a disallowed service_url.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-03 16:37:03 +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 endpoints 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`` — 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]