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:
Eduard van Valkenburg
2026-06-12 08:34:08 +02:00
committed by GitHub
Unverified
parent e5a6e35843
commit 36ce0950e4
50 changed files with 1290 additions and 11651 deletions
@@ -36,14 +36,10 @@ from agent_framework_hosting import (
ChannelContribution,
ChannelIdentity,
ChannelRequest,
ChannelResponseContext,
ChannelResponseHook,
ChannelRunHook,
ChannelSession,
ChannelStreamTransformHook,
HostedRunResult,
apply_response_hook,
apply_run_hook,
ChannelStreamUpdateHook,
logger,
)
from starlette.requests import Request
@@ -83,26 +79,6 @@ def telegram_isolation_key(chat_id: Any) -> str:
return f"telegram:{chat_id}"
def _text_result(text: str) -> HostedRunResult[AgentResponse]:
"""Build a host delivery payload from text accumulated by this channel."""
return HostedRunResult(AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(text=text)])]))
def _is_echo_payload(payload: HostedRunResult[AgentResponse]) -> bool:
"""Return ``True`` when a push payload is an echoed user turn.
Per the :class:`~agent_framework_hosting.ChannelPush` contract the host
mirrors the originating user's input as a one-or-more message
:class:`~agent_framework.AgentResponse` with every ``role == "user"``,
delivered *before* the agent's (``role == "assistant"``) reply. Treating a
payload whose messages are all user-role as an echo lets the channel pick
echo-only delivery options (e.g. silent notifications) without the host
having to thread an explicit ``is_echo`` flag through ``push``.
"""
messages = getattr(payload.result, "messages", None) or []
return bool(messages) and all(getattr(m, "role", None) == "user" for m in messages)
def _telegram_media_file_id(message: Mapping[str, Any]) -> tuple[str, str] | None:
"""Return ``(file_id, fallback_media_type)`` for any media on the message."""
photo = message.get("photo")
@@ -155,9 +131,9 @@ class TelegramChannel:
Telegram message as ``AgentResponseUpdate`` chunks arrive (Telegram has
no native streaming primitive). Pass ``stream=False`` on the constructor
to opt out for all messages, or override per-request inside the
``run_hook`` (set ``ChannelRequest.stream = False``). A ``stream_transform_hook``
can rewrite or drop individual updates before they hit the wire — useful
for redaction, formatting, or merging tool-call deltas.
the constructor. A ``stream_update_hook`` can rewrite or drop individual
updates before they hit the wire — useful for redaction, formatting, or
merging tool-call deltas.
"""
name = "telegram"
@@ -180,7 +156,7 @@ class TelegramChannel:
transport: Literal["auto", "polling", "webhook"] = "auto",
polling_timeout: int = 30,
stream: bool = True,
stream_transform_hook: ChannelStreamTransformHook | None = None,
stream_update_hook: ChannelStreamUpdateHook | None = None,
stream_edit_min_interval: float = 0.4,
) -> None:
self.path = path
@@ -190,7 +166,7 @@ class TelegramChannel:
self._hook = run_hook
self.response_hook = response_hook
self._stream_default = stream
self._stream_transform_hook = stream_transform_hook
self._stream_update_hook = stream_update_hook
self._stream_edit_min_interval = stream_edit_min_interval
self._api = f"{api_base}/bot{bot_token}"
self._webhook_url = webhook_url
@@ -542,15 +518,7 @@ class TelegramChannel:
stream=self._stream_default,
identity=ChannelIdentity(channel=self.name, native_id=str(chat_id)),
)
if self._hook is not None:
channel_request = await apply_run_hook(
self._hook,
channel_request,
target=self._ctx.target,
protocol_request=update,
)
await self._dispatch(chat_id, channel_request)
await self._dispatch(chat_id, channel_request, protocol_request=update)
async def _handle_callback_query(self, callback: Mapping[str, Any]) -> None:
"""Handle an inline-button click.
@@ -588,15 +556,7 @@ class TelegramChannel:
stream=self._stream_default,
identity=ChannelIdentity(channel=self.name, native_id=str(chat_id)),
)
if self._hook is not None:
channel_request = await apply_run_hook(
self._hook,
channel_request,
target=self._ctx.target,
protocol_request=callback,
)
await self._dispatch(chat_id, channel_request)
await self._dispatch(chat_id, channel_request, protocol_request=callback)
async def _resolve_file_url(self, file_id: str) -> str | None:
"""Resolve a Telegram file_id into an HTTPS URL via getFile."""
@@ -613,21 +573,31 @@ class TelegramChannel:
# -- outbound helpers -------------------------------------------------- #
async def _dispatch(self, chat_id: int, request: ChannelRequest) -> None:
async def _dispatch(self, chat_id: int, request: ChannelRequest, *, protocol_request: Any | None = None) -> None:
"""Run the request and forward results to ``chat_id``."""
if self._ctx is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("telegram channel not started")
if not request.stream:
if self._send_typing_action:
await self._send_chat_action(chat_id, "typing")
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)
await self._reply_with_result(chat_id, result.result)
result = await self._ctx.run(
request,
run_hook=self._hook,
protocol_request=protocol_request,
response_hook=self.response_hook,
channel_name=self.name,
)
await self._reply_with_result(chat_id, result.result)
return
stream = self._ctx.run_stream(request)
stream = await self._ctx.run_stream(
request,
run_hook=self._hook,
protocol_request=protocol_request,
stream_update_hook=self._stream_update_hook,
response_hook=self.response_hook,
channel_name=self.name,
)
await self._stream_to_chat(chat_id, request, stream)
async def _stream_to_chat(
@@ -724,13 +694,6 @@ class TelegramChannel:
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
@@ -756,14 +719,7 @@ class TelegramChannel:
final = None
# Final edit applies parse_mode (if configured) to the full text.
final_text = (accumulated or last_sent)[:_TELEGRAM_MAX_TEXT_LEN]
result = _text_result(final_text) if final_text else None
include_originating = True
if result is not None and self._ctx is not None:
include_originating = await self._ctx.deliver_response(request, result)
if include_originating:
result = await self._apply_response_hook(result, request)
final_text = result.result.text[:_TELEGRAM_MAX_TEXT_LEN]
final_text = (getattr(final, "text", None) or accumulated or last_sent)[:_TELEGRAM_MAX_TEXT_LEN]
if message_id is not None and final_text and final_text != last_sent:
payload: dict[str, Any] = {
"chat_id": chat_id,
@@ -784,29 +740,9 @@ class TelegramChannel:
# If nothing ever streamed (no text chunks at all), fall back to the
# full result so images / tool outputs still reach the user.
if not accumulated and include_originating:
if final is not None:
wrapped_final = await self._apply_response_hook(HostedRunResult(final), request)
final = wrapped_final.result
if not accumulated:
await self._reply_with_result(chat_id, final)
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 _reply_with_result(self, chat_id: int, result: Any) -> None:
"""Forward an AgentRunResponse back to Telegram.
@@ -852,35 +788,6 @@ class TelegramChannel:
payload.update(extra)
await self._http.post(f"{self._api}/sendMessage", json=payload)
# -- ChannelPush -------------------------------------------------------- #
async def push(self, identity: ChannelIdentity, payload: HostedRunResult[AgentResponse]) -> None:
"""Proactive delivery to a Telegram chat.
Implements :class:`host.ChannelPush` so other channels' callers can
target Telegram via ``ChannelRequest.response_target``
(e.g. ``ResponseTarget.channels(["telegram:8741188429"])`` from a
``/responses`` request). ``identity.native_id`` is the Telegram
chat id.
"""
try:
chat_id = int(identity.native_id)
except ValueError as exc:
raise ValueError(f"Telegram push requires an int chat_id, got {identity.native_id!r}") from exc
if self._http is None:
raise RuntimeError("TelegramChannel.push called before startup")
# The Bot API can only ever send AS the bot, so there is no way to
# impersonate the user for an echo (the MTProto ``send_as`` field is
# not exposed to bots). The next best UX is to deliver echoes
# *silently* (``disable_notification``) so a mirrored input doesn't
# buzz the user's device the way a genuine reply does. Echo phases are
# identified per the ChannelPush contract: a payload whose messages are
# all ``role == "user"`` is the originating turn mirrored here.
extra: dict[str, Any] = {}
if _is_echo_payload(payload):
extra["disable_notification"] = True
await self._send(chat_id, payload.result.text, **extra)
async def _send_photo(self, chat_id: int, photo_url: str, caption: str | None = None) -> None:
"""POST a ``sendPhoto`` to Telegram with an optional caption."""
if self._http is None: # pragma: no cover - guarded by lifecycle
@@ -11,12 +11,11 @@ from __future__ import annotations
import asyncio
import contextlib
from collections.abc import Mapping
from collections.abc import Awaitable, Mapping
from dataclasses import dataclass
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from agent_framework import AgentResponse, Content, Message
from agent_framework_hosting import (
AgentFrameworkHost,
ChannelCommand,
@@ -119,10 +118,6 @@ class _FakeAgent:
return _coro()
def _run_result(text: str) -> HostedRunResult[AgentResponse]:
return HostedRunResult(AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(text=text)])]))
def _make_telegram(
stream_default: bool = False, *, path: str = "/telegram/webhook"
) -> tuple[TelegramChannel, _FakeAgent]:
@@ -186,10 +181,10 @@ class TestTelegramWebhook:
assert not agent.runs
async def test_response_hook_can_rewrite_originating_reply(self) -> None:
contexts: list[Any] = []
seen_kwargs: list[dict[str, Any]] = []
def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult:
contexts.append(kwargs["context"])
seen_kwargs.append(dict(kwargs))
return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session)
ch, agent = _make_telegram()
@@ -198,11 +193,22 @@ class TestTelegramWebhook:
class _Ctx:
target: Any = agent
async def run(self, _request: ChannelRequest) -> HostedRunResult:
return HostedRunResult(_FakeAgentResponse(text="hi"))
async def deliver_response(self, _request: ChannelRequest, _payload: HostedRunResult) -> bool:
return True
async def run(
self,
_request: ChannelRequest,
*,
run_hook: Any | None = None,
protocol_request: Any | None = None,
response_hook: Any | None = None,
channel_name: str | None = None,
) -> HostedRunResult:
result = HostedRunResult(_FakeAgentResponse(text="hi"))
if response_hook is None:
return result
shaped = response_hook(result, request=_request, channel_name=channel_name or _request.channel)
if isinstance(shaped, Awaitable):
return await shaped
return shaped
ch._ctx = _Ctx() # type: ignore[assignment] # pyright: ignore[reportPrivateUsage]
@@ -213,42 +219,11 @@ class TestTelegramWebhook:
args, kwargs = ch._http.post.call_args # type: ignore[attr-defined]
assert args[0].endswith("/sendMessage")
assert kwargs["json"]["text"] == "HI"
assert contexts
assert contexts[0].channel_name == "telegram"
assert contexts[0].originating is True
assert contexts[0].destination_identity is None
assert seen_kwargs
assert seen_kwargs[0]["channel_name"] == "telegram"
class TestPushAndCommand:
async def test_push_calls_send(self) -> None:
ch, _agent = _make_telegram()
from agent_framework_hosting import ChannelIdentity
await ch.push(ChannelIdentity(channel="telegram", native_id="42"), _run_result("hi"))
assert ch._http is not None
ch._http.post.assert_called() # type: ignore[attr-defined]
args, kwargs = ch._http.post.call_args # type: ignore[attr-defined]
assert args[0].endswith("/sendMessage")
assert kwargs["json"]["chat_id"] in ("42", 42)
assert kwargs["json"]["text"] == "hi"
# Agent replies must stay loud: no silent flag on a non-echo push.
assert "disable_notification" not in kwargs["json"]
async def test_push_echo_is_silent(self) -> None:
ch, _agent = _make_telegram()
from agent_framework_hosting import ChannelIdentity
echo = HostedRunResult(
AgentResponse(messages=[Message(role="user", contents=[Content.from_text(text="said via X")])])
)
await ch.push(ChannelIdentity(channel="telegram", native_id="42"), echo)
assert ch._http is not None
_args, kwargs = ch._http.post.call_args # type: ignore[attr-defined]
# Bots cannot impersonate the user (no MTProto send_as), so the echo is
# delivered silently instead of buzzing the device like a real reply.
assert kwargs["json"]["disable_notification"] is True
assert kwargs["json"]["text"] == "said via X"
class TestCommand:
async def test_command_handler_invoked(self) -> None:
captured: list[ChannelCommandContext] = []
@@ -422,9 +397,7 @@ class TestShutdownDrainsWorkers:
def _deletewebhook_called(http_mock: MagicMock) -> bool:
return any(
call.args and str(call.args[0]).endswith("/deleteWebhook") for call in http_mock.post.call_args_list
)
return any(call.args and str(call.args[0]).endswith("/deleteWebhook") for call in http_mock.post.call_args_list)
class TestWebhookShutdownTeardown: