Files
agent-framework/python/packages/hosting
T
Eduard van Valkenburg e5a6e35843 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>
e5a6e35843 · 2026-06-03 16:37:03 +02:00
History
..

agent-framework-hosting

Multi-channel hosting for Microsoft Agent Framework agents.

agent-framework-hosting lets you serve a single agent (or workflow) target through one or more channels — pluggable adapters that expose the target over different transports. The result is a single Starlette ASGI application you can host anywhere (local Hypercorn, Azure Container Apps, Foundry Hosted Agents, …).

The base package contains only the channel-neutral plumbing:

  • AgentFrameworkHost — the Starlette host
  • Channel / ChannelPush — the channel protocols
  • ChannelRequest / ChannelSession / ChannelIdentity / ResponseTarget — the request envelope and routing primitives
  • ChannelContext / ChannelContribution / ChannelCommand — the channel-side hooks for invoking the target and contributing routes, commands, and lifecycle callbacks
  • ChannelRunHook / ChannelStreamTransformHook — the per-request customization seams
  • DurableTaskRunner + InProcessTaskRunner — the seam used to dispatch non-originating push fan-out; the in-process runner is the default. Plug in a durable adapter (e.g. agent-framework-hosting-durabletask) for runtime_mode="ephemeral" deployments.

Concrete channels live in their own packages so you only install what you use:

Package Transport
agent-framework-hosting-responses OpenAI Responses API
agent-framework-hosting-invocations Foundry-native invocation envelope
agent-framework-hosting-telegram Telegram Bot API
agent-framework-hosting-activity-protocol Bot Framework Activity Protocol (Teams, Direct Line, Web Chat, …)
agent-framework-hosting-teams Microsoft Teams (Teams SDK)
agent-framework-hosting-entra Entra (OAuth) identity-link sidecar

Architecture

graph LR
    Caller[External caller /<br/>messaging app]

    subgraph Host[AgentFrameworkHost]
        direction TB
        ASGI[Starlette app]
        Router[Channel router]
        Parse{parse →<br/>command or<br/>message?}
        Auth[host.authorize]
        Resolver[IdentityResolver]
        Delivery[_deliver_response]
        Push[_handle_push_task]
    end

    Channels[Channels<br/>Responses · Invocations ·<br/>Telegram · Activity ·<br/>IdentityLinker]
    CmdHandler[CommandHandler<br/>via ChannelCommandContext]
    Target[(Agent or Workflow)]
    Runner[DurableTaskRunner]
    StateStore[(HostStateStore)]

    Caller --> ASGI
    ASGI --> Router
    Router --> Parse
    Parse -- /command --> CmdHandler
    Parse -- message --> Auth
    CmdHandler -- ctx.run --> Auth
    CmdHandler -- local reply --> Channels
    Auth --> Resolver
    Resolver --> StateStore
    Auth --> Target
    Target --> Delivery
    Delivery -- originating sync --> Channels
    Delivery -- non-originating --> Runner
    Runner --> Push
    Push --> Channels
    Channels --> ASGI

For a richer set of flow diagrams — identity linking, multi-channel fan-out, server-side relays, background runs, durable-runner codec envelopes, echo idempotency, workflow targets — see the Python hosting spec.

Install

pip install agent-framework-hosting agent-framework-hosting-responses
# or with uvicorn pre-installed for the demo `host.serve(...)` helper
pip install "agent-framework-hosting[serve]" agent-framework-hosting-responses
# add the [disk] extra to opt in to on-disk persistence (see below)
pip install "agent-framework-hosting[disk]"

Quickstart

from agent_framework.openai import OpenAIChatClient
from agent_framework_hosting import AgentFrameworkHost, Channel

agent = OpenAIChatClient().as_agent(name="Assistant")

# Add channels from sibling packages, e.g. `agent-framework-hosting-responses`
# exposes a `ResponsesChannel` that serves the OpenAI Responses API.
channels: list[Channel] = []

host = AgentFrameworkHost(target=agent, channels=channels)
host.serve(port=8000)

See the hosting samples for richer multi-channel apps (Telegram + Teams + Responses fan-out, identity linking, ResponseTarget routing, etc.).

Optional disk persistence (state_dir)

By default the host keeps everything in memory: the durable-task runner's pending push queue, the per-isolation-key session aliases, the active-channel map, and the per-channel ChannelIdentity map. That is the right shape for ephemeral runtimes (Foundry Hosted Agents et al.) where the host is restarted per request and persistence lives behind a service like the Foundry response store, and for short-lived local dev.

For long-running deployments (an always-on container, a local dev server you restart often, a single-VM bot) opt in to disk persistence by passing state_dir to AgentFrameworkHost. The runner queue and the session bookkeeping use diskcache (installed via the [disk] extra) protected by an OS-level advisory file lock so two hosts pointed at the same directory can't double-execute scheduled pushes. Workflow checkpoints (when the target is a Workflow) use the framework's FileCheckpointStorage — no extra dependency. The identity-link store path is offered to linkers that implement SupportsLinkStorePath; linkers that manage persistence themselves should be configured directly.

from agent_framework_hosting import AgentFrameworkHost

# Single path → host auto-derives `runner/`, `sessions/`, `links/`, and
# (for workflow targets) `checkpoints/` subpaths.
host = AgentFrameworkHost(
    target=agent,
    channels=channels,
    state_dir="./.host-state",
)

# Or route components to different roots — use the HostStatePaths TypedDict
# (or a plain dict with the same keys) for editor autocomplete on the keys.
# Omit a key to opt that component out of persistence.
from agent_framework_hosting import HostStatePaths

host = AgentFrameworkHost(
    target=workflow,
    channels=channels,
    state_dir=HostStatePaths(
        runner="/var/lib/myapp/tasks",
        sessions="/var/lib/myapp/state",
        checkpoints="/var/lib/myapp/checkpoints",
        links="/var/lib/myapp/links",
    ),
)

What survives a restart:

  • Pending durable-task records — scheduled but not-yet-completed push deliveries replay on the next host startup via runner.resume(). Records that crashed mid-attempt resume with their already-consumed retry budget.
  • _session_aliases — per-isolation-key session-id rewrites (via the reset-session command).
  • _active — the most recently active channel for each isolation key (consumed by ResponseTarget.active).
  • _identities — channel-native ChannelIdentity rows used by ResponseTarget.channels([...]) / .all_linked fan-out.
  • Workflow checkpoints — when the target is a Workflow, the host wraps the checkpoints path in a per-isolation-key FileCheckpointStorage (equivalent to passing checkpoint_location=... directly; the explicit parameter takes precedence and emits a warning when both are set).
  • Identity-link store — when the configured linker implements SupportsLinkStorePath, the host passes the links path to it so pending challenges, linked identities, and verified claims can survive restarts.

What doesn't:

  • Live AgentSession objects (rehydrated lazily by the history provider on the next turn).
  • The ContinuationToken store (separate concern, plug in your own).

Unpicklable push payloads raise PushPayloadNotPicklable eagerly from schedule() so issues surface at the call site, not on the next restart.