mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: add agent-framework-hosting-activity-protocol channel (#5641)
* feat(hosting-activity-protocol): rename Bot Framework channel to ActivityProtocolChannel The existing Bot-Framework-via-Azure-Bot-Service channel was previously shipped under the name ``hosting-teams`` / ``TeamsChannel``. That name is misleading for what the channel actually does -- it speaks the Bot Framework Activity Protocol against Azure Bot Service, which fans out across MS Teams, Slack, Webex, Telegram-via-Bot-Service, etc., and does not provide any Teams-specific affordances. This PR renames the package atomically and frees the ``hosting-teams`` name for a future Teams-native channel built on ``microsoft-teams-apps`` (PR-5b, spec req #28). Renames (all in one commit): - Package: ``agent-framework-hosting-teams`` -> ``agent-framework-hosting-activity-protocol`` - Module: ``agent_framework_hosting_teams`` -> ``agent_framework_hosting_activity_protocol`` - Channel class: ``TeamsChannel`` -> ``ActivityProtocolChannel`` - Helper: ``teams_isolation_key`` -> ``activity_protocol_isolation_key`` (isolation key prefix ``teams:`` -> ``activity:``) - Channel name: ``"teams"`` -> ``"activity"``; default mount path ``/teams`` -> ``/activity`` - Internal helper: ``_parse_teams_activity`` -> ``_parse_activity`` - Worker task name + a couple of error strings updated for consistency Updates README.md and the module docstring to call out: - this is the channel-neutral Activity Protocol channel, - it surfaces what every Bot-Service-connected channel has in common (text in / text out), - a forthcoming ``agent-framework-hosting-teams`` package will layer Teams-specific affordances (adaptive cards, message extensions, dialogs, SSO, ...) on the same Bot Service transport. Workspace: registers ``agent-framework-hosting-activity-protocol`` in ``python/pyproject.toml`` and adds the matching pyright ``executionEnvironments`` entry. Behavior is unchanged. Pyright + mypy clean, 11 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * review: address PR-5 round 2 feedback - security (#3198327004): add `service_url_allowed_hosts` constructor option (default `botframework.com` + `smba.trafficmanager.net`) and reject inbound activities whose `serviceUrl` host falls outside it with HTTP 400 — without this gate a malicious caller could redirect outbound replies (and the attached bearer token) to an attacker-controlled host - security (#3198324219): add `inbound_auth_validator` async callback; log a loud WARNING at startup when no validator AND no operator reverse-proxy is configured so the dev-mode bypass cannot accidentally ship to production. Document the contract: prototype intentionally does not ship JWT validation (out of scope); operators must plug a validator or terminate auth in front of the channel - retry semantics (#3198328746): distinguish transient outbound failures (httpx network errors, non-2xx from Bot Service) — return 502 so Bot Service retries — from deterministic agent failures — return 200 so Bot Service does not retry the same broken activity in a loop - bug (#3198330424): fix the placeholder-failure deadlock. When `send_initial_placeholder` fails, `activity_id` stays `None`, the edit-worker loop exit condition (`accumulated == last_sent`) is unreachable while no PUT is possible, and the worker would deadlock on `wake.wait()` forever after `worker_done` is set. Now: skip the worker entirely on placeholder failure and POST a single final activity at the end with whatever accumulated - tests (#3198334465, #3187178091, #3198336045): add coverage for - `_is_service_url_allowed` allow/deny matrix + webhook 400 on disallowed serviceUrl - `inbound_auth_validator` allow/deny/raises paths - outbound `Authorization: Bearer <token>` header presence in production mode and absence in dev mode - the streaming path (`_stream_to_conversation`): placeholder + final edit, placeholder-failure fallback (with timeout guard against deadlock regression), and empty-stream `(no response)` placeholder replacement - retry-signal differentiation: outbound `httpx.ConnectError` → 502; deterministic `ValueError` from the agent → 200 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(hosting): drop redundant @pytest.mark.asyncio decorators asyncio_mode = "auto" is configured in pyproject.toml across the hosting packages, so individual @pytest.mark.asyncio decorators are unnecessary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(hosting-activity-protocol): add response hooks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(hosting-activity-protocol): mark constructor keyword args 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
f0b9ab6733
commit
cdea9fa956
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,43 @@
|
||||
# agent-framework-hosting-activity-protocol
|
||||
|
||||
Bot Framework **Activity Protocol** channel for
|
||||
[agent-framework-hosting](../hosting). Connects to **Azure Bot Service** so
|
||||
the same agent can be reached from Microsoft Teams, Slack, Webex,
|
||||
Telegram-via-bot-channel, and any other channel Azure Bot Service
|
||||
supports — without having to learn each channel's native protocol.
|
||||
|
||||
> Looking for a deeper Microsoft Teams integration with adaptive cards,
|
||||
> message extensions, dialogs, SSO, etc? See the companion
|
||||
> [`agent-framework-hosting-teams`](../hosting-teams) package, which is
|
||||
> built on `microsoft-teams-apps` and exposes Teams-specific affordances
|
||||
> on top of (still) Azure Bot Service.
|
||||
|
||||
Handles inbound `message` activities, outbound replies, mid-stream
|
||||
`updateActivity` edits, typing indicators, and both client-secret and
|
||||
certificate credential modes for the outbound Bot Framework token.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from agent_framework_hosting import AgentFrameworkHost
|
||||
from agent_framework_hosting_activity_protocol import ActivityProtocolChannel
|
||||
|
||||
host = AgentFrameworkHost(
|
||||
target=my_agent,
|
||||
channels=[
|
||||
ActivityProtocolChannel(
|
||||
app_id="<entra app id>",
|
||||
client_secret="<entra client secret>",
|
||||
tenant_id="botframework.com", # or your tenant id
|
||||
)
|
||||
],
|
||||
)
|
||||
host.serve()
|
||||
```
|
||||
|
||||
For tenants that disallow client secrets, supply `certificate_path=` (and
|
||||
optionally `certificate_password=`) instead. See the docstring at the top of
|
||||
`_channel.py` for the openssl one-liner that generates a usable PEM.
|
||||
|
||||
In dev mode (no credentials), the channel skips outbound auth so the Bot
|
||||
Framework Emulator can hit the endpoint without setup.
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Bot Framework Activity Protocol channel for :mod:`agent_framework_hosting`."""
|
||||
|
||||
from ._channel import ActivityProtocolChannel, activity_protocol_isolation_key
|
||||
|
||||
__all__ = ["ActivityProtocolChannel", "activity_protocol_isolation_key"]
|
||||
+734
@@ -0,0 +1,734 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
r"""Built-in channel: Bot Framework Activity Protocol (Azure Bot Service).
|
||||
|
||||
Activity Protocol is the Bot Framework messaging shape used by Azure Bot
|
||||
Service to fan one bot endpoint out across many surfaces (Microsoft
|
||||
Teams, Slack, Webex, Telegram, …). An incoming ``Activity`` is POSTed to
|
||||
your bot's ``/messages`` endpoint, and you reply by POSTing one or more
|
||||
``Activity`` objects back to the conversation URL the inbound activity
|
||||
carried in ``serviceUrl``. Auth is an OAuth2 client-credentials token
|
||||
from Entra (the legacy multi-tenant ``botframework.com`` authority for
|
||||
public Bot Framework channels, or your own tenant for single-tenant
|
||||
bots).
|
||||
|
||||
This is the channel-neutral Activity-Protocol channel — it surfaces what
|
||||
every Bot-Service-connected channel has in common (text in, text out).
|
||||
For deeper Microsoft Teams affordances (adaptive cards, message
|
||||
extensions, dialogs, SSO, …) on the same Bot Service transport, see the
|
||||
companion ``agent-framework-hosting-teams`` package.
|
||||
|
||||
This channel handles:
|
||||
|
||||
- inbound ``message`` activities — text and attachments resolved to URIs,
|
||||
- outbound replies via ``POST /v3/conversations/{id}/activities``,
|
||||
- streaming via ``PUT /v3/conversations/{id}/activities/{id}`` mid-stream
|
||||
edits (Teams supports updateActivity in personal chats and groups),
|
||||
- typing indicators while the agent works,
|
||||
- per-conversation isolation key ``activity:<conversation_id>`` so a Responses
|
||||
caller can resume a Teams conversation by passing the conversation id,
|
||||
- two credential modes for the outbound token — **client secret** or
|
||||
**certificate** (for tenants that disallow secrets) — both via
|
||||
``azure.identity.aio``,
|
||||
- dev-mode auth bypass when no credentials are passed so the Bot Framework
|
||||
Emulator can hit the endpoint with no credentials.
|
||||
|
||||
Out of scope for the prototype: full JWT validation of inbound requests,
|
||||
adaptive cards, file uploads, OAuth sign-in flows, and the Teams streaming
|
||||
preview API (``StreamItem``).
|
||||
|
||||
Generating a certificate
|
||||
------------------------
|
||||
For tenants that disallow client secrets, register a certificate on your
|
||||
Bot Framework / Entra app instead. Self-signed PEM (private key + cert in
|
||||
one file) is what ``azure.identity.CertificateCredential`` expects::
|
||||
|
||||
# 1. Generate a 2048-bit RSA key + self-signed cert (10y), single PEM.
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \\
|
||||
-subj "/CN=my-teams-bot" \\
|
||||
-keyout teams-bot.key -out teams-bot.crt
|
||||
cat teams-bot.key teams-bot.crt > teams-bot.pem
|
||||
|
||||
# 2. Upload teams-bot.crt to your Entra app under
|
||||
# "Certificates & secrets" → "Certificates" → "Upload certificate".
|
||||
|
||||
# 3. Point the channel at the combined PEM:
|
||||
ActivityProtocolChannel(
|
||||
app_id="<app id>",
|
||||
tenant_id="<tenant id>", # or "botframework.com" for legacy bots
|
||||
certificate_path="teams-bot.pem",
|
||||
)
|
||||
|
||||
To encrypt the private key, drop ``-nodes`` from the openssl command and
|
||||
pass ``certificate_password=<bytes>`` to the channel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
Content,
|
||||
Message,
|
||||
ResponseStream,
|
||||
)
|
||||
from agent_framework_hosting import (
|
||||
ChannelContext,
|
||||
ChannelContribution,
|
||||
ChannelRequest,
|
||||
ChannelResponseContext,
|
||||
ChannelResponseHook,
|
||||
ChannelRunHook,
|
||||
ChannelSession,
|
||||
ChannelStreamTransformHook,
|
||||
HostedRunResult,
|
||||
apply_response_hook,
|
||||
apply_run_hook,
|
||||
logger,
|
||||
)
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from azure.identity.aio import CertificateCredential, ClientSecretCredential
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.routing import Route
|
||||
|
||||
# Bot Framework v4 multi-tenant authority used by the public Bot Framework
|
||||
# channels (including Microsoft Teams). Single-tenant bots should override
|
||||
# ``tenant_id`` with their own tenant.
|
||||
_BOTFRAMEWORK_TENANT = "botframework.com"
|
||||
_BOTFRAMEWORK_SCOPE = "https://api.botframework.com/.default"
|
||||
|
||||
# Default allow-list of host suffixes the channel will POST a bearer token
|
||||
# to. Bot Service surfaces ``serviceUrl`` per-conversation as one of these
|
||||
# canonical hosts; a malicious inbound activity claiming a serviceUrl
|
||||
# outside this set could otherwise exfiltrate a real Bot Framework access
|
||||
# token. Operators with a private deployment (sovereign cloud, Direct Line
|
||||
# only, etc.) override this via ``service_url_allowed_hosts``.
|
||||
_DEFAULT_SERVICE_URL_HOSTS = (
|
||||
"botframework.com",
|
||||
"smba.trafficmanager.net",
|
||||
)
|
||||
|
||||
|
||||
InboundAuthValidator = Callable[[Request], Awaitable[bool]]
|
||||
|
||||
|
||||
def activity_protocol_isolation_key(conversation_id: Any) -> str:
|
||||
"""Build the namespaced isolation key the Teams channel writes under.
|
||||
|
||||
Exposed at module scope so other channels' run hooks can opt into the
|
||||
same per-conversation session (e.g. a Responses caller resuming a Teams
|
||||
conversation by passing the conversation id).
|
||||
"""
|
||||
return f"activity:{conversation_id}"
|
||||
|
||||
|
||||
class _OutboundError(RuntimeError):
|
||||
"""Marker for transient outbound failures that should produce 502/retry."""
|
||||
|
||||
|
||||
def _parse_activity(activity: Mapping[str, Any]) -> Message:
|
||||
"""Translate one Bot Framework ``message`` Activity into an Agent Framework Message.
|
||||
|
||||
Pulls the activity's ``text`` plus any image/file attachments with a
|
||||
``contentType`` and resolvable URL into ``Content`` parts. If the
|
||||
activity has no usable parts an empty text part is emitted so the
|
||||
caller never sees a content-less message.
|
||||
"""
|
||||
parts: list[Content] = []
|
||||
if (text := activity.get("text")) and isinstance(text, str):
|
||||
parts.append(Content.from_text(text=text))
|
||||
|
||||
for attachment in activity.get("attachments") or []:
|
||||
if not isinstance(attachment, Mapping):
|
||||
continue
|
||||
url = attachment.get("contentUrl") or attachment.get("content")
|
||||
content_type = attachment.get("contentType")
|
||||
if isinstance(url, str) and isinstance(content_type, str) and "/" in content_type:
|
||||
parts.append(Content.from_uri(uri=url, media_type=content_type))
|
||||
|
||||
if not parts:
|
||||
parts.append(Content.from_text(text=""))
|
||||
return Message("user", parts)
|
||||
|
||||
|
||||
class ActivityProtocolChannel:
|
||||
"""Microsoft Teams channel via Bot Framework v4 webhook.
|
||||
|
||||
Streaming
|
||||
---------
|
||||
When ``stream=True`` (default), the channel sends an initial placeholder
|
||||
activity, then edits it in place as the agent emits ``AgentResponseUpdate``
|
||||
chunks (``PUT /v3/conversations/{id}/activities/{id}``). When ``stream=False``
|
||||
it just sends the final reply. A ``stream_transform_hook`` can rewrite or
|
||||
drop individual updates before they hit the wire.
|
||||
"""
|
||||
|
||||
name = "activity"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
path: str = "/activity",
|
||||
app_id: str | None = None,
|
||||
app_password: str | None = None,
|
||||
certificate_path: str | None = None,
|
||||
certificate_password: bytes | None = None,
|
||||
tenant_id: str = _BOTFRAMEWORK_TENANT,
|
||||
token_scope: str = _BOTFRAMEWORK_SCOPE,
|
||||
credential: AsyncTokenCredential | None = None,
|
||||
run_hook: ChannelRunHook | None = None,
|
||||
response_hook: ChannelResponseHook | None = None,
|
||||
send_typing_action: bool = True,
|
||||
stream: bool = True,
|
||||
stream_transform_hook: ChannelStreamTransformHook | None = None,
|
||||
stream_edit_min_interval: float = 0.7,
|
||||
inbound_auth_validator: InboundAuthValidator | None = None,
|
||||
service_url_allowed_hosts: tuple[str, ...] = _DEFAULT_SERVICE_URL_HOSTS,
|
||||
) -> None:
|
||||
"""Configure the Teams channel.
|
||||
|
||||
Keyword Args:
|
||||
path: Mount path. The webhook lives at ``{path}/messages``.
|
||||
app_id: Bot Framework / Entra application (client) id. Required
|
||||
whenever any credential is supplied.
|
||||
app_password: Application secret for OAuth2 client credentials.
|
||||
Mutually exclusive with ``certificate_path``.
|
||||
certificate_path: Path to a PEM file containing **both** the
|
||||
private key and the X.509 certificate. Use this for tenants
|
||||
that disallow client secrets. See the module docstring for an
|
||||
``openssl`` recipe.
|
||||
certificate_password: Password for the PEM private key, if any.
|
||||
tenant_id: Entra tenant. Defaults to ``"botframework.com"`` for
|
||||
public Bot Framework channels; pass your tenant id for
|
||||
single-tenant bots.
|
||||
token_scope: OAuth2 scope to request. Defaults to the Bot
|
||||
Framework resource.
|
||||
credential: Bring your own ``AsyncTokenCredential`` (e.g. a
|
||||
``DefaultAzureCredential`` configured elsewhere). Overrides
|
||||
``app_password`` / ``certificate_path``.
|
||||
run_hook: Optional rewrite of ``ChannelRequest`` before invocation.
|
||||
response_hook: Optional rewrite of the
|
||||
:class:`HostedRunResult` before the originating Activity
|
||||
reply is serialized. The host also invokes this hook when
|
||||
delivering to this channel as a non-originating push
|
||||
destination.
|
||||
send_typing_action: Whether to send ``typing`` activities while
|
||||
the agent runs.
|
||||
stream: Whether to stream by default. ``run_hook`` can flip per
|
||||
request.
|
||||
stream_transform_hook: Optional rewrite of each
|
||||
``AgentResponseUpdate`` before it hits the wire.
|
||||
stream_edit_min_interval: Seconds between successive in-place
|
||||
edits. Teams is more rate-sensitive than Telegram, so default
|
||||
is higher.
|
||||
inbound_auth_validator: Optional async callable invoked for each
|
||||
inbound webhook request **before** the activity is parsed.
|
||||
Return ``True`` to allow, ``False`` to reject with HTTP 401.
|
||||
The webhook endpoint accepts unauthenticated requests by
|
||||
default — Bot Framework normally validates inbound calls via
|
||||
the JWT in the ``Authorization`` header (see Microsoft's
|
||||
bot framework auth docs). The prototype intentionally does
|
||||
NOT ship a built-in JWT validator (key rotation, OpenID
|
||||
config caching, etc. are out of scope); plug your own
|
||||
validator here, or terminate auth in front of the channel
|
||||
(e.g. APIM, Application Gateway). When no credentials AND
|
||||
no validator are configured the channel logs a loud
|
||||
warning at startup so the dev-mode bypass cannot
|
||||
accidentally ship.
|
||||
service_url_allowed_hosts: Host (or host suffix) allow-list the
|
||||
channel will POST a bearer token to. Defaults to the public
|
||||
Bot Framework host suffixes (``botframework.com`` and
|
||||
``smba.trafficmanager.net``). An inbound activity claiming a
|
||||
``serviceUrl`` outside this set is rejected — without this
|
||||
gate a malicious caller could redirect outbound replies (and
|
||||
the attached bearer token) to an attacker-controlled host.
|
||||
Pass an extended tuple for sovereign clouds or private
|
||||
deployments; pass ``()`` to disable the check entirely
|
||||
(only safe with strong inbound auth).
|
||||
"""
|
||||
if app_password and certificate_path:
|
||||
raise ValueError("ActivityProtocolChannel: pass either app_password or certificate_path, not both.")
|
||||
self.path = path
|
||||
self._app_id = app_id
|
||||
self._token_scope = token_scope
|
||||
self._tenant_id = tenant_id
|
||||
self._hook = run_hook
|
||||
self.response_hook = response_hook
|
||||
self._send_typing_action = send_typing_action
|
||||
self._stream_default = stream
|
||||
self._stream_transform_hook = stream_transform_hook
|
||||
self._stream_edit_min_interval = stream_edit_min_interval
|
||||
self._inbound_auth_validator = inbound_auth_validator
|
||||
self._service_url_allowed_hosts = tuple(h.lower().lstrip(".") for h in service_url_allowed_hosts)
|
||||
self._ctx: ChannelContext | None = None
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
|
||||
# Build the credential up front so misconfiguration fails at construction.
|
||||
self._credential: AsyncTokenCredential | None
|
||||
if credential is not None:
|
||||
self._credential = credential
|
||||
elif app_id and certificate_path:
|
||||
self._credential = CertificateCredential(
|
||||
tenant_id=tenant_id,
|
||||
client_id=app_id,
|
||||
certificate_path=certificate_path,
|
||||
password=certificate_password,
|
||||
)
|
||||
elif app_id and app_password:
|
||||
self._credential = ClientSecretCredential(
|
||||
tenant_id=tenant_id,
|
||||
client_id=app_id,
|
||||
client_secret=app_password,
|
||||
)
|
||||
else:
|
||||
self._credential = None # dev mode
|
||||
|
||||
def contribute(self, context: ChannelContext) -> ChannelContribution:
|
||||
"""Capture the host context and register the ``POST /messages`` webhook."""
|
||||
self._ctx = context
|
||||
return ChannelContribution(
|
||||
routes=[Route("/messages", self._handle, methods=["POST"])],
|
||||
on_startup=[self._on_startup],
|
||||
on_shutdown=[self._on_shutdown],
|
||||
)
|
||||
|
||||
# -- lifecycle --------------------------------------------------------- #
|
||||
|
||||
async def _on_startup(self) -> None:
|
||||
"""Open the outbound HTTP client and emit a startup banner.
|
||||
|
||||
When no Bot Framework credential is configured we log a loud warning —
|
||||
outbound replies will not authenticate, which is only acceptable
|
||||
against the local Bot Framework Emulator.
|
||||
|
||||
When no inbound auth validator is configured we also log a loud
|
||||
warning so the dev-mode bypass cannot accidentally ship to
|
||||
production: Bot Framework normally validates inbound requests via
|
||||
a JWT in ``Authorization``; without that gate any caller that can
|
||||
reach the webhook can drive the bot.
|
||||
"""
|
||||
if self._http is None:
|
||||
self._http = httpx.AsyncClient(timeout=30.0)
|
||||
if self._credential is None:
|
||||
logger.warning(
|
||||
"ActivityProtocolChannel running without credentials — outbound replies "
|
||||
"will not authenticate. Use only with the Bot Framework "
|
||||
"Emulator for local development."
|
||||
)
|
||||
else:
|
||||
cred_kind = type(self._credential).__name__
|
||||
logger.info(
|
||||
"ActivityProtocolChannel listening on %s/messages (auth=%s, tenant=%s)",
|
||||
self.path,
|
||||
cred_kind,
|
||||
self._tenant_id,
|
||||
)
|
||||
if self._inbound_auth_validator is None:
|
||||
logger.warning(
|
||||
"ActivityProtocolChannel %s/messages has no inbound_auth_validator — "
|
||||
"the webhook will accept ANY caller. Plug an inbound_auth_validator "
|
||||
"or terminate auth in front of the channel before exposing this "
|
||||
"endpoint to a public network.",
|
||||
self.path,
|
||||
)
|
||||
|
||||
async def _on_shutdown(self) -> None:
|
||||
"""Close the HTTP client and best-effort close the credential.
|
||||
|
||||
Credential ``close`` failures are logged but never raised — shutdown
|
||||
must never be allowed to mask the original cause of an app exit.
|
||||
"""
|
||||
if self._http is not None:
|
||||
await self._http.aclose()
|
||||
if self._credential is not None:
|
||||
close = getattr(self._credential, "close", None)
|
||||
if close is not None:
|
||||
try:
|
||||
await close()
|
||||
except Exception: # pragma: no cover - best-effort
|
||||
logger.exception("ActivityProtocolChannel credential close failed")
|
||||
|
||||
# -- token management -------------------------------------------------- #
|
||||
|
||||
async def _get_token(self) -> str | None:
|
||||
"""Acquire (and cache) an outbound bearer token.
|
||||
|
||||
``azure.identity`` credentials cache and refresh internally, so we
|
||||
just delegate.
|
||||
"""
|
||||
if self._credential is None:
|
||||
return None
|
||||
access_token = await self._credential.get_token(self._token_scope)
|
||||
return access_token.token
|
||||
|
||||
def _auth_headers(self, token: str | None) -> dict[str, str]:
|
||||
"""Return Bot Framework auth headers, or an empty dict in dev mode."""
|
||||
return {"Authorization": f"Bearer {token}"} if token else {}
|
||||
|
||||
# -- request handling -------------------------------------------------- #
|
||||
|
||||
def _is_service_url_allowed(self, service_url: str | None) -> bool:
|
||||
"""Return ``True`` if ``service_url`` host matches the allow-list."""
|
||||
if not self._service_url_allowed_hosts:
|
||||
return True
|
||||
if not service_url:
|
||||
return False
|
||||
try:
|
||||
host = (urlparse(service_url).hostname or "").lower()
|
||||
except Exception:
|
||||
return False
|
||||
if not host:
|
||||
return False
|
||||
return any(host == allowed or host.endswith(f".{allowed}") for allowed in self._service_url_allowed_hosts)
|
||||
|
||||
async def _handle(self, request: Request) -> Response:
|
||||
"""Bot Framework webhook entry point.
|
||||
|
||||
Only ``message`` activities are processed; ``conversationUpdate``,
|
||||
``invoke``, ``typing`` and other activity types are silently
|
||||
acknowledged. Auth-rejected requests return 401, malformed JSON
|
||||
returns 400, and serviceUrl outside the allow-list returns 400.
|
||||
|
||||
For *transient* outbound failures (network error / non-2xx from
|
||||
Bot Service / token acquisition failure) we surface 502 so Bot
|
||||
Service retries the inbound activity. Non-transient failures
|
||||
(parsing errors, validation errors, deterministic agent crashes)
|
||||
return 200 so Bot Service does not retry the same broken
|
||||
activity in a loop.
|
||||
"""
|
||||
if self._inbound_auth_validator is not None:
|
||||
try:
|
||||
allowed = await self._inbound_auth_validator(request)
|
||||
except Exception:
|
||||
logger.exception("ActivityProtocolChannel inbound_auth_validator raised; rejecting request")
|
||||
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||
if not allowed:
|
||||
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||
|
||||
try:
|
||||
activity = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "invalid json"}, status_code=400)
|
||||
|
||||
# We accept only message activities for now. ``conversationUpdate``,
|
||||
# ``invoke``, ``typing`` and friends are silently ack'd.
|
||||
if activity.get("type") != "message":
|
||||
return JSONResponse({}, status_code=202)
|
||||
|
||||
service_url = activity.get("serviceUrl")
|
||||
if not self._is_service_url_allowed(service_url if isinstance(service_url, str) else None):
|
||||
logger.warning(
|
||||
"ActivityProtocolChannel rejecting activity with serviceUrl=%r (not in allow-list)",
|
||||
service_url,
|
||||
)
|
||||
return JSONResponse({"error": "serviceUrl not allowed"}, status_code=400)
|
||||
|
||||
try:
|
||||
await self._process_activity(activity)
|
||||
except (httpx.HTTPError, _OutboundError):
|
||||
# Transient outbound failure (network error, non-2xx from Bot
|
||||
# Service, token acquisition error). Surface 502 so Bot
|
||||
# Service retries the inbound activity rather than dropping it.
|
||||
logger.exception("ActivityProtocolChannel outbound transient failure — signalling Bot Service to retry")
|
||||
return JSONResponse({"error": "upstream failure"}, status_code=502)
|
||||
except Exception:
|
||||
# Deterministic / agent-side failure: 200 so Bot Service does
|
||||
# not retry the same broken activity in a loop. Operator picks
|
||||
# the failure up via logs / telemetry.
|
||||
logger.exception("ActivityProtocolChannel activity processing failed")
|
||||
# Bot Framework expects 200 OK to dequeue the activity.
|
||||
return JSONResponse({}, status_code=200)
|
||||
|
||||
async def _process_activity(self, activity: Mapping[str, Any]) -> None:
|
||||
"""Build a :class:`ChannelRequest` from a message Activity and dispatch.
|
||||
|
||||
The Teams isolation key is per-conversation so all members of a
|
||||
group chat share session state. Activity metadata (``reply_to_id``,
|
||||
``recipient``) is preserved so reply-as-reaction style flows can
|
||||
reconstruct the original message context.
|
||||
"""
|
||||
if self._ctx is None: # pragma: no cover - guarded by lifecycle
|
||||
raise RuntimeError("activity channel not started")
|
||||
conversation = activity.get("conversation") or {}
|
||||
conversation_id = conversation.get("id")
|
||||
service_url = activity.get("serviceUrl")
|
||||
if not isinstance(conversation_id, str) or not isinstance(service_url, str):
|
||||
logger.warning("Teams activity missing conversation.id or serviceUrl — dropping")
|
||||
return
|
||||
|
||||
parsed = _parse_activity(activity)
|
||||
channel_request = ChannelRequest(
|
||||
channel=self.name,
|
||||
operation="message.create",
|
||||
input=[parsed],
|
||||
session=ChannelSession(isolation_key=activity_protocol_isolation_key(conversation_id)),
|
||||
attributes={
|
||||
"conversation_id": conversation_id,
|
||||
"service_url": service_url,
|
||||
"from_id": (activity.get("from") or {}).get("id"),
|
||||
"channel_id": activity.get("channelId"),
|
||||
},
|
||||
metadata={"reply_to_id": activity.get("id"), "recipient": activity.get("recipient")},
|
||||
stream=self._stream_default,
|
||||
)
|
||||
if self._hook is not None:
|
||||
channel_request = await apply_run_hook(
|
||||
self._hook,
|
||||
channel_request,
|
||||
target=self._ctx.target,
|
||||
protocol_request=activity,
|
||||
)
|
||||
|
||||
await self._dispatch(activity, channel_request)
|
||||
|
||||
# -- outbound helpers -------------------------------------------------- #
|
||||
|
||||
async def _dispatch(self, inbound: Mapping[str, Any], request: ChannelRequest) -> None:
|
||||
"""Run the target and ship the result back into the originating Teams conversation.
|
||||
|
||||
Optionally fires a typing indicator before non-streaming runs;
|
||||
streaming runs route through ``_stream_to_conversation`` which
|
||||
progressively edits a single placeholder activity.
|
||||
"""
|
||||
if self._ctx is None: # pragma: no cover - guarded by lifecycle
|
||||
raise RuntimeError("activity channel not started")
|
||||
if self._send_typing_action:
|
||||
await self._send_typing(inbound)
|
||||
|
||||
if not request.stream:
|
||||
result = await self._ctx.run(request)
|
||||
include_originating = await self._ctx.deliver_response(request, result)
|
||||
if include_originating:
|
||||
result = await self._apply_response_hook(result, request)
|
||||
text = getattr(result.result, "text", None) or "(no response)"
|
||||
await self._send_message(inbound, text)
|
||||
return
|
||||
|
||||
stream = self._ctx.run_stream(request)
|
||||
await self._stream_to_conversation(inbound, stream)
|
||||
|
||||
async def _apply_response_hook(
|
||||
self,
|
||||
result: HostedRunResult[Any],
|
||||
request: ChannelRequest,
|
||||
) -> HostedRunResult[Any]:
|
||||
"""Apply the channel-level response hook for an originating reply."""
|
||||
if self.response_hook is None:
|
||||
return result
|
||||
context = ChannelResponseContext(
|
||||
request=request,
|
||||
channel_name=self.name,
|
||||
destination_identity=None,
|
||||
originating=True,
|
||||
is_echo=False,
|
||||
)
|
||||
return await apply_response_hook(self.response_hook, result, context=context)
|
||||
|
||||
async def _stream_to_conversation(
|
||||
self,
|
||||
inbound: Mapping[str, Any],
|
||||
stream: ResponseStream[AgentResponseUpdate, AgentResponse],
|
||||
) -> None:
|
||||
"""Iterate the stream and progressively edit a single Teams activity.
|
||||
|
||||
If the initial placeholder POST fails we fall back to buffering
|
||||
the whole stream and POSTing a single final message at the end.
|
||||
Without that fallback the edit-loop's exit condition
|
||||
``accumulated == last_sent`` is unreachable while ``activity_id``
|
||||
is ``None`` (no PUT possible), and the worker would deadlock
|
||||
forever on ``wake.wait()`` after ``worker_done`` is set.
|
||||
"""
|
||||
accumulated = ""
|
||||
last_sent = ""
|
||||
last_edit_at = 0.0
|
||||
activity_id: str | None = None
|
||||
placeholder_ok = False
|
||||
worker_done = asyncio.Event()
|
||||
wake = asyncio.Event()
|
||||
|
||||
async def send_initial_placeholder() -> None:
|
||||
nonlocal activity_id, last_edit_at, placeholder_ok
|
||||
try:
|
||||
activity_id = await self._send_message(inbound, "…")
|
||||
last_edit_at = time.monotonic()
|
||||
placeholder_ok = activity_id is not None
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Activity placeholder send failed — falling back to single final POST",
|
||||
)
|
||||
placeholder_ok = False
|
||||
|
||||
async def edit_worker() -> None:
|
||||
nonlocal last_sent, last_edit_at
|
||||
# When the placeholder failed we have no activity_id to PUT
|
||||
# into; the loop's only useful work is exiting cleanly. Skip
|
||||
# straight to that — the final flush below will POST the
|
||||
# accumulated text in one shot.
|
||||
if not placeholder_ok:
|
||||
return
|
||||
while not (worker_done.is_set() and accumulated == last_sent):
|
||||
await wake.wait()
|
||||
wake.clear()
|
||||
if accumulated == last_sent:
|
||||
continue
|
||||
elapsed = time.monotonic() - last_edit_at
|
||||
if elapsed < self._stream_edit_min_interval:
|
||||
try:
|
||||
await asyncio.wait_for(wake.wait(), timeout=self._stream_edit_min_interval - elapsed)
|
||||
wake.clear()
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
snapshot = accumulated
|
||||
if snapshot == last_sent:
|
||||
continue
|
||||
try:
|
||||
await self._update_activity(inbound, activity_id or "", snapshot)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Activity interim edit failed")
|
||||
last_sent = snapshot
|
||||
last_edit_at = time.monotonic()
|
||||
|
||||
await send_initial_placeholder()
|
||||
edit_task = asyncio.create_task(edit_worker(), name="activity-edit-worker")
|
||||
|
||||
try:
|
||||
async for update in stream:
|
||||
if self._stream_transform_hook is not None:
|
||||
transformed = self._stream_transform_hook(update)
|
||||
if isinstance(transformed, Awaitable):
|
||||
transformed = await transformed
|
||||
if transformed is None:
|
||||
continue
|
||||
update = transformed
|
||||
chunk = getattr(update, "text", None)
|
||||
if chunk:
|
||||
accumulated += chunk
|
||||
wake.set()
|
||||
except Exception:
|
||||
logger.exception("Activity streaming consumption failed")
|
||||
finally:
|
||||
worker_done.set()
|
||||
wake.set()
|
||||
try:
|
||||
await edit_task
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Activity edit worker crashed")
|
||||
|
||||
try:
|
||||
await stream.get_final_response()
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Stream finalize failed")
|
||||
|
||||
# Final flush — make sure the user sees everything that arrived after
|
||||
# the worker's last edit. If the placeholder failed we POST a fresh
|
||||
# activity here with whatever accumulated.
|
||||
if not placeholder_ok:
|
||||
text = accumulated or "(no response)"
|
||||
try:
|
||||
await self._send_message(inbound, text)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Activity fallback final send failed")
|
||||
elif activity_id is not None and accumulated and accumulated != last_sent:
|
||||
try:
|
||||
await self._update_activity(inbound, activity_id, accumulated)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Activity final edit failed")
|
||||
elif not accumulated and activity_id is not None:
|
||||
# No text streamed — replace the placeholder with a stub so the
|
||||
# user isn't left staring at "…".
|
||||
try:
|
||||
await self._update_activity(inbound, activity_id, "(no response)")
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Activity placeholder replace failed")
|
||||
|
||||
# -- Bot Framework REST helpers --------------------------------------- #
|
||||
|
||||
def _activity_payload(self, inbound: Mapping[str, Any], text: str) -> dict[str, Any]:
|
||||
"""Build the outbound Activity envelope (text-only message)."""
|
||||
recipient = inbound.get("from") or {}
|
||||
from_user = inbound.get("recipient") or {}
|
||||
return {
|
||||
"type": "message",
|
||||
"from": from_user,
|
||||
"recipient": recipient,
|
||||
"conversation": inbound.get("conversation") or {},
|
||||
"replyToId": inbound.get("id"),
|
||||
"channelId": inbound.get("channelId"),
|
||||
"serviceUrl": inbound.get("serviceUrl"),
|
||||
"text": text,
|
||||
"textFormat": "plain",
|
||||
}
|
||||
|
||||
async def _send_message(self, inbound: Mapping[str, Any], text: str) -> str | None:
|
||||
"""POST a new Activity. Returns the assigned activity id."""
|
||||
if self._http is None: # pragma: no cover - guarded by lifecycle
|
||||
raise RuntimeError("activity channel not started")
|
||||
service_url = str(inbound.get("serviceUrl") or "").rstrip("/")
|
||||
conversation_id = (inbound.get("conversation") or {}).get("id")
|
||||
if not service_url or not isinstance(conversation_id, str):
|
||||
return None
|
||||
url = f"{service_url}/v3/conversations/{conversation_id}/activities"
|
||||
token = await self._get_token()
|
||||
response = await self._http.post(
|
||||
url, json=self._activity_payload(inbound, text), headers=self._auth_headers(token)
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json() if response.content else {}
|
||||
return payload.get("id") if isinstance(payload, dict) else None
|
||||
|
||||
async def _update_activity(self, inbound: Mapping[str, Any], activity_id: str, text: str) -> None:
|
||||
"""PUT-edit an existing Activity (Teams updateActivity)."""
|
||||
if self._http is None: # pragma: no cover - guarded by lifecycle
|
||||
raise RuntimeError("activity channel not started")
|
||||
service_url = str(inbound.get("serviceUrl") or "").rstrip("/")
|
||||
conversation_id = (inbound.get("conversation") or {}).get("id")
|
||||
if not service_url or not isinstance(conversation_id, str):
|
||||
return
|
||||
url = f"{service_url}/v3/conversations/{conversation_id}/activities/{activity_id}"
|
||||
token = await self._get_token()
|
||||
response = await self._http.put(
|
||||
url, json=self._activity_payload(inbound, text), headers=self._auth_headers(token)
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
async def _send_typing(self, inbound: Mapping[str, Any]) -> None:
|
||||
"""Send a Teams typing indicator; failures are logged and swallowed.
|
||||
|
||||
The typing activity is purely a UX nicety — if it fails (token
|
||||
expired, transient network issue, channel that doesn't support
|
||||
typing) we never surface that to the user or block the actual
|
||||
agent run.
|
||||
"""
|
||||
if self._http is None: # pragma: no cover - guarded by lifecycle
|
||||
raise RuntimeError("activity channel not started")
|
||||
service_url = str(inbound.get("serviceUrl") or "").rstrip("/")
|
||||
conversation_id = (inbound.get("conversation") or {}).get("id")
|
||||
if not service_url or not isinstance(conversation_id, str):
|
||||
return
|
||||
url = f"{service_url}/v3/conversations/{conversation_id}/activities"
|
||||
token = await self._get_token()
|
||||
try:
|
||||
await self._http.post(
|
||||
url,
|
||||
json={
|
||||
"type": "typing",
|
||||
"from": inbound.get("recipient") or {},
|
||||
"recipient": inbound.get("from") or {},
|
||||
"conversation": inbound.get("conversation") or {},
|
||||
"serviceUrl": inbound.get("serviceUrl"),
|
||||
},
|
||||
headers=self._auth_headers(token),
|
||||
)
|
||||
except Exception: # pragma: no cover - non-critical UX
|
||||
logger.exception("Teams typing send failed")
|
||||
|
||||
|
||||
__all__ = ["ActivityProtocolChannel", "activity_protocol_isolation_key"]
|
||||
@@ -0,0 +1,107 @@
|
||||
[project]
|
||||
name = "agent-framework-hosting-activity-protocol"
|
||||
description = "Bot Framework Activity Protocol channel for agent-framework-hosting (Teams, Slack, etc. via Azure Bot Service)."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"agent-framework-hosting==1.0.0a260424",
|
||||
"httpx>=0.27,<1",
|
||||
"azure-identity>=1.20,<2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = []
|
||||
timeout = 120
|
||||
markers = [
|
||||
"integration: marks tests as integration tests that require external services",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
include = ["agent_framework_hosting_activity_protocol"]
|
||||
exclude = ['tests']
|
||||
# Bot Framework activities arrive as loosely-typed JSON-ish maps. Strict
|
||||
# ``Unknown`` reporting on every ``.get(...)`` adds noise without catching
|
||||
# real bugs — narrowing happens via runtime isinstance checks instead.
|
||||
reportUnknownArgumentType = "none"
|
||||
reportUnknownMemberType = "none"
|
||||
reportUnknownVariableType = "none"
|
||||
reportUnknownLambdaType = "none"
|
||||
reportOptionalMemberAccess = "none"
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_hosting_activity_protocol"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_activity_protocol"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_activity_protocol --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
@@ -0,0 +1,452 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for :mod:`agent_framework_hosting_activity_protocol`.
|
||||
|
||||
The Bot Framework outbound calls and azure-identity credentials are mocked
|
||||
out so the suite never touches the network. Live token acquisition,
|
||||
streaming edits and certificate paths are out of scope here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from agent_framework_hosting import AgentFrameworkHost, HostedRunResult
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from agent_framework_hosting_activity_protocol import ActivityProtocolChannel, activity_protocol_isolation_key
|
||||
from agent_framework_hosting_activity_protocol._channel import _parse_activity
|
||||
|
||||
|
||||
def test_activity_protocol_isolation_key_format() -> None:
|
||||
assert activity_protocol_isolation_key("19:meeting_xyz@thread.v2") == "activity:19:meeting_xyz@thread.v2"
|
||||
assert activity_protocol_isolation_key(123) == "activity:123"
|
||||
|
||||
|
||||
class TestParseActivity:
|
||||
def test_text_only(self) -> None:
|
||||
msg = _parse_activity({"type": "message", "text": "hello"})
|
||||
assert msg.role == "user"
|
||||
assert msg.text == "hello"
|
||||
|
||||
def test_with_attachment(self) -> None:
|
||||
msg = _parse_activity({
|
||||
"type": "message",
|
||||
"text": "see this",
|
||||
"attachments": [
|
||||
{"contentType": "image/png", "contentUrl": "https://example.com/x.png"},
|
||||
],
|
||||
})
|
||||
assert msg.text == "see this"
|
||||
assert any((getattr(c, "uri", None) or "").endswith("/x.png") for c in msg.contents)
|
||||
|
||||
def test_skips_invalid_attachments(self) -> None:
|
||||
msg = _parse_activity({
|
||||
"type": "message",
|
||||
"text": "hi",
|
||||
"attachments": [
|
||||
"not-a-mapping",
|
||||
{"contentType": "image/png"}, # no url
|
||||
{"contentUrl": "https://example.com/y", "contentType": "no-slash"},
|
||||
],
|
||||
})
|
||||
assert msg.text == "hi"
|
||||
# No URI content survived.
|
||||
assert not any(getattr(c, "uri", None) for c in msg.contents)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeAgentResponse:
|
||||
text: str
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
def __init__(self, reply: str = "ok") -> None:
|
||||
self._reply = reply
|
||||
self.runs: list[Any] = []
|
||||
|
||||
def create_session(self, *, session_id: str | None = None) -> Any:
|
||||
return {"session_id": session_id}
|
||||
|
||||
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
|
||||
self.runs.append({"messages": messages, "stream": stream, "kwargs": kwargs})
|
||||
|
||||
async def _coro() -> _FakeAgentResponse:
|
||||
return _FakeAgentResponse(text=self._reply)
|
||||
|
||||
return _coro()
|
||||
|
||||
|
||||
def _make_teams(stream: bool = False) -> tuple[ActivityProtocolChannel, _FakeAgent]:
|
||||
agent = _FakeAgent("hi there")
|
||||
ch = ActivityProtocolChannel(stream=stream, send_typing_action=False)
|
||||
fake_http = MagicMock()
|
||||
response_mock = MagicMock()
|
||||
response_mock.raise_for_status = MagicMock()
|
||||
response_mock.json = MagicMock(return_value={"id": "act-1"})
|
||||
fake_http.post = AsyncMock(return_value=response_mock)
|
||||
fake_http.put = AsyncMock(return_value=response_mock)
|
||||
fake_http.aclose = AsyncMock()
|
||||
ch._http = fake_http
|
||||
return ch, agent
|
||||
|
||||
|
||||
_VALID_ACTIVITY: dict[str, Any] = {
|
||||
"type": "message",
|
||||
"id": "in-1",
|
||||
"text": "hello bot",
|
||||
"conversation": {"id": "19:meeting_xyz@thread.v2"},
|
||||
"from": {"id": "user-1"},
|
||||
"recipient": {"id": "bot-1"},
|
||||
"channelId": "msteams",
|
||||
"serviceUrl": "https://smba.trafficmanager.net/amer/",
|
||||
}
|
||||
|
||||
|
||||
class TestTeamsWebhook:
|
||||
def test_message_activity_dispatches_to_agent(self) -> None:
|
||||
ch, agent = _make_teams()
|
||||
host = AgentFrameworkHost(target=agent, channels=[ch])
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
|
||||
assert r.status_code == 200
|
||||
assert agent.runs, "expected the agent to be invoked"
|
||||
# And the channel posted a reply back to the conversation URL.
|
||||
assert ch._http is not None
|
||||
ch._http.post.assert_called() # type: ignore[attr-defined]
|
||||
url, _ = ch._http.post.call_args[0], ch._http.post.call_args[1] # type: ignore[attr-defined] # noqa: F841
|
||||
assert "/v3/conversations/" in ch._http.post.call_args[0][0] # type: ignore[attr-defined]
|
||||
body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
|
||||
assert body["text"] == "hi there"
|
||||
|
||||
def test_response_hook_can_rewrite_originating_reply(self) -> None:
|
||||
contexts: list[Any] = []
|
||||
|
||||
def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult:
|
||||
contexts.append(kwargs["context"])
|
||||
return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session)
|
||||
|
||||
ch, agent = _make_teams()
|
||||
ch.response_hook = hook
|
||||
host = AgentFrameworkHost(target=agent, channels=[ch])
|
||||
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert ch._http is not None
|
||||
body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
|
||||
assert body["text"] == "HI THERE"
|
||||
assert contexts
|
||||
assert contexts[0].channel_name == "activity"
|
||||
assert contexts[0].originating is True
|
||||
|
||||
def test_non_message_activities_are_acked(self) -> None:
|
||||
ch, agent = _make_teams()
|
||||
host = AgentFrameworkHost(target=agent, channels=[ch])
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post(
|
||||
"/activity/messages",
|
||||
json={"type": "conversationUpdate", "conversation": {"id": "x"}},
|
||||
)
|
||||
assert r.status_code == 202
|
||||
assert not agent.runs
|
||||
|
||||
def test_invalid_json_returns_400(self) -> None:
|
||||
ch, agent = _make_teams()
|
||||
host = AgentFrameworkHost(target=agent, channels=[ch])
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post(
|
||||
"/activity/messages",
|
||||
content=b"not-json",
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert not agent.runs
|
||||
|
||||
def test_message_missing_serviceurl_is_dropped(self) -> None:
|
||||
ch, agent = _make_teams()
|
||||
host = AgentFrameworkHost(target=agent, channels=[ch])
|
||||
bad = dict(_VALID_ACTIVITY)
|
||||
bad.pop("serviceUrl")
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post("/activity/messages", json=bad)
|
||||
# No serviceUrl → fails the allow-list check (None doesn't match
|
||||
# any allowed host suffix), surfaced as 400 so a misconfigured
|
||||
# caller knows the activity was structurally invalid.
|
||||
assert r.status_code == 400
|
||||
assert not agent.runs
|
||||
|
||||
|
||||
class TestOutbound:
|
||||
async def test_send_message_posts_to_conversation_url(self) -> None:
|
||||
ch, _agent = _make_teams()
|
||||
await ch._send_message(_VALID_ACTIVITY, "hi")
|
||||
assert ch._http is not None
|
||||
ch._http.post.assert_called() # type: ignore[attr-defined]
|
||||
url = ch._http.post.call_args[0][0] # type: ignore[attr-defined]
|
||||
assert "/v3/conversations/" in url
|
||||
body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
|
||||
assert body["text"] == "hi"
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_rejects_both_secret_and_certificate(self) -> None:
|
||||
with pytest.raises(ValueError, match="not both"):
|
||||
ActivityProtocolChannel(
|
||||
app_id="x",
|
||||
app_password="s",
|
||||
certificate_path="/tmp/does-not-exist.pem",
|
||||
)
|
||||
|
||||
def test_dev_mode_no_credential(self) -> None:
|
||||
ch = ActivityProtocolChannel()
|
||||
assert ch._credential is None
|
||||
|
||||
|
||||
class TestServiceUrlAllowList:
|
||||
"""``serviceUrl`` is supplied by the inbound activity and the channel
|
||||
POSTs a real bearer token to it — anything outside the Bot Framework
|
||||
host suffixes must be rejected so a malicious caller can't redirect
|
||||
outbound replies to an attacker-controlled host."""
|
||||
|
||||
def test_default_allows_smba_trafficmanager(self) -> None:
|
||||
ch = ActivityProtocolChannel()
|
||||
assert ch._is_service_url_allowed("https://smba.trafficmanager.net/amer/")
|
||||
assert ch._is_service_url_allowed("https://emea.smba.trafficmanager.net/")
|
||||
assert ch._is_service_url_allowed("https://api.botframework.com/")
|
||||
|
||||
def test_default_rejects_arbitrary_host(self) -> None:
|
||||
ch = ActivityProtocolChannel()
|
||||
assert not ch._is_service_url_allowed("https://attacker.example.com/")
|
||||
assert not ch._is_service_url_allowed("https://botframework.com.attacker.com/")
|
||||
assert not ch._is_service_url_allowed("")
|
||||
assert not ch._is_service_url_allowed(None)
|
||||
|
||||
def test_custom_allowlist(self) -> None:
|
||||
ch = ActivityProtocolChannel(service_url_allowed_hosts=("internal.contoso.com",))
|
||||
assert ch._is_service_url_allowed("https://internal.contoso.com/v3/")
|
||||
assert ch._is_service_url_allowed("https://eu.internal.contoso.com/")
|
||||
assert not ch._is_service_url_allowed("https://smba.trafficmanager.net/")
|
||||
|
||||
def test_empty_allowlist_disables_check(self) -> None:
|
||||
ch = ActivityProtocolChannel(service_url_allowed_hosts=())
|
||||
assert ch._is_service_url_allowed("https://anywhere.example.org/")
|
||||
|
||||
def test_webhook_rejects_disallowed_serviceurl(self) -> None:
|
||||
ch, agent = _make_teams()
|
||||
host = AgentFrameworkHost(target=agent, channels=[ch])
|
||||
bad = dict(_VALID_ACTIVITY)
|
||||
bad["serviceUrl"] = "https://attacker.example.com/v3/"
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post("/activity/messages", json=bad)
|
||||
assert r.status_code == 400
|
||||
assert not agent.runs
|
||||
# No outbound POST attempted with a bearer token.
|
||||
assert ch._http is not None
|
||||
ch._http.post.assert_not_called() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestInboundAuthValidator:
|
||||
def test_allow_passes_through(self) -> None:
|
||||
async def allow(_req: Any) -> bool:
|
||||
return True
|
||||
|
||||
ch, agent = _make_teams()
|
||||
ch._inbound_auth_validator = allow
|
||||
host = AgentFrameworkHost(target=agent, channels=[ch])
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
|
||||
assert r.status_code == 200
|
||||
assert agent.runs
|
||||
|
||||
def test_reject_returns_401(self) -> None:
|
||||
async def deny(_req: Any) -> bool:
|
||||
return False
|
||||
|
||||
ch, agent = _make_teams()
|
||||
ch._inbound_auth_validator = deny
|
||||
host = AgentFrameworkHost(target=agent, channels=[ch])
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
|
||||
assert r.status_code == 401
|
||||
assert not agent.runs
|
||||
|
||||
def test_validator_raises_returns_401(self) -> None:
|
||||
async def boom(_req: Any) -> bool:
|
||||
raise RuntimeError("validator broke")
|
||||
|
||||
ch, agent = _make_teams()
|
||||
ch._inbound_auth_validator = boom
|
||||
host = AgentFrameworkHost(target=agent, channels=[ch])
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
|
||||
assert r.status_code == 401
|
||||
assert not agent.runs
|
||||
|
||||
|
||||
class TestOutboundAuthHeader:
|
||||
async def test_no_credential_sends_no_authorization_header(self) -> None:
|
||||
ch, _agent = _make_teams()
|
||||
# Default _make_teams has no credential — dev mode.
|
||||
await ch._send_message(_VALID_ACTIVITY, "hi")
|
||||
assert ch._http is not None
|
||||
headers = ch._http.post.call_args[1]["headers"] # type: ignore[attr-defined]
|
||||
assert "Authorization" not in headers
|
||||
|
||||
async def test_with_credential_sends_bearer_token(self) -> None:
|
||||
ch, _agent = _make_teams()
|
||||
# Inject a fake credential with a fixed token.
|
||||
token_obj = MagicMock()
|
||||
token_obj.token = "tok-abc123"
|
||||
cred = MagicMock()
|
||||
cred.get_token = AsyncMock(return_value=token_obj)
|
||||
ch._credential = cred # type: ignore[assignment]
|
||||
await ch._send_message(_VALID_ACTIVITY, "hi")
|
||||
assert ch._http is not None
|
||||
headers = ch._http.post.call_args[1]["headers"] # type: ignore[attr-defined]
|
||||
assert headers.get("Authorization") == "Bearer tok-abc123"
|
||||
|
||||
|
||||
class TestRetrySignal:
|
||||
"""Distinguish transient outbound failures (network / 5xx) — which
|
||||
must surface 502 so Bot Service retries — from deterministic agent
|
||||
failures (which must return 200 to avoid retry loops)."""
|
||||
|
||||
def test_outbound_http_error_returns_502(self) -> None:
|
||||
import httpx as _httpx
|
||||
|
||||
ch, agent = _make_teams()
|
||||
# Make _send_message raise a transient httpx error.
|
||||
assert ch._http is not None
|
||||
ch._http.post = AsyncMock(side_effect=_httpx.ConnectError("nope")) # type: ignore[attr-defined]
|
||||
host = AgentFrameworkHost(target=agent, channels=[ch])
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
|
||||
assert r.status_code == 502
|
||||
|
||||
def test_deterministic_agent_failure_returns_200(self) -> None:
|
||||
ch, agent = _make_teams()
|
||||
|
||||
def boom(messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
|
||||
async def _coro() -> Any:
|
||||
raise ValueError("agent crashed")
|
||||
|
||||
return _coro()
|
||||
|
||||
agent.run = boom # type: ignore[assignment]
|
||||
host = AgentFrameworkHost(target=agent, channels=[ch])
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
|
||||
# Deterministic failure → 200 (Bot Service does not retry the same
|
||||
# broken activity in a loop).
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
class TestStreaming:
|
||||
async def test_stream_sends_placeholder_and_edits(self) -> None:
|
||||
ch, _agent = _make_teams(stream=True)
|
||||
|
||||
# Build a fake stream that emits two text chunks then finalizes.
|
||||
@dataclass
|
||||
class _Up:
|
||||
text: str
|
||||
|
||||
class _Stream:
|
||||
def __init__(self) -> None:
|
||||
self._chunks = ["hel", "lo"]
|
||||
|
||||
def __aiter__(self) -> Any:
|
||||
async def gen() -> Any:
|
||||
for c in self._chunks:
|
||||
yield _Up(c)
|
||||
|
||||
return gen()
|
||||
|
||||
async def get_final_response(self) -> Any:
|
||||
return _FakeAgentResponse(text="hello")
|
||||
|
||||
# Use a tight throttle so the test doesn't sit on `wait_for`.
|
||||
ch._stream_edit_min_interval = 0.0
|
||||
await ch._stream_to_conversation(_VALID_ACTIVITY, _Stream()) # type: ignore[arg-type]
|
||||
assert ch._http is not None
|
||||
# Placeholder POST + at least one final PUT.
|
||||
ch._http.post.assert_called() # type: ignore[attr-defined]
|
||||
ch._http.put.assert_called() # type: ignore[attr-defined]
|
||||
# Final edit body carries the full accumulated text.
|
||||
last_put_body = ch._http.put.call_args[1]["json"] # type: ignore[attr-defined]
|
||||
assert last_put_body["text"] == "hello"
|
||||
|
||||
async def test_stream_placeholder_failure_falls_back_to_single_post(self) -> None:
|
||||
# The bug: when send_initial_placeholder fails, activity_id stays
|
||||
# None, the edit_worker can never reach its exit condition
|
||||
# (`accumulated == last_sent` while no PUT possible) and the
|
||||
# whole conversation deadlocks. After the fix we fall back to
|
||||
# buffering the stream and POSTing a single final activity.
|
||||
ch, _agent = _make_teams(stream=True)
|
||||
# Make the FIRST POST (placeholder) raise; subsequent POST (final
|
||||
# fallback) succeeds.
|
||||
import httpx as _httpx
|
||||
|
||||
ok_response = MagicMock()
|
||||
ok_response.raise_for_status = MagicMock()
|
||||
ok_response.json = MagicMock(return_value={"id": "act-final"})
|
||||
ok_response.content = b"{}"
|
||||
post_mock = AsyncMock(side_effect=[_httpx.HTTPError("boom"), ok_response])
|
||||
assert ch._http is not None
|
||||
ch._http.post = post_mock # type: ignore[attr-defined]
|
||||
|
||||
@dataclass
|
||||
class _Up:
|
||||
text: str
|
||||
|
||||
class _Stream:
|
||||
def __aiter__(self) -> Any:
|
||||
async def gen() -> Any:
|
||||
yield _Up("partial-1")
|
||||
yield _Up("-partial-2")
|
||||
|
||||
return gen()
|
||||
|
||||
async def get_final_response(self) -> Any:
|
||||
return _FakeAgentResponse(text="partial-1-partial-2")
|
||||
|
||||
ch._stream_edit_min_interval = 0.0
|
||||
# Should NOT hang. Use asyncio.wait_for with a small timeout to
|
||||
# guard the test against future regressions of the deadlock.
|
||||
import asyncio as _asyncio
|
||||
|
||||
await _asyncio.wait_for(
|
||||
ch._stream_to_conversation(_VALID_ACTIVITY, _Stream()), # type: ignore[arg-type]
|
||||
timeout=2.0,
|
||||
)
|
||||
# Two POSTs total: placeholder (failed) + fallback final.
|
||||
assert post_mock.await_count == 2
|
||||
# Fallback POST contains the full accumulated text.
|
||||
fallback_body = post_mock.call_args[1]["json"]
|
||||
assert fallback_body["text"] == "partial-1-partial-2"
|
||||
|
||||
async def test_stream_with_no_text_replaces_placeholder(self) -> None:
|
||||
ch, _agent = _make_teams(stream=True)
|
||||
|
||||
class _EmptyStream:
|
||||
def __aiter__(self) -> Any:
|
||||
async def gen() -> Any:
|
||||
if False:
|
||||
yield None # type: ignore[unreachable]
|
||||
|
||||
return gen()
|
||||
|
||||
async def get_final_response(self) -> Any:
|
||||
return _FakeAgentResponse(text="")
|
||||
|
||||
ch._stream_edit_min_interval = 0.0
|
||||
await ch._stream_to_conversation(_VALID_ACTIVITY, _EmptyStream()) # type: ignore[arg-type]
|
||||
# The placeholder PUT-replaces with "(no response)" so the user
|
||||
# isn't left staring at "…".
|
||||
assert ch._http is not None
|
||||
last_put_body = ch._http.put.call_args[1]["json"] # type: ignore[attr-defined]
|
||||
assert last_put_body["text"] == "(no response)"
|
||||
@@ -88,6 +88,7 @@ agent-framework-github-copilot = { workspace = true }
|
||||
agent-framework-hosting = { workspace = true }
|
||||
agent-framework-hosting-invocations = { workspace = true }
|
||||
agent-framework-hosting-telegram = { workspace = true }
|
||||
agent-framework-hosting-activity-protocol = { workspace = true }
|
||||
agent-framework-hyperlight = { workspace = true }
|
||||
agent-framework-lab = { workspace = true }
|
||||
agent-framework-mem0 = { workspace = true }
|
||||
@@ -213,6 +214,7 @@ executionEnvironments = [
|
||||
{ root = "packages/hosting/tests", reportPrivateUsage = "none" },
|
||||
{ root = "packages/hosting-invocations/tests", reportPrivateUsage = "none" },
|
||||
{ root = "packages/hosting-telegram/tests", reportPrivateUsage = "none" },
|
||||
{ root = "packages/hosting-activity-protocol/tests", reportPrivateUsage = "none" },
|
||||
{ root = "packages/lab/gaia/tests", reportPrivateUsage = "none" },
|
||||
{ root = "packages/lab/lightning/tests", reportPrivateUsage = "none" },
|
||||
{ root = "packages/lab/tau2/tests", reportPrivateUsage = "none" },
|
||||
|
||||
Generated
+13
@@ -51,6 +51,7 @@ members = [
|
||||
"agent-framework-hosting-responses",
|
||||
"agent-framework-hosting-invocations",
|
||||
"agent-framework-hosting-telegram",
|
||||
"agent-framework-hosting-activity-protocol",
|
||||
"agent-framework-hyperlight",
|
||||
"agent-framework-lab",
|
||||
"agent-framework-mem0",
|
||||
@@ -671,6 +672,17 @@ requires-dist = [
|
||||
{ name = "openai", specifier = ">=1.99.0,<3" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-hosting-activity-protocol"
|
||||
version = "1.0.0a260424"
|
||||
source = { editable = "packages/hosting-activity-protocol" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-hosting-invocations"
|
||||
version = "1.0.0a260424"
|
||||
@@ -678,6 +690,7 @@ source = { editable = "packages/hosting-invocations" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "azure-identity", specifier = ">=1.20,<2" },
|
||||
{ name = "httpx", specifier = ">=0.27,<1" },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user