mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Simplify Python hosting core (#6492)
Remove linking, multicast, durable delivery, and host push machinery from the v1 hosting core. Keep those scenarios in a proposed follow-up ADR and update channel packages, samples, docs, tests, and workspace metadata around the smaller host/channel contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
e5a6e35843
commit
36ce0950e4
@@ -8,7 +8,7 @@ The general hosting plumbing lives in
|
||||
its own package (`agent-framework-hosting-responses`,
|
||||
`agent-framework-hosting-invocations`,
|
||||
`agent-framework-hosting-telegram`, `agent-framework-hosting-activity-protocol`,
|
||||
`agent-framework-hosting-entra`).
|
||||
`agent-framework-hosting-discord`).
|
||||
|
||||
| Sample | What it shows | Packaging |
|
||||
|---|---|---|
|
||||
@@ -16,8 +16,7 @@ its own package (`agent-framework-hosting-responses`,
|
||||
| [`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`). |
|
||||
| [`foundry_telegram_invocations_weather/`](./foundry_telegram_invocations_weather) | Experimental Telegram weather bot that mounts `TelegramChannel` at `POST /invocations`, registers the Foundry Hosted Agents Invocations URL as the Telegram webhook, and uses `FoundryHostedAgentHistoryProvider` for storage. | Ships with `Dockerfile` + `agent.yaml` + `agent.manifest.yaml` + `azure.yaml`; used to validate whether a non-Responses channel can run under Foundry Invocations. |
|
||||
| [`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. |
|
||||
| [`local_telegram/`](./local_telegram) | Adds Telegram, a `@tool`, `FileHistoryProvider`, run hooks (per-user / per-chat session keying), and extra Telegram commands. Runs under Hypercorn with multiple workers. | **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
|
||||
@@ -40,9 +39,9 @@ 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 |
|
||||
| Server stack | `agent-framework-hosting` + per-channel packages (`-responses`, `-invocations`, `-telegram`, `-activity-protocol`, `-discord`) | `agent-framework-hosted` only — the Foundry Hosted Agents runtime owns the HTTP surface |
|
||||
| Channels other than Responses / Invocations | Yes — Telegram, Activity Protocol (Teams), Discord | No — the platform exposes Responses + Invocations only |
|
||||
| Run target | Local Hypercorn (`local_responses/`, `local_telegram/`); 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
|
||||
|
||||
@@ -25,10 +25,8 @@ 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).
|
||||
For richer local scenarios (custom tools, history providers, run hooks,
|
||||
Telegram, and Activity Protocol) see [`../local_telegram`](../local_telegram).
|
||||
|
||||
## Layout
|
||||
|
||||
|
||||
@@ -162,7 +162,6 @@ def build_host() -> AgentFrameworkHost:
|
||||
# 3. Register Telegram at /invocations and keep Responses available for sanity checks.
|
||||
return AgentFrameworkHost(
|
||||
target=agent,
|
||||
allow_in_process_runner=True,
|
||||
channels=[
|
||||
ResponsesChannel(response_id_factory=foundry_response_id),
|
||||
TelegramChannel(
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,395 +0,0 @@
|
||||
# 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")))
|
||||
@@ -1,72 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,32 +0,0 @@
|
||||
[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" }
|
||||
@@ -1,4 +1,4 @@
|
||||
# local_telegram — `@tool`, file-backed history, hooks, multicast
|
||||
# local_telegram — `@tool`, file-backed history, hooks, Telegram
|
||||
|
||||
Builds on `foundry_hosted_agent/` with the hooks and config most real apps need:
|
||||
|
||||
@@ -11,9 +11,6 @@ Builds on `foundry_hosted_agent/` with the hooks and config most real apps need:
|
||||
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).
|
||||
@@ -49,8 +46,6 @@ 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`
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,7 +1,7 @@
|
||||
[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)."
|
||||
description = "Advanced multi-channel hosting sample (Responses + Telegram with @tool, FileHistoryProvider, hooks)."
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"agent-framework-foundry",
|
||||
|
||||
Reference in New Issue
Block a user