mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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>
This commit is contained in:
committed by
GitHub
Unverified
parent
e8c22caaeb
commit
e5a6e35843
@@ -48,7 +48,7 @@ from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import PlainTextResponse
|
||||
from starlette.routing import BaseRoute, Mount, Route
|
||||
from starlette.routing import BaseRoute, Mount, Route, WebSocketRoute
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from ._authorization import (
|
||||
@@ -110,6 +110,21 @@ _EPHEMERAL_RUNTIME_MARKERS: tuple[str, ...] = (
|
||||
RuntimeMode = Literal["long_running", "ephemeral"]
|
||||
|
||||
|
||||
def _exact_path_route(path: str, route: BaseRoute) -> BaseRoute | None:
|
||||
"""Clone a root route so ``Mount('/x', Route('/'))`` also handles ``/x`` without a redirect."""
|
||||
if isinstance(route, Route) and route.path == "/":
|
||||
return Route(
|
||||
path,
|
||||
route.endpoint,
|
||||
methods=route.methods,
|
||||
name=route.name,
|
||||
include_in_schema=route.include_in_schema,
|
||||
)
|
||||
if isinstance(route, WebSocketRoute) and route.path == "/":
|
||||
return WebSocketRoute(path, route.endpoint, name=route.name)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_runtime_mode(env: Mapping[str, str] | None = None) -> tuple[RuntimeMode, str | None]:
|
||||
"""Inspect deployment markers and return ``(mode, matched_marker_or_None)``.
|
||||
|
||||
@@ -257,7 +272,7 @@ def _workflow_event_to_update(event: WorkflowEvent[Any]) -> AgentResponseUpdate
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _suppress_already_consumed() -> AsyncIterator[None]: # noqa: RUF029
|
||||
async def _suppress_already_consumed() -> AsyncIterator[None]:
|
||||
"""Yield, swallowing finalizer failures so consumer cleanup never crashes the host.
|
||||
|
||||
The bridge stream calls ``get_final_response()`` after iterating the
|
||||
@@ -1233,7 +1248,7 @@ class AgentFrameworkHost:
|
||||
|
||||
Mirrors the ``AgentServerHost`` convention from
|
||||
``azure.ai.agentserver.core``: one INFO line that captures the
|
||||
target type, every channel + its mount path, the bind address
|
||||
target type, every channel + its endpoint path, the bind address
|
||||
(when known), whether we're running inside a Foundry Hosted
|
||||
Agents container, and the worker count. Keeps log noise low
|
||||
while still giving an operator a single grep-able anchor when
|
||||
@@ -1290,11 +1305,19 @@ class AgentFrameworkHost:
|
||||
for channel in self.channels:
|
||||
contribution = channel.contribute(context)
|
||||
# Channels publish routes relative to their root; mount under channel.path.
|
||||
# An empty path means "mount at the app root" — useful for single-channel hosts
|
||||
# that don't want a prefix (e.g. ResponsesChannel exposing POST /responses directly).
|
||||
# An empty path means "mount at the app root" — useful when an external
|
||||
# platform requires the channel endpoint at "/" or at a route contributed
|
||||
# by the channel.
|
||||
if contribution.routes:
|
||||
if channel.path:
|
||||
routes.append(Mount(channel.path, routes=list(contribution.routes)))
|
||||
channel_routes = list(contribution.routes)
|
||||
exact_routes = [
|
||||
exact_route
|
||||
for route in channel_routes
|
||||
if (exact_route := _exact_path_route(channel.path, route)) is not None
|
||||
]
|
||||
routes.extend(exact_routes)
|
||||
routes.append(Mount(channel.path, routes=channel_routes))
|
||||
else:
|
||||
routes.extend(contribution.routes)
|
||||
on_startup.extend(contribution.on_startup)
|
||||
|
||||
@@ -710,7 +710,7 @@ class Channel(Protocol):
|
||||
"""
|
||||
|
||||
name: str
|
||||
path: str # default mount path (e.g. "/responses"); use "" to mount routes at the app root
|
||||
path: str # default endpoint path (e.g. "/responses"); use "" to mount contributed routes at the app root
|
||||
|
||||
def contribute(self, context: ChannelContext) -> ChannelContribution: ...
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ class _RecordingChannel:
|
||||
self.pushes: list[tuple[ChannelIdentity, HostedRunResult[Any]]] = []
|
||||
self._push_raises: Exception | None = None
|
||||
self._supports_push = supports_push
|
||||
# Provide a single trivial route so contribute() exercises the mount path.
|
||||
# Provide a single trivial route so contribute() exercises the endpoint path.
|
||||
self._routes: Sequence[BaseRoute] = (Route("/ping", _ping),)
|
||||
|
||||
def contribute(self, context: ChannelContext) -> ChannelContribution:
|
||||
@@ -239,6 +239,18 @@ class TestHostWiring:
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True}
|
||||
|
||||
def test_app_mounts_root_route_at_exact_channel_path(self) -> None:
|
||||
agent = _FakeAgent()
|
||||
ch = _RecordingChannel(path="/fake")
|
||||
ch._routes = (Route("/", _ping),)
|
||||
host = AgentFrameworkHost(target=agent, channels=[ch])
|
||||
|
||||
with TestClient(host.app, follow_redirects=False) as client:
|
||||
r = client.get("/fake")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True}
|
||||
assert client.get("/fake/").status_code == 200
|
||||
|
||||
def test_app_mounts_at_root_when_path_is_empty(self) -> None:
|
||||
agent = _FakeAgent()
|
||||
ch = _RecordingChannel(path="")
|
||||
|
||||
Reference in New Issue
Block a user