mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
co-authored by
Copilot
parent
e5a6e35843
commit
36ce0950e4
@@ -1,478 +1,145 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: eavanvalkenburg
|
||||
date: 2026-04-24
|
||||
date: 2026-06-11
|
||||
deciders: eavanvalkenburg
|
||||
---
|
||||
|
||||
# Agent Framework hosting core with pluggable channels
|
||||
# Python minimal hosting core and pluggable channels
|
||||
|
||||
## What are the business goals for this feature?
|
||||
## Context and Problem Statement
|
||||
|
||||
Give Agent Framework app authors — in every supported language — one low-level hosting surface that can expose a single **hostable target** (an agent or a workflow) on **one or more channels** (Responses API, Invocations API, Telegram, future A2A, MCP-tool, Activity Protocol via Azure Bot Service — which fronts Teams, Web Chat, Slack, …— WhatsApp, optional future direct-to-Teams, custom webhooks) without requiring them to hand-build protocol routing or server glue per protocol, **and** let an end user start a conversation on one channel (e.g. Telegram on their phone) and seamlessly continue it on another (e.g. Teams at their desk via the Activity channel) against the same target and the same conversation history.
|
||||
Agent Framework has several protocol-specific hosting surfaces. App authors who want one agent or workflow on multiple protocols must compose servers, routes, middleware, session handling, and lifecycle code by hand.
|
||||
|
||||
This consolidates the protocol-specific hosting layers that exist today (in Python: `agent-framework-foundry-hosting`, `-ag-ui`, `-a2a`, `-devui`; in .NET: the analogous per-protocol hosting helpers) into a shared composable model where:
|
||||
|
||||
- a host owns the application object and channels own protocol shape,
|
||||
- the host's hostable target may be an **agent** (executed via the per-language agent execution seam) **or** a **workflow** (executed via the per-language workflow execution seam) — channels do not care which, because the channel's `run_hook` adapts the inbound `ChannelRequest` into the input shape the target needs, and
|
||||
- session identity is **channel-neutral** — the host resolves a session from a channel-supplied `isolation_key` (e.g. a stable user identity) so two channels mounted on the same host can resolve to the **same** session for the same end user, and a shared session store extends that continuity across hosts and processes.
|
||||
- channel-native identity is **mapped, not assumed** — every channel has its own user namespace (Telegram `chat_id`, Teams AAD object id, WhatsApp phone number, Slack user id, …). The host provides a first-class **identity resolver** seam that maps a channel-native identifier into the channel-neutral `isolation_key`, and a first-class **identity linker** seam that lets an end user **connect** a new channel to an existing `isolation_key` through a well-known mechanism (OAuth, MFA, signed one-time code, …) so cross-channel continuity is achievable without ad-hoc per-channel bookkeeping, and
|
||||
- **response delivery is decoupled from request origin** — a target's response can be routed back to the **originating** channel (default), the user's **active** channel (the channel most recently observed for that `isolation_key`), a **specific** channel, **all linked** channels (fan-out), or **none** (background). Background/asynchronous runs are first-class: a channel can kick off a run, return a `ContinuationToken` to the caller, and the response is delivered when the user is next observed on any (or a chosen) channel — so a user can start a long task on Telegram and pick up the result on Teams.
|
||||
|
||||
We know we're successful when:
|
||||
|
||||
- after the target is created, a basic multi-channel sample requires only one host, channel objects, and one start call — no handwritten protocol routes and no per-protocol server bootstrap. The hosting core itself takes no dependency on the legacy protocol-specific hosts (e.g. Python's `agentserver`); individual channel packages MAY consume lower-level building blocks shipped in those packages where they ship reusable SDKs (e.g. the Foundry response-store SDK in `azure.ai.agentserver`),
|
||||
- the same host construction works whether the target is an agent or a workflow — only the `run_hook` (channel-default or app-supplied) changes to adapt the input,
|
||||
- a single host configured with two channels (e.g. Telegram + a future Activity Protocol channel — Teams via Azure Bot Service) can be exercised by one end user across both channels and observe one continuous conversation, **and**
|
||||
- the same conceptual model applies in Python and .NET.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### How do developers solve this problem today?
|
||||
|
||||
Today, every protocol surface is its own integration package with its own server. A developer who wants to expose one agent over both the Responses API and a webhook channel has to stand up two separate hosts and stitch them into one application by hand. In Python that means manually mounting two `agentserver`-based hosts into a Starlette app and calling `uvicorn.run(...)`. In .NET it means composing two protocol-specific hosting helpers into one `WebApplication` and wiring middleware twice.
|
||||
|
||||
Adding a Telegram bot to the same agent today means leaving the hosting stack entirely: spinning up a separate process, installing a Telegram SDK, writing the polling/webhook loop, manually translating updates into agent calls, and wiring command handlers (`/start`, `/new`, `/cancel`, …) and native command registration (`set_my_commands(...)`) by hand — none of which is reusable across other message channels (Teams, WhatsApp, …) or across languages.
|
||||
|
||||
### Why does this problem require a new hosting abstraction?
|
||||
|
||||
The gap is between **owning a hostable target** (an agent or a workflow) and **operationalizing it on multiple channels**. Agent Framework already provides agents, workflows, sessions, run inputs, response/update streaming, and per-language execution seams (`SupportsAgentRun.run(...)` and the workflow execution seam in Python; `AIAgent.RunAsync(...)` and the workflow execution seam in .NET). What's missing is a generic host that:
|
||||
|
||||
1. Owns one application object and one set of lifecycle hooks per language.
|
||||
2. Lets channels contribute routes, middleware, commands, and startup/shutdown without protocol leakage into the host.
|
||||
3. Standardizes how protocol requests become target invocations (input, options, session, streaming) and how target results flow back out — independent of whether the target is an agent or a workflow.
|
||||
4. **Resolves a session from a channel-neutral `isolation_key`** so two channels mounted on the same host can converge on the same session for the same end user — enabling cross-channel chat continuity (start on Telegram, continue on Teams) without per-channel session bookkeeping.
|
||||
5. **Bridges channel-native identities into the shared `isolation_key` namespace** — every channel has its own user identifier (Telegram `chat_id`, Teams AAD object id, WhatsApp phone, Slack user id). The generic host needs (a) an **identity resolver** seam that maps a channel-native id to an `isolation_key` for already-known users, and (b) an **identity linker** seam that lets an end user **connect** a new channel to an existing `isolation_key` through a well-known mechanism (OAuth, MFA, signed one-time code) — without each channel reinventing the linking flow.
|
||||
6. Provides a first-class extension seam for webhook/message channels with native command catalogs (per PR #5393 Telegram sample).
|
||||
7. Treats the **run hook** as the developer's runtime escape hatch over a uniform request envelope. Every channel translates its native protocol payload (Responses JSON body, Telegram update, Invocations request, …) into the same `ChannelRequest` shape — that uniformity is what lets one host front many channels with one target. The run hook runs **after** that channel-internal translation and **before** the target is invoked, receives the channel-built `ChannelRequest`, and returns a possibly-modified `ChannelRequest`. The same seam covers, for example: reshaping a free-form chat message into the typed input a workflow target requires, removing or adding fields on `ChatOptions` (e.g. dropping `temperature`/`store` that a particular target should never see, or injecting a default `model`), enforcing app policy (rejecting requests that omit a required option), or overriding `session_mode` / `response_target`. The list is illustrative, not exhaustive — anything the channel put on the `ChannelRequest` is fair game for the hook to validate, rewrite, or strip.
|
||||
8. Treats **response delivery** as a first-class, configurable concern — by default the response goes back to the originating channel synchronously, but the host must support routing the response to a different channel (the user's most recently active channel, a specific channel, or all linked channels) and **background runs** where the request returns immediately with a `ContinuationToken` and the response is delivered later via a channel push when the user is next observed (or polled by the caller).
|
||||
9. Applies the same conceptual model across language ecosystems so concepts, terminology, and behavior transfer between teams and docs.
|
||||
|
||||
The current top-level protocol-specific hosts (e.g. `ResponsesAgentServerHost`, `InvocationAgentServerHost`) are valuable prior art but sit too high in the stack — they encode protocol ownership at the host level and are duplicated per language. The new generic core learns from their behavior without depending on those top-level wrappers; individual channel packages may still consume the lower-level SDKs that ship alongside (notably the Foundry response-store SDK).
|
||||
|
||||
## Non-Goals / Relationship to existing hosting packages
|
||||
|
||||
The hosting core is deliberately **not** a replacement for the existing protocol packages in their first form, and it is not a multi-agent router. It is a peer abstraction layer that lets future protocol packages share one host.
|
||||
|
||||
| Dimension | Existing protocol packages | Hosting core |
|
||||
|---|---|---|
|
||||
| **Mental model** | One package = one protocol surface, owns its own server | One host owns the app; channels plug protocols in |
|
||||
| **Scope** | Protocol-specific request/session/event mapping | Generic host + channel contract; protocol logic lives in channel packages |
|
||||
| **Composition** | One protocol per process or per Mount | Many channels per host, shared middleware, lifecycle, session resolution |
|
||||
| **Multi-agent** | Out of scope per package | **No.** One host = one agent. Future work, if desired. |
|
||||
| **Cross-language** | Per language, per protocol | Same conceptual model in every implementing language |
|
||||
|
||||
**Explicit non-goals:**
|
||||
|
||||
- Migrating existing protocol packages (AG-UI, A2A, DevUI in Python; analogous .NET helpers) onto the new core in the first implementation.
|
||||
- Standardizing a persistent session storage contract across all channels in the first phase. (Cross-channel continuity within one host is enabled by `isolation_key` resolution; cross-host/cross-process continuity requires the pluggable session store, listed as a fast follow.)
|
||||
- Hosting multiple agents behind one router in this first design.
|
||||
- Designing every detail of WhatsApp, the full Activity Protocol surface, or a future direct-to-Teams channel now (only Telegram is concretely targeted, informed by PR #5393; Activity Protocol via Azure Bot Service is designed-in fast follow alongside A2A and MCP-tool). Within Telegram and Activity, **broadcast Telegram Channels** (the read-only product) and **adaptive-card `Invoke` activity** flows are explicitly fast-follow scope; v1 ships group/supergroup/forum-topic and `personal` / `groupChat` / `channel` `conversationType` support.
|
||||
- Shipping the **A2A** (agent-to-agent) and **MCP-tool** (exposing the agent as an MCP tool) channels in the first implementation. Both are explicitly **in scope for the overall design** — the host contract, `ChannelRequest` envelope, identity/session/response-target stack, and persisted delivery envelope must accommodate them as caller-supplied-session channels — but their concrete protocol bindings, route catalogs, and packages are **fast-follow work** after the first Telegram + Responses + Invocations release.
|
||||
- Replacing protocol-specific serializers with one generic event model.
|
||||
- Taking a runtime or package dependency on the legacy protocol-specific top-level hosts (e.g. `ResponsesAgentServerHost` / `InvocationAgentServerHost` in Python's `agentserver`) from the new hosting core. Channel packages MAY depend on lower-level building blocks shipped alongside those hosts where they provide reusable SDKs (notably the Foundry response-store SDK consumed by `FoundryHostedAgentHistoryProvider`).
|
||||
- Forcing identical type names across languages — each language follows its own idioms while preserving the same concepts and terminology.
|
||||
|
||||
**Boundary rule:** If you need protocol-specific event semantics, codecs, or signature validation, that lives in the channel package. The host owns the application object, lifecycle, session resolution, and the call into the agent's run/stream seam.
|
||||
We will introduce a small Python hosting core that owns the common server shape and leaves protocol details inside channel packages. The first public contract must be intentionally narrow so Python can ship a base contract before adding identity linking, proactive delivery, or multicast behavior. Other language implementations may reuse the same conceptual boundary, but this ADR records the Python decision.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
These are the design principles applied on top of the [business goals](#what-are-the-business-goals-for-this-feature) above.
|
||||
|
||||
- Keep the app author experience simple for the common case (one host, channels, one start call).
|
||||
- Treat agents and workflows as peer hostable targets behind one host, so the same channel ecosystem (Responses, Invocations, Telegram, Activity, …) can serve either without rework.
|
||||
- Preserve room for channel-specific capabilities (signature validation, conversations, streaming, native commands, action surfaces).
|
||||
- Support message-channel capabilities — native commands, command menus, action surfaces — from the start.
|
||||
- Support channels that need startup/shutdown behavior (long polling, platform-side command registration) in addition to routes.
|
||||
- Use the existing protocol-specific implementations as prior art **without** taking a runtime dependency on them.
|
||||
- Keep the new core protocol-agnostic.
|
||||
- Align to the per-language agent **and workflow** execution seams rather than introducing a new contract for the target.
|
||||
- Follow each language's idiomatic packaging conventions rather than growing a monolithic integration package.
|
||||
- Avoid forcing migration of existing protocol packages as part of the first implementation.
|
||||
- Keep the abstractions language-neutral so the same conceptual model can be implemented by Python, .NET, and future language ecosystems with idiomatic code.
|
||||
- Keep the first host easy to explain: one app, one hostable target, one or more channels.
|
||||
- Reuse Agent Framework's existing agent, workflow, session, history, and checkpoint primitives.
|
||||
- Let channel packages own protocol parsing, protocol responses, authentication details, and native command surfaces.
|
||||
- Make session continuity explicit through a channel-supplied `ChannelSession(isolation_key=...)`.
|
||||
- Avoid approving cross-channel identity and delivery semantics before their safety model is reviewed.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- Keep the current protocol-specific hosting packages only.
|
||||
- Create one monolithic `hosting` package with the host and all channels built in.
|
||||
- Create a new hosting core plus new channel packages, but reimplement the channel stack from scratch with no reference to the current protocol implementations.
|
||||
- Create a new hosting core plus separate channel packages, informed by the current protocol-specific implementations but without depending on them.
|
||||
1. Keep only protocol-specific hosts.
|
||||
2. Ship a large hosting core with identity linking, authorization, background delivery, active-channel routing, and multicast in v1.
|
||||
3. Ship a minimal host/channel core now and track linking/multicast as follow-up work.
|
||||
|
||||
### Keep only protocol-specific hosts
|
||||
|
||||
- Good: no new abstraction or package surface.
|
||||
- Neutral: each protocol can continue evolving independently.
|
||||
- Bad: every multi-channel app still has to compose servers, lifecycle, and session handling by hand.
|
||||
|
||||
### Ship the large cross-channel host in v1
|
||||
|
||||
- Good: the richest cross-channel scenarios are available immediately.
|
||||
- Neutral: the host becomes the natural place to demonstrate identity and delivery policy.
|
||||
- Bad: v1 becomes a security-sensitive identity and delivery system before the safety model is reviewed.
|
||||
|
||||
### Ship the minimal core now
|
||||
|
||||
- Good: the host/channel boundary can be implemented, tested, and explained without solving linking and durable delivery at the same time.
|
||||
- Neutral: apps that need richer behavior must build it locally or wait for ADR-0028 follow-up work.
|
||||
- Bad: proactive delivery and multicast scenarios are deliberately absent from v1.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: **Create a new hosting core plus separate channel packages, informed by the current protocol-specific implementations but without depending on them.** Apply the same conceptual model in Python and .NET, with idiomatic per-language API shapes.
|
||||
Chosen option: **minimal host/channel core now, follow-up enhancements later**.
|
||||
|
||||
### Summary
|
||||
`AgentFrameworkHost` owns:
|
||||
|
||||
We will introduce a new hosting core distribution package per language. The full conceptual vocabulary is defined once in [Terminology](#terminology); this section calls out only the design decisions baked into each concept.
|
||||
- one application object,
|
||||
- one hostable target (`SupportsAgentRun` agent-compatible object or a `Workflow`), and
|
||||
- one or more channels.
|
||||
|
||||
- **Host** (`AgentFrameworkHost`) — owns the application object (Starlette in Python, ASP.NET Core / Kestrel in .NET), one **hostable target**, and a sequence of channels. Exposes the underlying app as the canonical portability surface and a `serve(...)`-style convenience for the common single-process case. **Named `AgentFrameworkHost` rather than `AgentHost` because the target is not restricted to agents.**
|
||||
- **Hostable target** — may be either an **agent** (per-language agent execution seam) or a **workflow** (per-language workflow execution seam). The host detects the kind and dispatches; channels are unchanged.
|
||||
- **Channel**, **`ChannelContext`**, **`ChannelRequest`**, **`ChannelSession`**, **`ChannelContribution`**, **`ChannelCommand`** — the channel-authoring surface. Defined in Terminology.
|
||||
- **`ChannelRunHook`** — the developer's runtime escape hatch over the uniform `ChannelRequest` envelope. Channels translate their native protocol payload into `ChannelRequest`; the hook then runs **after** that translation and **before** target invocation, receiving and returning a `ChannelRequest`. Examples (illustrative): reshaping a chat message into a workflow's typed input, dropping/injecting `ChatOptions` fields, enforcing required options, overriding `session_mode` / `response_target`.
|
||||
- **`ChannelResponseHook`** — the *outbound* counterpart to `ChannelRunHook`. Applied per destination after target invocation, before the channel pushes the result onto its wire. Receives the `HostedRunResult` and a `ChannelResponseContext` (request, channel name, destination identity, originating flag, `is_echo` phase flag) and returns a (possibly transformed) `HostedRunResult`. Used for channel-side projections of the target's output: a text-only wire reads `result.result.text` (for agent targets) or projects `result.result.get_outputs()` into a single text turn (for workflow targets); a card-capable channel iterates the underlying contents; a workflow result with a typed final output can be rebound to a channel-friendly `AgentResponse` via `result.replace(result=...)`. Hooks are stored as a `response_hook` attribute on the channel instance — duck-typed, not part of the `Channel` Protocol, so adding hook support to a new channel package never breaks the Protocol contract. The host clones the `HostedRunResult` envelope per destination before invoking the hook so one channel's `replace(result=...)` cannot leak into another's payload.
|
||||
- **`HostedRunResult[TResult]` is a generic typed envelope around the target's full-fidelity output.** For agent targets `TResult` narrows to `AgentResponse` (channels read `result.messages`, `result.value`, `result.usage_details`, `result.response_id`, … directly); for workflow targets to `WorkflowRunResult` (channels iterate `result.get_outputs()` / inspect `result.get_final_state()`). The host never collapses or pre-shapes — multi-modality and structured outputs survive end-to-end. The envelope also carries the resolved `session: AgentSession | None` (None for workflows, which do not own session state in the agent sense). Channels decide what subset their wire can carry through their `response_hook` and their native serializer.
|
||||
- **`IdentityResolver`** + **`IdentityLinker`** + **`IdentityAllowlist`** — the channel-neutral identity stack. Resolver maps channel-native ids to `isolation_key`; linker runs the **link/connect ceremony** (OAuth / MFA / signed one-time code) so a new channel can join an existing `isolation_key`. The host owns the routes and short-lived state the linker needs; channels surface entry points. `IdentityAllowlist` is the **authorization** seam, orthogonal to linking: combined with the per-channel `require_link: bool` it produces three named profiles — **open** (default), **forced-link** (any authenticated identity), and **allowlist** (native-id list, IdP-claim list, or composition of both). Decisions are tri-state (`ALLOW` / `DENY` / `ABSTAIN`) so the host can run the allowlist twice — once with the raw channel identity and again with linker-emitted claims — and compose multiple lists (`AnyOfAllowlists`, `AllOfAllowlists`) without one list's missing information silently denying the request. The host runs a startup validator that rejects silent-deny-everyone configurations (e.g. a claim-based allowlist with no source of verified claims). Channels may declare `require_link=True` to enforce "authenticate before chatting", and the linker stores verified IdP claims (e.g. Entra ID `oid`) so subsequent channels that supply the same claim are auto-merged onto the same `isolation_key` without a second ceremony.
|
||||
- **`ResponseTarget`** + **`ChannelPush`** + **`ContinuationToken`** + **active channel** — the response-delivery stack. `ResponseTarget` decouples *where* a response is delivered from *where* it originated; `ChannelPush` is the optional channel capability used for non-`originating` delivery; `ContinuationToken` makes background runs first-class with a stable id and status; the host tracks last-seen `(isolation_key, channel)` to resolve `response_target="active"`. `ResponseTarget` constructors that name destinations accept `echo_input=True` to also push the originating user message onto each non-originating destination before the agent reply — keeps the destination channel's UI coherent with the user's actual turn when the host orchestrates cross-channel delivery.
|
||||
- **`confidentiality_tier`** + **`LinkPolicy`** — the multi-tier-on-one-host stack. `confidentiality_tier` is an opaque per-channel label; `LinkPolicy` is the host-level decision over which channel pairs may share an `isolation_key` (link) and which may push to one another (deliver). Built-in `DenyAllLinks` enforces "share a target, never share a session"; running multiple hosts is always a valid alternative.
|
||||
- **Intent-only delivery envelope + pluggable `DurableTaskRunner`** — assistant messages stored by the host carry an `intended_targets[]` array on `Message.additional_properties["hosting"]` capturing the resolved destination set (after `ResponseTarget` + `LinkPolicy` filtering). The write is **immutable** — a single record of intent, never mutated post-push. Per-destination operational state (attempts, retries, last error, success timestamp, channel-issued id) lives in a pluggable `DurableTaskRunner` (`register` / `schedule` / `get`) that the host uses to fan out non-originating pushes. Built-in `InProcessTaskRunner` (asyncio + bounded retry) is the default for `long_running` deployments; adapter packages (`agent-framework-hosting-durabletask`, future Foundry adapter) plug in for `ephemeral` deployments. Replay is a property of the configured runner — native for durable adapters, not supported for in-process. This eliminates the earlier `pending`/`delivered`/`failed`/`skipped` state machine, the `SupportsDeliveryTracking` provider capability, and the Foundry `update_item` service ask.
|
||||
- **Caller-supplied vs. host-tracked session carriage** — channels split into two families based on whether the upstream protocol carries a per-conversation key on every request. *Caller-supplied* channels (Responses' `previous_response_id`, Invocations, A2A, MCP) parse it into `ChannelSession.key` and let the caller branch threads by sending fresh ids. *Host-tracked* channels (Telegram, Activity Protocol via Azure Bot Service — Teams/Web Chat/Slack/…— WhatsApp) carry only a stable identity and rely on the host's per-`isolation_key` session alias plus a `host.reset_session(...)` `/new`-style command. The split is invisible to the agent target and explains why `reset_session` and aliasing exist at all (host-tracked channels have no other way to start a fresh thread). Anonymous vs. identified is an orthogonal axis; identity is supplied by the channel, the resolver, or both.
|
||||
- **Multi-user surfaces are first-class.** Telegram groups, supergroups, forum topics, and Activity Protocol multi-user `conversationType`s (`groupChat`, `channel`) are designed-in from v1 — not retrofitted. The contract enforces a clean separation of **user identity** (`ChannelIdentity.native_id` = `from.id` / `from.aadObjectId`) and **conversation locator** (`ChannelRequest.conversation_id` = `chat.id` (+ optional `message_thread_id` / `replyToId`)). Channel implementations expose a `conversation_scope` option (`per_user`, `per_user_per_conversation` (default in groups), `per_conversation`) and an `accept_in_group` addressing rule (`mention_only` (default), `command_only`, `mention_or_command`, `all`) so the bot does not respond to every message in a group and so a single user's group context does not leak into their DM by default. Linker challenge messages (OAuth URL / one-time code) MUST redirect to the user's DM in group contexts.
|
||||
- **Built-in channels** — own their protocol-defined endpoint paths (`/responses`, `/invocations`, `/telegram/webhook`) without the app author spelling those out.
|
||||
Channels own:
|
||||
|
||||
Channel implementations live in **separate distribution packages**, one per channel, with public surfaces kept stable per language.
|
||||
- contributed routes, middleware, commands, and lifecycle callbacks,
|
||||
- protocol-native request parsing into `ChannelRequest`,
|
||||
- protocol-native rendering of the originating response, and
|
||||
- any channel-specific authentication or signature validation.
|
||||
|
||||
| Concept | Python (proposed) | .NET (proposed) |
|
||||
|---|---|---|
|
||||
| Core | `agent-framework-hosting` → `agent_framework.hosting` | `Microsoft.Agents.AI.Hosting` |
|
||||
| Responses channel | `agent-framework-hosting-responses` → `agent_framework.hosting.ResponsesChannel` (lazy) | `Microsoft.Agents.AI.Hosting.Responses` |
|
||||
| Invocations channel | `agent-framework-hosting-invocations` → `agent_framework.hosting.InvocationsChannel` (lazy) | `Microsoft.Agents.AI.Hosting.Invocations` |
|
||||
| Telegram channel | `agent-framework-hosting-telegram` → `agent_framework.hosting.TelegramChannel` (lazy) | `Microsoft.Agents.AI.Hosting.Telegram` |
|
||||
The host owns:
|
||||
|
||||
Each language follows its own conventions:
|
||||
- route/lifecycle aggregation,
|
||||
- invocation of the target,
|
||||
- `ChannelSession(isolation_key=...)` to `AgentSession` resolution and caching,
|
||||
- `reset_session(isolation_key=...)`,
|
||||
- host-level middleware, including Foundry isolation middleware only when the Foundry hosting environment flag is present,
|
||||
- invocation of per-channel hooks (`ChannelRunHook`, `ChannelResponseHook`, `ChannelStreamUpdateHook`), and
|
||||
- workflow checkpoint wiring through an explicit `checkpoint_location`.
|
||||
|
||||
- Python keeps the public import path stable at `agent_framework.hosting` via lazy imports.
|
||||
- .NET keeps the public namespaces stable per package, following existing `Microsoft.Agents.AI.*` conventions.
|
||||
`ChannelIdentity`, when present, is request metadata only. In v1 it is not a linking, authorization, or delivery key.
|
||||
|
||||
The new hosting core and its channel packages **must not** take a dependency on legacy protocol-specific hosts; those are prior art and parity reference only.
|
||||
### Trust boundary for `isolation_key`
|
||||
|
||||
The initial design target, in every implementing language, is:
|
||||
The host treats `ChannelSession.isolation_key` as a session partition key, not as proof of identity. Channels or host middleware must authenticate and authorize any externally supplied value before passing it to the host. For example, a Responses caller must not be allowed to choose an arbitrary `previous_response_id` or header-derived key unless the platform or middleware has already established that the caller owns that conversation. The host deliberately does not infer that trust from the string itself.
|
||||
|
||||
- any execution-seam-compatible target (not just the concrete `Agent`/`ChatClientAgent`),
|
||||
- built-in channel designs for Responses and Invocations,
|
||||
- a documented authoring model for webhook/message channels, including a first detailed Telegram design,
|
||||
- conceptual alignment with existing protocol packages but no implementation or migration requirement for those in the first phase.
|
||||
### Hook ownership
|
||||
|
||||
### Conceptual API shape
|
||||
Channels provide hook configuration and protocol-native context. The host invokes those hooks as part of the common invocation pipeline:
|
||||
|
||||
The top-level user experience should look the same conceptually in every language: compose one host with one agent and a list of channels, then start it. The channel-authoring seam should follow each language's idioms while preserving the same concepts.
|
||||
- `ChannelRunHook` runs after channel parsing and before target invocation.
|
||||
- `ChannelResponseHook` runs after target invocation and before the originating channel serializes its response.
|
||||
- `ChannelStreamUpdateHook` is applied by the host while the channel consumes streamed updates because streaming serialization is protocol-specific.
|
||||
|
||||
| Concept | Python idiom | .NET idiom |
|
||||
|---|---|---|
|
||||
| Define a host | `AgentFrameworkHost(target, channels=[...])` (target = agent or workflow) | `AgentFrameworkHostBuilder` / `AddAgentFrameworkHost(target, ...)` on the host builder |
|
||||
| Canonical app surface | `host.app` (Starlette `Starlette`) — supports HTTP **and** WebSocket scopes via ASGI | `WebApplication` (ASP.NET Core) — supports HTTP **and** WebSocket via `app.UseWebSockets()` / `MapWebSocket(...)` |
|
||||
| Convenience start | `host.serve(host=, port=)` (lazy `uvicorn`) | `host.RunAsync()` (Kestrel) |
|
||||
| Channel contract | `Channel` Protocol with `contribute(context) -> ChannelContribution` | `IChannel` interface with `Contribute(IChannelContext)` returning `ChannelContribution` |
|
||||
| Per-request hook | `ChannelRunHook = Callable[..., ChannelRequest \| Awaitable[ChannelRequest]]` invoked as `hook(request, *, target=..., protocol_request=...)` | `Func<ChannelRequest, ChannelRunHookKwargs, ValueTask<ChannelRequest>>` / delegate with named extras |
|
||||
| Identity resolver | `IdentityResolver = Callable[[ChannelIdentity], str \| None]` | `IIdentityResolver` (returns `isolation_key`) |
|
||||
| Identity linker | `IdentityLinker` Protocol with `begin(...)` / `complete(...)` plus `routes()` for callback / verification endpoints | `IIdentityLinker` interface with begin/complete + route contributions |
|
||||
| Authorization policy | `require_link: bool` + `allowlist: IdentityAllowlist \| Literal["inherit"] \| None` on each channel; built-in allowlists `AllowAll`, `NativeIdAllowlist`, `LinkedClaimAllowlist`, `AnyOfAllowlists`, `AllOfAllowlists`, `CallableAllowlist`; host seam `host.authorize(identity, *, require_link, allowlist, verified_claims) -> AuthorizationOutcome` (`Allowed` \| `LinkRequired` \| `Denied`) with tri-state `AllowlistDecision` (`ALLOW` / `DENY` / `ABSTAIN`); named factories on `AuthPolicy` (`.open()` / `.require_link()` / `.native_allowlist(...)` / `.linked_claim_allowlist(...)`) | `IIdentityAllowlist.EvaluateAsync(AuthorizationContext)` returning `AllowlistDecision`; built-ins `AllowAll`, `NativeIdAllowlist`, `LinkedClaimAllowlist`, `AnyOfAllowlists`, `AllOfAllowlists`; `IAgentFrameworkHost.AuthorizeAsync(...)` returning `AuthorizationOutcome` discriminated union with `Allowed` / `LinkRequired` / `Denied` variants |
|
||||
| Response routing | `ChannelRequest.response_target = ResponseTarget.originating \| .active \| .channel("activity") \| .all_linked \| .none`; channels expose `ChannelPush` if they can deliver proactively | `ChannelRequest.ResponseTarget` discriminated union; `IChannelPush` interface for proactive delivery |
|
||||
| Background runs | `ContinuationToken` returned by `host.run_in_background(request)`; channels may return it as their protocol response and/or expose a poll route | `ContinuationToken` record + `HostStateStore` for persistence (file-based default; pluggable Cosmos / SQL / Redis) |
|
||||
| Runtime mode | `runtime_mode: Literal["long_running", "ephemeral"] \| None = None` on `AgentFrameworkHost`; `None` triggers auto-detect via deployment env markers (`FOUNDRY_HOSTING_ENVIRONMENT`, `AZURE_FUNCTIONS_ENVIRONMENT`, `AWS_LAMBDA_FUNCTION_NAME`); falls back to `"long_running"`. Advisory — drives defaults for `HostStateStore` / `DurableTaskRunner` / identity-link state. | `RuntimeMode` enum on `AgentFrameworkHostBuilder` with the same auto-detection contract |
|
||||
| Durable task runner | `DurableTaskRunner` Protocol (`register` / `schedule` / `get`) on the host; built-in `InProcessTaskRunner` (asyncio + bounded retry); adapter packages plug TaskHub / Foundry / SQLite backends. Used internally for non-originating push fan-out; in v1 fast-follow shared with background-run plumbing. | `IDurableTaskRunner` interface with the same register/schedule/get triple; built-in in-process runner; adapter packages mirror the Python set |
|
||||
| Confidentiality tier on a channel | `Channel.confidentiality_tier: str \| None` (opaque) | `IChannel.ConfidentialityTier { get; }` (opaque string) |
|
||||
| Link / delivery policy | `LinkPolicy = Callable[[LinkPolicyContext], bool]` with built-ins `AllowAllLinks`, `SameConfidentialityTierOnly`, `ExplicitAllowList`, `DenyAllLinks` | `ILinkPolicy.IsAllowed(LinkPolicyContext)` with the same set of built-in implementations |
|
||||
| Command descriptor | `ChannelCommand` dataclass | `ChannelCommand` record |
|
||||
| Lifecycle | `on_startup` / `on_shutdown` callables | `IHostedService` integration / explicit lifecycle delegates |
|
||||
`ChannelStreamUpdateHook` is an update hook, not a final-response sanitizer. Channels that use it for redaction or filtering must also apply equivalent policy to any final response they render. Channels choose whether the response is streaming before run hooks execute.
|
||||
|
||||
Built-in channels own the default mapping from each protocol's request model into a `ChannelRequest`, **and** expose a per-request invocation-hook seam so app authors can validate or rewrite invocation behavior before the host invokes the agent.
|
||||
This keeps hook call conventions centralized while leaving protocol payload parsing and response formatting in channel packages.
|
||||
|
||||
The full Python API surface — exact types, fields, default routes, code samples — is specified in the companion Python spec. A future .NET spec captures the .NET-idiomatic API surface for the same model.
|
||||
### State owned by v1
|
||||
|
||||
#### Runtime topology
|
||||
`state_dir` is limited to host-owned local files for reset-session aliases and workflow checkpoint path derivation. It does not store linked identities, active-channel state, response-routing state, continuation records, durable runner queues, or delivery attempts. Those storage concerns belong to ADR-0028.
|
||||
|
||||
How the pieces wire at runtime. Channels contribute routes to the host's app; inbound traffic splits at the parse step into a command dispatch (handled in the channel) or a message that flows through `host.authorize` → target invocation → response delivery. Non-originating destinations go through the configured `DurableTaskRunner`; the originating channel is rendered synchronously.
|
||||
## Non-goals for v1
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Caller[External caller /<br/>messaging app]
|
||||
The following are deliberately **not** part of the v1 contract:
|
||||
|
||||
subgraph Host[AgentFrameworkHost]
|
||||
direction TB
|
||||
ASGI[Starlette / ASP.NET Core app]
|
||||
Router[Channel router]
|
||||
Parse{parse →<br/>command or<br/>message?}
|
||||
Auth[host.authorize]
|
||||
Resolver[IdentityResolver]
|
||||
Delivery[_deliver_response]
|
||||
Push[_handle_push_task]
|
||||
end
|
||||
- cross-channel identity linking (`IdentityLinker`, `local_identity_link`, or `agent-framework-hosting-entra`),
|
||||
- identity allowlists or authorization policy (`IdentityAllowlist`, `AuthPolicy`),
|
||||
- response routing beyond the originating channel (`ResponseTarget`, active channel, specific linked channel, `all_linked`),
|
||||
- push or payload codecs (`ChannelPush`, `ChannelPushCodec`),
|
||||
- background/continuation delivery,
|
||||
- durable task runners (`DurableTaskRunner`, `InProcessTaskRunner`),
|
||||
- retry/replay policy (`RetryPolicy`),
|
||||
- fan-out, multicast, or all-linked delivery,
|
||||
- confidentiality tiers and `LinkPolicy`, and
|
||||
- a host-level multi-agent router.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
#### Channel contribution shape
|
||||
|
||||
Every channel exposes the same three contribution slots (all optional except `routes`). The host duck-types each slot and stitches them in at construction.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph C[ConcreteChannel<br/>e.g. TelegramChannel]
|
||||
direction TB
|
||||
Routes[routes:<br/>webhook / poller / API endpoints<br/>→ Starlette router]
|
||||
Commands[commands: Sequence ChannelCommand<br/>name · description · handle ·<br/>scopes · locales · expose_in_ui]
|
||||
Push[ChannelPush.push<br/>+ optional ChannelPushCodec<br/>+ optional response_hook]
|
||||
end
|
||||
|
||||
Host[Host]
|
||||
Native[Platform native catalog<br/>Telegram set_my_commands ·<br/>Teams app manifest · …]
|
||||
Dispatch[CommandHandler dispatch]
|
||||
Delivery[Originating sync delivery<br/>+ runner-scheduled fan-out]
|
||||
|
||||
Routes -- contribute at startup --> Host
|
||||
Commands -- startup projection --> Native
|
||||
Commands -- runtime dispatch --> Dispatch
|
||||
Push -- driven by --> Delivery
|
||||
```
|
||||
|
||||
#### Authorization decision
|
||||
|
||||
`require_link` and `allowlist` are orthogonal axes. The `require_link` gate runs first against the current link state from `StateStore`; an unlinked identity on a `require_link=True` channel returns `LinkRequired` regardless of allowlist. A claim-dependent allowlist that has not yet seen claims returns `ABSTAIN` from `evaluate` and is converted into a `LinkRequired` outcome so the user gets a link prompt rather than a silent deny.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Start([authorize identity,<br/>require_link, allowlist])
|
||||
Linked{identity already<br/>linked?<br/>StateStore lookup}
|
||||
Required{require_link?}
|
||||
OpenPath{allowlist is None?}
|
||||
Resolve[/isolation_key:<br/>linked → existing,<br/>else auto-issue channel:native_id/]
|
||||
Evaluate[/allowlist.evaluate context/]
|
||||
Decision{decision}
|
||||
Abstain{requires_linked_claims?}
|
||||
Allowed([Allowed isolation_key])
|
||||
DeniedPre([Denied<br/>allowlist_denied_pre_link])
|
||||
LinkReq([LinkRequired<br/>via configured linker])
|
||||
|
||||
Start --> Linked
|
||||
Linked -- yes --> OpenPath
|
||||
Linked -- no --> Required
|
||||
Required -- yes --> LinkReq
|
||||
Required -- no --> OpenPath
|
||||
OpenPath -- yes --> Resolve --> Allowed
|
||||
OpenPath -- no --> Evaluate --> Decision
|
||||
Decision -- ALLOW --> Resolve
|
||||
Decision -- DENY --> DeniedPre
|
||||
Decision -- ABSTAIN --> Abstain
|
||||
Abstain -- yes --> LinkReq
|
||||
Abstain -- no --> Resolve
|
||||
```
|
||||
|
||||
## Terminology
|
||||
|
||||
These terms are language-neutral and shared between Python and .NET implementations. Each language realizes them with idiomatic types and naming.
|
||||
|
||||
- **Host**: The object that owns one application, one execution-seam-compatible target, and a sequence of channels. Provides the underlying app object (canonical portability surface) and a convenience start method.
|
||||
- **Channel**: A pluggable component that contributes routes (HTTP and/or WebSocket), middleware, commands, and lifecycle hooks to a host. One channel = one external protocol surface. Used interchangeably with "head" in earlier discussions; **Channel** is the canonical name.
|
||||
- **`ChannelRequest`**: The host-neutral, normalized invocation envelope produced by a channel before the host invokes the agent. Carries `input`, `options`, `session` hint, `session_mode`, and channel-specific `attributes`. Also carries a small set of **typed slots** for protocol-extension data so multiple event-rich channels (AG-UI today, future custom front-ends) can settle on a shared shape rather than smuggling fields through `attributes`: `client_state` (mutable per-request state object), `client_tools` (frontend tool catalog the agent should see but not execute), and `forwarded_props` (pass-through bag for resume/command/HITL payloads). All three are optional and channel-defined in shape; the host treats them as opaque.
|
||||
- **`ChannelSession`**: A small session hint with a stable lookup key, an optional protocol-visible conversation/thread identifier, and an opaque `isolation_key`. The host resolves it into a framework session; storage specifics are deferred.
|
||||
- **`isolation_key`**: An opaque partition boundary aligned with hosted-agent terminology — may represent a user, tenant, chat, or other scope without baking direct identity semantics into the generic host.
|
||||
- **Channel-native identity**: The **user/account** identifier the channel observes from its own platform (Telegram `from.id`, Teams `from.aadObjectId`, WhatsApp phone number, Slack user id). Always per-channel; never assumed to align across channels. Distinct from the **conversation locator** (e.g. Telegram `chat.id`, Teams `conversation.id` + optional `replyToId`) which lives on `ChannelRequest.conversation_id` — in multi-user surfaces (Telegram groups, Teams group chats and team channels) the two never coincide, and the spec defines a `conversation_scope` knob (`per_user`, `per_user_per_conversation` (default in groups), `per_conversation`) plus default `mention_only` addressing so the bot does not respond to every message in a group.
|
||||
- **Identity resolver**: Host-level seam that maps a channel-native identity into an `isolation_key`. Default behavior **auto-issues and persists** a fresh, stable `isolation_key` on first contact per `(channel, native_id)` so every end user automatically gets a per-user partition without app code; linking merges the second channel's auto-issued key onto the first channel's existing key. Apps that already own an identity namespace can supply a custom resolver that returns those values directly.
|
||||
- **Identity linker**: Host-level seam that runs a connect ceremony — typically OAuth, MFA, or a signed one-time code — to associate a new channel-native identity with an existing `isolation_key`. Channels expose entry points (e.g. a `/link` command or button); the host owns the ceremony's routes and short-lived state. Mechanism (OAuth provider, MFA factor, code transport) is pluggable; the contract is not.
|
||||
- **`ResponseTarget`**: Per-request directive on `ChannelRequest` that controls **where** the response is delivered: `originating` (default), `active` (the user's most recently observed channel), a specific channel, a list of channels, `all_linked`, or `none` (background-only). Independent of `session_mode`. When the target differs from the originating channel, delivery uses the destination channel's `ChannelPush` capability.
|
||||
- **`ChannelPush`**: Optional channel capability for **proactive** outbound delivery (proactive Telegram message, Activity Protocol proactive message via Azure Bot Service, webhook callback, SSE broadcast). Channels that don't implement it cannot be the destination of a non-`originating` `ResponseTarget`.
|
||||
- **Active channel**: The channel most recently observed for a given `isolation_key`. The host tracks last-seen `(isolation_key, channel)` so `response_target="active"` resolves to whichever channel the user is currently using.
|
||||
- **`confidentiality_tier`** (channel-level): An opaque label declared on a channel (`"corp"`, `"public"`, `"internal"`, …) consumed by the host's `LinkPolicy`. Two channels with different confidentiality tiers can share an agent target on one host while remaining session-isolated.
|
||||
- **`LinkPolicy`**: Host-level decision over which channel pairs may share an `isolation_key` (link) and which channel pairs may be `ResponseTarget` source/destination for one another (deliver). Built-in variants: allow-all (default), same-tier-only, explicit allow-list, deny-all (the explicit "no cross-channel continuity" mode). Running multiple hosts is always a valid alternative; the policy exists for cases where one shared host with policy-enforced isolation is preferred.
|
||||
- **`ContinuationToken`**: First-class artifact for background/asynchronous runs. Carries an opaque, URL-safe `token`, current status (`queued` | `running` | `completed` | `failed`), and the resolved `isolation_key`. Channels may return it directly in their protocol response (e.g. an Invocations 202 with the token plus a polling URL) so the caller can poll later, while the host also pushes the result to the configured response target when ready. Persisted via the host-level `HostStateStore` (file-based default in v1) so background runs survive host restarts.
|
||||
- **`session_mode`**: Per-request directive (`auto` | `required` | `disabled`) that controls whether the host resolves a session before invoking the agent. Lets channels honor protocol semantics like Responses `store=False` and lets app authors enforce extra policy.
|
||||
- **`ChannelContribution`**: What a channel returns from its `contribute(...)` method — routes, middleware, commands, and startup/shutdown lifecycle hooks. The host aggregates contributions into one application.
|
||||
- **`ChannelCommand`**: A transport-neutral command descriptor. Message channels project these into native command surfaces — Telegram bot commands, future Activity Protocol slash commands / adaptive cards, WhatsApp menus.
|
||||
- **`ChannelRunHook`**: Per-request callable on built-in channels. Runs after the channel's default `ChannelRequest` is produced, before session resolution. The escape hatch for forcing or forbidding session use, requiring extra options, or adapting to targets like `A2AAgent`.
|
||||
- **Native command registration**: The startup-time projection of `ChannelCommand` metadata into a platform's native command catalog (e.g. Telegram `set_my_commands(...)`).
|
||||
- **Hostable target**: The executable object the host fronts — either an **agent** (invoked via the agent execution seam) or a **workflow** (invoked via the workflow execution seam). The host detects the kind and dispatches to the appropriate runner; channels remain unchanged.
|
||||
- **Execution seam**: The framework's existing per-language invocation contracts — for agents, `SupportsAgentRun.run(...)` in Python and `AIAgent.RunAsync(...)` in .NET; for workflows, the equivalent per-language workflow execution seam. The host requires one of these from the hosted target.
|
||||
- **`HostedStreamResult.raw_events`**: Optional passthrough seam onto the underlying agent event stream **before** update normalization, for channels whose protocol carries domain events the framework does not model (e.g. AG-UI's `StateSnapshotEvent` / `StateDeltaEvent` / `ToolCallStartEvent`). Channels that consume `raw_events` bear responsibility for the full event translation; the request still flows through `context.stream(...)` so session resolution, identity, push, and policy continue to apply. The high-level normalized `updates` stream remains the happy path for Responses, Invocations, Telegram, and most channels.
|
||||
- **Per-conversation storage seam**: One public seam — `ContextProvider`. Messages flow through `HistoryProvider` (its canonical subclass); non-message per-thread state for event-rich channels (e.g. AG-UI `client_state`) flows through a channel-owned `ContextProvider` subclass that writes into the same per-source state slot. **No parallel `StateProvider` Protocol is introduced.** Host-level pluggable state (`ContinuationToken`s, identity-link grants, last-seen records) and workflow `CheckpointStorage` are deliberately separate seams because the data shapes are structurally different; all three MAY be backed by the same physical store. Per-request transport state (response ids, platform isolation keys, future signals) flows from channels via `ChannelRequest.attributes` into `ContextProvider.bind_request_context(**attrs)`, so providers consume backend-specific request signals without app authors having to wrap the host's ASGI app or install middleware.
|
||||
These areas are follow-up enhancements covered by [ADR-0028](0028-hosting-linking-multicast-enhancements.md). They are not prerequisites for shipping or using the v1 host.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Good, because app authors get one consistent low-level hosting story for single- and multi-channel scenarios in each supported language.
|
||||
- Good, because channel packages can stay opinionated about protocol payloads and capabilities without pushing those semantics into the core.
|
||||
- Good, because the existing protocol-specific implementations provide proven prior art and behavioral guidance.
|
||||
- Good, because the design supports webhook/message channels that do not look like OpenAI or Foundry APIs.
|
||||
- Good, because command-capable message channels such as Telegram are first-class channels rather than special-case samples.
|
||||
- Good, because architectural portability stays at the **standard web-application object** level (ASGI app in Python, `WebApplication` in .NET), so the host is not fundamentally coupled to any one server implementation even when a `serve(...)` convenience uses one.
|
||||
- Good, because channels can ship sensible invocation defaults while still giving app authors a clear place to enforce extra policy or adapt to different agent implementations (e.g. `A2AAgent`).
|
||||
- Good, because cross-channel chat continuity for one end user is achievable in the first phase whenever channels can produce a stable `isolation_key`, without requiring any new cross-package storage contract.
|
||||
- Good, because the same conceptual model is shared across languages — concepts, terminology, and behavior transfer between Python and .NET teams and docs.
|
||||
- Bad, because we introduce new package and namespace surface area that must be versioned and documented in each language.
|
||||
- Bad, because we still need to reimplement the needed behavior in Agent Framework-owned code per language.
|
||||
- Bad, because there will be a temporary overlap with the existing protocol-specific hosts until the new channel packages are implemented and stabilized.
|
||||
- Neutral, because existing protocol packages remain outside the first implementation scope even though the model keeps a path open for later convergence.
|
||||
Positive:
|
||||
|
||||
## Validation
|
||||
- The host/channel model can be implemented and tested without designing a security-sensitive identity graph.
|
||||
- Existing and new channel packages can share one Starlette app, middleware stack, lifecycle, and target invocation path.
|
||||
- Session continuity is explicit and debuggable: two channels share history only when they produce the same `isolation_key`.
|
||||
- Hook invocation is centralized in the host, so channels do not each invent the call convention.
|
||||
|
||||
The decision is validated when, in each implementing language:
|
||||
Negative:
|
||||
|
||||
1. a one-channel Responses sample and a two-channel Responses + Invocations sample can be expressed with one host, default route layouts under `/responses` and `/invocations`, and no handwritten protocol routing,
|
||||
2. a Responses channel by default forwards official request parameters like `temperature` into agent options and maps `store=False` into disabled session use,
|
||||
3. app authors can override that default per request with an run hook that validates or rewrites the final `ChannelRequest` (for example requiring `temperature`, ignoring `store`, or adapting for `A2AAgent`),
|
||||
4. a Telegram-style message channel can express command metadata, command registration, and either webhook or polling lifecycle behavior through the new channel contract,
|
||||
5. a custom webhook/message channel can be authored only against the new channel contract plus the language's web-framework primitives and lifecycle hooks,
|
||||
6. two channels mounted on the same host (e.g. Telegram + a future Activity Protocol channel — Teams via Azure Bot Service) configured with a stable per-user `isolation_key` resolve to the same session for the same end user, so a conversation started on one channel can be continued on the other against the same conversation history,
|
||||
7. an end user who is known on one channel can **link a second channel to the same `isolation_key`** through a host-provided ceremony (OAuth, MFA, or a signed one-time code) without each channel reinventing the linking flow, and subsequent requests from the linked channel resolve to the same session as the original channel,
|
||||
8. a request submitted on one channel can opt into **delivery on a different channel** — `response_target="active"` (whichever channel the user is currently using), a specific channel id, all linked channels, or `none` (background only) — using the destination channel's `ChannelPush` capability, without the originating channel having to know how the destination delivers,
|
||||
9. **background runs are first-class**: a channel can submit a request that returns a `ContinuationToken` immediately and the response is later delivered both via channel push (when the user is next observed on the configured target channel) and via a poll route the caller can hit with the token,
|
||||
10. the **same host construction** can front either an agent or a workflow target — the channel ecosystem (Responses, Invocations, Telegram, …) is unchanged, and only the `run_hook` (channel-default or app-supplied) differs to adapt the inbound `ChannelRequest` into the input shape the target requires,
|
||||
11. a host configured with at least the Responses and Invocations channels can be packaged into a container image whose runtime contract (exposed routes, request/response shapes, health/lifecycle behavior) is **compatible with the Hosted Agents platform**, so the same image can be deployed to that platform without protocol shims,
|
||||
12. a channel can contribute a **WebSocket endpoint** alongside its HTTP routes through the same `Channel` contract, the host's app object exposes it through the standard ASGI / ASP.NET Core WebSocket scope, and the built-in Responses channel exposes a WebSocket transport (default `/responses/ws`) carrying the same Responses request/event model as its HTTP+SSE transport — so the host is forward-compatible with the OpenAI Responses WebSocket transport without changing the hosting contract,
|
||||
13. a host can **mix channels of different confidentiality tiers** under a `LinkPolicy` so e.g. a corporate-tier channel (Teams) and a public-tier channel (Telegram) share one agent target without sharing a session, cross-tier link attempts are refused with a typed error, cross-tier `ResponseTarget` deliveries are dropped, and the same outcome is reachable by simply running two separate hosts (validating that the policy is a convenience, not a load-bearing mechanism), and
|
||||
14. the first Responses and Invocations implementations achieve parity with the important behavior of the current protocol-specific hosts without introducing a runtime dependency on them or leaking protocol-specific request models into the hosting core.
|
||||
- Apps that need OAuth linking, allowlists, proactive messages, or multicast must continue to implement those behaviors outside the v1 host.
|
||||
- Some richer cross-channel scenarios from the original design move to a separate decision and validation cycle.
|
||||
- The host must document `isolation_key` trust clearly because it now provides the shared session boundary.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
## Validation Gates
|
||||
|
||||
### Keep the current protocol-specific hosting packages only
|
||||
Before this ADR is accepted:
|
||||
|
||||
- Good, because no new package or abstraction needs to be introduced.
|
||||
- Good, because each protocol can move independently.
|
||||
- Bad, because users still cannot host one agent on multiple channels through one shared host.
|
||||
- Bad, because request/session/event bridging keeps being rebuilt at the protocol layer.
|
||||
- Bad, because webhook/message channels still have no natural home.
|
||||
- Bad, because the same gap exists in every language with no shared conceptual model.
|
||||
|
||||
### One monolithic `hosting` package with all channels built in
|
||||
|
||||
- Good, because discovery is straightforward.
|
||||
- Good, because cross-channel refactoring is simpler inside one package.
|
||||
- Bad, because every app pays the dependency and maintenance cost of every channel.
|
||||
- Bad, because lifecycle and stability become coupled across unrelated channels.
|
||||
- Bad, because it does not fit either ecosystem's subpackage direction.
|
||||
|
||||
### New hosting core plus new channel packages, reimplemented without reference to current hosting implementations
|
||||
|
||||
- Good, because the abstraction boundary can be kept very clean.
|
||||
- Good, because package ownership is clear.
|
||||
- Bad, because it ignores useful prior art in the current hosting implementations.
|
||||
- Bad, because it increases implementation cost and migration risk.
|
||||
- Bad, because it makes early channel parity harder.
|
||||
|
||||
### New hosting core plus separate channel packages, informed by current protocol-specific implementations
|
||||
|
||||
- Good, because it gives us a reusable host abstraction without discarding what we learned from current protocol work.
|
||||
- Good, because the core stays protocol-agnostic while channel packages remain Agent Framework-owned and dependency-free with respect to the legacy protocol-specific hosts.
|
||||
- Good, because it gives future channels a deeper seam than today's top-level host wrappers.
|
||||
- Good, because the conceptual model can be applied uniformly in Python and .NET.
|
||||
- Neutral, because some implementation details may look similar to the current hosts when they are solving the same problem.
|
||||
- Bad, because the design team must still curate the boundary carefully to avoid copying protocol-specific assumptions into the generic host.
|
||||
|
||||
## Open Questions
|
||||
|
||||
| # | Question | Notes |
|
||||
|---|---|---|
|
||||
| 6 | Is "Channel" the GA name in both languages? "Head" was used interchangeably during design discussions. | Use "Channel" for now in spec, ADR, samples, and sub-package names. Other names remain on the table; revisit before public docs in either language. |
|
||||
| 14 | For the Responses WebSocket transport, what subprotocol identifier and auth carrier should the channel adopt — `Authorization` header on the `Upgrade`, a `Sec-WebSocket-Protocol` token, or a query-string-bound short-lived token? | Wait for the upstream OpenAI Responses WS spec to land. The channel codec is intentionally swappable (the host contract does not depend on the WS framing) so the channel package can track upstream changes without touching the host. Document the swappable-codec property explicitly in the spec. |
|
||||
|
||||
## Resolved Questions (decisions log)
|
||||
|
||||
| # | Question | Decision |
|
||||
|---|---|---|
|
||||
| 1 | Final distribution package and namespace names per language. | Accept the proposed Python distribution + import names (`agent-framework-hosting` → `agent_framework.hosting`, plus per-channel `agent-framework-hosting-{responses,invocations,telegram}`). Keep the proposed .NET namespaces (`Microsoft.Agents.AI.Hosting{,.Responses,.Invocations,.Telegram}`) as the working target. |
|
||||
| 2 | How tightly do Python and .NET API names need to match? | Keep concepts and terminology identical across languages; allow idiomatic naming differences (e.g. `serve` vs `RunAsync`). |
|
||||
| 3 | Should generic auth helpers (HMAC signature, bearer token) live in core, in optional shared helpers, or per channel? | Per-channel auth + host-level middleware composition (current draft). No separate shared-helpers package in v1. **Cross-check the matching decision in the Python spec.** |
|
||||
| 4 | Should a later phase define a pluggable session store interface, and should it be cross-language or per-language? | Per-language interface (idiomatic per ecosystem). Cross-language compatibility is **not** a v1 goal; revisit if/when concrete demand emerges. |
|
||||
| 5 | Should the host support multi-target hosting (one host fronting a router across multiple agents/workflows)? | **No.** One host = one target. External routers compose multiple single-target hosts (e.g. via Starlette mount in Python, equivalent in .NET). Confirms the existing non-goal. |
|
||||
| 7 | Should command scopes / projection metadata (private vs group, per-locale descriptions) become first-class on `ChannelCommand`? | Add **optional** `scopes` and `locales` fields on `ChannelCommand`. Channels are free to ignore them. Keeps the cross-channel surface lean while letting Telegram (and the future Activity Protocol channel) project the metadata into their native command catalog. |
|
||||
| 8 | Which identity-linking mechanisms ship in the first phase? | Ship two first-party helpers in v1 fast-follow: **Entra OAuth** (preset on `OAuthIdentityLinker`) and **`OneTimeCodeIdentityLinker`** (cross-channel code exchange). **Drop `MfaIdentityLinker`** from the v1 fast-follow list. The generic `IdentityLinker` contract still admits any other linker app authors want to write. |
|
||||
| 9 | Where do issued link grants live? | **File storage for v1**, leveraging Hosted Agents' isolated, persistent per-instance file storage. Resolved together with Q11. |
|
||||
| 10 | Should the identity resolver be invoked per channel or once on the host with `(channel_id, native_id)`? | **Host-level resolver receiving `(channel_id, native_id)`** so cross-channel decisions stay in one place. Per-channel overrides remain a future option if real cases emerge. |
|
||||
| 11 | Where does the continuation-token store live? At-rest format and TTL? | Same as Q9 — **file storage for v1** (`FileHostStateStore` under `./.af-hosting/continuations/`, atomic JSON-per-token writes, 24h default TTL on completed entries). Shares the host-level `HostStateStore` contract with link grants and last-seen records. Pluggable Cosmos / SQL / Redis adapters tracked in spec req #23. |
|
||||
| 12 | Contract for `ChannelPush` failure (offline destination, opt-out, expired token)? | **Retry handled by the configured `DurableTaskRunner` per its `RetryPolicy`.** The host registers a single internal `"hosting.push"` handler at startup; each non-originating destination becomes a `runner.schedule("hosting.push", payload)` call. Failures inside the handler are caught by the runner, retried with backoff, and ultimately marked terminal-failed when `max_attempts` is exhausted. Downstream push outcomes live in the runner's own log — there is no per-destination return surface. The earlier `DeliveryReport` value type has been removed; the host's internal `_deliver_response` helper returns `bool` (whether any work was scheduled) for the originating channel. Per-request override via `run_hook`. See the Python spec's [Intended targets + durable delivery](../specs/002-python-hosting-channels.md#intended-targets--durable-delivery) and [Durable task runner](../specs/002-python-hosting-channels.md#durable-task-runner). |
|
||||
| 13 | Should `response_target="active"` use a time window? Behavior on expiry? | Yes — configurable `active_window_seconds` on the host (suggested default **300 s**). On expiry, fall back to `originating`, then to `all_linked`. Per-request override via `run_hook`. |
|
||||
| 15 | Should `Channel.confidentiality_tier` stay opaque or become an ordered enum? | **Keep as opaque string.** Apps define their own taxonomy. Built-in policies do equality / set membership checks only — no ordered-comparison policy is shipped. |
|
||||
| 16 | Should authorization (per-channel allowlist) ship as a single `auth_mode` enum or as two orthogonal parameters? | **Two orthogonal parameters (`require_link: bool` + `allowlist: IdentityAllowlist`)** plus named `AuthPolicy` factories for ergonomics. A single enum cannot express the Mixed profile (native ids bypass auth, everyone else is funneled into linking) without sub-parameters that defeat the point. Composition uses a **tri-state `AllowlistDecision` (`ALLOW` / `DENY` / `ABSTAIN`)** so claim-based allowlists can `ABSTAIN` until claims are available without that being read as a denial. `LinkedClaimAllowlist` use without a source of verified claims is rejected at host startup via a typed `ChannelConfigurationError` — silent deny-everyone is the worst possible default and is not allowed. The core PR includes the channel-neutral pieces: `IdentityAllowlist` Protocol, `AllowlistDecision`, built-ins (`AllowAll` / `NativeIdAllowlist` / `LinkedClaimAllowlist` / combinators / `CallableAllowlist`), `IdentityLinker` Protocol, `LinkedIdentity`, `LinkChallenge`, `AuthPolicy` factories, `Allowed` / `LinkRequired` / `Denied` outcomes, `Host(default_allowlist=..., identity_linker=...)` + per-channel `allowlist`, three-rule construction validator (`require_link=True` without a linker now raises), and `host.authorize(...)` for open, native-id, and linked-claim profiles. Provider-specific linkers (for example Entra OAuth helpers) ship as separate packages. See the Python spec's [Authorization profiles section](../specs/002-python-hosting-channels.md#authorization-profiles-and-the-identityallowlist-seam) for full mechanics. |
|
||||
| 17 | How does the host decide long-running vs ephemeral runtime, and is that distinction enforced? | **Single `runtime_mode` parameter, advisory, auto-detected by default.** `None` (the default) inspects known deployment markers (`FOUNDRY_HOSTING_ENVIRONMENT`, `AZURE_FUNCTIONS_ENVIRONMENT`, `AWS_LAMBDA_FUNCTION_NAME`) and picks `"ephemeral"` on the first hit; otherwise falls back to `"long_running"` (sensible local-dev / always-on default). The mode drives the *default selection* of seams that have FHA-shaped vs container-shaped defaults — `HostStateStore`, `DurableTaskRunner`, identity-link state — but every choice remains independently overridable. Detected mode is logged at startup so misdetection is visible. See the Python spec's [Runtime modes](../specs/002-python-hosting-channels.md#runtime-modes). |
|
||||
| 18 | How does delivery to non-originating destinations actually happen, and what is the retry / replay contract? | **Out-of-band via a pluggable `DurableTaskRunner`.** The host registers an internal `"hosting.push"` handler at startup; each non-originating destination becomes a `runner.schedule("hosting.push", payload)` call. The originating destination (when `ResponseTarget` includes it) is **still rendered synchronously** on the originating channel's wire — only fan-out goes through the runner. Default runner is `InProcessTaskRunner` (asyncio + bounded retry, no cross-restart persistence — suitable for `long_running`). Durable adapter packages (`agent-framework-hosting-durabletask`, future Foundry adapter) plug into the same Protocol for `ephemeral` deployments. Replay across host restarts is a property of the configured runner (native for durable adapters; not supported for the in-process runner). See the Python spec's [Durable task runner](../specs/002-python-hosting-channels.md#durable-task-runner). |
|
||||
| 19 | What is the audit shape on the assistant message — full per-destination state machine, or intent only? | **Intent only.** `Message.additional_properties["hosting"]["intended_targets"]` is a single immutable write that records the resolved destination set (after `ResponseTarget` + `LinkPolicy` filtering). Operational state — attempt count, last error, success timestamp, channel-issued id — lives in the `DurableTaskRunner` and is observed via the runner's backend. This eliminates the earlier per-destination `pending`/`delivered`/`failed`/`skipped` state machine, the `SupportsDeliveryTracking` provider capability, and the Foundry `update_item` service ask. See the Python spec's [Intended targets + durable delivery](../specs/002-python-hosting-channels.md#intended-targets--durable-delivery). |
|
||||
| 20 | What is the wire contract between `DurableTaskRunner` and push-capable channels when the runner is out-of-process? | **A two-piece contract.** Each `DurableTaskRunner` declares its `payload_mode` (`OBJECT` for in-process pass-by-reference; `JSON` for runners that round-trip through JSON). Push-capable channels that ship non-JSON-native payloads expose a `ChannelPushCodec` (`encode` / `decode`). At construction the host validates the pairing and refuses a `JSON`-mode runner paired with codec-less push channels (`ChannelConfigurationError`). The push handler accepts both `OBJECT` and `JSON` envelope shapes so the same handler serves both runner backends. See the Python spec's [Codec contract for durable serialisation](../specs/002-python-hosting-channels.md#codec-contract-for-durable-serialisation). |
|
||||
| 21 | What is the operational contract for `runtime_mode="ephemeral"` without a configured durable runner, and for clean shutdown of the in-process runner? | **Strict ephemeral by default + 2-phase drain.** `ephemeral` + default (in-process) runner raises `RuntimeError` at construction unless `allow_in_process_runner=True` is opted in (warning logged) — silently using the in-process runner in an ephemeral environment would drop in-flight pushes on the next scale-to-zero. For `long_running`, `InProcessTaskRunner` ships a `shutdown_grace_seconds` window (default `5.0`) that lets in-flight retries finish before cancellation; `CancelledError` from the cancellation phase is swallowed as the expected shutdown shape. When `echo_input=True`, the push task carries an `echo_done` cursor in runner-owned state so a retry that fires after the echo succeeded does not double-echo. See the Python spec's [Durable task runner](../specs/002-python-hosting-channels.md#durable-task-runner). |
|
||||
|
||||
## Decisions-driven follow-ups
|
||||
|
||||
These are spec-body / sample / code edits implied by the resolutions above, **out of scope for this ADR pass** but tracked here so they aren't lost:
|
||||
|
||||
- **Q3** — cross-check the Python spec's auth-helpers stance against the resolved "per-channel + host middleware" decision; reconcile any drift.
|
||||
- **Q7** — spec, `ChannelCommand` reference, and the Telegram channel design need optional `scopes` and `locales` fields with clear "channels free to ignore" semantics.
|
||||
- **Q8** — ✅ Done in spec rev. Req #24 lists only `OAuthIdentityLinker` and `OneTimeCodeIdentityLinker`; the linker-helper table and the OAuth scenario no longer reference `MfaIdentityLinker`.
|
||||
- **Q9 + Q11** — ✅ Resolved in spec rev. Spec req #23 now names the seam **`HostStateStore`** with a v1 default of `FileHostStateStore` (atomic JSON writes under `./.af-hosting/`), so continuation tokens, link grants, and last-seen records all survive single-node restarts. Pluggable Cosmos / SQL / Redis adapters remain v1 fast follow.
|
||||
- **Q12** — ✅ Resolved by the durable-delivery seam (Q18). The `ChannelPush` failure narrative is now "retry per `RetryPolicy` via the runner, observe in the runner's backend"; no separate `deliveries[]` annotation required.
|
||||
- **Q13** — add `active_window_seconds` (default 300 s) to the host config surface and document the `originating` → `all_linked` fallback chain.
|
||||
- **Q14** — explicitly document the **swappable WS codec** property in the Responses channel section (host contract does not depend on the framing) so the spec stays valid as upstream OpenAI evolves.
|
||||
- **Q15** — confirm the spec consistently treats `confidentiality_tier` as an opaque string and that no built-in policy assumes an ordered hierarchy.
|
||||
- **Q17 / Q18 / Q19** — ✅ Spec text added: new top-level §[Runtime modes](../specs/002-python-hosting-channels.md#runtime-modes), rewritten §[Intended targets + durable delivery](../specs/002-python-hosting-channels.md#intended-targets--durable-delivery), new §[Durable task runner](../specs/002-python-hosting-channels.md#durable-task-runner). Core code lands `DurableTaskRunner` Protocol + `InProcessTaskRunner` + `runtime_mode` constructor parameter + auto-detection in this PR; durable runner adapters (`agent-framework-hosting-durabletask`, Foundry adapter) ship as separate follow-up packages.
|
||||
- A sample can expose one target on multiple channels with one `AgentFrameworkHost` and no handwritten Starlette route composition.
|
||||
- Built-in channel tests prove that routes, commands, startup, and shutdown callbacks are contributed by channels and aggregated by the host.
|
||||
- Session tests prove that identical `ChannelSession.isolation_key` values resolve to the same cached `AgentSession`, and `reset_session` rotates that mapping.
|
||||
- Channel tests prove that each channel renders only its own originating response; there is no host-level push, multicast, or active-channel delivery path.
|
||||
- Workflow tests or samples use an explicit `checkpoint_location`.
|
||||
- Foundry isolation middleware is documented and covered by integration or contract tests, including the non-Foundry case where raw isolation headers are ignored.
|
||||
- The v1 API and packages do not expose the removed symbols or packages listed in [Non-goals for v1](#non-goals-for-v1).
|
||||
- The Python spec is updated to match this simplified contract and uses "public", "stable", or "released" terminology for Agent Framework APIs.
|
||||
|
||||
## More Information
|
||||
|
||||
See [Non-Goals](#non-goals--relationship-to-existing-hosting-packages) for what this ADR explicitly does **not** require in the first phase.
|
||||
|
||||
The Telegram sample proposed in PR #5393 is prior art for native command catalogs and for channels that need startup/shutdown lifecycle behavior beyond plain route registration. The same shape is expected to inform the future Activity Protocol channel (Teams/Web Chat/etc. via Azure Bot Service) and a future WhatsApp channel in both languages.
|
||||
|
||||
**Designed-in followup channels.** Three further channels are explicitly part of the overall design but are scheduled as fast-follow work after the first Responses + Invocations + Telegram release:
|
||||
|
||||
- **A2A channel** — exposes the hostable target over the Agent-to-Agent protocol so other agents can consume it as a peer. Fits the existing **caller-supplied session** family (alongside Responses and Invocations): A2A's per-conversation identifier is parsed into `ChannelSession.key`, the calling agent's identity (e.g. its A2A agent card / signed JWT) flows through the standard `IdentityResolver` seam, and structured replies fit the existing `ChannelRequest` / `ResponseTarget` envelope. No new host primitives are required to support it; the work is the protocol binding and the package.
|
||||
- **MCP-tool channel** — exposes the hostable target as a **Model Context Protocol tool** so MCP clients (other agents, IDE tooling, …) can invoke it. Same caller-supplied-session family: the MCP `tool/call` carries the conversation key into `ChannelSession.key`, the MCP client identity flows through `IdentityResolver`, and the tool result is the target's response. Streaming MCP tools map onto the host's existing streaming response delivery; non-streaming MCP tools map onto background runs with `ContinuationToken` if the target needs more time than a single tool-call round-trip allows.
|
||||
- **Activity Protocol channel** (`ActivityChannel`) — exposes the hostable target behind **Azure Bot Service**, which fronts Teams, Web Chat, Slack-style connectors, and the rest of the Bot Framework / M365 connector ecosystem. Native translations from Activity Protocol objects (`Activity`, `ConversationReference`, adaptive cards, `Invoke` activities, …) onto the host's `ChannelRequest` / `ChannelResponse` types — so the contract is **explicit** rather than smuggled through a generic Invocations endpoint. Fits the **host-tracked session** family: Bot Service authenticates with a JWT carrying the AAD object id, the channel populates `ChannelIdentity` from `from.aadObjectId`, the host's per-`isolation_key` alias decides which `AgentSession` to resolve, and `host.reset_session(...)` is reachable via a Teams slash command or adaptive-card action. `ChannelPush` is implemented over Bot Service's `ConversationReference` + `continueConversationAsync` pattern. Naming this channel **Activity** rather than **Teams** keeps a `TeamsChannel` name available for any future direct-to-Teams transport that bypasses Bot Service.
|
||||
|
||||
All three channels MUST be reachable through the **same** `AgentFrameworkHost` as Responses, Invocations, and Telegram so the cross-channel `isolation_key` continuity story (start a task via MCP from an IDE, follow up on Telegram, deliver the result on Teams via the Activity channel) is coherent. Their detailed API surfaces are deferred to dedicated follow-up specs.
|
||||
|
||||
Companion specs cover the per-language API surface, information design, and sample code:
|
||||
|
||||
- [SPEC-002 Python hosting core and pluggable channels](../specs/002-python-hosting-channels.md)
|
||||
- *(future)* SPEC-00X .NET hosting core and pluggable channels
|
||||
|
||||
## Appendix A — Comparison with Microsoft 365 Activity Protocol
|
||||
|
||||
The [Microsoft 365 Agents SDK Activity Protocol](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/activity-protocol) (and its underlying [protocol-activity spec](https://github.com/microsoft/Agents/blob/main/specs/activity/protocol-activity.md)) is the closest existing Microsoft prior art for a multi-channel hosting layer. It powers Microsoft 365 Copilot, Copilot Studio, and the M365 Agents SDK across Teams, web chat, Slack-style connectors, and so on. This appendix contrasts the two designs so future readers know which problems we deliberately solve differently and why.
|
||||
|
||||
### Mental model
|
||||
|
||||
| Concept | Activity Protocol | This ADR |
|
||||
|---|---|---|
|
||||
| **Inbound + outbound envelope** | A single `Activity` JSON envelope used in both directions, distinguished by `type` (`message`, `event`, `invoke`, `conversationUpdate`, `typing`, …). | Asymmetric: `ChannelRequest` for inbound, `HostedRunResult` / `HostedStreamResult` / `ChannelPush` for outbound. Protocol-native bytes never leave the channel package. |
|
||||
| **Channel surface** | A `ChannelID` string (e.g. `msteams`) on every Activity; channels are connected via Bot Framework Connector Service or M365 Agents SDK adapters. | A `Channel` Protocol contributed by an in-process Python package. Each channel owns its own routes, parsing, auth validation, and protocol model — no central connector service. |
|
||||
| **Adapter** | `Adapter` / `CloudAdapter` translates channel-native protocol ↔ Activity and runs the turn. Adapters are framework-supplied. | `Channel.contribute(context) -> ChannelContribution` returns Starlette routes + lifecycle. Channels are user-extensible packages. |
|
||||
| **Turn** | `TurnContext` bundles incoming `Activity`, outbound `SendActivityAsync`, `TurnState`, and adapter. Per-turn, disposed at end. | Channel handler calls `await context.run(channel_request)` / `context.stream(...)`; reply is the awaited `HostedRunResult`. No per-turn state object beyond the request itself. Earlier draft had a `ChannelRunHookContext`; that wrapper was removed in favor of `(request, **kwargs)`. |
|
||||
| **Identity** | `Activity.From` + `Activity.Recipient` carry per-turn identities; cross-channel identity unification is not in protocol. | `ChannelIdentity(channel, native_id, attributes)` extracted by the channel; host-level `IdentityResolver` maps to a stable `isolation_key`; `IdentityLinker` performs cross-channel link ceremonies. |
|
||||
| **Conversation context** | `Activity.Conversation.id` is the per-channel conversation key; conversation history is the agent author's responsibility. | `ChannelSession(key, isolation_key)` resolves to an `AgentSession` host-side, with cross-channel continuity when channels emit the same `isolation_key`. |
|
||||
| **Routing reply target** | Reply goes to `Activity.Conversation.id` on the originating channel. Cross-channel proactive sends require manually persisting a `ConversationReference`. | `ResponseTarget` (`originating`, `active`, `channel(name)`, `channels([...])`, `all_linked`, `none`) is first-class on every request, resolved by the host against last-seen channel state and the identity store. |
|
||||
| **Background work** | No first-class `ContinuationToken`; long work uses proactive messaging via stored `ConversationReference`. | `ContinuationToken` + `host.run_in_background(...)` + per-channel poll routes are part of the host contract; result delivery follows `ResponseTarget`. |
|
||||
| **Auth** | Bot Framework Auth: JWTs signed by the Bot Connector Service, verified by the SDK adapter. | Each channel implements its own validation against the upstream protocol (Telegram secret token, Bot Service JWT for the planned `ActivityChannel`, OAuth on identity-link routes); host can layer Starlette middleware. |
|
||||
| **Activity types beyond messages** | First-class `ConversationUpdate`, `Event`, `Invoke`, `Typing`, plus 20+ others — channels emit them uniformly. | `ChannelRequest.operation` is a free-form discriminator (default `"message.create"`); other categories (typing indicators, membership change, structured `invoke` request/reply) are channel-package concerns and not modeled centrally. |
|
||||
| **Outbound streaming** | `SendActivityAsync(typing)` + multiple `SendActivity` calls. | `HostedStreamResult` async iterator returned to the channel; channel decides how to render onto its protocol (SSE for Responses, long messages for Telegram, etc.). |
|
||||
|
||||
### Where we deliberately diverge
|
||||
|
||||
1. **Asymmetric envelopes instead of a single `Activity`.** The Activity envelope is heavyweight and tightly coupled to Bot Framework conventions (`From`/`Recipient`/`Conversation`/`ServiceUrl`). For a hosting layer that fronts the Responses HTTP API, OpenAI-style invocations, and Telegram all at once, forcing every channel through a unified envelope would either dilute it (Responses-shaped JSON wedged into `Activity.Value`) or impose Bot Framework semantics on protocols that don't carry them (Responses has no per-message `From` to fill). The cost of asymmetry is that channels write their own outbound serialization; the gain is each channel stays idiomatic to its upstream protocol.
|
||||
|
||||
2. **In-process channel packages instead of a connector service.** Activity Protocol assumes a Bot Connector Service (cloud-hosted by Microsoft for Teams/Web Chat/etc.) sits between the channel and the agent. We target a single Starlette ASGI app the developer runs anywhere, with each channel package owning its own webhook/HTTP/SSE/WS surface. This is critical for the Responses and Invocations channels (which **are** the upstream protocol; there is no connector to terminate them) and removes the operational dependency for self-hosted deployments. The trade-off is that scaling, auth federation, and channel-update rollout become the operator's problem instead of being centralized. **Note:** the planned `ActivityChannel` (designed-in fast follow) does deliberately sit behind Azure Bot Service so we inherit the connector model for Teams/Web Chat/Slack — that channel is the *interop* path for Activity Protocol; the contrast above is about the rest of the channel set (Responses, Invocations, Telegram, A2A, MCP-tool) where there is no equivalent service and a direct in-process binding is the only sensible option.
|
||||
|
||||
3. **Cross-channel identity is first-class.** Activity Protocol has no native concept of "this Teams user is the same person as this Telegram user." Bot Framework's User Authentication / OAuth Connection Settings handle per-channel sign-in but not the merge. Our `IdentityLinker` + host-managed identity store explicitly model the link ceremony and the resulting merge so a single `AgentSession` can span channels. This is required for the multi-channel scenarios this hosting layer was created to support (Scenarios 7 and 8 in SPEC-002) and is intentionally above what the Activity Protocol contract guarantees.
|
||||
|
||||
4. **`ResponseTarget` as a request-level field instead of an out-of-band proactive-send pattern.** Activity Protocol treats proactive cross-channel delivery as a deployment exercise (persist `ConversationReference`, restore later, call `continueConversationAsync`). We elevate it to a typed field on every request, consumed by the host. This makes "submit on Telegram, deliver result on Teams" a one-line authoring change instead of a custom pipeline, but it does require that channels capable of proactive delivery implement the `ChannelPush` capability.
|
||||
|
||||
5. **No central activity-type taxonomy in v1.** `ChannelRequest.operation` is intentionally free-form. Activity Protocol's `Type` discriminator (`message`, `event`, `invoke`, `conversationUpdate`, `typing`, …) is a real strength — it lets generic middleware reason about non-message events uniformly. We accept the gap in v1 because (a) the Responses + Invocations + Telegram set has effectively one "type" (a message that wants a reply), and (b) modeling the long tail of typed events properly is a design exercise that should not block hosting v1. See **Possible influence on future iterations** below.
|
||||
|
||||
6. **No `TurnContext`-style per-turn bag.** Earlier drafts of this ADR proposed `ChannelRunHookContext` to play a similar role to `TurnContext`. It was removed in favor of `def hook(request, **kwargs) -> ChannelRequest` because the only consumers (run hooks) don't need most of what `TurnContext` provides, and forcing a wrapper made simple hooks awkward to write inline. Channels that need adapter-style state can compose it inside their own `Channel` implementation.
|
||||
|
||||
### Where Activity Protocol could influence future iterations
|
||||
|
||||
- **Typed event taxonomy.** Adopting a small enum for `ChannelRequest.operation` modeled on Activity Protocol's set (`message`, `event`, `conversationUpdate`, `invoke`, `typing`) would let generic middleware (rate limit, audit, content moderation) reason about channel traffic uniformly. This is additive and could land alongside the v1.x telemetry work without breaking the free-form string field.
|
||||
- **Outbound `Activity`-style envelope as a serialization target.** This is the planned `ActivityChannel` (designed-in fast follow) — it maps `HostedRunResult` ↔ `Activity` inside the channel package and forwards through Azure Bot Service. The hosting contract was designed so this binding requires no new host primitives.
|
||||
- **`ConversationReference`-style proactive seed.** When `ResponseTarget.active` cannot find a recently seen channel, falling back to a stored `ConversationReference`-equivalent (last-known channel + last-known native id, persisted in the identity store) would mirror Bot Framework's proactive-message recovery story. This is implicit in the v1.x identity-store work (Open Question 9).
|
||||
- **Invoke-style synchronous request/reply.** Activity Protocol's `Invoke` (`task/fetch`, `task/submit`) is a useful precedent for what a typed `InvocationsChannel.invoke()` operation could look like beyond "post one message, get one reply" — particularly for Teams adaptive-card submit flows that the `ActivityChannel` will eventually need to host.
|
||||
|
||||
### Summary
|
||||
|
||||
Activity Protocol optimizes for **a single Microsoft-operated abstraction over many client surfaces**, with a uniform envelope, a connector service in the middle, and per-channel adapters supplied by the SDK. This ADR optimizes for **a self-hosted, in-process Python (and later .NET) layer that fronts both LLM-shaped HTTP protocols and human-chat channels**, with each channel owning its idiomatic protocol and the host owning identity, sessions, and cross-channel routing. The two designs solve overlapping but distinct problems; nothing in this ADR precludes a future Activity Protocol channel package, and several of Activity Protocol's primitives (typed event taxonomy, conversation reference, invoke) are tracked as candidate future enhancements.
|
||||
- Python v1 specification: [SPEC-002](../specs/002-python-hosting-channels.md)
|
||||
- Follow-up linking and multicast ADR: [ADR-0028](0028-hosting-linking-multicast-enhancements.md)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: eavanvalkenburg
|
||||
date: 2026-06-11
|
||||
deciders: eavanvalkenburg
|
||||
---
|
||||
|
||||
# Hosting linking and multicast enhancements
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
[ADR-0027](0027-hosting-channels.md) defines the minimal v1 hosting core: originating-channel responses, explicit `ChannelSession.isolation_key`, and no host-level identity linking, push, multicast, background delivery, or durable runners.
|
||||
|
||||
This ADR tracks the richer cross-channel behaviors that were removed from v1. These enhancements are **follow-up work** and are **not prerequisites** for shipping, using, or stabilizing the v1 host/channel core.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Cross-channel continuity must not create accidental cross-user, cross-tenant, or cross-channel data leaks.
|
||||
- Non-originating delivery must be observable, idempotent, retryable, and supportable.
|
||||
- Protocol payloads must remain channel-native while still being safe to persist and replay.
|
||||
- App authors need opt-in policy controls, not hidden defaults.
|
||||
- The enhancement stack should layer on top of the v1 host without reshaping the minimal channel contract.
|
||||
|
||||
## Enhancement Areas
|
||||
|
||||
The follow-up design should cover these capabilities together because they share identity, storage, delivery, and replay concerns:
|
||||
|
||||
- **Cross-channel identity linking** — a user can connect multiple `ChannelIdentity` values to one channel-neutral `isolation_key`.
|
||||
- **Authorization and allowlist policy** — channels or hosts can require verified identity, allow specific native identities or claims, and deny unknown callers.
|
||||
- **Non-originating response delivery** — a run can respond somewhere other than the request's originating protocol when explicitly configured.
|
||||
- **Active-channel routing** — delivery can target the most recently observed linked channel for an `isolation_key`.
|
||||
- **Multicast / all-linked delivery** — delivery can fan out to every linked channel or a selected set.
|
||||
- **Background runs and continuation tokens** — long-running requests can return immediately and complete later, with a polling/status fallback.
|
||||
- **Durable delivery runners** — delivery work can survive process restarts and support dead-letter handling.
|
||||
- **Retry and replay semantics** — delivery attempts are bounded, deduplicated, and safe to replay.
|
||||
- **Payload serialization** — channel-specific payloads can be persisted, redacted, versioned, and reconstructed without losing protocol fidelity.
|
||||
|
||||
Candidate API names from the broader design (`IdentityLinker`, `IdentityAllowlist`, `AuthPolicy`, `ResponseTarget`, `ChannelPush`, `ChannelPushCodec`, `DurableTaskRunner`, `InProcessTaskRunner`, `RetryPolicy`, `LinkPolicy`) remain design vocabulary for this ADR. They are not approved v1 APIs.
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option A — Leave all behavior to applications
|
||||
|
||||
Applications implement linking, authorization, push, retry, and serialization independently.
|
||||
|
||||
- Good: the hosting core stays very small.
|
||||
- Neutral: advanced apps can still build what they need.
|
||||
- Bad: every app must solve the same security and delivery problems, likely inconsistently.
|
||||
|
||||
### Option B — Add the full enhancement stack to v1
|
||||
|
||||
The first host release includes linking, authorization, active channel, multicast, background runs, durable runners, and codecs.
|
||||
|
||||
- Good: the original cross-channel experience is available immediately.
|
||||
- Neutral: samples can demonstrate rich end-to-end flows.
|
||||
- Bad: v1 becomes security-sensitive, storage-heavy, and harder to stabilize.
|
||||
|
||||
### Option C — Layer opt-in enhancement packages after v1
|
||||
|
||||
Ship the minimal host first, then add linking, authorization, and delivery packages behind explicit configuration.
|
||||
|
||||
- Good: v1 remains simple while leaving room for a reviewed, supportable enhancement stack.
|
||||
- Neutral: apps that need advanced delivery wait for follow-up packages.
|
||||
- Bad: the first release does not satisfy proactive or all-linked scenarios.
|
||||
|
||||
### Option D — Build only platform-specific integrations
|
||||
|
||||
Implement linking and proactive delivery separately in Telegram, Activity Protocol, Discord, and future channels.
|
||||
|
||||
- Good: each package can match its protocol exactly.
|
||||
- Neutral: some shared abstractions may emerge later.
|
||||
- Bad: cross-channel behavior becomes fragmented and hard to reason about.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Proposed direction: **Option C — layered opt-in enhancement packages after v1**.
|
||||
|
||||
The minimal host remains the foundation. Follow-up packages may add linking, authorization, delivery, and durable execution, but must be explicitly enabled and must pass the validation gates below before becoming part of the public contract.
|
||||
|
||||
## Safety Requirements
|
||||
|
||||
### Threat model
|
||||
|
||||
The design must account for:
|
||||
|
||||
- spoofed channel-native identities,
|
||||
- stolen or replayed link challenges,
|
||||
- cross-tenant or cross-confidentiality data leakage,
|
||||
- unsolicited proactive messages,
|
||||
- malicious payloads persisted for replay,
|
||||
- denial-of-service through fan-out or retry storms, and
|
||||
- privacy leakage through logs, metrics, or support tooling.
|
||||
|
||||
Required mitigations include verified identity claims where available, signed and expiring link challenges, explicit user consent, per-channel capability checks, default-deny policy options, tenant partitioning, and uninformative denial messages on shared channels.
|
||||
|
||||
### Idempotency and replay
|
||||
|
||||
Exactly-once delivery is not a realistic guarantee. The design must provide:
|
||||
|
||||
- stable run, continuation, and delivery-attempt identifiers,
|
||||
- channel-level idempotency keys where protocols support them,
|
||||
- bounded retry with jitter and explicit terminal states,
|
||||
- replay windows and expiration,
|
||||
- duplicate suppression for persisted attempts, and
|
||||
- clear semantics for "delivered", "accepted by platform", and "observed by user".
|
||||
|
||||
### Storage
|
||||
|
||||
Enhancement storage must stay distinct from v1 `AgentSession` history and workflow checkpoints unless an implementation deliberately backs them with the same physical store.
|
||||
|
||||
Stored data should be schema-versioned, minimized, encrypted or otherwise protected as appropriate, and partitioned by tenant/project. Link records, continuation records, active-channel state, delivery attempts, dead letters, and serialized payloads need independent TTL and deletion policies.
|
||||
|
||||
### Observability and support
|
||||
|
||||
The design must include structured logs, traces, and metrics for link attempts, authorization decisions, delivery scheduling, retries, replay, and dead-letter outcomes. Logs must avoid message content and sensitive identity claims by default. Operators need a way to inspect, revoke, replay, or purge stuck records safely.
|
||||
|
||||
## Validation Gates
|
||||
|
||||
Before these enhancements are accepted:
|
||||
|
||||
- A reviewed threat model covers identity linking, authorization, non-originating delivery, multicast, and replay.
|
||||
- Cross-channel linking tests prove a verified identity can link two channels and that unlink/deny paths do not leak information.
|
||||
- Authorization tests cover native-id allowlists, verified-claim allowlists, default-deny behavior, and misconfiguration failures.
|
||||
- Delivery tests cover originating-only, specific-channel, active-channel, selected-channel, and all-linked routing.
|
||||
- Background/continuation tests cover polling fallback, cancellation or expiration, process restart, retry, and dead-letter behavior.
|
||||
- Codec tests prove payloads are versioned, redacted where needed, backward compatible, and rejected safely when unknown.
|
||||
- Multicast tests prove fan-out is bounded, independently retried, and idempotent per destination.
|
||||
- Observability tests or manual validation prove support operators can correlate a request to delivery attempts without exposing sensitive content.
|
||||
|
||||
## Relationship to ADR-0027
|
||||
|
||||
ADR-0027 remains valid without any of these enhancements. This ADR extends the hosting model only after the safety, storage, and support requirements above are satisfied.
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user