mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
e5a6e35843
* 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>
186 lines
7.7 KiB
Python
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]
|