mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
6b822853eb
* 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>
396 lines
16 KiB
Python
396 lines
16 KiB
Python
# 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")))
|