From 36ce0950e493c81a039ca9589256db6c44195dfe Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 12 Jun 2026 08:34:08 +0200 Subject: [PATCH] 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> --- docs/decisions/0027-hosting-channels.md | 523 +--- ...-hosting-linking-multicast-enhancements.md | 132 + docs/specs/002-python-hosting-channels.md | 2460 ++--------------- .../_channel.py | 204 +- .../tests/test_channel.py | 171 +- .../_channel.py | 105 +- .../tests/discord/test_channel.py | 201 +- python/packages/hosting-entra/LICENSE | 21 - python/packages/hosting-entra/README.md | 39 - .../agent_framework_hosting_entra/__init__.py | 15 - .../agent_framework_hosting_entra/_channel.py | 505 ---- python/packages/hosting-entra/pyproject.toml | 108 - .../packages/hosting-entra/tests/__init__.py | 0 .../hosting-entra/tests/test_channel.py | 464 ---- .../_channel.py | 66 +- .../hosting-invocations/tests/test_channel.py | 30 +- .../__init__.py | 2 - .../_channel.py | 126 +- .../_parsing.py | 69 +- .../hosting-responses/tests/test_channel.py | 68 +- .../hosting-responses/tests/test_parsing.py | 60 - .../_channel.py | 147 +- .../hosting-telegram/tests/test_channel.py | 73 +- python/packages/hosting/README.md | 177 +- .../agent_framework_hosting/__init__.py | 83 +- .../agent_framework_hosting/_authorization.py | 485 ---- .../hosting/agent_framework_hosting/_host.py | 1473 ++-------- .../agent_framework_hosting/_persistence.py | 107 +- .../agent_framework_hosting/_runner.py | 751 ----- .../agent_framework_hosting/_state_store.py | 308 +-- .../hosting/agent_framework_hosting/_types.py | 794 +----- .../hosting/tests/test_authorization.py | 580 ---- python/packages/hosting/tests/test_host.py | 706 +---- .../packages/hosting/tests/test_host_disk.py | 247 +- .../packages/hosting/tests/test_isolation.py | 33 +- python/packages/hosting/tests/test_runner.py | 333 --- .../hosting/tests/test_runner_disk.py | 278 -- python/packages/hosting/tests/test_types.py | 288 -- python/pyproject.toml | 2 - .../samples/04-hosting/af-hosting/README.md | 11 +- .../af-hosting/foundry_hosted_agent/README.md | 6 +- .../app.py | 1 - .../af-hosting/local_identity_link/README.md | 67 - .../af-hosting/local_identity_link/app.py | 395 --- .../local_identity_link/call_server.py | 72 - .../local_identity_link/pyproject.toml | 32 - .../af-hosting/local_telegram/README.md | 7 +- .../local_telegram/call_server_multicast.py | 92 - .../af-hosting/local_telegram/pyproject.toml | 2 +- python/uv.lock | 22 - 50 files changed, 1290 insertions(+), 11651 deletions(-) create mode 100644 docs/decisions/0028-hosting-linking-multicast-enhancements.md delete mode 100644 python/packages/hosting-entra/LICENSE delete mode 100644 python/packages/hosting-entra/README.md delete mode 100644 python/packages/hosting-entra/agent_framework_hosting_entra/__init__.py delete mode 100644 python/packages/hosting-entra/agent_framework_hosting_entra/_channel.py delete mode 100644 python/packages/hosting-entra/pyproject.toml delete mode 100644 python/packages/hosting-entra/tests/__init__.py delete mode 100644 python/packages/hosting-entra/tests/test_channel.py delete mode 100644 python/packages/hosting/agent_framework_hosting/_authorization.py delete mode 100644 python/packages/hosting/agent_framework_hosting/_runner.py delete mode 100644 python/packages/hosting/tests/test_authorization.py delete mode 100644 python/packages/hosting/tests/test_runner.py delete mode 100644 python/packages/hosting/tests/test_runner_disk.py delete mode 100644 python/samples/04-hosting/af-hosting/local_identity_link/README.md delete mode 100644 python/samples/04-hosting/af-hosting/local_identity_link/app.py delete mode 100644 python/samples/04-hosting/af-hosting/local_identity_link/call_server.py delete mode 100644 python/samples/04-hosting/af-hosting/local_identity_link/pyproject.toml delete mode 100644 python/samples/04-hosting/af-hosting/local_telegram/call_server_multicast.py diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index 7e8c5e3611..ffbad09d81 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -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>` / 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 /
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 →
command or
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
Responses · Invocations ·
Telegram · Activity ·
IdentityLinker] - CmdHandler[CommandHandler
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
e.g. TelegramChannel] - direction TB - Routes[routes:
webhook / poller / API endpoints
→ Starlette router] - Commands[commands: Sequence ChannelCommand
name · description · handle ·
scopes · locales · expose_in_ui] - Push[ChannelPush.push
+ optional ChannelPushCodec
+ optional response_hook] - end - - Host[Host] - Native[Platform native catalog
Telegram set_my_commands ·
Teams app manifest · …] - Dispatch[CommandHandler dispatch] - Delivery[Originating sync delivery
+ 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,
require_link, allowlist]) - Linked{identity already
linked?
StateStore lookup} - Required{require_link?} - OpenPath{allowlist is None?} - Resolve[/isolation_key:
linked → existing,
else auto-issue channel:native_id/] - Evaluate[/allowlist.evaluate context/] - Decision{decision} - Abstain{requires_linked_claims?} - Allowed([Allowed isolation_key]) - DeniedPre([Denied
allowlist_denied_pre_link]) - LinkReq([LinkRequired
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) diff --git a/docs/decisions/0028-hosting-linking-multicast-enhancements.md b/docs/decisions/0028-hosting-linking-multicast-enhancements.md new file mode 100644 index 0000000000..390881b7ea --- /dev/null +++ b/docs/decisions/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. diff --git a/docs/specs/002-python-hosting-channels.md b/docs/specs/002-python-hosting-channels.md index af7ae6cd46..fbfaa0a814 100644 --- a/docs/specs/002-python-hosting-channels.md +++ b/docs/specs/002-python-hosting-channels.md @@ -1,2276 +1,320 @@ --- status: proposed contact: eavanvalkenburg -date: 2026-04-24 +date: 2026-06-11 deciders: eavanvalkenburg --- # Python hosting core and pluggable channels -## What are the business goals for this feature? +## Scope -Give Python app authors one low-level, Starlette-based hosting surface that can expose a single **hostable target** — either a `SupportsAgentRun`-compatible 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, etc.) 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. +This specification is the Python implementation plan for [ADR-0027](../decisions/0027-hosting-channels.md). It documents the simplified v1 host/channel contract only. -This consolidates the protocol-specific hosting layers that exist today (`agent-framework-foundry-hosting`, `agent-framework-ag-ui`, `agent-framework-a2a`, `agent-framework-devui`) into a shared composable model where: +The v1 contract is: -- a host owns the ASGI app and channels own protocol shape, -- 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** `AgentSession` for the same end user, and a future pluggable session store extends that continuity across hosts and processes, and -- channel-native identity is **mapped, not assumed** — the host owns a first-class `IdentityResolver` seam (channel-native id → `isolation_key`) and an `IdentityLinker` seam (well-known connect ceremony — OAuth, MFA, signed one-time code — to associate a new channel-native id with an existing `isolation_key`), so cross-channel continuity does not depend on each channel's user namespace happening to align, and -- response delivery is **decoupled from request origin** — every `ChannelRequest` carries a `ResponseTarget` (`originating` (default), `active` for the user's most recently used channel, a specific channel id, all linked channels, or `none` for background-only). Background/asynchronous runs are first-class via a `ContinuationToken` returned by `host.run_in_background(...)` so a user can submit a long-running request on one channel and receive the result on another (or poll by continuation token), and -- channels can be assigned different **confidentiality tiers** so two channels on one host can share an agent without sharing a session — e.g. Teams (corporate, allowed to access internal resources) and Telegram (public) can run against the same target while remaining session-isolated, with a host-level `LinkPolicy` that decides which confidentiality tiers may be linked (and includes an explicit "deny all" variant for hosts that want no cross-channel continuity at all). Running two separate hosts is always a valid alternative; the per-tier policy exists for cases where one shared host with two policy-isolated tiers is preferred, and -- **multi-user surfaces** (Telegram groups, supergroups, forum topics; Teams group chats and team channels) are first-class — the channel layer separates user identity from conversation locator, defaults to safe behavior (`mention_only` addressing, `per_user_per_conversation` session scoping, link ceremonies redirected to DMs), and exposes per-channel options to opt into shared-context modes when desired (see [Multi-user conversations](#multi-user-conversations-telegram-groups-teams-group-chats-and-channels)). +- `AgentFrameworkHost` owns one Starlette app, one hostable target, and one or more channels. +- A hostable target is either a `SupportsAgentRun`-compatible agent or a `Workflow`. +- Channels contribute routes, middleware, commands, and lifecycle callbacks. +- Channels parse protocol-native input into `ChannelRequest`. +- Channels render their own originating response. +- Session continuity is explicit: a channel supplies `ChannelSession(isolation_key=...)`, and the host resolves/caches an `AgentSession` for that key. +- The host invokes `ChannelRunHook` and `ChannelResponseHook`; channels provide hook configuration and protocol context. -We know we're successful when: +The host does not link identities, route responses to other channels, run background continuations, or multicast in v1. Those enhancements are tracked in [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md). -- after the agent is created, a basic multi-channel sample requires only one `AgentFrameworkHost`, channel objects, and one `host.serve(...)` call — no handwritten protocol routes and no per-protocol server bootstrap. The hosting core itself takes no dependency on `agentserver`; individual channel packages MAY depend on it where it provides directly reusable building blocks (e.g. `agent-framework-foundry-hosting` builds on the Foundry response-store SDK that ships in `azure.ai.agentserver`), -- a single `AgentFrameworkHost` 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, -- an end user known on one channel can run a host-provided `link`/`connect` command on a second channel, complete an OAuth (or MFA, or one-time-code) ceremony, and see subsequent messages on the second channel resolved against the same `AgentSession` as the first, **and** -- a user can submit a long-running request on Telegram with `response_target="active"`, switch to Teams (via the Activity channel), and receive the result there as a proactive message — with a poll route as a fallback for callers that prefer polling. +## Goals -## Problem Statement +- Let an app expose one agent or workflow on multiple protocols without handwritten Starlette composition. +- Keep protocol parsing and response formatting inside channel packages. +- Provide one session-resolution path shared by all channels. +- Keep the channel authoring surface small enough for new channels to implement. +- Preserve full-fidelity agent and workflow results until a channel decides how to render them. -### How do developers solve this problem today? +## Non-goals for v1 -Today, every protocol surface is its own 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 ASGI app by hand: +The following are removed from the v1 implementation pass: + +- `IdentityLinker`, `IdentityAllowlist`, `AuthPolicy`, and `LinkPolicy` +- `ResponseTarget`, active-channel routing, `all_linked`, fan-out, and multicast +- `ChannelPush` and `ChannelPushCodec` +- `DurableTaskRunner`, `InProcessTaskRunner`, and `RetryPolicy` +- continuation tokens and background delivery +- confidentiality tiers +- `agent-framework-hosting-entra` +- `local_identity_link` + +These are follow-up design topics, not hidden requirements of the v1 host. + +## Packages + +| Package | Import surface | Contents | +|---|---|---| +| `agent-framework-hosting` | `agent_framework_hosting` | `AgentFrameworkHost`, channel protocols, key request/result types, hooks, `reset_session`, state-path helpers. | +| `agent-framework-hosting-responses` | `agent_framework_hosting_responses` | `ResponsesChannel`. | +| `agent-framework-hosting-invocations` | `agent_framework_hosting_invocations` | `InvocationsChannel`. | +| `agent-framework-hosting-telegram` | `agent_framework_hosting_telegram` | `TelegramChannel` and Telegram command helpers. | +| `agent-framework-hosting-activity-protocol` | `agent_framework_hosting_activity_protocol` | `ActivityProtocolChannel` for Activity Protocol over Azure Bot Service. | +| `agent-framework-hosting-discord` | `agent_framework_hosting_discord` | `DiscordChannel` and Discord command/interaction helpers. | +| `agent-framework-foundry-hosting` | `agent_framework.foundry_hosting` | Foundry isolation middleware and Foundry-backed hosting helpers usable with the v1 host. | + +Channel packages may depend on their native SDKs. The core hosting package should not depend on channel SDKs or on top-level legacy protocol hosts. + +## Key Types + +### `AgentFrameworkHost` + +The host constructor accepts: + +- `target`: one `SupportsAgentRun`-compatible object or one `Workflow` +- `channels`: one or more `Channel` instances +- optional Starlette middleware +- optional `state_dir` +- optional workflow `checkpoint_location` + +The host exposes: + +- `app`: the canonical Starlette ASGI application +- `serve(...)`: a convenience wrapper for local serving +- `reset_session(isolation_key: str)`: rotate the cached `AgentSession` for a host-tracked conversation + +`state_dir` is narrowed to v1 host-owned local files only: + +- session aliases (`isolation_key` to current `AgentSession` id), and +- workflow checkpoint paths when the app chooses the host-provided file layout. + +It is not a store for identity links, continuations, active-channel state, delivery attempts, or multicast payloads. + +Externally supplied isolation keys are trusted only after the channel or host middleware has authenticated and authorized the caller. The host uses `isolation_key` as a partition key; the string itself is not proof of identity or ownership. + +### `Channel` + +A channel implements a small protocol: + +- declare a stable channel id/name, +- contribute routes, middleware, commands, and lifecycle callbacks, +- parse inbound protocol data into `ChannelRequest`, +- call the host through `ChannelContext.run(...)` or `ChannelContext.run_stream(...)`, and +- serialize the returned result to the originating protocol response. + +Channels own protocol authentication, signature validation, native command registration, and protocol-specific error bodies. + +### `ChannelContribution` + +`ChannelContribution` is the channel's host-facing contribution: + +- Starlette routes and optional middleware, +- native command descriptors, +- startup and shutdown callbacks, and +- any channel-local metadata needed by the package. + +The host aggregates contributions but does not interpret protocol payloads. + +### `ChannelRequest` + +`ChannelRequest` is the host-neutral request envelope produced by a channel. It carries: + +- target input, +- optional `ChannelSession`, +- optional `ChannelIdentity`, +- options and attributes produced by the channel, and +- request metadata useful to hooks and context providers. + +The host may pass attributes through to context providers and middleware. Channels should treat attributes as a documented extension bag, not as a cross-channel delivery contract. + +### `ChannelSession` + +`ChannelSession(isolation_key=...)` is the only v1 session-continuity mechanism. + +When a request contains an isolation key: + +1. The host looks up or creates the cached `AgentSession` for that key. +2. The target runs with that `AgentSession` when the target is an agent. +3. `reset_session(isolation_key)` rotates the alias so the next request starts a new conversation. + +If two channels produce the same isolation key on the same host, they share the same cached session. If they produce different keys, they do not share session state. + +### `ChannelIdentity` + +`ChannelIdentity` is optional request metadata such as channel id, native user id, tenant id, claims, or display attributes. + +In v1, `ChannelIdentity` does not link channels, authorize callers, select delivery destinations, or imply that two identities should share an `AgentSession`. A channel that wants shared history must still produce the same `ChannelSession.isolation_key`. + +### Hooks + +Hooks are optional and channel-owned: + +- `ChannelRunHook`: runs after channel parsing and before host invocation; returns the `ChannelRequest` to execute. +- `ChannelResponseHook`: runs after target completion and before the originating channel renders a one-shot response. +- `ChannelStreamUpdateHook`: the host applies it to streamed updates before the originating channel serializes the stream. + +Common uses include adapting chat text into workflow inputs, enforcing deployment-specific options, flattening rich output for text-only protocols, or filtering streamed updates for a protocol. Stream update hooks are update-only; they do not automatically sanitize `get_final_response()` output. Channels choose their response transport from the parsed protocol request before invoking run hooks. + +### `HostedRunResult` + +`HostedRunResult[T]` wraps the target's full-fidelity result plus the resolved `AgentSession | None`. + +- Agent targets produce `HostedRunResult[AgentResponse]`. +- Workflow targets produce `HostedRunResult[WorkflowRunResult]`. + +The host does not flatten, filter, or translate the result. Each channel decides how much of the result its protocol can carry. + +## Host Behavior + +1. `AgentFrameworkHost` builds one Starlette app and asks each channel for its contribution. +2. A channel route receives a protocol-native request. +3. The channel validates/parses the native payload and creates `ChannelRequest`. +4. The channel passes the request, optional `ChannelRunHook`, and protocol-native context to the host. +5. The host invokes `ChannelRunHook`, if configured, and receives the prepared request. +6. The host resolves an `AgentSession` from `ChannelSession.isolation_key` when present. +7. The host invokes the agent or workflow target. +8. The host wraps the result in `HostedRunResult` or the streaming equivalent. +9. The host invokes `ChannelResponseHook`, if configured, for non-streaming/final response shaping. +10. The host applies stream update hooks while the channel consumes streams; the channel renders the originating protocol response. + +There is no host-level route from one channel's request to another channel's response in v1. + +## Workflow Checkpoints + +Workflow checkpointing is explicit. Apps either configure checkpoint storage on the workflow itself or pass a `checkpoint_location` to the host so the workflow dispatch path can use the intended file location. + +`state_dir` may provide a conventional location for workflow checkpoint files, but checkpointing is still opt-in and separate from agent session history. Checkpoints are workflow-runtime state, not channel state and not identity-link state. + +## Foundry Isolation Middleware + +V1 keeps Foundry isolation as middleware rather than as a channel-linking feature. + +The middleware is installed only when the Foundry hosting environment flag is present. In that environment it reads Foundry-provided isolation values at the trusted hosting boundary, exposes them as read-only request context for Foundry-aware history or memory providers, and rejects unsafe session resumes when the live isolation context does not match persisted session context. Outside Foundry, raw isolation headers are ignored unless an app supplies its own trusted middleware. + +This middleware does not create cross-channel identity links and does not authorize non-Foundry channels. + +## Current Channels + +### Responses + +`ResponsesChannel` exposes the OpenAI-compatible Responses API shape. It maps request body fields such as input, options, and conversation identifiers into `ChannelRequest`, and it renders Responses-compatible one-shot or streaming responses. + +Responses session continuity uses a channel-selected `isolation_key`, commonly derived from a response/conversation id, caller-provided session id, Foundry isolation context, or deployment-specific request metadata. + +### Invocations + +`InvocationsChannel` exposes an invocation endpoint for server-side callers and tools. It maps the request body into `ChannelRequest` and renders the invocation result on the same HTTP response. + +Invocations is useful for typed workflow inputs because a `ChannelRunHook` can translate the request body into the workflow's expected input type. + +### Telegram + +`TelegramChannel` supports webhook or polling transport, native command registration, and message rendering back to the originating Telegram chat. + +The channel chooses a default `isolation_key` from Telegram-native data such as chat id, user id, or a configured user/chat scope. A `/new` or equivalent command may call `reset_session` for that isolation key. + +### Activity Protocol + +`ActivityChannel` supports Activity Protocol requests, typically through Azure Bot Service for Teams, Web Chat, and other Bot Framework-fronted surfaces. + +The channel maps incoming `Activity` objects to `ChannelRequest` and renders a reply activity to the originating conversation. Proactive Activity delivery, active-channel routing, and all-linked fan-out are not v1 host semantics. + +### Discord + +`DiscordChannel` supports Discord messages, slash commands, and interactions as channel-native input. + +The channel maps Discord-native user, guild, channel, thread, and interaction data into `ChannelRequest` metadata and a configured `ChannelSession.isolation_key`. It renders the result to the originating Discord response path. + +## High-level Samples + +### One agent on Responses ```python -# Today: developer composes two protocol-specific hosts manually -import os -import uvicorn -from starlette.applications import Starlette -from starlette.routing import Mount - -from agent_framework import Agent -from agent_framework.openai import OpenAIChatClient -from agent_framework.foundry_hosting import ( - ResponsesHostServer, - InvocationsHostServer, -) - -agent = Agent( - name="WeatherAgent", - instructions="You are a helpful weather agent.", - client=OpenAIChatClient(model="gpt-4.1-mini"), -) - -# Two separate, protocol-specific host wrappers, each with their own -# request/session/event mapping inside. -responses_host = ResponsesHostServer(agent=agent) -invocations_host = InvocationsHostServer(agent=agent) - -# Manually mount each into a Starlette app so they share a process. -app = Starlette(routes=[ - Mount("/responses", app=responses_host.app), - Mount("/invocations", app=invocations_host.app), -]) - -# Bring up the server by hand. -if __name__ == "__main__": - uvicorn.run(app, host="localhost", port=8000) -``` - -Adding a Telegram bot to the same agent today means leaving this 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 `set_my_commands(...)` registration by hand — none of which is reusable across other message channels. - -### Why does this problem require a new hosting abstraction? - -The gap is between **owning a hostable target** (a `SupportsAgentRun` agent or a `Workflow`) and **operationalizing it on multiple channels**. Agent Framework already provides agents, workflows, sessions, run inputs, response/update streaming, the `SupportsAgentRun` execution seam, and the `Workflow` execution seam. What's missing is a generic host that: - -1. Owns one Starlette app and one set of lifecycle hooks. -2. Lets channels contribute routes, middleware, commands, and startup/shutdown without protocol leakage into the host. -3. Standardizes how protocol requests become agent invocations (input, options, session, streaming) and how agent results flow back out. -4. **Resolves a session from a channel-neutral `isolation_key`** so two channels mounted on the same host can converge on the same `AgentSession` for the same end user — enabling cross-channel chat continuity (start on Telegram, continue on Teams) without per-channel session bookkeeping. -5. Provides a first-class extension seam for webhook/message channels with native command catalogs (per PR #5393 Telegram sample). - -The current `agentserver`-based hosts are valuable prior art but sit too high in the stack — they encode protocol ownership at the host level. The new generic core learns from their behavior without depending on them; individual channel packages may still depend on the parts of `agentserver` that ship reusable building blocks (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. Hosting core, `ag-ui`, `a2a`, `devui`, and `foundry-hosting` solve adjacent but distinct problems: - -| Dimension | Existing protocol packages | `agent-framework-hosting` | -|---|---|---| -| **Mental model** | One package = one protocol surface, owns its own server | One host owns ASGI 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. | - -**Explicit non-goals:** -- Migrating `ag-ui`, `a2a`, or `devui` onto the new core in the first implementation. -- Standardizing a persistent session storage contract across all channels. -- 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, A2A, MCP-tool, and Teams-native via `microsoft/teams.py` are designed-in fast follow — see reqs #25–#28). -- Replacing protocol-specific serializers with one generic event model. -- Taking a runtime or package dependency on the legacy protocol-specific hosts (e.g. `ResponsesAgentServerHost`, `InvocationAgentServerHost`) from the new hosting core. Channel packages MAY depend on lower-level parts of `azure.ai.agentserver` where it ships reusable building blocks (e.g. the Foundry response-store SDK consumed by `FoundryHostedAgentHistoryProvider`). - -**Boundary rule:** If you need protocol-specific event semantics, codecs, or signature validation, that lives in the channel package. The host owns ASGI, lifecycle, session resolution, and the call into the target's execution seam (`SupportsAgentRun.run(...)` for agents, the workflow execution seam for workflows). - -## Requirements - -After we deliver `agent-framework-hosting` and its first channel packages, users will be able to: - -1. **Compose one host with one or more channels** — instantiate `AgentFrameworkHost(target=..., channels=[...])` where `target` is either a `SupportsAgentRun`-compatible agent or a `Workflow`, and get one Starlette application with all channels mounted. -2. **Expose the Responses API** — add `ResponsesChannel()` and serve `/responses` without writing protocol handlers. -3. **Expose the Invocations API** — add `InvocationsChannel()` and serve `/invocations` without writing protocol handlers. -4. **Expose a Telegram bot** — add `TelegramChannel(bot_token=...)` with either `polling` or `webhook` transport, and register native commands declaratively with `ChannelCommand`. -5. **Override endpoint paths** — pass `path="/public/responses"` to move a channel endpoint, or `path=""` when an external platform must call the app root. -6. **Customize per-request invocation behavior** — pass a `run_hook` to any built-in channel. The hook receives the channel-produced `ChannelRequest` (the host-neutral envelope each channel builds from its own protocol parsing — see [Key Types](#key-types)) and returns a possibly-modified `ChannelRequest`. Use it to validate, rewrite, or strip channel-derived options (e.g. enforce or drop `temperature`, override `session_mode`) before the host calls the target's execution seam. It is also the **adapter** that reshapes the channel's default `ChannelRequest.input` into the typed inputs a workflow target requires. -7. **Control session use per request** — built-in channels set `ChannelRequest.session_mode` to `auto`, `required`, or `disabled`; the host honors that when resolving `AgentSession`. -8. **Partition sessions by isolation key** — channels populate `ChannelSession.isolation_key` (user, tenant, chat, …) using hosted-agent terminology. -9. **Resolve to the same session across channels on one host** — two channels mounted on the same `AgentFrameworkHost` that produce the same `isolation_key` (e.g. a stable user identity mapped from each channel's native identifier) resolve to the same `AgentSession`, so an end user starting a chat on Telegram can continue it on Teams against the same conversation history without per-channel session bookkeeping. -10. **Map channel-native identity into `isolation_key`** — every channel has its own user namespace (Telegram `chat_id`, Teams AAD object id, WhatsApp phone, Slack user id). The host accepts a host-level `identity_resolver` callable that maps a `ChannelIdentity(channel_id, native_id, attributes)` into an `isolation_key` (or `None` if unknown). Channels publish the native identity they observed; the resolver decides whether it maps to an existing user. -11. **Link a new channel to an existing identity through a well-known ceremony** — the host accepts a host-level `identity_linker` (e.g. `OAuthIdentityLinker(...)`, `OneTimeCodeIdentityLinker(...)`) which contributes its own routes/lifecycle and exposes a `begin(channel_identity) -> LinkChallenge` / `complete(challenge_id, proof) -> isolation_key` flow. Channels surface a `link`/`connect` `ChannelCommand` that delegates to the linker; on success the resolver subsequently maps the new channel-native identity to the existing `isolation_key`. Mechanism (OAuth provider, signed one-time code, future linker types) is pluggable; the contract is fixed. -12. **Route the response to a chosen channel** — `ChannelRequest.response_target` accepts `ResponseTarget.originating` (default — synchronous response on the originating channel), `ResponseTarget.active` (the channel most recently observed for the resolved `isolation_key`), `ResponseTarget.channel("activity")` (specific channel id, recipient resolved from the link store), `ResponseTarget.channels([...])` (a list), `ResponseTarget.identities([ChannelIdentity(...)])` (one or more **explicit channel-native identities** — bypasses the link store, used when the caller already knows the recipient's channel-native id), `ResponseTarget.all_linked` (every channel where this `isolation_key` is known), or `ResponseTarget.none` (background-only — caller must poll the `ContinuationToken`). When the target is not the originating channel, the host delivers via the destination channel's `ChannelPush` capability. -13. **Push proactively from a channel** — channels that can deliver outbound messages without a prior request (Telegram bot proactive message, Activity Protocol proactive message via Azure Bot Service, webhook callbacks, SSE broadcasts) implement an optional `ChannelPush` capability on top of the base `Channel` protocol. Channels without push can only be the `originating` target. -14. **Submit background runs as a first-class operation** — `host.run_in_background(request) -> ContinuationToken` returns immediately with an opaque, URL-safe `token` and a status (`queued` | `running` | `completed` | `failed`). The host invokes the target asynchronously and, when complete, both delivers the result via the configured `ResponseTarget` push **and** records it against the token so callers can poll `host.get_continuation(token)`. Built-in channels expose poll routes (`/responses/{continuation_token}`, `/invocations/{continuation_token}`) that surface this without app code. Continuation tokens are persisted via a `HostStateStore` (file-based by default — see [Host state storage](#host-state-storage)) so background runs survive host restarts. -15. **Track the active channel per `isolation_key`** — the host records `(isolation_key, last_seen_channel, last_seen_at)` on every successfully resolved request so `ResponseTarget.active` resolves correctly. Apps can override in the `run_hook` (e.g. force `active` to a specific channel for a particular request). -16. **Add Starlette middleware at the host level** — pass `middleware=[Middleware(CORSMiddleware, ...)]` to `AgentFrameworkHost`. -17. **Serve with one call** — call `host.serve(host="localhost", port=8000)` without manually importing `uvicorn`, while `host.app` remains the canonical ASGI surface for any other server (Hypercorn, Daphne, Granian, Gunicorn+uvicorn workers). -18. **Author new channels** — implement the `Channel` protocol, return a `ChannelContribution` with routes/middleware/commands/lifecycle hooks, and call `context.run(...)` or `context.stream(...)` to invoke the agent. -19. **Target any `SupportsAgentRun` or `Workflow`** — host an `Agent`, `A2AAgent`, or a `Workflow`; the `run_hook` is the seam for adapting the channel's default `ChannelRequest` into the target-specific input shape (free-form messages for agents, typed inputs for workflows). -20. **Contribute WebSocket endpoints from a channel** — `ChannelContribution.routes` accepts both `Route` (HTTP) and `WebSocketRoute` (WS); the channel codec is responsible for framing and the same `run_hook` / default mapping pipeline applies. Built-in `ResponsesChannel` exposes a WebSocket transport (default `/responses/ws`, controlled by `transports=("http", "websocket")`) alongside its HTTP+SSE transport, anticipating the OpenAI Responses WebSocket transport. The host requires an ASGI server with WebSocket scope support (Uvicorn, Hypercorn, Daphne, Granian). -21. **Mix channels of different confidentiality tiers on one host** — every `Channel` may declare an opaque `confidentiality_tier: str | None` (e.g. `"corp"`, `"public"`). The host's `LinkPolicy` decides which `(source_tier, target_tier)` pairs may share an `isolation_key` (link) and which may be `ResponseTarget` source/destination for one another (deliver). Built-in policies (`AllowAllLinks` (default), `SameConfidentialityTierOnly`, `ExplicitAllowList`, `DenyAllLinks`) and the policy contract are defined in [LinkPolicy](#linkpolicy-and-confidentiality_tier). Cross-tier link attempts are refused with a typed error; cross-tier deliveries are dropped — so two tiers can share **an agent target** on one host while remaining strictly session-isolated. -22. **Choose an authorization profile per channel** — every channel that emits a `ChannelIdentity` composes from two orthogonal parameters, `require_link: bool` and `allowlist: IdentityAllowlist | None`, producing the three named profiles **open** (default), **forced-link** (must authenticate, any authenticated identity accepted), and **allowlist** (only listed identities — keyed on either the channel-native id pre-link or on a verified IdP claim post-link). Built-in allowlists (`NativeIdAllowlist`, `LinkedClaimAllowlist`, plus `AnyOfAllowlists` / `AllOfAllowlists` combinators) and the unified host seam (`host.authorize(...)` → `AuthorizationOutcome` of `Allowed` / `LinkRequired` / `Denied`) are defined in [Authorization profiles and the IdentityAllowlist seam](#authorization-profiles-and-the-identityallowlist-seam). The host applies a `default_allowlist` to every channel whose `allowlist` is left at the sentinel `"inherit"`, so app authors can lock down a whole bot in one place. Configuration combinations that would silently deny every user (e.g. `LinkedClaimAllowlist` on a channel with `require_link=False` and no native verified claims) are rejected at host startup with a typed `ChannelConfigurationError`. - -### v1 Fast Follow -23. **Generic auth helpers** — shared middleware for common channel auth patterns (HMAC signature, bearer token). -24. **Pluggable host state store** — interface for cross-host persistence of `ContinuationToken`s, identity-link grants, and last-seen `(isolation_key, channel)` records. Default implementation in v1 is **file-based** (`FileHostStateStore`); `InMemoryHostStateStore` is available for tests. A future `CosmosHostStateStore` / `SQLHostStateStore` would extend cross-channel chat continuity (req #9), background runs (req #14), and identity-link continuity (req #11) beyond a single host/process — but the v1 file-based default already survives host restarts on a single node. Same protocol covers session aliasing where applicable. -25. **First-party identity linker helpers** — concrete `OAuthIdentityLinker` (with provider presets) and `OneTimeCodeIdentityLinker` (cross-channel code exchange) shipped as opt-in helpers on top of the `IdentityLinker` contract. Investigation of additional first-party linker types tracked as a follow-up. -26. **`A2AChannel` package** (`agent-framework-hosting-a2a`) — exposes the hostable target over the Agent-to-Agent protocol so other agents can consume it as a peer. Caller-supplied-session family (alongside Responses and Invocations): A2A's per-conversation id maps to `ChannelSession.key`; the calling agent's identity (e.g. its A2A agent card / signed JWT) flows through `IdentityResolver`; structured replies fit the existing `ChannelRequest` + `ResponseTarget` envelope. No new host primitives required — only the protocol binding and package. -27. **`MCPToolChannel` package** (`agent-framework-hosting-mcp`) — 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`; the tool result is the target's response. Streaming MCP tools map onto the host's existing streaming response delivery; long-running MCP tools map onto background runs with `ContinuationToken` when the work outlasts a single tool-call round-trip. -28. **`ActivityChannel` package** (`agent-framework-hosting-activity`) — 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. Provides **native translations** between Activity Protocol objects (`Activity`, `ConversationReference`, adaptive cards, `Invoke` activities, …) and the host's `ChannelRequest` / `ChannelResponse` types — so the contract is **explicit** rather than implicit through a generic Invocations endpoint. 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 the Teams-native channel below (req #29) and for any future direct-to-Teams transport. -29. **`TeamsChannel` package** (`agent-framework-hosting-teams`) — Teams-native channel built on the MIT-licensed [`microsoft/teams.py`](https://github.com/microsoft/teams.py) SDK (`microsoft-teams-apps`, `microsoft-teams-api`, `microsoft-teams-cards`). Where `ActivityChannel` (req #28) targets the **generic** Activity Protocol surface across all Bot Service-fronted channels, `TeamsChannel` exploits **Teams-specific affordances** that the generic Activity Protocol does not surface natively: - - **Adaptive Cards** via the typed `microsoft-teams-cards` builder, attached as tool side-effects through a `ContextVar`-scoped pending-cards collector consumed by the channel's result projector. - - **Streamed assistant replies** via `ctx.stream.emit(chunk)` — the channel projects `agent.run(..., stream=True)` chunks directly. - - **Teams "AI generated" badge**, **built-in feedback controls + custom feedback form**, **suggested-prompt chips** (`SuggestedActions` / `CardAction(IM_BACK)`), **inline citations** (`CitationAppearance` populated from a `FunctionMiddleware` that assigns stable positions to tool-result sources). - - **Modal Dialogs** (multi-step forms) with submission events routed through the host's normal request pipeline. - - **Message Extensions** — action commands (modal forms invoked from the compose box / message context menu), search commands (typed-ahead inline cards), and link unfurling (preview cards on URL paste). Each is exposed via the same `ChannelCommand` model as Telegram-style slash commands. - - **Proactive, targeted (ephemeral), and threaded messages** via `app.send(conversation_id, MessageActivityInput(...))`, `with_recipient(account, is_targeted=True)`, and `to_threaded_conversation_id(conversation_id, message_id)` — used by `ChannelPush` and by `ResponseTarget.identities([ChannelIdentity(channel="teams", chat_id=…)])`. - - **SSO / OAuth** via the SDK's MSAL-backed connections, surfaced through `IdentityResolver` and the channel's run hook. - - **Teams API client + Microsoft Graph client** preconfigured on the SDK's `App`, available to the run hook for Teams-specific lookups (team roster, channel metadata, …) without re-implementing auth. - - Mounts the SDK's `App` into the host's Starlette app via a custom `HttpServerAdapter` that defers `register_route(...)` to `ChannelContribution.routes` — the SDK does **not** start its own server; the host owns the lifecycle. Host-tracked-session family (same as `ActivityChannel`): `from.aadObjectId` populates `ChannelIdentity`. The result projector reads `AgentRunResult.messages[*].contents` and routes the rich content variants to their Teams-native renderings (`TextContent` → markdown body, `DataContent`/structured output → Adaptive Card, citation entries from `additional_properties` → `add_citation`, `ErrorContent` → typed error card). - - **Note on transport.** `TeamsChannel` **still rides on Azure Bot Service in v1** — the `microsoft/teams.py` SDK is a higher-level Pythonic wrapper over the same Activity Protocol pipeline that `ActivityChannel` exposes raw. The difference is **what the developer writes against**, not the underlying network path. A truly Bot-Service-free Teams transport is *not currently possible* and is tracked as a separate, speculative stretch item (req #31); when/if Microsoft ships one, the new transport would slot in under the same `TeamsChannel` package without changing this requirement. - - **`ActivityChannel` vs `TeamsChannel` — pick by audience:** - - | Channel | Built on | Audience | - |---|---|---| - | `ActivityChannel` (req #28) | Activity Protocol over HTTP, no Teams-specific helpers | Bot Service-fronted channels generically (Teams, Web Chat, Slack-style connectors, DirectLine, …); maximum portability across the Bot Framework / M365 connector ecosystem | - | `TeamsChannel` (req #29) | `microsoft/teams.py` `App` mounted via custom `HttpServerAdapter` into the host's Starlette app | Teams-first deployments that want Adaptive Cards, modal Dialogs, Message Extensions, citations, feedback, suggested-prompt chips, and SSO out-of-the-box | - - Deployments that only need plain Activity Protocol over Bot Service stick with `ActivityChannel`; `TeamsChannel` is the upgrade path when Teams-native richness is wanted. - -### Stretch -30. **WhatsApp channel package** — using the same `Channel` + `ChannelCommand` model, designed so it participates in cross-channel continuity (req #9) and can serve as a `ChannelPush` destination (req #13) when paired with a stable per-user `isolation_key`. -31. **Direct-to-Teams channel package** — *speculative*. Reserved for a future transport that connects to Teams **without going through Azure Bot Service** (and therefore without the Activity Protocol pipeline that backs both `ActivityChannel` (req #28) and `TeamsChannel` (req #29)). At the time of writing **no such transport is publicly available** — the Microsoft Graph chat APIs (`/teams/{id}/channels/{id}/messages`, `/chats/{id}/messages`) and the `microsoft/teams.py` SDK both ultimately route through Bot Service for the bot-as-conversation-participant pattern. This requirement is kept on the roadmap purely to preserve the `TeamsChannel` naming line for if/when Microsoft ships a Bot-Service-free transport (a native Teams REST/RPC, a Graph subscription strong enough to drive both inbound and outbound message flow, or similar). Until then, **the canonical Teams channel is `TeamsChannel` (req #29)** and `ActivityChannel` (req #28) covers the generic Bot Service surface. - -## API Surface - -### Architecture overview - -The host wires one `Agent` (or `Workflow`) to one or more channels, each contributing routes, commands, and a push back-channel. **1a** is the runtime topology — how an inbound request flows through the host. **1b** is the contribution shape — what each channel hands the host at construction. - -#### Runtime topology - -```mermaid -graph LR - Caller[External caller /
messaging app] - - subgraph Host[AgentFrameworkHost] - direction TB - ASGI[Starlette app] - Router[Channel router] - Parse{parse →
command or
message?} - Auth[host.authorize] - Resolver[IdentityResolver] - Delivery[_deliver_response] - Push[_handle_push_task] - Annot[_annotate_intended_targets] - end - - Channels[Channels
Responses · Invocations ·
Telegram · Activity ·
IdentityLinker] - CmdHandler[CommandHandler
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 - Delivery --> Annot - 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
e.g. TelegramChannel] - direction TB - Routes[routes:
webhook / poller / API endpoints
→ Starlette router] - Commands[commands: Sequence ChannelCommand
name · description · handle ·
scopes · locales · expose_in_ui] - Push[ChannelPush.push
+ optional ChannelPushCodec
+ optional response_hook] - end - - Host[Host] - Native[Platform native catalog
Telegram set_my_commands ·
Teams app manifest · …] - Dispatch[CommandHandler dispatch] - Delivery[Originating sync delivery
+ runner-scheduled fan-out] - - Routes -- contribute at startup --> Host - Commands -- startup projection --> Native - Commands -- runtime dispatch --> Dispatch - Push -- driven by --> Delivery -``` - -The `IdentityLinker` is itself a Channel specialisation: when one is configured, the host auto-inserts a `link` / `connect` `ChannelCommand` into every other channel's catalog (opt-out per channel via `expose_in_ui=False` or rename via metadata). - -### Packages - -| Distribution package | Public import surface | Purpose | -| --- | --- | --- | -| `agent-framework-hosting` | `agent_framework.hosting` | Core Starlette host, channel contract, session/request bridge | -| `agent-framework-hosting-responses` | `agent_framework.hosting` (lazy) | `ResponsesChannel` | -| `agent-framework-hosting-invocations` | `agent_framework.hosting` (lazy) | `InvocationsChannel` | -| `agent-framework-hosting-telegram` | `agent_framework.hosting` (lazy) | `TelegramChannel` and Telegram-specific helpers | - -The split is between distribution packages. The **public import path stays stable at `agent_framework.hosting`** via lazy imports, consistent with the repository's packaging conventions. - -### Built-in routes - -For built-in channels, `path` is the configurable endpoint root. Use `path=""` when an external platform requires that channel at the app root. - -| Channel | Default `path` | Default exposed route(s) | -| --- | --- | --- | -| `ResponsesChannel` | `/responses` | `/responses` | -| `InvocationsChannel` | `/invocations` | `/invocations` | -| `TelegramChannel` | `/telegram/webhook` | webhook mode: `/telegram/webhook`; polling mode: no required HTTP route | - -Overrides replace the endpoint path: - -```python -ResponsesChannel(path="/public/responses") # -> /public/responses -InvocationsChannel(path="/internal/invocations") # -> /internal/invocations -TelegramChannel(path="/bots/telegram/webhook", bot_token=token) # -> /bots/telegram/webhook -``` - -### Key Types - -**`AgentFrameworkHost`** — owner of the Starlette app and channel lifecycle. Fronts one **hostable target** (an agent or a workflow). - -| Field / Method | Type | Description | -|---|---|---| -| `__init__(target, *, channels, middleware=(), identity_resolver=None, identity_linker=None, debug=False)` | constructor | Composes one host from one **hostable target** (`SupportsAgentRun` or `Workflow`) and a sequence of channels. Optional `identity_resolver` and `identity_linker` provide channel-native-id → `isolation_key` mapping and a connect ceremony for linking new channels to existing identities. The host detects the target kind and dispatches to the appropriate runner. | -| `app` | `Starlette` | Canonical ASGI surface; can be handed to any ASGI server. | -| `serve(*, host="127.0.0.1", port=8000, **kwargs)` | method | Convenience wrapper around `uvicorn.run(self.app, ...)`. Lazy-imports `uvicorn`. | -| `run_in_background(request)` | `-> ContinuationToken` | Submits a `ChannelRequest` for asynchronous execution. Returns a `ContinuationToken` immediately; the result is delivered via the configured `ResponseTarget` push when ready and recorded against the token (in the configured `HostStateStore`) for later polling. Channels typically call this when their protocol response should be a 202 / acknowledgement rather than the agent reply. | -| `get_continuation(token)` | `-> ContinuationToken \| None` | Look up a previously submitted background run by its opaque token. Returns `None` when the token is unknown or has expired. Reads through the `HostStateStore` so tokens issued before the most recent restart still resolve. | - -**`HostableTarget`** — the union of executable targets the host can front. - -| Variant | Type | Execution seam | -|---|---|---| -| Agent | `SupportsAgentRun` | `target.run(input, *, session=..., stream=...)` | -| Workflow | `Workflow` | `target.run(input, ...)` (workflow execution seam) | - -**`Channel`** (Protocol) — anything that contributes routes/commands/lifecycle to a host. - -| Field | Type | Description | -|---|---|---| -| `name` | `str` | Channel name used for routing, telemetry, and `ChannelRequest.channel`. | -| `confidentiality_tier` | `str?` | Optional opaque confidentiality tier (e.g. `"corp"`, `"public"`). Consumed by the host's `LinkPolicy` to decide which channels may be linked into the same `isolation_key` and which may be `ResponseTarget` destinations for a given originating request. `None` = single-tier (no policy filtering). See `LinkPolicy`. | -| `contribute(context: ChannelContext) -> ChannelContribution` | method | Called once at host construction; returns routes/middleware/commands/lifecycle. | - -**`ChannelContext`** — host-owned bridge channels use to invoke the agent. - -| Method | Type | Description | -|---|---|---| -| `run(request: ChannelRequest)` | `-> HostedRunResult[Any]` | One-shot invocation. For agent targets `TResult` narrows to `AgentResponse`; for workflow targets to `WorkflowRunResult`. | -| `stream(request: ChannelRequest)` | `-> HostedStreamResult` | Streaming invocation. | - -**`ChannelContribution`** — what a channel returns from `contribute(...)`. - -| Field | Type | Description | -|---|---|---| -| `routes` | `Sequence[BaseRoute]` | Starlette routes mounted under the channel's `path`. Accepts both `Route` (HTTP) and `WebSocketRoute` (WS) — both are `BaseRoute`. | -| `middleware` | `Sequence[Middleware]` | Channel-scoped middleware. | -| `commands` | `Sequence[ChannelCommand]` | Native command catalog (e.g. Telegram bot commands). | -| `on_startup` | `Sequence[Callable]` | Lifecycle hooks for polling workers, command registration, etc. | -| `on_shutdown` | `Sequence[Callable]` | Lifecycle hooks for cleanup. | - -**`ChannelRequest`** — normalized ingress passed to the host. - -| Field | Type | Description | -|---|---|---| -| `channel` | `str` | Originating channel name. | -| `operation` | `str` | e.g. `message.create`, `command.invoke`, `approval.respond`. | -| `input` | `AgentRunInputs` | Reuses framework input types. | -| `session` | `ChannelSession?` | Session hint from the channel. | -| `options` | `ChatOptions?` | Caller-derived options (e.g. Responses `temperature`). | -| `session_mode` | `Literal["auto", "required", "disabled"]` | Whether host-managed session use is automatic, mandatory, or bypassed. | -| `metadata` | `Mapping[str, Any]` | Protocol-level metadata for telemetry. | -| `attributes` | `Mapping[str, Any]` | Channel-specific structured values (signature state, capability hints). Host code never reads this map; reserved for channel-private bookkeeping. | -| `client_state` | `Mapping[str, Any] \| None` | Bidirectional, mutable per-request state object supplied by event-rich front-ends (e.g. AG-UI). Channel-defined shape; the host treats it as opaque. Channels typically thread this into a channel-owned `ContextProvider` (see [Channel-owned per-thread state](#channel-owned-per-thread-state)) and read it back after the run to emit state-snapshot/delta events. | -| `client_tools` | `Sequence[ToolDescriptor] \| None` | Frontend tool catalog supplied per request. The channel forwards definitions onto the agent's `ChatOptions` so the LLM can call them, but tool *execution* returns to the originating client (the host does not invoke them). Run hooks may filter or rewrite the catalog. | -| `forwarded_props` | `Mapping[str, Any] \| None` | Pass-through bag for channel-protocol extras the run hook needs to route into the target — e.g. AG-UI `resume` / `command` / HITL response payloads that drive workflow `RequestInfo` / `RequestResponse` round-trips. Opaque to the host; the run hook decides where it lands on the rebuilt `ChannelRequest.input`. | -| `identity` | `ChannelIdentity?` | Channel-native **user** identity observed on this request — `(channel, native_id, attributes)`. Channels populate it from the inbound payload's user field (Telegram `from.id`, Teams `from.aadObjectId`, Responses `safety_identifier`, …) — **not** the chat / conversation id, which is carried separately on `conversation_id` and matters in multi-user surfaces (Telegram groups, Teams group chats and channels — see [Multi-user conversations](#multi-user-conversations-telegram-groups-teams-group-chats-and-channels)). The host records `(isolation_key, channel) → identity` on every successful resolve so `ResponseTarget.active`, `.channel(name)`, `.channels([...])`, and `.all_linked` can find a destination native id without per-request payload bookkeeping. | -| `stream` | `bool` | Whether to invoke `stream(...)` rather than `run(...)`. | -| `response_target` | `ResponseTarget` | Where the response is delivered (default: `ResponseTarget.originating`). See `ResponseTarget` below. | -| `background` | `bool` | If `True`, host returns a `ContinuationToken` immediately rather than awaiting the response. Forced `True` when `response_target == ResponseTarget.none`. | - -**`ChannelSession`** — small, host-neutral session hint. - -| Field | Type | Description | -|---|---|---| -| `key` | `str?` | Stable host lookup key for an `AgentSession`. **Caller-supplied** channels populate it from the wire payload (e.g. `previous_response_id`, request-body `session_id`). **Host-tracked** channels leave it `None` and let the host's per-`isolation_key` alias decide which `AgentSession` to resolve (see [Channel session-carriage models](#channel-session-carriage-models)). | -| `conversation_id` | `str?` | Protocol-visible conversation/thread identifier when one exists. | -| `isolation_key` | `str?` | Opaque isolation boundary (user, tenant, chat, …) using hosted-agent terminology. | -| `attributes` | `Mapping[str, Any]` | Channel-specific session hints. | - -**`ChannelRunHook`** — per-request escape hatch for built-in channels. - -```python -ChannelRunHook = Callable[..., Awaitable[ChannelRequest] | ChannelRequest] -``` - -Channels invoke the hook positionally with the channel-built `ChannelRequest` and pass named extras as keyword arguments. The minimum signature an app author needs is: - -```python -def my_hook(request: ChannelRequest, **kwargs) -> ChannelRequest: ... -``` - -Hooks that want the named extras pull them out by name: - -| Keyword | Type | Description | -|---|---|---| -| `target` | `SupportsAgentRun \| Workflow` | The hosted target (so hooks can adapt to e.g. `A2AAgent` or to a `Workflow`'s typed inputs). | -| `protocol_request` | `Any?` | Original channel-native protocol payload — Responses JSON body, Telegram `Update` dict, Activity Protocol `Activity` dict, Invocations body, … (loosely typed in v1). | - -Runs **after** the channel has produced its default `ChannelRequest`, **before** the host resolves session behavior and calls the target's execution seam. This is the canonical adapter point for workflow targets, where the channel's free-form input must be reshaped into the workflow's typed inputs. - -> Earlier drafts wrapped these arguments into a `ChannelRunHookContext` object. The signature was simplified so the typical hook only needs `(request, **kwargs)` — making it safe against future named extras and easier to write inline. - -**`ChannelIdentity`** — the channel-native identity the host sees on each request, used as the resolver/linker input. - -| Field | Type | Description | -|---|---|---| -| `channel` | `str` | Originating channel name (matches `Channel.name`). | -| `native_id` | `str` | Channel-native **user** identifier (Telegram `from.id`, Teams `from.aadObjectId`, WhatsApp phone number, Slack user id, …). In 1:1 chats this often coincides with the chat / conversation id; in multi-user surfaces (Telegram groups, Teams group chats and channels) it is **strictly the user** — the conversation locator lives separately on `ChannelRequest.conversation_id` / `ChannelSession.conversation_id`. Always per-channel; never assumed to align across channels. | -| `attributes` | `Mapping[str, Any]` | Optional per-channel context (display name, locale, group/private chat flag, Teams `tenantId`, Telegram `chat.type`, Teams `conversationType`, …) the resolver/linker may key on. | - -**`IdentityResolver`** — host-level seam that maps a `ChannelIdentity` to an `isolation_key`. - -```python -IdentityResolver = Callable[[ChannelIdentity], Awaitable[str | None] | (str | None)] -``` - -The **default resolver auto-issues** an `isolation_key` the first time a `(channel, native_id)` is seen and persists the mapping in the host's identity store, so every end user automatically gets a stable per-user `isolation_key` on first contact through **any** channel — no per-channel boilerplate is required for the single-channel case. Returning `None` is reserved for advanced cases where the resolver wants to refuse unknown identities; the dedicated host seam for accept/reject decisions is **`IdentityAllowlist`** — see [Authorization profiles and the IdentityAllowlist seam](#authorization-profiles-and-the-identityallowlist-seam) below. - -Cross-channel continuity is then a one-shot **merge** operation: after a successful link ceremony (Scenario 6), the host atomically rewrites the second channel's auto-issued key to point at the first channel's existing `isolation_key`. Apps never have to write per-channel mapping hooks just to get continuity to work. - -Apps that already own an identity namespace (corporate user id, tenant-scoped account id) can supply a custom resolver that returns those values directly — bypassing auto-issuance. - -**`IdentityLinker`** (Protocol) — host-level seam that runs a connect ceremony to associate a new `ChannelIdentity` with an existing `isolation_key`. The linker is a peer of `Channel` for routing purposes and contributes its own routes/lifecycle. - -| Field / Method | Type | Description | -|---|---|---| -| `name` | `str` | Linker name; used for telemetry and to namespace its routes. | -| `contribute(context: ChannelContext) -> ChannelContribution` | method | Same shape as `Channel.contribute(...)`; lets the linker publish callback/verification routes (e.g. `/identity/oauth/callback`, `/identity/verify`) and lifecycle hooks. | -| `begin(identity: ChannelIdentity, *, requested_isolation_key=None) -> LinkChallenge` | method | Starts the ceremony for a channel-native identity. Returns a `LinkChallenge` describing what the user must do (URL to visit, code to enter, MFA prompt). | -| `complete(challenge_id: str, proof: Mapping[str, Any]) -> str` | method | Verifies the proof and returns the resolved `isolation_key`. On success the host atomically records both `(channel, native_id) → isolation_key` and any verified IdP claim recovered from the proof (e.g. `(microsoft.oid, )`) so subsequent channels that supply the same claim auto-link without a second ceremony. | -| `is_linked(identity: ChannelIdentity, *, verified_claims: Mapping[str, str] = {}) -> str \| None` | method | Returns the `isolation_key` for an already-linked identity, or `None` if no link exists. Channels with `require_link=True` call this on every inbound request before invoking the agent. When `verified_claims` are supplied (e.g. Teams' AAD `oid` from the inbound activity bearer) and a match exists in the link store, the linker silently auto-merges the new `(channel, native_id)` onto the existing `isolation_key` and returns it — this is the "sign in once, every other channel just works" mechanism. | - -| Built-in helper | Mechanism | Notes | -|---|---|---| -| `OAuthIdentityLinker(provider, ...)` | OAuth authorization-code redirect | Contributes `/identity/oauth/{provider}/start` + `/callback`; ships with provider presets (Microsoft, Google, GitHub) as opt-in helpers. Stores the verified IdP `sub` / `oid` as a verified claim alongside the channel-native identity so channels that authenticate with the same IdP (e.g. Teams via Entra ID) auto-link on first contact. | -| `OneTimeCodeIdentityLinker(...)` | Signed short-lived code | User runs `/link` on channel A, receives a code; runs `/link ` on channel B; host verifies and merges. | - -A built-in `link` (or `connect`) `ChannelCommand` is exposed automatically when an `IdentityLinker` is configured. Its `handle` invokes `linker.begin(...)` and replies with the `LinkChallenge` payload (URL, code, instructions) projected through the channel's native rendering. Channels may opt out (`expose_in_ui=False`) or override the command's name per channel. - -**`require_link` (per-channel)** — every channel that emits a `ChannelIdentity` accepts a `require_link: bool = False` constructor argument. When `True`, the channel calls `linker.is_linked(identity, verified_claims=…)` before producing a `ChannelRequest`; un-linked identities are short-circuited to a rendered `LinkChallenge` reply (the same payload the `link` command would emit) and the agent is **not** invoked for that turn. Combined with the linker's verified-claim auto-link, this gives an "authenticate before chatting" enforcement model where the first channel forces the OAuth ceremony and subsequent channels join the same `isolation_key` silently. See [Scenario 6](#scenario-6-linking-a-new-channel-to-an-existing-identity-via-oauth) for the end-to-end flow. Default is `False`, which preserves the opportunistic flow (auto-issued `isolation_key`, link manually later). Channels whose protocol does not authenticate the user (e.g. anonymous Responses calls) ignore the flag. `require_link` is the **"identity must be linked"** axis; the **orthogonal "identity is on the accept list"** axis is `allowlist` — see [Authorization profiles and the IdentityAllowlist seam](#authorization-profiles-and-the-identityallowlist-seam) below. - -#### Authorization profiles and the `IdentityAllowlist` seam - -`require_link` (above) and `allowlist` (below) compose into the **three named authorization profiles** the spec supports for any channel that emits a `ChannelIdentity`. The two parameters stay **orthogonal** on the channel constructor — there is no single `auth_mode` enum — but the host exposes named factories on `AuthPolicy` (`AuthPolicy.open()` / `.require_link()` / `.native_allowlist(...)` / `.linked_claim_allowlist(...)` / `.mixed(...)`) for ergonomic configuration: - -| Profile | Channel config | What gets gated | Typical use | -|---|---|---|---| -| **Open** (default) | `require_link=False`, `allowlist=None` | Nothing — every identity gets an auto-issued `isolation_key` on first contact. | Public chatbot, internal dev/demo, single-tenant deployments. | -| **Forced link** | `require_link=True`, `allowlist=None` | Identity must complete the link ceremony at least once. Any successfully authenticated identity is then allowed. | "Sign in once with your corporate account, then chat freely" style bots that gate on tenancy via the IdP rather than per-user. | -| **Native allowlist** | `require_link=False`, `allowlist=NativeIdAllowlist(...)` | Only listed channel-native ids (Telegram `chat_id`s, WhatsApp numbers, Slack user ids) get through. Pre-link, no IdP claim involved. | Personal bots, single-user prototypes, small fixed-membership channels. | -| **Linked-claim allowlist** | `require_link=True`, `allowlist=LinkedClaimAllowlist(...)` | Identity must (a) complete the link ceremony **and** (b) carry an IdP claim whose value is on the list (e.g. AAD `oid in {…}` or `tid == ""`). | Multi-channel corporate bot where any channel works but only specific people in a specific tenant are admitted. | -| **Mixed** | `require_link=False`, `allowlist=AnyOfAllowlists(NativeIdAllowlist(...), LinkedClaimAllowlist(...))` | Either the native id is preapproved **or** the user successfully links and matches the claim allowlist. Native-id hits bypass the link ceremony; everyone else is funneled into it. | A bot that wants ops-team Telegram ids in immediately while still letting other corp users self-onboard via OAuth. | - -The decision pipeline that produces each of those profiles: - -```mermaid -flowchart TB - Start([authorize identity,
require_link, allowlist]) - Linked{identity already
linked?
StateStore lookup} - Required{require_link?} - OpenPath{allowlist is None?} - Resolve[/isolation_key:
linked → existing,
else auto-issue channel:native_id/] - Evaluate[/allowlist.evaluate context/] - Decision{decision} - Abstain{requires_linked_claims?} - Allowed([Allowed isolation_key]) - DeniedPre([Denied
allowlist_denied_pre_link]) - LinkReq([LinkRequired
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 -``` - -The flow shows three terminal states: `Allowed`, `LinkRequired`, `Denied`. `LinkRequired` is reachable whenever `require_link=True` and the identity has not completed the link ceremony (or an allowlist `ABSTAIN`ed and `requires_linked_claims=True`), independent of whether an allowlist is configured. - -##### `IdentityAllowlist` Protocol (tri-state) - -Allowlists are evaluated by a host-level pipeline (`host.authorize(...)`, below) that calls them twice — once with the raw channel-native identity (`phase="pre_link"`) and, if necessary, again after the link ceremony surfaces verified IdP claims (`phase="post_link"`). To make composition (`AnyOfAllowlists`, `AllOfAllowlists`) well-defined and to keep claim-based allowlists from accidentally denying everyone when claims are not yet available, the contract is **tri-state**: - -```python -class AllowlistDecision(StrEnum): - ALLOW = "allow" # accept this identity unconditionally - DENY = "deny" # reject this identity unconditionally - ABSTAIN = "abstain" # this allowlist has no opinion at this phase - # (e.g. a claim-based list during pre_link) - -@dataclass(frozen=True) -class AuthorizationContext: - identity: ChannelIdentity - phase: Literal["pre_link", "post_link"] - isolation_key: str | None # None at pre_link; resolved at post_link - verified_claims: Mapping[str, str] # {} when no claims; populated post_link - claim_source: Literal["linker", "channel", "none"] - # "channel" when the channel itself emits - # verified claims (e.g. Activity Protocol - # bearer with AAD oid); "linker" when the - # IdentityLinker surfaces them; "none" otherwise. - -class IdentityAllowlist(Protocol): - requires_linked_claims: bool = False # if True, host validation rejects - # configurations where neither `require_link` - # nor a claim-emitting channel can deliver - # the claims this allowlist needs. - - async def evaluate(self, context: AuthorizationContext) -> AllowlistDecision: ... -``` - -`ABSTAIN` is **not** a denial — it is "this allowlist has no information yet". The host's decision pipeline (below) is what turns an all-`ABSTAIN` outcome into the appropriate next step (allow when open, escalate to a link ceremony when the configuration calls for one). Boolean allowlists were rejected as part of this design pass because two-state composition cannot distinguish "claim allowlist denies you" from "claim allowlist hasn't seen any claims yet" — a critical distinction for the **Mixed** profile. - -##### Built-in allowlists - -| Helper | Pre-link behavior | Post-link behavior | Notes | -|---|---|---|---| -| `AllowAll()` | `ALLOW` | `ALLOW` | Explicit "open" sentinel; useful for tests and for overriding a host-level `default_allowlist`. | -| `NativeIdAllowlist(channel=None, native_ids=...)` | `ALLOW` if `(channel, native_id)` is on the list; `DENY` if `channel` matches but `native_id` does not; `ABSTAIN` if `channel` does not match (allows mixing per-channel native lists under one `AnyOfAllowlists`). | Same as pre-link — native-id allowlists do not depend on link state. | Constructor accepts `native_ids: Collection[str] \| Callable[[], Awaitable[Collection[str]]]` so the list can be loaded asynchronously (config file, secret store). | -| `LinkedClaimAllowlist(claim, values)` | `ABSTAIN` (no claims available yet). | `ALLOW` if `verified_claims.get(claim)` is in `values`; `DENY` otherwise. | `requires_linked_claims = True`. Host construction-time validator rejects use with `require_link=False` on a channel that does not also emit verified claims natively — this prevents the silent-deny-everyone footgun. | -| `AnyOfAllowlists(*allowlists)` | `ALLOW` if any child `ALLOW`s; `DENY` only if **all** children `DENY`; otherwise `ABSTAIN`. | Same rule. | Composition for the **Mixed** profile. | -| `AllOfAllowlists(*allowlists)` | `DENY` if any child `DENY`s; `ALLOW` only if **all** children `ALLOW`; otherwise `ABSTAIN`. | Same rule. | E.g. require both tenancy (`LinkedClaimAllowlist("tid", ...)`) **and** group membership (`LinkedClaimAllowlist("groups", ...)`). | -| `CallableAllowlist(fn)` | Calls `fn(context)` and returns its result. | Same. | Escape hatch for app-specific logic; recommended only after exhausting the structured variants. | - -##### Host configuration: `default_allowlist` + explicit channel inheritance - -Allowlists can be configured at the host level (`AgentFrameworkHost(default_allowlist=...)`) and per-channel. The channel-side default is **explicit inheritance**, not an implicit `None`: - -```python -class SomeChannel: - def __init__( - self, - *, - require_link: bool = False, - allowlist: IdentityAllowlist | Literal["inherit"] | None = "inherit", - ): ... -``` - -- `allowlist="inherit"` (default) → the host's `default_allowlist` applies. If the host did not set one either, the channel is open. -- `allowlist=None` → the channel is **explicitly open**, even if the host has a `default_allowlist`. Used to carve out a public endpoint inside an otherwise-locked-down host. -- `allowlist=` → that allowlist applies, overriding the host default. To **add to** the host default rather than replace it, compose explicitly: `allowlist=AllOfAllowlists(host.default_allowlist, MyExtraList())`. - -##### `host.authorize(...)` and `AuthorizationOutcome` - -Channels do not run the decision pipeline themselves — they call into a single host seam after extracting `ChannelIdentity` and any natively verified claims: - -```python -@dataclass(frozen=True) -class Allowed: - isolation_key: str - -@dataclass(frozen=True) -class LinkRequired: - challenge: LinkChallenge - -@dataclass(frozen=True) -class Denied: - reason_code: str # stable, machine-readable - user_message: str | None = None # safe to render publicly (group-chat-safe) - log_details: Mapping[str, Any] = {} # never shown to users; structured for audit - -AuthorizationOutcome = Allowed | LinkRequired | Denied - -async def host.authorize( - identity: ChannelIdentity, - *, - require_link: bool, - allowlist: IdentityAllowlist | None, - verified_claims: Mapping[str, str] | None = None, - conversation_context: ConversationContext | None = None, # for group-chat policy -) -> AuthorizationOutcome: ... -``` - -**Decision order** (the pipeline the host runs): - -1. Build `AuthorizationContext(phase="pre_link", verified_claims=verified_claims or {}, claim_source=…)`. -2. `decision_pre = allowlist.evaluate(context_pre)` (defaults to `ALLOW` when `allowlist is None`). -3. `decision_pre == DENY` → `Denied(reason_code="allowlist_denied_pre_link", ...)`. -4. `decision_pre == ALLOW`: - - If `require_link=True` and the linker has no record yet → `LinkRequired(linker.begin(identity))`. - - Otherwise → `Allowed(resolved_or_auto_issued_isolation_key)`. -5. `decision_pre == ABSTAIN`: - - If `require_link=True` **or** the allowlist declared `requires_linked_claims`: attempt `linker.is_linked(identity, verified_claims=…)`. - - Not linked → `LinkRequired(linker.begin(identity))`. - - Linked → evaluate again at `phase="post_link"` with the linker-emitted claims. - - `ALLOW` → `Allowed(linked_isolation_key)`. - - `DENY` → `Denied(reason_code="allowlist_denied_post_link", ...)`. - - `ABSTAIN` post-link is a misconfiguration (no allowlist had an opinion even after linking); logged and treated as `Denied(reason_code="allowlist_abstain_after_link")`. - - Otherwise (open profile, no claim dependency): `Allowed(auto_issued_isolation_key)`. - -The channel **renders** the outcome — `Allowed` proceeds to `ChannelRequest`, `LinkRequired` projects the `LinkChallenge` through the channel's native UX (same path the `link` command already uses), `Denied` projects `user_message` (when set) through a short refusal. The channel **never** sees `log_details` and is responsible for not echoing `reason_code` to end users. - -##### Configuration validation (fail-fast) - -The host runs a startup validator across `(channel.require_link, channel.allowlist)` for every channel: - -1. If `channel.allowlist` (after resolving `"inherit"`) contains any allowlist with `requires_linked_claims=True`, the channel **must** either have `require_link=True` or declare via a channel attribute that it natively emits verified claims (`Channel.emits_verified_claims: bool = False`). Otherwise: `raise ChannelConfigurationError("LinkedClaimAllowlist requires a source of verified claims; set require_link=True on or use a channel that emits them natively")`. -2. If `channel.allowlist` contains a `LinkedClaimAllowlist` and the host has no `identity_linker` configured: same `ChannelConfigurationError`. -3. If `channel.allowlist` contains a `NativeIdAllowlist(channel=)` whose `` is not a known channel on this host: `ChannelConfigurationError`. - -These errors are raised eagerly at `AgentFrameworkHost.__init__` (or `host.serve(...)` startup), not on the first inbound request — silent deny-everyone is the worst possible default and is not allowed. - -##### Group chats and privacy of denial - -Authorization runs **per message**, not per conversation: in a group chat, one allowlisted user invoking the bot does not authorize other group members for subsequent messages. The host also mirrors the `LinkChallenge` group-chat redirect pattern (see [Multi-user conversations](#multi-user-conversations-telegram-groups-teams-group-chats-and-channels)) for denials: - -- In a 1:1 chat, the channel may render the full `user_message` from `Denied`. -- In a group chat, the channel renders a generic refusal in-room (e.g. "You don't have access to this bot.") and, where the channel supports it, follows up with a DM containing the longer `user_message`. The full `log_details` payload only reaches the host's structured logs / OpenTelemetry span — never the wire. - -Built-in `user_message` defaults are intentionally bland and tenancy-free ("You don't have access to this bot." / "Please link your account to continue.") to avoid leaking who else is in the allowlist or which tenant gates it. - -##### v1 shipping surface - -The core PR includes the channel-neutral authorization and identity-linking seam; provider-specific linker packages (for example Entra OAuth helpers) plug into it without making the core package depend on an IdP SDK: - -- `IdentityAllowlist` Protocol + `AllowlistDecision` enum + `AuthorizationContext` dataclass. -- `AllowAll`, `NativeIdAllowlist`, `LinkedClaimAllowlist`, `AnyOfAllowlists`, `AllOfAllowlists`, `CallableAllowlist` built-ins. -- `IdentityLinker` Protocol, `LinkedIdentity`, and `LinkChallenge` core types. A linker resolves a channel-native identity in one call, returning either a linked identity with verified claims or a challenge for the channel to render. -- `AuthorizationOutcome` (`Allowed` / `LinkRequired` / `Denied`) types. -- `AuthPolicy` factory helpers on the public surface. -- `Host(default_allowlist=..., identity_linker=...)` + per-channel `allowlist: ... | Literal["inherit"] | None` parameter and the construction-time config validator. The validator enforces rules #1 (claim-source), **#2 (linker presence — channels with `require_link=True` must be paired with a configured `identity_linker`; otherwise a `ChannelConfigurationError` is raised at construction so misconfigurations cannot ship)**, and #3 (NativeIdAllowlist channel typo). Combinator walking (`AnyOf` / `AllOf`) is recursive so nested misconfigurations are caught at the host level. -- `host.authorize(identity, *, require_link, allowlist, verified_claims=None)` supports open, native-id allowlist, and claim allowlist profiles end-to-end. The open path returns `Allowed` with an auto-issued `:` isolation key (linear-scan registry lookup re-issues a known key when the identity has been seen before). Native-id allowlists return `Allowed`/`Denied` per the list. Claim-based allowlists use channel-emitted `verified_claims` when present; otherwise, when a linker is configured, the host returns `LinkRequired(challenge)` for unresolved identities or evaluates `LinkedClaimAllowlist` against the linker's verified claims for resolved identities. - - - -#### `LinkPolicy` and `confidentiality_tier` - -**`LinkPolicy`** — host-level decision over which channels may share an `isolation_key` and which channels may be a `ResponseTarget` for one another. Consumed by both the `IdentityLinker` (to refuse incompatible link attempts) and the host's response-routing layer (to filter `all_linked` / `active` / specific destinations). - -```python -LinkPolicy = Callable[[LinkPolicyContext], bool] -``` - -`LinkPolicyContext` carries the originating `Channel` (and its `confidentiality_tier`), the prospective destination `Channel` (and its `confidentiality_tier`), and the operation kind (`"link"` or `"deliver"`). Returns `True` to allow, `False` to refuse. Refusal during `link` raises a typed error to the user; refusal during `deliver` excludes that destination from the route set (and falls back to `originating` if the route set becomes empty). - -| Built-in policy | Behavior | -|---|---| -| `AllowAllLinks()` | Default. Any pair allowed; preserves today's single-tier behavior. | -| `SameConfidentialityTierOnly()` | Only allows pairs whose `confidentiality_tier` matches (including both `None`). Most common multi-tier setup. | -| `ExplicitAllowList(allowed_pairs={("public", "corp"), ...})` | Allows only the listed `(source, target)` pairs. Useful for one-directional escalation flows. | -| `DenyAllLinks()` | Refuses every link attempt and excludes every non-`originating` destination — channels share an agent target on the host but never share sessions. Equivalent to running each channel on its own host minus the deployment overhead. | - -Confidentiality tiers are **opaque labels** — the host does not interpret them; the policy decides what they mean. Setting `confidentiality_tier=None` on every channel preserves single-tier behavior. Two separate hosts is always a valid alternative to using `LinkPolicy`; the policy exists for cases where shared deployment, shared middleware, or a shared target object are preferred over running multiple hosts. - -#### Multi-user conversations (Telegram groups, Teams group chats and channels) - -Telegram and Activity Protocol (Bot Service) both surface **multi-user conversations** alongside 1:1 chats — Telegram has private chats, groups, supergroups, forum topics inside supergroups, and broadcast channels; Activity Protocol has `conversationType` of `personal`, `groupChat`, and `channel` (a Teams team channel, with optional threaded `replyToId`). The hosting contract treats these uniformly, but channel implementations and host configuration both need to make a few explicit choices: - -**Identity vs. conversation are two axes, not one.** `ChannelIdentity.native_id` is always the **user** (`from.id` / `from.aadObjectId`); `ChannelRequest.conversation_id` is the **chat / channel / thread**. In 1:1 chats they collapse onto the same value (Telegram `chat.id == from.id`); in groups they don't and must not be conflated. The default `IdentityResolver` keys on `(channel, native_id)`, so a single user automatically gets one `isolation_key` whether they message in a group or in DM — that may or may not be what you want (see scoping below). - -**Conversation scoping policy.** A channel exposes a `conversation_scope` constructor option declaring how the host should derive the resolved `isolation_key` for multi-user surfaces. Three built-ins: - -| Scope | `isolation_key` derivation in multi-user conversations | When to pick it | -|---|---|---| -| `per_user` | The user's `isolation_key` from `IdentityResolver(ChannelIdentity)` only — group and DM share state. | Personal-assistant agents where the bot follows the user across surfaces and their preferences/memory should travel with them. Risky if the agent emits user-specific data in a public group. | -| `per_user_per_conversation` (default for multi-user) | `f"{user_isolation_key}:{conversation_id}"` — same user gets a different `isolation_key` per group / channel / topic / DM. | Default and safest. The agent's memory of a Teams team channel is separate from its memory of the same user's DM. | -| `per_conversation` | `f"_conv:{channel}:{conversation_id}"` — every member of the group shares one `isolation_key` and one `AgentSession`. The user identity is still attached to each turn (via `ChannelRequest.identity`) so the agent can address users by name, but session state is shared. | "Bot lives in this channel" deployments: meeting-notes bot, shared scratchpad, support-triage queue. | - -1:1 chats always derive `isolation_key` from the user identity alone — the per-user-per-conversation key would just include the user's own DM and add no isolation value. - -**Addressing rule.** Group surfaces typically don't want the bot replying to every message. Channels expose an `accept_in_group` constructor option: - -| Mode | Semantics | Default for | -|---|---|---| -| `mention_only` | Accept only messages that explicitly mention the bot (`@bot` for Telegram, `botname` mention entity for Teams). | Telegram groups, Teams `groupChat`, Teams team channels | -| `command_only` | Accept only registered `ChannelCommand` invocations (e.g. `/ask …`). | — | -| `mention_or_command` | Either of the above. | — | -| `all` | Accept every inbound message. | 1:1 chats; opt-in for groups when the agent really is the only conversational participant | - -Messages that don't satisfy the rule are ignored at the channel layer — no `ChannelRequest` is produced and the agent is never invoked. This is purely an inbound filter; outbound delivery (push / response routing) is unaffected. - -**Reply / `originating` routing.** The `originating` `ResponseTarget` always replies in the **same conversation** the request came from — including the same Teams team-channel thread (`replyToId`) or Telegram forum topic (`message_thread_id`). Channels carry the conversation-locator details on `ChannelRequest.conversation_id` (and additional fields on `ChannelRequest.attributes` when needed, e.g. `thread_id`); the channel's reply path reads them back. Channels that cannot reply in-thread (rare) fall back to a fresh top-level reply in the same conversation. - -**`ChannelPush` in groups.** When a non-`originating` `ResponseTarget` lands on a multi-user surface, the push must address a `(user, conversation)` pair: the host calls `ChannelPush.push(identity, payload)` where `identity.attributes` includes the recorded `conversation_id` (and thread/topic id when applicable) of the most recent observation under that scope. For `per_conversation` scope, every member's `ChannelIdentity` resolves to the same `isolation_key`, so the host instead picks the most recently observed `conversation_id` for that key and posts a single message to the conversation rather than fanning out to each user. - -**Linker ceremonies in groups.** OAuth and one-time-code link flows MUST NOT post the challenge URL or code into a group conversation visible to other users. Channels that support groups MUST detect group context (via `ChannelIdentity.attributes`) and, when `require_link=True` triggers a `LinkChallenge`, redirect the rendered challenge to the user's DM (Telegram: bot DM with the user; Teams: `personal` scope conversation with the same user). If a DM cannot be opened (Telegram user has not started the bot, Teams personal scope not installed), the channel returns a short prompt asking the user to DM the bot and retry. Verified-claim auto-link is unaffected — when a Teams `groupChat` request carries an AAD-verified `from.aadObjectId` that already matches an existing claim in the link store, the merge happens silently with no group-visible artifact. - -**Confidentiality tier interaction.** A Teams team channel post is visible to every member of the team; a 1:1 DM is not. Operators who care about the distinction MUST configure separate `Channel` instances (e.g. `ActivityChannel(scopes=["personal"], confidentiality_tier="user")` + `ActivityChannel(scopes=["channel", "groupChat"], confidentiality_tier="team")`) and apply a `LinkPolicy` so cross-tier `ResponseTarget` deliveries and identity links are filtered. The hosting layer does not infer tier from `conversationType`; it is an explicit deployment choice. - -**Telegram broadcast `Channel` (the Telegram product) and forum topics.** - -- *Broadcast Channels* — bots that are members of a Telegram broadcast Channel can post but generally do not receive user replies; treat as `ChannelPush`-only and configure with `accept_in_group="command_only"` so admin-issued commands (`/announce …`) are the only inbound trigger. Out of scope for v1; v1 ships group/supergroup support and leaves broadcast Channels for fast follow. -- *Forum topics* — supergroups with topics surface `message_thread_id`. The `TelegramChannel` populates `ChannelRequest.conversation_id` as `f"{chat_id}:{message_thread_id}"` so `per_user_per_conversation` and `per_conversation` scopes naturally separate topics from each other and from the group's general thread. - -**Activity Protocol specifics for `ActivityChannel`.** - -- `conversationType` mapping: `personal` → 1:1 (`accept_in_group="all"` rule applied), `groupChat` and `channel` → multi-user (default `mention_only`). -- Teams team channels carry both a channel id and an optional `replyToId`. The channel populates `conversation_id` as `f"{conversation.id}:{replyToId}"` when replying in-thread is desired (`per_user_per_conversation` scope makes thread-isolated sessions easy); deployments that prefer a single session per Teams channel can set `conversation_scope="per_conversation"` and the channel will key on `conversation.id` alone. -- `tenantId` is recorded on `ChannelIdentity.attributes` so multi-tenant deployments can implement an `IdentityResolver` that scopes `isolation_key` by tenant (or refuses unknown tenants). -- Adaptive-card submit (`Invoke` activities) flows are addressed in fast-follow alongside the `ActivityChannel` package; v1 of the host contract supports them via `ChannelRequest.forwarded_props`, so no host-level change is needed. - -**`ResponseTarget`** — directs **where** the host delivers the agent response. Independent of `session_mode`. - -| Variant | Constructor | Behavior | -|---|---|---| -| Originating | `ResponseTarget.originating` (default) | Synchronous response on the originating channel. | -| Active | `ResponseTarget.active` | Delivered to the channel most recently observed for the resolved `isolation_key`. | -| Specific channel (link-store recipient) | `ResponseTarget.channel("activity")` | Delivered via the named channel's `ChannelPush` to whichever channel-native identity is recorded for the resolved `isolation_key` in the link store. | -| Explicit identities | `ResponseTarget.identities([ChannelIdentity("telegram", native_id=""), ...])` | Delivered via each named channel's `ChannelPush` to the **caller-supplied channel-native identity** — bypasses the link store entirely. Used when the originating caller already knows the recipient's channel-native id (e.g. a server-side Responses caller relaying for a known user). The host still consults `LinkPolicy` for each delivery. Convenience alias: `ResponseTarget.identity(ChannelIdentity(...))` for the single-identity case. | -| Multiple channels | `ResponseTarget.channels(["telegram", "activity"])` | Delivered to each named channel (link-store recipient per channel). | -| All linked | `ResponseTarget.all_linked` | Delivered to every channel where the resolved `isolation_key` is known. | -| None | `ResponseTarget.none` | Background-only — caller must poll the `ContinuationToken`. Forces `background=True`. | - -`ResponseTarget` constructors that take at least one channel id (`.channel(...)`, `.channels([...])`, `.identities([...])`) accept an `echo_input: bool = False` kwarg. When true, the host pushes the **originating user's input** to each non-originating destination as a `HostedRunResult[AgentResponse]` whose underlying `messages[*].role == "user"` **before** the agent reply (whose `messages[*].role == "assistant"`). Used when the developer wants downstream channels to mirror what the user said so their UI stays coherent (e.g. a workflow originating on Telegram that pushes to Teams as well — the Teams transcript shows both turns). The echo and the response are bundled into the **same scheduled push task** per destination (the runner-managed unit of work — see [Intended targets + durable delivery](#intended-targets--durable-delivery)); the echo is dispatched first, and an echo-push failure is logged and swallowed inside the task so a channel that drops echoes still receives the agent reply. Both pushes go through the same `ChannelPush.push(identity, payload)` entry point — channels distinguish the echo phase from the response phase by inspecting `payload.result.messages[*].role`, or (for channels that wire a `response_hook`) by branching on `ChannelResponseContext.is_echo` directly. Channels that cannot impersonate the user on their wire (most chat bots can only send as the bot) typically render echoes as a quoted / prefixed block, drop them, or rewrite them via their `response_hook`. - -When `response_target` is anything other than `originating`, the originating channel's protocol response is the **`ContinuationToken`** (e.g. an Invocations 202 with the token in the response body and/or a polling URL header), and the actual agent response is delivered out-of-band via the destination channel(s)' `ChannelPush`. If the destination channel doesn't implement `ChannelPush`, the host falls back per the configured policy (default: deliver to `originating`; surfaces a warning in telemetry). The configured `LinkPolicy` is consulted for every destination — destinations that fail the policy (e.g. a corp-tier channel addressed from a public-tier originating request) are dropped, and if every destination is dropped the host falls back to `originating`. - -**`ChannelPush`** (Protocol) — optional capability for channels that can deliver outbound messages without a prior request. - -| Method | Type | Description | -|---|---|---| -| `push(identity: ChannelIdentity, payload: HostedRunResult)` | async | Proactively delivers a completed run result to the given channel-native identity (Telegram proactive message, Activity Protocol proactive message via Bot Service `continueConversation`, webhook callback, SSE broadcast). Channels implement this in addition to `Channel`; channels that cannot push omit it. | - -**`ContinuationToken`** — first-class artifact for asynchronous / background runs. - -| Field | Type | Description | -|---|---|---| -| `token` | `str` | Opaque, URL-safe continuation token. The only field channels expose to callers; all other fields are implementation detail of the host's `HostStateStore`. Stable for the lifetime of the run record (until expiry / eviction). | -| `status` | `Literal["queued", "running", "completed", "failed"]` | Current status. | -| `isolation_key` | `str?` | The resolved isolation key the run is associated with. | -| `created_at` | `datetime` | Submission time. | -| `completed_at` | `datetime?` | Set when status is `completed` or `failed`. | -| `result` | `HostedRunResult?` | Populated on `completed`. | -| `error` | `str?` | Populated on `failed`. | -| `response_target` | `ResponseTarget` | The configured delivery target (recorded for diagnostics). | - -The host stores `ContinuationToken`s through a `HostStateStore` (see [Host state storage](#host-state-storage)). The v1 default is **`FileHostStateStore`** — one JSON file per token under a configurable directory (default `./.af-hosting/continuations/`), written atomically (`.tmp` + `os.replace`) so a host crash mid-write doesn't corrupt the record. This means background runs **survive host restarts**: a caller that polls `/responses/{continuation_token}` after the process recycles still gets a valid status (and the result if the run had completed before the crash). Completed/failed entries are evicted by a configurable TTL (default 24h). `InMemoryHostStateStore` is available for tests / ephemeral hosts. Built-in channels expose poll routes that surface the token in their native shape (`/responses/{continuation_token}` returns a Responses-shaped object; `/invocations/{continuation_token}` returns the Invocations status envelope). - -#### Host state storage - -`HostStateStore` is the single persistence seam for **host-execution metadata** that needs to outlive a single request: continuation tokens, identity-link grants, and last-seen `(isolation_key, channel)` records. It is deliberately separate from `ContextProvider` (per-conversation context) and `CheckpointStorage` (workflow checkpoints) because the data shapes are structurally different — but a deployment MAY back all three with the same physical store. - -| Method | Purpose | -|---|---| -| `put_continuation(token: ContinuationToken)` / `get_continuation(token: str)` / `delete_continuation(token: str)` | Background-run records. | -| `put_link_grant(grant: LinkGrant)` / `get_link_grant(code: str)` / `consume_link_grant(code: str)` | Pending identity-link grants (Entra OAuth state, one-time codes). | -| `record_last_seen(isolation_key: str, channel: str, identity: ChannelIdentity, ts: datetime)` / `get_last_seen(isolation_key: str)` | Backs `ResponseTarget.active`. | - -V1 ships two implementations: - -- **`FileHostStateStore(directory: Path = "./.af-hosting/")`** — default; one JSON file per record under `continuations/`, `link_grants/`, plus a `last_seen.json` keyed by isolation key. Atomic writes; per-namespace TTL cleanup (continuations 24h, link grants 15min, last-seen 30d by default). Suitable for single-node hosts and dev; works in hosted-agent environments where the working directory is persisted and isolated per agent. -- **`InMemoryHostStateStore()`** — testing / ephemeral; same protocol, no persistence. - -Pluggable v1-fast-follow implementations (Cosmos, SQL, Redis) plug into the same protocol — see req #24. - -In the Python core package, the host-level `state_dir` shorthand reserves a -`links` component for this identity-link store. Passing a single path derives -`state_dir/links/`; the `HostStatePaths` mapping form accepts `links=...` for -placing link-store data on a separate volume. The core host offers that path to -identity linkers that implement `SupportsLinkStorePath`; linkers that own a -provider-specific store can ignore it and be configured directly. - -**`ChannelCommand` / `ChannelCommandContext` / `CommandHandler`** — cross-channel native command model (per PR #5393). - -| Type | Fields | Description | -|---|---|---| -| `ChannelCommand` | `name`, `description`, `handle`, `expose_in_ui=True`, `metadata={}` | Transport-neutral command descriptor. | -| `ChannelCommandContext` | `session`, `state`, `raw_event`, `reply(...)`, `run(request)` | Runtime context for command handlers. | -| `CommandHandler` | `Callable[[ChannelCommandContext], Awaitable[None] \| None]` | Command implementation; may reply locally, mutate state, or invoke the agent. | - -**`HostedRunResult` / `HostedStreamResult`** — outbound results from the host. - -| Type | Fields | Description | -|---|---|---| -| `HostedRunResult[TResult]` | `result: TResult`, `session: AgentSession \| None` | One-shot outcome. `result` carries the **target's full-fidelity output unchanged**: `HostedRunResult[AgentResponse]` for agent targets (channels read `result.messages`, `result.text`, `result.value`, `result.response_id`, `result.usage_details`, … directly off the underlying response), `HostedRunResult[WorkflowRunResult]` for workflow targets (channels iterate `result.get_outputs()` and inspect `result.get_final_state()`). The host never pre-shapes, flattens, or filters — multi-modality and structured outputs survive end-to-end and each channel (through its `response_hook` and its native serializer) decides what subset its wire renders. The echo-input phase synthesises an `HostedRunResult[AgentResponse]` wrapping the originating user turn so the same delivery machinery applies. `session` carries the resolved per-isolation_key `AgentSession` (`None` for workflows, which do not own session state in the agent sense). Treat instances as immutable — the host clones per-destination via `result.replace(result=...)` before invoking each channel's `response_hook`; `replace()` is shallow, so channels that need to mutate ``result`` itself are responsible for their own deep copy. | -| `HostedStreamResult` | `updates: ResponseStream[...]`, `raw_events: AsyncIterable[Any] \| None`, `session: AgentSession?` | Streaming outcome. `updates` is the **normalized** stream of `AgentRunResponseUpdate` (lossless for messages, function calls, usage) and is the happy path for Responses, Invocations, Telegram, and most channels. `raw_events` is an 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. `None` when the host has no raw upstream (e.g. a workflow-only target produced from cached events). | - -The host does **not** emit protocol events directly — channels translate `HostedRunResult`/`HostedStreamResult` into Responses events, Invocations SSE, webhook callbacks, or platform messages. - -**`ChannelResponseHook` / `ChannelResponseContext`** — dev-supplied post-processing seam applied per destination before push. - -| Type | Shape | Description | -|---|---|---| -| `ChannelResponseHook` | `Callable[[HostedRunResult[Any], *, context: ChannelResponseContext], HostedRunResult[Any] \| Awaitable[HostedRunResult[Any]]]` | Stored as a `response_hook` attribute on a channel instance — **duck-typed**, not part of the `Channel` Protocol. Receives a per-destination clone of the `HostedRunResult` and returns a (possibly rewritten) replacement. Hooks rebind ``result`` via `HostedRunResult.replace(result=...)` rather than mutating it in place. Common uses: flatten multi-modal output to text for a text-only wire, filter out tool-call contents, project a workflow `WorkflowRunResult` into a channel-friendly `AgentResponse` for text-only channels, attach citation entities, decide an Adaptive Card vs plain-text presentation. The hook signature stays `Any`-typed in the envelope's `TResult` so a single channel can serve both agent (`HostedRunResult[AgentResponse]`) and workflow (`HostedRunResult[WorkflowRunResult]`) payloads; channels narrow at hook entry if they want static checking. | -| `ChannelResponseContext` | `request: ChannelRequest`, `channel_name: str`, `destination_identity: ChannelIdentity`, `originating: bool`, `is_echo: bool` | Per-destination context passed to a hook. `originating=False` for push deliveries (current scope of the host's `_deliver_response`); `is_echo=True` when this invocation is for the `ResponseTarget.echo_input` user-message phase rather than the agent reply phase. | -| `apply_response_hook(hook, result, *, context)` | helper | Standardised invocation convention so channels (and the host's delivery layer) all call hooks the same way. | - -The host runs each destination's hook on a **cloned** `HostedRunResult`, so a hook that rebinds `result` cannot leak into the payload another destination observes. The clone is shallow — channels that need to mutate `result` itself (rather than rebind it via `replace()`) are responsible for their own deep copy. - - - -### Built-in channel constructors - -```python -class ResponsesChannel(Channel): - def __init__( - self, - *, - path: str = "/responses", - run_hook: ChannelRunHook | None = None, - expose_conversations: bool = True, - transports: Sequence[Literal["http", "websocket"]] = ("http",), - websocket_path: str = "/ws", - options: object | None = None, - ) -> None: ... - -class InvocationsChannel(Channel): - def __init__( - self, - *, - path: str = "/invocations", - run_hook: ChannelRunHook | None = None, - openapi_spec: dict[str, Any] | None = None, - ) -> None: ... - -class TelegramChannel(Channel): - def __init__( - self, - *, - bot_token: str, - transport: Literal["webhook", "polling"] = "webhook", - path: str = "/telegram", - run_hook: ChannelRunHook | None = None, - commands: Sequence[ChannelCommand] = (), - register_native_commands: bool = True, - require_link: bool = False, - ) -> None: ... -``` - -`options` on `ResponsesChannel` is intentionally loosely typed in this draft because the option-mapping boundary is still settling. If it becomes a formal type later, it should be Agent Framework-owned, not imported from `agentserver`. - -#### Conversation history for the Responses channel - -The Responses channel does **not** introduce its own history seam. Conversation history for every channel — Responses, Invocations, Telegram, Activity Protocol — flows through the agent's standard core `HistoryProvider` (`agent_framework._sessions.HistoryProvider`). The Responses channel is a *caller-supplied session* channel (see [Channel session-carriage models](#channel-session-carriage-models)): it parses `previous_response_id` (and/or `conversation_id`) off the inbound request and projects it into `ChannelSession.key`. The host then resolves an `AgentSession` for that key and the agent's `HistoryProvider` does the load / append exactly as it would for any other session. - -```text -POST /responses { "previous_response_id": "resp_018f…", "input": [...] } - -> ResponsesChannel parses previous_response_id - -> ChannelRequest.session = ChannelSession(key="resp_018f…") - -> host resolves AgentSession(id="resp_018f…") - -> agent.HistoryProvider.load_messages(session=…) # if load_messages=True - -> agent.run(input, session=…) - -> agent.HistoryProvider.save_messages(session=…, new_messages) - -> ResponsesChannel serializes the result with response_id="resp_018f…+1" -``` - -This means **any** AF `HistoryProvider` backs Responses out of the box — `FileHistoryProvider`, an in-memory provider, a future `CosmosHistoryProvider`, etc. The wire `previous_response_id` is just a session id with channel-defined formatting; nothing in the provider has to know "this is a Responses session". - -##### The Responses `store` parameter - -The OpenAI Responses API exposes a `store` boolean on every request. Its meaning in the official SDK is "service-side: persist this response so a later call can reference it via `previous_response_id`." In the hosting world this gets more interesting because there are **three** independent places a turn can end up persisted: - -- **Service-side** — the upstream provider's response store (e.g. OpenAI's hosted response store, accessible by `previous_response_id` against that provider directly). Controlled by the `store` flag on the agent's underlying `ChatClient` at construction time. -- **Hosted-agent storage** — the `HistoryProvider`(s) attached to the agent (`FileHistoryProvider`, `FoundryHostedAgentHistoryProvider`, in-memory, dual-write, …). Controlled by the host's `session_mode` directive, which `run_hook` can rewrite per request. -- **Caller-side** — the API caller keeps the `response_id` returned by the host and chains future calls with `previous_response_id`. Always available; out of host scope. - -These axes are **independent**. The same wire `store` value can land in any combination of them — or none — depending on (a) how the developer assembled the agent (`HistoryProvider` attached or not? `ChatClient` configured with its own `store=True` or not?) and (b) what the channel's `run_hook` does with the value. **The point of the matrix below is that `store` does not have a single canonical meaning at the hosted-agent layer — the developer of the hosted agent decides what it means.** - -| Caller sends | **Service-side** (underlying `ChatClient`'s own `store`) | **Hosted-agent storage** (agent's `HistoryProvider`) | **Caller-side** (caller chains `previous_response_id`) | -|---|---|---|---| -| `store=true` (or omitted; OpenAI default is `true`) | Writes **iff** the `ChatClient` was constructed to honor `store=true` against the upstream service. The host forwards the wire value into the chat client's options but does not look at it itself. | **Default:** loads and writes via the configured `HistoryProvider` (`session_mode="auto"`).
**Developer overrides** (via `run_hook`): `session_mode="disabled"` to suppress (compliance hold, ephemeral one-shots); `session_mode="required"` to fail closed if no session can be resolved instead of auto-issuing. | Always available — the host returns a chained `response_id` the caller may keep and re-send as `previous_response_id`. | -| `store=false` | Typically suppresses the service-side write — but the exact behavior depends on the `ChatClient` (some providers ignore the per-request flag, some honor it, some require a different opt-out). The host does not interpret it on the chat client's behalf. | **Default:** **still loads and writes** via the configured `HistoryProvider` — `store=false` is **not** auto-translated into a session-disable. The `HistoryProvider` is configured on the agent for app-level reasons (audit, replay, multi-channel continuity) the API caller has no business unilaterally overriding.
**Developer overrides** (via `run_hook`): `session_mode="disabled"` to **honor caller intent** (the path most apps that expose `store=false` as a real "stateless" guarantee will take); `session_mode="required"` (Scenario 3) to **ignore caller intent** and force host-managed sessions; conditional rules (e.g. honor `store=false` only from internal callers). | Always available — and the default fallback when both server-side surfaces are suppressed. | - -The same `store=false` request can therefore end up persisted in: - -- **service-side only** (chat client honors the flag → no service-side write; `HistoryProvider` not attached → no hosted-agent write; caller keeps `response_id`), -- **hosted-agent storage only** (chat client honors the flag → no service-side write; `HistoryProvider` attached and `run_hook` does not override → host writes anyway), -- **both** (chat client ignores the flag → service-side write happens; `HistoryProvider` attached and not overridden → hosted-agent write also happens), -- **neither** (chat client honors the flag and `run_hook` translates it into `session_mode="disabled"` → only the caller's local copy exists). - -Two design properties fall out of this: - -1. **`store` is forwarded, not auto-mapped to host policy.** The caller's `store` value is forwarded into the chat client's options (where the upstream provider's own `store` semantics apply), but it is **not** translated into a `session_mode` directive against the agent's `HistoryProvider` by default. Collapsing the two — for example to make `store=false` a real end-to-end "stateless" guarantee — is an explicit developer choice expressed in `run_hook`. -2. **Documenting `store` semantics is a per-deployment responsibility.** Because the resolved persistence depends on three independent developer decisions, the meaning of `store=true` / `store=false` against any given hosted agent is something the deployment **must document for its callers** — there is no framework-level guarantee beyond "the wire value is forwarded to the chat client, and the host's `HistoryProvider` runs by default unless `run_hook` says otherwise." -3. **Richer storage vocabulary via `extra_body`.** A single boolean is often too coarse to express what a deployment actually wants to offer. The OpenAI Responses request envelope supports an `extra_body` mapping (the official Python SDK exposes it on every call as a passthrough into the request JSON); the `ResponsesChannel` parses unknown body keys onto `ChannelRequest.attributes`, so `run_hook` can read deployment-specific knobs from there and translate them into `session_mode`, the chat client's `store` flag, or anything else. Examples a deployment might expose: `extra_body={"af_store": "audit_only"}` to write to the `HistoryProvider` but suppress the service-side mirror; `{"af_store": "ephemeral"}` to skip both server-side surfaces; `{"af_store": "replay_safe"}` to force `session_mode="required"` and reject calls without a resolvable session. The framework does not standardize these names — they are part of the deployment's documented contract with its callers, on top of the standard `store` flag. - -##### `FoundryHostedAgentHistoryProvider` — Foundry-backed history - -For users who want the conversation persisted in the **same Foundry response store** that `azure.ai.agentserver.responses.store._foundry_provider.FoundryStorageProvider` writes to (so e.g. Foundry Workbench can replay the conversation, or other Foundry tools can introspect it), a new provider is added — proposed name `FoundryHostedAgentHistoryProvider` — implementing the standard `HistoryProvider` Protocol and built **on top of** the Foundry response-store SDK that ships in `azure.ai.agentserver` (so the wire contract, auth, and isolation headers stay aligned with the SDK without re-implementation). Shipped in `agent-framework-foundry-hosting`, attached the same way any other history provider is attached to an agent: - -```python -agent = Agent( - client=client, - history_provider=FoundryHostedAgentHistoryProvider( - endpoint=os.environ["FOUNDRY_ENDPOINT"], - load_messages=True, - ), -) - -host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()]) -``` - -The provider implements the standard `HistoryProvider` interface — there is no Responses-specific Protocol in between. It is also valid for any other channel (Telegram, Invocations, …) — Foundry storage simply becomes the chosen backend. - -Foundry's storage backend keys writes off two platform-injected request headers (`x-agent-user-isolation-key`, `x-agent-chat-isolation-key`) rather than the request body. The Responses and Invocations channels parse both headers off the inbound request and forward them as an opaque mapping on `ChannelRequest.attributes["isolation"]` (`{"user_key", "chat_key"}`); the host's per-request `bind_request_context` then passes that value to `FoundryHostedAgentHistoryProvider.bind_request_context(isolation=...)`, which the provider applies to its storage calls. Channels never import `IsolationContext`; the provider accepts both an `IsolationContext` instance and a plain mapping. When the headers are absent (local dev outside the Hosted Agents runtime) the attribute is omitted and storage falls back to non-isolated reads/writes, so the same code path works in both environments. - -##### Multi-provider composition - -The existing AF convention applies: an agent may compose **multiple** `HistoryProvider`s, but **only one** carries `load_messages=True`. Common patterns: - -- *Single store.* `FileHistoryProvider(load_messages=True)` — local dev. Or `FoundryHostedAgentHistoryProvider(load_messages=True)` — Foundry-backed prod. -- *Audit dual-write.* `FoundryHostedAgentHistoryProvider(load_messages=True)` + `CosmosHistoryProvider(load_messages=False)` — Foundry is the source of truth used to reconstruct context for the LLM; Cosmos receives a write-only audit copy. -- *Mirror to Foundry for Workbench replay only.* Conversely, an in-house store can hold `load_messages=True` while `FoundryHostedAgentHistoryProvider(load_messages=False)` mirrors writes into Foundry purely so the conversation shows up in Foundry tooling. - -The choice of where to store, and whether to dual-write, is fully the developer's. The channel does not need to know which backing store(s) the agent is using. - -#### Channel-owned per-thread state - -Some channel protocols carry **non-message** durable state attached to the conversation — most notably AG-UI's per-thread `state` object, mutated mid-stream via `StateSnapshotEvent` / `StateDeltaEvent` (JSON-Patch-shaped) and read by the front-end on the next turn. This is *not* message history, so it does not belong on `HistoryProvider`; but it has the same lifetime, isolation, and "opaque to the host" properties as messages, so the framework already has the right primitive: **`ContextProvider`**. - -`HistoryProvider` is only one concrete `ContextProvider` (the one that uses the per-source `state: dict[str, Any]` slot to hold messages). Channels with non-message per-thread state SHOULD ship their own `ContextProvider` subclass and write into the same per-source `state` slot. - -Sketch (for AG-UI; the same pattern applies to any event-rich front-end): - -```python -from agent_framework import ContextProvider, ContextProviderState - -class AgUiStateProvider(ContextProvider): - """Per-thread non-message state for AG-UI front-ends. - - Persists the AG-UI ``state`` object scoped by ``source_id`` (the - AgentSession id). Reads from ``ChannelRequest.client_state`` before - the run, exposes the current value to the agent via Context, and lets - the channel diff it after the run to emit StateSnapshotEvent / - StateDeltaEvent on the wire. - """ - - state_key = "ag_ui_state" # slot in the per-source state dict - - async def before_run(self, context, *, source_id, **kw): - slot = context.state.setdefault(source_id, {}) - # If the request supplied a fresh client_state, seed/replace it. - if (incoming := context.request.client_state) is not None: - slot[self.state_key] = dict(incoming) - # Expose the live value to the agent (e.g. into context.metadata). - - async def after_run(self, context, *, source_id, **kw): - # The current value lives in context.state[source_id][self.state_key]; - # the channel reads it and emits StateSnapshotEvent / StateDeltaEvent. - ... -``` - -Composition rules are unchanged: one `HistoryProvider` carries `load_messages=True`, additional `ContextProvider`s (including `AgUiStateProvider`) attach alongside. Backing storage is whatever the user wires — in-memory for dev, the same physical store as messages for prod. **No new storage protocol is introduced for channel state**; it shares the same per-source state slot that `HistoryProvider` uses. - -#### Storage taxonomy - -To make the picture explicit: there are exactly three distinct *storage seams* in the hosting design, each with a clear scope. The first two are usually backed by the same physical store the user wires; they stay distinct as protocols because the data shapes differ. - -| Seam | Scope | Examples | -|---|---|---| -| **`ContextProvider`** (per-conversation) | Per-`source_id` data the agent needs at run time. Messages (via `HistoryProvider`), AG-UI per-thread state (via `AgUiStateProvider`), or any future per-conversation extension. **The only public per-conversation seam.** | `FileHistoryProvider`, `FoundryHostedAgentHistoryProvider`, `AgUiStateProvider` | -| **Host-level pluggable store** (per-host) | `ContinuationToken`s for background runs, identity-link grants, last-seen `(isolation_key, channel)` records. **File-based by default** in v1 (`FileHostStateStore`, atomic JSON writes under `./.af-hosting/`); `InMemoryHostStateStore` for tests; pluggable for Cosmos / SQL / Redis adapters in v1 fast follow (req #24). MAY be backed by the same physical store as `ContextProvider`, but the protocol is distinct because the data is host-execution metadata, not per-conversation context. | `FileHostStateStore` (v1 default), `InMemoryHostStateStore`, future Cosmos / SQL / Redis adapters | -| **`CheckpointStorage`** (workflow runtime) | Workflow executor frames so a workflow can resume after process restart. Structurally distinct from both seams above (the data is workflow-runtime state, not session/identity state). MAY share a physical backend, but the protocol stays separate. | `FileCheckpointStorage`, future `CosmosCheckpointStorage` | - -Concretely, this means an app deploying onto e.g. Foundry storage can run **all three** against the same Foundry backend and still have three orthogonal protocol surfaces — one per concern — instead of one universal store everything accidentally collides in. - -Channels surface per-request transport state (response ids, isolation keys, future signals) on `ChannelRequest.attributes`; the host's `bind_request_context` forwards those attributes as kwargs to each `ContextProvider.bind_request_context` call so providers can apply them to their reads and writes. Providers SHOULD accept `**_` to ignore unknown attributes for forward-compat. This keeps channel↔provider coupling to a documented attribute name (e.g. `"isolation"`) instead of requiring providers to install ASGI middleware. - -The `ResponsesChannel` exposes both an HTTP transport (`{path}/v1/...`) and an optional **WebSocket transport** (`{path}{websocket_path}`, default `/responses/ws`) controlled by `transports`. The WS transport carries the same Responses request/event model as the HTTP+SSE variant — clients open a single connection per conversation and send/receive Responses frames as JSON messages. Both transports go through the same `run_hook`, the same default mapping, and the same `ChannelRequest` shape; the channel codec is responsible for framing only. Auth is reused from the HTTP transport (Authorization header on the `Upgrade` request); subprotocol negotiation is open (see Open Questions). - -### Default invocation behavior by channel - -Each built-in channel owns a **default** mapping from its protocol request model into a `ChannelRequest`. That mapping flows through the optional `run_hook` before the host resolves session behavior and invokes the target. - -| Channel | Default mapping | -|---|---| -| `ResponsesChannel` | Forwards relevant caller settings (e.g. `temperature`, `store`) into `ChannelRequest.options` so the underlying chat client receives them; **does not** map `store=false` to `session_mode="disabled"` by default — see [The Responses store parameter](#the-responses-store-parameter) for the full matrix and the developer-override path. The same default mapping is used for both HTTP and WebSocket transports — WS frames are decoded into the same Responses request model before invocation. | -| `InvocationsChannel` | Maps the request body into `input`, `options`, and session behavior for the hosted target. | -| `TelegramChannel` | Maps incoming messages or commands into `input`, `stream`, and session defaults appropriate for the chat. | - -### ASGI server portability - -The hosting architecture is coupled to **ASGI/Starlette**, not to **Uvicorn** specifically. - -- `host.app` is the canonical portability surface. -- `host.serve(...)` is only the default convenience path (lazy-imports `uvicorn`). -- Because `host.app` is a standard Starlette/ASGI app, it can run on Hypercorn, Daphne, Granian, or Gunicorn-with-Uvicorn-workers. -- ASGI **WebSocket** scope/frames are first-class: any channel may contribute `WebSocketRoute`s alongside HTTP routes, and the chosen ASGI server must support the WebSocket scope (Uvicorn, Hypercorn, Daphne, and Granian all do). - -The packaging question for `uvicorn` (required dependency vs optional extra) is therefore a **convenience choice**, not an architectural constraint. See Open Questions. - -### Error Responses - -| Status | Condition | Notes | -|---|---|---| -| `400 Bad Request` | Channel-specific protocol validation failure | Owned by the channel codec. | -| `401 Unauthorized` / `403 Forbidden` | Channel-specific auth/signature validation failure | Owned by channel middleware (e.g. Telegram secret token, Invocations auth). | -| `404 Not Found` | Route not contributed by any channel | Standard Starlette behavior. | -| `409 Conflict` | Session-resolution conflict with `session_mode="required"` and no resolvable session | Host-level. | -| `422 Unprocessable Entity` | `run_hook` raised a validation error | Channel surfaces the hook's error per protocol conventions. | - -## Terminology - -- **Host** (`AgentFrameworkHost`): The Python object that owns one Starlette app, one **hostable target** (an agent or a workflow), and a sequence of channels. Provides `host.app` (canonical ASGI surface) and `host.serve(...)` (uvicorn convenience). Named `AgentFrameworkHost` rather than `AgentHost` because the target is not restricted to agents. -- **Hostable target**: The executable object the host fronts — either a `SupportsAgentRun`-compatible agent or a `Workflow`. The host detects the kind and dispatches to the appropriate execution seam; channels remain unchanged. -- **Channel**: A pluggable component that contributes routes, middleware, commands, and lifecycle hooks to a host. One channel = one external protocol surface (Responses, Invocations, Telegram, …). 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 calls the target's execution seam. Carries `input`, `options`, `session`, `session_mode`, and channel-specific `attributes`. -- **`ChannelSession`**: A small session hint with a stable `key`, an optional protocol-visible `conversation_id`, and an opaque `isolation_key`. The host resolves it into an `AgentSession`; 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** (`ChannelIdentity`): 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** (`ChannelRequest.conversation_id` / `ChannelSession.conversation_id`) — in multi-user surfaces (Telegram groups, Teams group chats and channels) the two never coincide. See [Multi-user conversations](#multi-user-conversations-telegram-groups-teams-group-chats-and-channels). -- **`IdentityResolver`**: Host-level callable that maps a `ChannelIdentity` to an `isolation_key`. The default resolver **auto-issues** a fresh, stable `isolation_key` the first time a `(channel, native_id)` pair is seen and persists it in the host's identity store, so every end user automatically gets a per-user partition on first contact through any channel — without app code. Linking (see `IdentityLinker`) **merges** the second channel's auto-issued key onto the first channel's `isolation_key`, so cross-channel continuity is a one-shot operation, not a per-channel mapping hook. Apps that already own an identity namespace (corporate user id, tenant-scoped account id) can supply a custom resolver that returns those values directly. -- **`IdentityLinker`**: Host-level component that runs a connect ceremony — typically OAuth, MFA, or a signed one-time code — to associate a new `ChannelIdentity` with an existing `isolation_key`. Contributes its own routes (e.g. OAuth callback) and lifecycle to the host. A built-in `link`/`connect` `ChannelCommand` is exposed automatically when one is configured. On successful ceremony completion, also stores any verified IdP claim recovered from the proof (e.g. Entra ID `oid`) so subsequent channels that supply the same claim can be auto-merged onto the same `isolation_key` silently. Combined with `Channel(require_link=True)`, this enables an "authenticate before chatting" enforcement model where the first channel forces the OAuth ceremony and every other channel using the same IdP joins the same session without a second `/link`. -- **`LinkChallenge`**: The protocol-neutral artifact returned by `IdentityLinker.begin(...)` describing what the user must do to complete the ceremony — typically one of: a URL to visit (OAuth), a short code to enter on the other channel (one-time code), or an MFA prompt. -- **`ResponseTarget`**: Per-request directive on `ChannelRequest` controlling **where** the response is delivered: `originating` (default), `active`, a specific channel, a list of channels, `all_linked`, or `none`. Independent of `session_mode`. -- **`ChannelPush`**: Optional channel capability for proactive outbound delivery — Telegram proactive message, Activity Protocol proactive message via Azure Bot Service, webhook callback, SSE broadcast. Required to be the destination of a non-`originating` `ResponseTarget`. -- **Active channel**: The channel most recently observed for a given `isolation_key`. Tracked by the host on every successfully resolved request; consumed by `ResponseTarget.active`. -- **`ContinuationToken`**: First-class artifact for background/asynchronous runs, returned immediately from `host.run_in_background(request)`. Carries an opaque, URL-safe `token` plus `status`, `isolation_key`, `result`/`error`, and the configured `response_target`. Persisted via `HostStateStore` (file-based by default in v1) so background runs survive host restarts. Host pushes the result to the response target when ready and serves it via channel poll routes. -- **Background run**: A `ChannelRequest` submitted via `host.run_in_background(request)` (or any request with `background=True`). The originating call returns a `ContinuationToken` immediately; the response is delivered later via the configured `ResponseTarget` and/or polled by token. -- **`HostStateStore`**: Single persistence seam for host-execution metadata — continuation tokens, identity-link grants, last-seen records. V1 default `FileHostStateStore` (atomic JSON writes under `./.af-hosting/`); `InMemoryHostStateStore` for tests; pluggable for Cosmos / SQL / Redis (fast follow, req #24). Distinct from `ContextProvider` (per-conversation) and `CheckpointStorage` (workflow), but a deployment MAY back all three with the same physical store. -- **`session_mode`**: Per-request directive (`auto` | `required` | `disabled`) that controls whether the host resolves a session before invoking the target. Lets `run_hook`s express explicit policy — e.g. translating Responses `store=false` into `session_mode="disabled"` to honor the caller's "don't store" intent at the `HistoryProvider` layer (the channel does not do this automatically — see [The Responses store parameter](#the-responses-store-parameter)). -- **`confidentiality_tier`** (channel-level): Opaque label (`"corp"`, `"public"`, `"internal"`, …) declared on a `Channel` and 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. See [LinkPolicy and confidentiality_tier](#linkpolicy-and-confidentiality_tier) for the full contract and built-ins table. -- **`ChannelContribution`**: What a channel returns from `contribute(...)` — routes, middleware, commands, and `on_startup`/`on_shutdown` hooks. The host aggregates contributions into one Starlette app. -- **`ChannelCommand`**: A transport-neutral command descriptor (`name`, `description`, `handle`). 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, adapting to targets like `A2AAgent`, **and** reshaping a channel's free-form input into the typed inputs a `Workflow` target expects. -- **Native command registration**: The startup-time projection of `ChannelCommand` metadata into a platform's native command catalog (e.g. Telegram `set_my_commands(...)`). -- **`SupportsAgentRun`**: The existing framework agent execution seam (`run(..., session=..., stream=...)`) — the contract the host uses when the hostable target is an agent. -- **`Workflow`**: The framework workflow execution seam — the contract the host uses when the hostable target is a workflow. The host wraps the workflow's outputs into the same `HostedRunResult` / `HostedStreamResult` shape so channels do not need to distinguish. - -## Runtime modes - -The host runs in one of two operational shapes, declared (or auto-detected) via a single `runtime_mode` parameter. The parameter is **advisory** — it sets defaults for the seams below; the developer can override any individual choice. - -```python -AgentFrameworkHost( - target=my_agent, - channels=[...], - runtime_mode=None, # None → auto-detect; "long_running" | "ephemeral" to force -) -``` - -| Value | Shape | When to use | -|---|---|---| -| `"long_running"` | Always-on container / process. Owns its own scheduler. Survives across many requests. | Local dev, OpenClaw-style hosted deployments, classic web-app rollouts on AKS / App Service / Container Apps. | -| `"ephemeral"` | Scale-to-zero / per-request lifecycle. Process may terminate between requests; cold-start cost on each one. | Foundry Hosted Agent, Azure Functions consumption plan, AWS Lambda, and similar serverless runtimes. | -| `None` (default) | Auto-detect. The host inspects environment markers at construction; falls back to `"long_running"` when nothing is detected. | The default. Recommended for portable code that works locally and ships to a serverless target. | - -**Auto-detection.** When `runtime_mode=None`, the host checks for known deployment markers in this order and picks `"ephemeral"` on the first hit: - -| Marker | Meaning | -|---|---| -| `FOUNDRY_HOSTING_ENVIRONMENT` (env var) | Running inside Foundry Hosted Agent. | -| `AZURE_FUNCTIONS_ENVIRONMENT` (env var) | Running inside the Azure Functions worker. | -| `AWS_LAMBDA_FUNCTION_NAME` (env var) | Running inside an AWS Lambda. | - -If none of the markers match, the host defaults to `"long_running"` (a sensible local-dev / container default). Additional markers may be added without bumping the API; the list is documented and overridable via the `runtime_mode` parameter itself. - -**Defaults selected by mode.** The mode drives the *default selection* for these seams. Each is independently overridable: - -| Concern | `"long_running"` default | `"ephemeral"` default | -|---|---|---| -| `HostStateStore` | `InMemoryHostStateStore` (process owns state) | `FileHostStateStore` (atomic JSON under `./.af-hosting/`; survives single-node restart) | -| `ContinuationToken` persistence | In-memory acceptable | Persistence required (file / Cosmos / Foundry) | -| `DurableTaskRunner` | `InProcessTaskRunner` (asyncio + bounded retry) | Adapter expected (`agent-framework-hosting-durabletask`, Foundry, …); falls back to `InProcessTaskRunner` with a startup warning when none configured | -| Background runs (req #14) | Owned by the long-running worker via `InProcessTaskRunner` | Hand off to the durable runner so the process can terminate between requests | -| Channel polling (e.g. Telegram `getUpdates`) | Natural fit — `on_startup` spawns the poller, `on_shutdown` cancels it | Requires an external scheduled trigger or webhook transport; polling channels emit a startup warning when paired with `"ephemeral"` | -| `IdentityLinker` short-lived grants | In-memory TTL fine | Must persist via `HostStateStore` | -| `IdentityAllowlist` lookup | In-memory cache fine | Persisted source or external IdP claim resolution | -| Health checks + readiness probes | First-class | Less relevant — runtime manages liveness | -| Per-channel polling-worker isolation | Important — leaks compound over days/weeks (see [`channels_vs_openclaw.md`](../../python/.user/channels_vs_openclaw.md)) | N/A — process recycles between requests | -| Process-recycle expectations | Days/weeks | Per-request | -| Memory/leak concerns | Important | Less relevant | - -**Detection failures.** Auto-detection is best-effort. If a deployment uses a custom runtime not in the marker list, callers SHOULD set `runtime_mode="ephemeral"` (or `"long_running"`) explicitly. The host logs the detected mode at startup so misdetection is visible in normal operation. - -**Why advisory and not enforced.** Most knobs make sense in both modes (e.g. a developer running a "long-running" container may still want `FileHostStateStore` for state durability across deploys); enforcing strict defaults per mode would force every override to fight a config error. The selected defaults are a starting point. - -## Hero Code Samples - -> **Common prerequisite:** Every sample below calls `host.serve(...)`, which lazy-imports `uvicorn`. Install `uvicorn` (e.g. `pip install uvicorn`) — or the corresponding `agent-framework-hosting[serve]` extra if the package ships one (see Open Question #2) — alongside the per-sample dependencies listed in each scenario's **Prerequisites** block. Samples that use `host.app` directly (handed to Hypercorn/Daphne/Granian/Gunicorn+uvicorn workers) do not require `uvicorn`. - -### Scenario 1: Expose one agent on the Responses API - -A developer has an agent and wants to expose it as the OpenAI-compatible Responses API on `localhost:8000` with no manual server bootstrap. - -> **Prerequisites:** This sample assumes: -> - `agent-framework-hosting` and `agent-framework-hosting-responses` are installed -> - An `OPENAI_API_KEY` is available in the environment - -```python -from agent_framework import Agent -from agent_framework.openai import OpenAIChatClient -from agent_framework.hosting import AgentFrameworkHost, ResponsesChannel - -agent = Agent( - name="WeatherAgent", - instructions="You are a helpful weather agent.", - client=OpenAIChatClient(model="gpt-4.1-mini"), -) - host = AgentFrameworkHost( target=agent, channels=[ResponsesChannel()], ) -if __name__ == "__main__": - host.serve(host="localhost", port=8000) +app = host.app ``` -This exposes the Responses route under `/responses`. No manual `uvicorn` import, no protocol handlers written by the user. - -### Scenario 2: Expose Responses + Invocations on one host with shared Starlette middleware - -Same agent, both protocols, with CORS applied at the host level. - -> **Prerequisites:** This sample assumes: -> - `agent-framework-hosting`, `-responses`, and `-invocations` are installed -> - A Foundry project with a `gpt-4.1` model deployment +### One agent on multiple channels ```python -from azure.identity import AzureCliCredential -from starlette.middleware import Middleware -from starlette.middleware.cors import CORSMiddleware - -from agent_framework import Agent -from agent_framework.foundry import FoundryChatClient -from agent_framework.hosting import AgentFrameworkHost, InvocationsChannel, ResponsesChannel - -agent = Agent( - name="TravelAgent", - instructions="Help users plan travel and keep answers concise.", - client=FoundryChatClient( - project_endpoint="https://my-project.services.ai.azure.com/api/projects/travel", - model="gpt-4.1", - credential=AzureCliCredential(), - ), -) - host = AgentFrameworkHost( target=agent, channels=[ - ResponsesChannel(), # -> /responses - InvocationsChannel(), # -> /invocations - ], - middleware=[ - Middleware( - CORSMiddleware, - allow_origins=["https://chat.contoso.com"], - allow_methods=["*"], - allow_headers=["*"], - ), + ResponsesChannel(), + InvocationsChannel(), + TelegramChannel(bot_token=os.environ["TELEGRAM_BOT_TOKEN"]), ], ) -# Hand the canonical ASGI app to any server, or use the convenience method. -app = host.app # for Hypercorn / Granian / Gunicorn+uvicorn workers host.serve(host="localhost", port=8000) ``` -### Scenario 3: Per-request run hook on the Responses channel +The host owns one Starlette app. Each channel contributes its own routes and renders its own response. -The developer wants to enforce that every Responses call sets `temperature`, and to **harden** session handling so that `session_mode="required"` (fail if no session can be resolved) — explicitly ignoring caller `store=false` since the channel's default already keeps the agent's `HistoryProvider` active regardless of that wire flag (see [The Responses store parameter](#the-responses-store-parameter)). None of this is part of the official Responses spec, but all of it is valid app policy. - -> **Prerequisites:** This sample assumes: -> - The Responses channel is wired into an `AgentFrameworkHost` (see Scenario 1) +### Adapting a request before execution ```python from dataclasses import replace -from agent_framework.hosting import ( - AgentFrameworkHost, - ChannelRequest, - ResponsesChannel, -) - -def responses_policy(request: ChannelRequest, **kwargs) -> ChannelRequest: - if request.options is None or request.options.temperature is None: - raise ValueError("This host requires temperature on every Responses call.") - - # Harden session handling: even when the caller sends store=false, keep host-managed - # sessions and fail closed instead of auto-issuing. The HistoryProvider would already - # run under the default "auto" mode; "required" upgrades that to a hard error if no - # session can be resolved (e.g. missing previous_response_id and no resolver match). - return replace(request, session_mode="required") +def enforce_options(request: ChannelRequest) -> ChannelRequest: + options = dict(request.options or {}) + options["temperature"] = 0 + return replace(request, options=options) host = AgentFrameworkHost( target=agent, - channels=[ResponsesChannel(run_hook=responses_policy)], + channels=[ResponsesChannel(run_hook=enforce_options)], ) -host.serve(host="localhost", port=8000) ``` -The hook runs **after** the channel produces its default `ChannelRequest` and **before** the host resolves session behavior and calls `SupportsAgentRun.run(...)`. The same shape works to adapt to targets like `A2AAgent` — strip or remap channel-derived options that the target does not consume. - -### Scenario 4: Telegram channel with native command catalog (polling) - -A developer wants to expose the same agent as a Telegram bot, with first-class native commands (`/start`, `/new`, `/sessions`, …) registered into Telegram's command menu at startup. Modeled after PR #5393. - -> **Prerequisites:** This sample assumes: -> - `agent-framework-hosting-telegram` is installed -> - `TELEGRAM_BOT_TOKEN` is set in the environment +### Workflow with explicit checkpoints ```python -import os - -from agent_framework.hosting import ( - AgentFrameworkHost, - ChannelCommand, - ChannelCommandContext, - TelegramChannel, -) - - -async def handle_start(context: ChannelCommandContext) -> None: - await context.reply( - "Hi! Commands: /new, /sessions, /todo, /memories, /reminders, " - "/resume, /cancel, /reasoning, /tokens." - ) - - -async def handle_noop(context: ChannelCommandContext) -> None: - await context.reply("Command received.") - - -TELEGRAM_COMMANDS = [ - ChannelCommand("start", "Introduce the bot", handle_start), - ChannelCommand("new", "Start a new local session", handle_noop), - ChannelCommand("sessions", "List local sessions", handle_noop), - ChannelCommand("todo", "List todos for the active session", handle_noop), - ChannelCommand("memories", "List memory topics for the active session", handle_noop), - ChannelCommand("reminders", "List reminders for the active session", handle_noop), - ChannelCommand("resume", "Resume the latest pending or previous session", handle_noop), - ChannelCommand("cancel", "Cancel the active response", handle_noop), - ChannelCommand("reasoning", "Toggle the transient reasoning preview", handle_noop), - ChannelCommand("tokens", "Toggle token usage details", handle_noop), -] - -telegram = TelegramChannel( - bot_token=os.environ["TELEGRAM_BOT_TOKEN"], - transport="polling", - commands=TELEGRAM_COMMANDS, - register_native_commands=True, -) - -host = AgentFrameworkHost(target=agent, channels=[telegram]) -host.serve(host="localhost", port=8000) -``` - -This mirrors the important shape from PR #5393: command metadata is declared once, the channel registers it into Telegram's native menu at startup (`set_my_commands(...)`), and runtime command dispatch stays channel-local. - -### Scenario 5: Telegram webhook mode on the same host as Responses + Invocations - -Same agent, three channels, one Starlette app, one process. - -> **Prerequisites:** Same as Scenario 4, plus a public HTTPS URL for the webhook. - -```python -host = AgentFrameworkHost( - target=agent, - channels=[ - ResponsesChannel(), # -> /responses - InvocationsChannel(), # -> /invocations - TelegramChannel( - bot_token=os.environ["TELEGRAM_BOT_TOKEN"], - transport="webhook", # -> /telegram/webhook - commands=TELEGRAM_COMMANDS, - ), - ], -) - -host.serve(host="0.0.0.0", port=8000) -``` - -Webhook transport contributes `/telegram/webhook` by default; the command catalog remains identical to the polling sample. - -### Scenario 6: Linking a new channel to an existing identity via OAuth - -A developer wants every Telegram chat to be **authenticated up front** via OAuth (Microsoft Entra ID) before the agent will respond, and wants Teams chats from the same Entra ID user to be **auto-linked** to the existing session — no second `/link` ceremony, just sign in once on the first channel and the rest follow automatically. This delivers cross-channel chat continuity as a side-effect of identity linking; Scenario 7 covers the alternative pattern where a trusted server-side relay supplies identity directly without a link ceremony. - -```mermaid -sequenceDiagram - autonumber - actor User - participant Tg as TelegramChannel - participant Host - participant Linker as IdentityLinker
(EntraOAuth) - participant IdP as OAuth provider - participant Store as HostStateStore
(identity_links) - participant Act as ActivityChannel - - User->>Tg: /link - Tg->>Host: ChannelRequest(identity=tg:12345) - Host->>Linker: begin(identity=tg:12345) - Linker-->>Host: LinkChallenge(url, state) - Host->>Tg: response_hook → push challenge URL - Tg-->>User: "click here to sign in" - - User->>IdP: sign in (browser) - IdP-->>User: redirect with code - User->>Linker: /callback?code=…&state=… - Linker->>IdP: exchange code → tokens + claims - IdP-->>Linker: claims (oid, email, …) - Linker->>Store: persist link(tg:12345 ↔ linked_claims) - Linker-->>User: "linked ✅" - - Note over User: later, on Teams (same Entra OID) - - User->>Act: hello - Act->>Host: ChannelRequest(identity=teams-aad-oid) - Host->>Store: lookup linked_claims by oid - Store-->>Host: existing isolation_key (matches tg:12345) - Host->>Host: same session as Telegram -``` - -> **Prerequisites:** This sample assumes: -> - `agent-framework-hosting`, `agent-framework-hosting-telegram`, and the (future) `agent-framework-hosting-activity` channel are installed -> - An OAuth provider is configured (Microsoft Entra ID in this example) - -```python -import os - -from agent_framework.hosting import ( - AgentFrameworkHost, - OAuthIdentityLinker, - TelegramChannel, -) - - -# The OAuth linker contributes its own /identity/oauth/microsoft/{start,callback} -# routes to the host. On successful completion, the host's built-in identity -# store atomically records BOTH the originating channel-native identity AND the -# verified IdP claim (Entra ID object id) so future channels that authenticate -# the same IdP account can auto-link without a second ceremony. -linker = OAuthIdentityLinker( - provider="microsoft", - client_id=os.environ["AAD_CLIENT_ID"], - client_secret=os.environ["AAD_CLIENT_SECRET"], -) - -host = AgentFrameworkHost( - target=agent, - identity_linker=linker, - channels=[ - # require_link=True gates the channel: any inbound message from an - # un-linked ChannelIdentity is short-circuited to a LinkChallenge reply - # instead of being dispatched to the agent. - TelegramChannel( - bot_token=os.environ["TELEGRAM_BOT_TOKEN"], - transport="webhook", - require_link=True, - ), - # ActivityChannel(app_id=..., require_link=True), # future — same flag - ], -) -host.serve(host="0.0.0.0", port=8000) -``` - -The flow: - -1. `alice` sends her first message on Telegram. The `TelegramChannel` extracts `ChannelIdentity(channel="telegram", native_id="")` and asks the linker `is_linked(...)`. It is not. Because `require_link=True`, the channel does **not** invoke the agent; instead it asks `linker.begin(channel_identity)` for a `LinkChallenge`, renders the challenge URL into Telegram (clickable button), and returns. -2. `alice` clicks the button, signs in with Microsoft Entra ID, and the OAuth callback hits the linker's route. `linker.complete(...)` verifies the authorization code and records **two things atomically** in the identity store: - - `(channel="telegram", native_id="") → isolation_key="hk_018f…a3"` - - `verified_claim("microsoft.oid", "") → isolation_key="hk_018f…a3"` -3. `alice` replies on Telegram. The channel sees the link is now present, resolves the existing `isolation_key`, and forwards the message to the agent normally. From here on, Telegram chats are routed without further ceremony. -4. The next day, `alice` opens Teams. The `ActivityChannel` extracts both the channel-native identity (`activity`, ``) **and** the verified IdP claim from the inbound activity (Teams already authenticates with Entra ID via Bot Service, so the AAD object id is trusted). It asks the linker `is_linked(...)`. The `(activity, )` pair is **not** in the store — but the verified claim `("microsoft.oid", "")` **is**. The linker auto-merges `(activity, ) → isolation_key="hk_018f…a3"` without any user-visible `/link` ceremony. -5. From the next turn on, both Telegram and Teams resolve to the **same** `isolation_key` and the **same** `AgentSession`. The agent sees the conversation history from both channels as one continuous thread. - -The two enabling pieces: - -- **`require_link: bool` on the channel** — when `True`, the channel checks the linker before dispatching every inbound request. Un-linked identities are short-circuited to a rendered `LinkChallenge` instead of an agent invocation. Default is `False` (the opportunistic flow below). -- **Verified IdP claims in the linker's identity store** — when an OAuth ceremony completes, the linker records the verified identity claim (e.g. `(microsoft.oid, )`) alongside the channel-native identity. Channels that can supply the same kind of verified claim from their own auth context (Teams via the AAD bearer on the activity, future M365 channels via the same bearer, …) get **auto-linked silently** on first contact when their claim matches an existing entry. This is what makes "sign in once on Telegram, Teams just works" possible without any per-channel link ceremony. - -**Variant — opportunistic linking (`require_link=False`).** Leave the flag at its default and the channel will dispatch un-linked identities straight to the agent (the host's default resolver auto-issues a fresh `isolation_key` for them). The user can later run the `link` `ChannelCommand` manually to merge that auto-issued key onto an existing one. This is the lower-friction onboarding flow at the cost of allowing pre-link conversations to exist in their own isolated session until merged. - -**Variant — alternative ceremony.** Swapping the linker for `OneTimeCodeIdentityLinker(...)` changes the ceremony to "complete `/link` on channel A, get a 6-digit code, run `/link 482931` on channel B"; with `require_link=True` the channel just renders the code-entry instructions instead of an OAuth URL. Apps with their own corporate identity namespace can additionally pass a custom `identity_resolver` so the post-link `isolation_key` is the corporate user id instead of the host-issued opaque key. Channels themselves are unchanged across these variants — only the linker and (optionally) the resolver change. - -### Scenario 7: Trusted server-side caller relays a Responses request and pushes the answer back to the user's Telegram chat - -A developer runs an internal application server that already knows its end users (e.g. via an SSO session) and wants to expose **two surfaces against the same agent**: the OpenAI-compatible **Responses API** (so the application backend can drive the agent programmatically on behalf of the signed-in user) and **Telegram** (so the same end user can also chat with the agent directly). When the application backend submits a Responses call, it should be possible to (a) link that call to the same `isolation_key` as the user's existing Telegram chats — so the agent sees one continuous conversation history — and optionally (b) have the agent's response pushed back to the user's Telegram chat instead of (or in addition to) being returned synchronously on the Responses HTTP call. - -This works **without** an `IdentityLinker` because the application backend is a **trusted relay**: it already authenticated the user through its own SSO and knows both the user's app-internal id and (because the user has previously connected their Telegram account in the application's own settings page) the user's Telegram `chat_id`. The host just needs to be told. - -```mermaid -sequenceDiagram - autonumber - actor Backend as Server-side backend - participant Resp as ResponsesChannel - participant Host - participant Hook as run_hook
(responses_relay_hook) - participant Store as HostStateStore
(continuations) - participant Target as Agent - participant Runner as DurableTaskRunner - participant Tg as TelegramChannel - - Backend->>Resp: POST /v1/responses
extra_body.hosting.push_to_telegram_chat_id= - Resp->>Host: ChannelRequest(...) - Host->>Hook: run_hook(request, context) - Hook->>Hook: rewrite to
background=True,
response_target=identities([tg:]) - Host->>Store: write continuation(token, status=in_progress) - Host-->>Resp: ContinuationToken (token) - Resp-->>Backend: 200 with continuation token - - Note over Host,Target: background task - - Host->>Target: run (async) - Target-->>Host: AgentResponse - Host->>Store: continuation.complete(token, result) - Host->>Runner: schedule("hosting.push",
payload for tg:) - Runner->>Host: _handle_push_task(payload) - Host->>Tg: response_hook → push - Tg-->>User: answer arrives in Telegram chat -``` - -> **Prerequisites:** This sample assumes: -> - `agent-framework-hosting`, `agent-framework-hosting-responses`, and `agent-framework-hosting-telegram` are installed -> - The application backend can attach two extra fields to its Responses call: an `app_user_id` (the user's stable id in the application's own namespace) and, optionally, a `push_to_telegram_chat_id` (the user's known Telegram chat id from the application's own database) - -```python -import os -from dataclasses import replace - -from agent_framework.hosting import ( - AgentFrameworkHost, - ChannelIdentity, - ChannelRequest, - IdentityResolver, - ResponseTarget, - ResponsesChannel, - TelegramChannel, -) - - -# A custom identity resolver that promotes the app's own user id to the -# isolation_key whenever a channel can supply one. The Telegram channel exposes -# the chat_id (pre-registered in the application's settings page → so the -# application maps chat_id → app_user_id and tells the host); the Responses -# channel exposes the app_user_id directly via extra_body (see run_hook below). -async def app_identity_resolver(identity: ChannelIdentity, **_) -> str | None: - # Both channels populate ChannelIdentity.attributes["app_user_id"] — see - # the run hooks below. - return identity.attributes.get("app_user_id") - - -# Telegram channel maps Telegram chat_id → app_user_id from the application's -# pre-registered chat-id table. Cached locally; in real apps this is whatever -# lookup matches the application's own user-account schema. -KNOWN_TELEGRAM_USERS: dict[str, str] = { - "": "user_alice", - # ... -} - - -async def telegram_promote_app_user(request: ChannelRequest, **_) -> ChannelRequest: - chat_id = request.identity.native_id - app_user_id = KNOWN_TELEGRAM_USERS.get(chat_id) - if app_user_id is None: - return request # falls back to host's auto-issued isolation_key - return replace( - request, - identity=replace( - request.identity, - attributes={**request.identity.attributes, "app_user_id": app_user_id}, - ), - ) - - -# The application backend POSTs to /responses with -# -# { -# "model": "...", -# "input": "...", -# "extra_body": { -# "hosting": { -# "app_user_id": "user_alice", # who this request is for -# "push_to_telegram_chat_id": "", # optional -# } -# } -# } -# -# The Responses channel surfaces extra_body["hosting"] on -# ChannelRequest.attributes["hosting"]; this run_hook reads it and rewrites -# both the identity (so the request resolves to the same isolation_key as the -# user's Telegram chats) and the response_target (so the answer is pushed to -# Telegram in addition to / instead of the synchronous Responses reply). -async def responses_relay_hook(request: ChannelRequest, **_) -> ChannelRequest: - hosting = request.attributes.get("hosting", {}) - app_user_id = hosting.get("app_user_id") - push_chat_id = hosting.get("push_to_telegram_chat_id") - - if app_user_id is None: - return request # plain Responses call, no relay → keep defaults - - # Promote app_user_id onto the identity so the resolver returns it as - # isolation_key. - new_identity = replace( - request.identity, - attributes={**request.identity.attributes, "app_user_id": app_user_id}, - ) - - # If the caller also supplied a Telegram chat id, push the answer there - # via ResponseTarget.identities (explicit recipient — bypasses the link - # store, which is empty for this user since no link ceremony ran). The - # Responses HTTP call returns a ContinuationToken so the application - # backend can correlate. - if push_chat_id: - return replace( - request, - identity=new_identity, - response_target=ResponseTarget.identities([ - ChannelIdentity(channel="telegram", native_id=push_chat_id), - ]), - background=True, - ) - - return replace(request, identity=new_identity) - - -host = AgentFrameworkHost( - target=agent, - identity_resolver=IdentityResolver(app_identity_resolver), - channels=[ - ResponsesChannel(run_hook=responses_relay_hook), - TelegramChannel( - bot_token=os.environ["TELEGRAM_BOT_TOKEN"], - transport="webhook", - run_hook=telegram_promote_app_user, - ), - ], -) -host.serve(host="0.0.0.0", port=8000) -``` - -The flow: - -1. Alice has previously connected her Telegram account on the application's settings page; the application stored `chat_id_of_alice → user_alice` in `KNOWN_TELEGRAM_USERS` (a real deployment uses a database). -2. Alice opens the application's web UI and types a question. The application backend (signed in as `user_alice`) calls the Responses API mounted on this host with `extra_body={"hosting": {"app_user_id": "user_alice"}}` (and no `push_to_telegram_chat_id`). The `responses_relay_hook` promotes `app_user_id` onto the identity, the resolver returns `isolation_key="user_alice"`, the agent runs, and the answer is returned synchronously over HTTP. The agent's `HistoryProvider` appends both turns to the session keyed by `user_alice`. -3. Later, Alice messages the same agent on Telegram from her registered chat. The Telegram channel's `run_hook` promotes `app_user_id="user_alice"` onto the identity (because her chat_id is in the known-users table), the resolver returns the **same** `isolation_key="user_alice"`, the agent loads the **same** session — and sees the earlier turn from the web UI. **One continuous conversation across two channels, no link ceremony required, no `IdentityLinker` configured.** -4. Now Alice walks away from her desk. The application backend wants to fire a long-running task on her behalf and have the answer reach her on Telegram. It calls the Responses API with `extra_body={"hosting": {"app_user_id": "user_alice", "push_to_telegram_chat_id": ""}}`. The `responses_relay_hook` rewrites the request to `background=True` and `response_target=ResponseTarget.identities([ChannelIdentity("telegram", "")])`. The Responses HTTP call returns a `ContinuationToken` immediately (so the application backend can correlate); when the agent completes, the host calls `TelegramChannel.push(ChannelIdentity("telegram", ""), result)` and the answer arrives in Alice's Telegram chat. - -The two enabling pieces: - -- **`extra_body["hosting"]` as a developer-controlled relay envelope.** The Responses channel surfaces an opaque `hosting` block from `extra_body` onto `ChannelRequest.attributes["hosting"]`. The hosting core does **not** define what goes in there — the developer decides what their trusted backend may carry (here `app_user_id` and `push_to_telegram_chat_id`) and reads it in their `run_hook`. This is the same pattern the `store=` table calls out for richer per-call control. -- **`ResponseTarget.identities([...])` for explicit caller-known recipients.** This bypasses the link store and pushes to a channel-native identity the caller already knows. Use it when the originating caller is a trusted relay that authenticated the user through some other means (corporate SSO, an internal API key bound to a user) and just needs the host to dispatch. `LinkPolicy` is still consulted per delivery, so a corp-tier Responses call cannot smuggle a public-tier Telegram push if the policy disallows it. - -**Variant — same scenario with an `IdentityLinker` configured.** If the host *does* have an `IdentityLinker` (Scenario 6), the application backend doesn't need to maintain its own `chat_id → app_user_id` table at all: when Alice runs `/link` once on Telegram, the linker records the channel-native identity against `isolation_key="user_alice"` (resolved from the Entra OAuth claim that matches the application's own SSO). After that, the run hook can simply use `ResponseTarget.channel("telegram")` (link-store recipient) instead of `ResponseTarget.identities([...])`. The explicit-identities variant remains useful when the application owns identity end-to-end and prefers not to delegate to a host-level linker. - -### Scenario 8: Background run with cross-channel response delivery - -A developer wants the user to start a long-running task on Telegram and pick up the response on Teams (whichever channel the user happens to be on when the result is ready). The originating Telegram message returns a `ContinuationToken` immediately; when the agent completes, the host pushes the result to the user's currently active channel via `ChannelPush`. A poll route is also exposed for callers that prefer polling. - -```mermaid -sequenceDiagram - autonumber - actor User - participant Tg as TelegramChannel - participant Host - participant Hook as run_hook - participant Store as HostStateStore
(continuations · last_seen) - participant Target as Agent - participant Runner as DurableTaskRunner - participant Act as ActivityChannel - - User->>Tg: long-running ask - Tg->>Host: ChannelRequest(identity=tg:12345) - Host->>Hook: run_hook - Hook->>Hook: background=True,
response_target=active - Host->>Store: write continuation(in_progress) - Host-->>Tg: ContinuationToken - Tg-->>User: "working on it…" - - Note over User: user opens Teams,
last_seen updates to "activity" - - User->>Act: hello on Teams - Act->>Host: ChannelRequest(identity=teams-aad-oid) - Host->>Store: record_last_seen(isolation_key, activity, now) - - Note over Host,Target: background completes - - Target-->>Host: AgentResponse - Host->>Store: get_last_seen(isolation_key) → activity - Host->>Runner: schedule("hosting.push",
payload for activity) - Runner->>Host: _handle_push_task - Host->>Act: push - Act-->>User: answer arrives on Teams -``` - -> **Prerequisites:** This sample assumes: -> - `agent-framework-hosting`, `agent-framework-hosting-telegram`, and the (future) `agent-framework-hosting-activity` channel are installed -> - The user is already linked across Telegram and Teams (Scenario 6) - -```python -import os -from dataclasses import replace - -from agent_framework.hosting import ( - AgentFrameworkHost, - ChannelRequest, - ResponseTarget, - TelegramChannel, -) - - -# Override the Telegram channel default: any inbound message becomes a -# background run delivered to the user's currently active channel. -async def telegram_background(request: ChannelRequest, **kwargs) -> ChannelRequest: - return replace( - request, - background=True, - response_target=ResponseTarget.active, - ) - - -host = AgentFrameworkHost( - target=agent, - identity_linker=linker, # from Scenario 6 - channels=[ - TelegramChannel( - bot_token=os.environ["TELEGRAM_BOT_TOKEN"], - transport="webhook", - run_hook=telegram_background, - ), - # ActivityChannel(...), # future - ], -) -host.serve(host="0.0.0.0", port=8000) -``` - -The flow: - -1. `alice` sends a Telegram message that triggers a long-running tool. The Telegram channel produces a `ChannelRequest`; the hook flips `background=True` and sets `response_target=ResponseTarget.active`. -2. `host.run_in_background(request)` returns a `ContinuationToken(token="ct_018f…", status="queued")`. The Telegram channel acknowledges with a short "Working on it…" reply that includes the token (it could equally render a "Cancel" inline button bound to the token). -3. The host runs the target asynchronously. When complete, it resolves `ResponseTarget.active` against the host-tracked last-seen channel for `isolation_key="alice@contoso.com"`. If `alice` is currently on Teams, the host calls `ActivityChannel.push(channel_identity, hosted_run_result)`; if she is still on Telegram, it calls `TelegramChannel.push(...)` (so the same setup gracefully degrades to "reply on Telegram if she never switched"). -4. `ContinuationToken` is updated to `status="completed"` with the populated `result`. Any caller can poll `GET /telegram/runs/{continuation_token}` (or the equivalent route the channel exposes) to retrieve the run state by id. - -Variants without changing channel code: - -- `ResponseTarget.channel("activity")` — always deliver to Teams, regardless of where the user is. -- `ResponseTarget.all_linked` — broadcast to every channel `alice` has linked. -- `ResponseTarget.none` — fully detached: caller polls `host.get_continuation(token)` (or the channel's poll route); no proactive push. -- `background=False` with `response_target=ResponseTarget.active` — synchronous wait, but result still routed away from the originating channel (rare; mostly useful for pipelines where the originating call is a programmatic trigger and the human user lives elsewhere). - -If the chosen destination channel does not implement `ChannelPush` (e.g. Responses), the host falls back to the `originating` channel and records the fallback in telemetry. This makes the Responses + background-run combo work as "submit on Responses, poll on Responses" without surprising silent drops. - -### Scenario 9: Hosting a `Workflow` instead of an agent (with checkpoint storage) - -The host shape is unchanged when the target is a `Workflow`; the result wrapper narrows to `HostedRunResult[WorkflowRunResult]` and `response_hook` carries the projection that lets text-only channels render workflow output. - -```mermaid -sequenceDiagram - autonumber - actor User - participant Channel - participant Host - participant Workflow - participant Store as HostStateStore
(workflow_checkpoints) - participant Hook as response_hook
(per-channel) - - User->>Channel: message - Channel->>Host: ChannelRequest - Host->>Workflow: run(messages) - loop per executor / event - Workflow->>Store: write checkpoint - Workflow-->>Host: WorkflowEvent - end - Workflow-->>Host: WorkflowRunResult - Host->>Host: wrap → HostedRunResult[WorkflowRunResult]
(get_outputs, get_final_state, …) - - alt text-only channel - Host->>Hook: response_hook(result, context) - Hook->>Hook: result.replace(
result=AgentResponse(
text=workflow.get_outputs()[-1])) - Hook-->>Host: HostedRunResult[AgentResponse] - Host->>Channel: push (sync or via runner) - else card-capable channel - Host->>Hook: response_hook(result, context) - Hook->>Hook: render adaptive card
from workflow get_outputs - Hook-->>Host: HostedRunResult[Any] - Host->>Channel: push - end - Channel-->>User: reply (channel-native) -``` - -> **Prerequisites:** This sample assumes: -> - `agent-framework-hosting` and `agent-framework-hosting-invocations` are installed -> - A `Workflow` definition with typed inputs (`OrderIntakeInputs`) -> - A directory writable by the host process for workflow checkpoints - -```python -from dataclasses import dataclass, replace -from pathlib import Path - -from agent_framework import FileCheckpointStorage, WorkflowBuilder -from agent_framework.hosting import ( - AgentFrameworkHost, - ChannelRequest, - InvocationsChannel, -) - - -@dataclass -class OrderIntakeInputs: - customer_id: str - sku: str - quantity: int - - -# Build the workflow with a CheckpointStorage so individual executor frames -# are persisted as the workflow runs. FileCheckpointStorage writes one file -# per checkpoint under the configured directory; survives host restarts. -checkpoint_storage = FileCheckpointStorage(directory=Path("./.af-hosting/checkpoints/")) - -workflow = ( - WorkflowBuilder(checkpoint_storage=checkpoint_storage) - .add_executor(...) # application-defined - .build() -) - - -def adapt_to_workflow_inputs(request: ChannelRequest, *, protocol_request=None, **kwargs) -> ChannelRequest: - # The channel produces a default ChannelRequest with text input. The workflow - # needs typed OrderIntakeInputs — the hook is the adapter point. The same - # hook is the place to surface a caller-supplied checkpoint id (to resume - # an interrupted run) by promoting it onto request.attributes; the host's - # workflow dispatch reads it on the way to Workflow.run(...). - payload = protocol_request # raw Invocations request body - inputs = OrderIntakeInputs( - customer_id=payload["customer_id"], - sku=payload["sku"], - quantity=int(payload["quantity"]), - ) - new_attrs = dict(request.attributes) - if checkpoint_id := payload.get("resume_from_checkpoint"): - new_attrs["workflow.checkpoint_id"] = checkpoint_id - return replace(request, input=inputs, attributes=new_attrs) - - host = AgentFrameworkHost( target=workflow, - channels=[ - InvocationsChannel(run_hook=adapt_to_workflow_inputs), - ], + channels=[InvocationsChannel(run_hook=adapt_to_workflow_input)], + checkpoint_location=Path("./.af-hosting/workflow_checkpoints"), ) -host.serve(host="localhost", port=8000) ``` -The host detects that `target` is a `Workflow` and dispatches the resulting `ChannelRequest.input` to `Workflow.run(...)` instead of `SupportsAgentRun.run(...)`. The channel does not need to know which kind of target it is fronting — `HostedRunResult` and `HostedStreamResult` are normalized across both seams. The same workflow target could equally be exposed on Telegram or a Responses channel by supplying the appropriate `run_hook` to translate inbound chat messages into typed workflow inputs. +The hook adapts channel-native input to the workflow's typed input. Checkpoints use the explicit workflow checkpoint location, not identity-link or delivery storage. -**Checkpoint storage** is wired onto the workflow itself (via `WorkflowBuilder(checkpoint_storage=...)` or per-run via `Workflow.run(..., checkpoint_storage=...)`), **not** on the host. The host treats it as workflow-runtime state — structurally distinct from the `HostStateStore` (which persists `ContinuationToken`s, identity-link grants, and last-seen records — host-execution metadata, not workflow internals) and from `ContextProvider` (per-conversation context). All three protocols stay separate, but a deployment MAY back them with the same physical store. When `request.attributes["workflow.checkpoint_id"]` is set (as the run hook does above when the caller supplies `resume_from_checkpoint`), the host's workflow dispatch path passes it through to `Workflow.run(checkpoint_id=...)` so the workflow resumes from that frame instead of running from scratch — useful for long-running intake flows that survive host restarts or retries. - -### Scenario 10: Authoring a new channel package - -The shape any new channel follows: parse external protocol → produce default `ChannelRequest` → optionally apply hook → `context.run(...)` / `context.stream(...)` → serialize back. +### Message channel reset command ```python -from starlette.requests import Request -from starlette.responses import JSONResponse -from starlette.routing import Route - -from agent_framework.hosting import ( - Channel, - ChannelContext, - ChannelContribution, - ChannelRequest, - ChannelSession, -) - - -class MyWebhookChannel: - name = "mywebhook" - - def __init__(self, *, path: str = "/mywebhook") -> None: - self._path = path - - def contribute(self, context: ChannelContext) -> ChannelContribution: - async def endpoint(request: Request) -> JSONResponse: - payload = await request.json() - channel_request = ChannelRequest( - channel=self.name, - operation="message.create", - input=payload["text"], - session=ChannelSession( - key=payload["thread_id"], - isolation_key=payload["account_id"], - ), - ) - result = await context.run(channel_request) - # See "Result is rich, not just text" below — `result.text` is the - # plain-text projection; this channel chooses to also surface - # citations and any tool-call traces it cares about. The exact - # serialization is the channel's call. - return JSONResponse(_render_for_mywebhook(result)) - - return ChannelContribution(routes=[Route(f"{self._path}/inbound", endpoint, methods=["POST"])]) +async def new_chat(context): + if context.request.session is not None: + await context.host.reset_session(context.request.session.isolation_key) + await context.reply("Started a new conversation.") ``` -**Result is rich, not just text.** `result` here is a `HostedRunResult[TResult]` — a thin generic envelope around the target's **full-fidelity output**. For agent targets `TResult` narrows to `AgentResponse`, so channels read everything the target produced directly off `result.result`: +Telegram, Activity Protocol, and Discord can expose equivalent native commands when their protocols support them. -- the full `messages: list[ChatMessage]` thread the agent produced this turn — each message holds an ordered list of typed `Contents` (see [`Contents` in core](https://github.com/microsoft/agent-framework/blob/main/python/packages/core/agent_framework/_types.py)): `TextContent`, `DataContent` (inline base64 blobs), `UriContent` (URLs to images/audio/files), `FunctionCallContent` and `FunctionResultContent` (tool-call traces), `HostedFileContent` / `HostedVectorStoreContent` (provider-side file/vector references), `UsageContent` (token usage), `ErrorContent`, `TextReasoningContent` (reasoning traces), and channel-extensible custom content kinds. Each content also has `additional_properties` for provider-specific extensions (citations, image alt text, source spans, …), -- `value: T | None` — the typed structured output when the agent returned one (e.g. via response-format / structured-output features), -- `response_id`, `usage_details: UsageDetails | None`, `raw_representation`, and per-message `additional_properties` carrying provider-native extras. +## Follow-up Enhancements -For workflow targets `TResult` is `WorkflowRunResult`, so `result.result.get_outputs()` iterates the per-executor output payloads and `result.result.get_final_state()` exposes terminal-state info. The host never collapses or pre-shapes workflow outputs — channels (and developer-supplied `response_hook`s) own the projection, since "what counts as a renderable output" is wire-format-specific. +See [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md) for the deferred design covering: -A channel author is free to project this into **whatever the channel's native shape supports**. Examples: +- cross-channel identity linking, +- authorization and allowlists, +- non-originating response delivery, +- active-channel routing, +- multicast and all-linked delivery, +- background runs and continuation tokens, +- durable delivery runners, +- retry/replay semantics, and +- payload serialization. -- The built-in **Telegram channel** renders `text` segments with Telegram's `MarkdownV2` parse mode (escaping the special set), uploads `DataContent` images via `sendPhoto` and audio via `sendAudio` as separate Telegram messages in the same chat, and emits inline-button keyboards from `FunctionCallContent` traces when the channel is configured to surface tool calls as user-confirmable actions. Citations attached to a `TextContent.additional_properties["citations"]` slot are rendered as numbered footnote links the user can tap. -- The built-in **Responses channel** preserves the full content-list shape on the wire — every `ChatMessage` round-trips as a Responses-shaped output item so callers can inspect the typed mix of text, function-call traces, image/file outputs, reasoning, and structured-output `value`s exactly as the agent produced them. There is no lossy collapse to a single text field. -- A channel fronting a **chat UI** can render `TextContent` as full GitHub-Flavored Markdown / HTML (tables, code fences with syntax highlighting, math), `DataContent` and `UriContent` as inline images/audio/video players, `FunctionCallContent` / `FunctionResultContent` as collapsible "tool ran" cards, and `TextReasoningContent` as a collapsible reasoning panel — all from the same `result`. -- A **voice channel** can route `TextContent` through TTS, play `DataContent(audio/*)` directly, and surface `FunctionCallContent` only as audio earcons (or skip them entirely) — the same `result` object drives a completely different surface. -- A **richly-typed RPC channel** can return `result.result.value` (the structured output) directly when the workflow / agent produced one, and fall back to a text projection only when no typed output is available. +Those enhancements must layer on top of this v1 contract without requiring v1 users to adopt them. -The host imposes no projection — channels read `result.result.text` for a convenience plain-text rollup on agent targets, but are encouraged to lean on the full underlying payload when their protocol supports more. +## Validation Gates -## Information Design +The Python implementation should be considered complete when: -### Canonical flow - -The default request/response shape — single channel, originating response, no fan-out. Authorization runs before `run_hook`; `response_hook` runs per-destination (here just one). - -```mermaid -sequenceDiagram - autonumber - actor User - participant Channel as Channel
(inbound) - participant Host - participant Auth as host.authorize - participant Target as Agent / Workflow - participant Annot as _annotate_intended_targets - - User->>Channel: native payload (webhook / poll / HTTP body) - Channel->>Channel: parse → ChannelRequest
(identity, conversation_id, content, response_target=originating) - Channel->>Host: dispatch(ChannelRequest) - Host->>Auth: authorize(identity, require_link, allowlist) - alt Denied / LinkRequired - Auth-->>Host: Denied(reason_code, user_message)
or LinkRequired(challenge) - Host-->>Channel: render denial / link challenge
(channel-appropriate UX) - Channel-->>User: short refusal in-room - else Allowed(isolation_key) - Host->>Host: resolve session via StateStore - Host->>Host: run_hook(request, context) - Host->>Target: target.run(messages, session=...) - Target-->>Host: AgentResponse / WorkflowRunResult - Host->>Host: wrap → HostedRunResult[TResult] - Host->>Annot: write hosting.intended_targets
onto assistant message - Host->>Channel: response_hook(result, context) - Channel->>Channel: shape to native payload - Channel-->>User: reply on originating wire - end -``` - -The textual trace of the same flow (showing more of the per-step bookkeeping): - -```text -external request/event - -> channel-specific parsing + validation - -> ChannelIdentity extraction (per-channel native id) - -> default channel invocation mapping - -> optional run_hook (dev-supplied; default no-op) - -> ChannelRequest (carries response_target, background, echo_input) - -> AgentFrameworkHost / ChannelContext - -> identity_resolver(ChannelIdentity) -> isolation_key - -> host records (isolation_key, channel, now) as last-seen (for ResponseTarget.active) - -> AgentSession resolution (per session_mode, scoped by isolation_key) - -> target execution seam (Agent.run / Workflow.run) - -> HostedRunResult[AgentResponse] | HostedRunResult[WorkflowRunResult] - (full-fidelity result carried unchanged; no pre-shaping by the host) - -> [foreground] fan-out: - for each destination resolved from ResponseTarget: - -> clone HostedRunResult envelope (per-destination isolation; shallow copy) - -> optional channel response_hook (dev-supplied; default = identity) - -> hook receives ChannelResponseContext(request, channel_name, destination_identity, originating, is_echo) - -> hook may rebind result via HostedRunResult.replace(result=...) - (e.g. project a WorkflowRunResult to an AgentResponse for a text-only wire) - -> channel-native serialization (channel chooses what content types / outputs it can render) - -> channel.push(identity, shaped_payload) | originating return value - if ResponseTarget.echo_input is True: - each non-originating destination receives the user's input first - (synthesised as a HostedRunResult[AgentResponse] with a role="user" message), - then the agent reply. Both pushes execute inside the same scheduled - push task; an echo-push failure is logged and swallowed so the - response push on the same destination is still attempted. - -> [background or response_target != originating] - -> ContinuationToken returned immediately to originating channel - -> target executes asynchronously - -> on completion, the same fan-out (clone + response_hook + push) applies - -> ContinuationToken updated; available via host.get_continuation(token) and channel poll routes -``` - -**Full-fidelity contract.** The host never collapses agent / workflow output. `HostedRunResult[TResult]` carries the target output unchanged: agent targets see the full `AgentResponse` (multi-modal `messages`, `value`, `usage_details`, `response_id`, …); workflow targets see the full `WorkflowRunResult` (per-executor outputs via `get_outputs()`, terminal state via `get_final_state()`). Each channel — through its `response_hook` and its own serializer — decides what subset its wire can carry. A text-only channel iterates `result.result.messages` (or projects the workflow's outputs into a single text turn via a response hook); a card-capable channel inspects the underlying contents directly. - -**Per-destination cloning.** Before invoking a channel's `response_hook`, the host clones the `HostedRunResult` envelope so one channel's `replace(result=...)` cannot leak into the payload another destination observes. The clone is shallow — channels that need to mutate `result` itself (rather than rebind it) own the deep copy. - -**`response_hook` is a channel-level convention, not part of the `Channel` Protocol.** Channels expose a `response_hook` attribute (callable accepting `(result, *, context: ChannelResponseContext) -> HostedRunResult[Any] | Awaitable[HostedRunResult[Any]]`). The host duck-types this attribute. Adding hook support to an existing channel package does not break the public `Channel` Protocol. - - -A parallel **link ceremony flow** runs out-of-band when a user invokes the host-provided `link`/`connect` command on a channel: - -```text -channel /link command - -> linker.begin(ChannelIdentity) -> LinkChallenge - -> channel-specific rendering (URL, code, MFA prompt) - -> user completes the ceremony out-of-band (browser, second channel, MFA app) - -> linker callback/verification route - -> linker.complete(challenge_id, proof) -> isolation_key - -> host atomically associates (channel, native_id) -> isolation_key - -> subsequent requests resolve to the linked AgentSession -``` - -### Inbound ownership - -| Concern | Owned by | Notes | -|---|---|---| -| HTTP / WebSocket route shape | Channel package | e.g. `/responses`, `/responses/ws`, `/invocations`, `/telegram/webhook` — channels may contribute either or both | -| Protocol request model | Channel package | e.g. Responses items (HTTP body or WS frames), Invocations body, Telegram webhook payload | -| Signature/auth validation | Channel package or host middleware | channel-specific unless generic Starlette middleware | -| Request-to-agent invocation mapping | Channel package + optional `run_hook` | forwards caller parameters into `ChannelRequest.options`, chooses `session_mode`, can enforce extra app policy | -| Native command catalog | Channel package using host-defined `ChannelCommand` | e.g. Telegram bot commands, future Activity Protocol slash-command / adaptive-card surfaces, WhatsApp menus | -| Command registration at startup | Channel package | e.g. Telegram `set_my_commands(...)` | -| Command dispatch | Channel package | commands may reply locally, manipulate channel-owned state, or invoke the agent | -| Normalized input to the agent | Host core | `ChannelRequest.input` reuses `AgentRunInputs` | -| Session resolution | Host core | based on `ChannelSession` + `ChannelRequest.session_mode`; storage specifics deferred | -| Channel-native identity extraction | Channel package | populates `ChannelIdentity(channel, native_id, attributes)` per request | -| Identity resolution (`native_id` → `isolation_key`) | Host core via `IdentityResolver` | default **auto-issues and persists** a per-user `isolation_key` on first contact per `(channel, native_id)`; user-supplied resolver can return app-owned identities directly | -| Identity store (`(channel, native_id) → isolation_key`) | Host core via `HostStateStore` | file-based by default in v1 (`FileHostStateStore`); pluggable for Cosmos / SQL / Redis in fast follow (req #24). Owns auto-issuance and atomic merge-on-link. | -| Identity link ceremony (OAuth / MFA / one-time code) | Host core via `IdentityLinker` | linker contributes its own routes + lifecycle; channels surface a built-in `link`/`connect` command | -| Authorization (allowlist + link enforcement) | Host core via `host.authorize(...)` + per-channel `IdentityAllowlist` | tri-state allowlist evaluated pre- and post-link; combines with `require_link` to produce one of three named profiles (open / forced-link / allowlist); see [Authorization profiles and the IdentityAllowlist seam](#authorization-profiles-and-the-identityallowlist-seam) | -| Link & delivery policy across confidentiality tiers | Host core via `LinkPolicy` | consulted at link time (refuse incompatible link attempts) and at delivery time (drop incompatible `ResponseTarget` destinations); built-in policies cover all-allow, same-tier, explicit allow-list, deny-all | -| Active-channel tracking | Host core | updated on every successfully resolved request; consumed by `ResponseTarget.active` | -| Response-target resolution | Host core | translates `ResponseTarget` (originating, active, specific, list, all_linked, none) into an ordered set of `(channel, ChannelIdentity)` deliveries | -| Proactive outbound delivery | Channel package via optional `ChannelPush` capability | channels that can push (Telegram, Activity Protocol via Bot Service, webhook, SSE) implement `push(identity, result)`; channels that can't are only valid as `originating` targets | -| Per-delivery audit + replay state | Host core writes intent-only — the resolved destination set onto the assistant `Message.additional_properties["hosting"]["intended_targets"]` (immutable, single write). Operational state (attempts, retries, last error, success timestamp) lives in the `DurableTaskRunner` and is observed via the runner's own backend. | Replay across host restarts is a property of the configured runner (native for durable adapters; not supported for `InProcessTaskRunner`). See [Intended targets + durable delivery](#intended-targets--durable-delivery) and [Durable task runner](#durable-task-runner). | -| Background-run lifecycle | Host core | owns `ContinuationToken` issuance, async execution, completion notification; persists via `HostStateStore` (file-based default — survives restarts) | -| Run poll routes | Channel package | each channel exposes its own protocol-shaped poll route (`/responses/{continuation_token}`, `/invocations/{continuation_token}`) backed by `host.get_continuation(token)` | -| Conversation history (all channels — Responses, Invocations, Telegram, Activity Protocol, …) | Agent's core `HistoryProvider` (`agent_framework._sessions.HistoryProvider`) | Channels project their wire id (`previous_response_id`, `conversation_id`, request body `session_id`, host-tracked alias, …) into `ChannelSession.key`; the host resolves an `AgentSession` and the agent's `HistoryProvider` does the load / append. No channel-specific history seam. Multi-provider composition (with a single `load_messages=True`) is the standard AF convention; see [Conversation history for the Responses channel](#conversation-history-for-the-responses-channel) for the Foundry-backed variant. | -| Channel-owned non-message per-thread state (e.g. AG-UI `client_state`) | Channel-shipped `ContextProvider` subclass written into the same per-source state slot | Reuses the existing `ContextProvider` seam — *not* a new storage protocol. Channel reads `ChannelRequest.client_state` in `before_run`, lets the agent observe/mutate the slot, then reads the post-run value in `after_run` to emit channel-specific events (e.g. AG-UI `StateSnapshotEvent` / `StateDeltaEvent`). Composition rules unchanged (one `HistoryProvider` carries `load_messages=True`; additional `ContextProvider`s attach alongside). See [Channel-owned per-thread state](#channel-owned-per-thread-state). | -| Agent invocation | Host core | always through the target's execution seam — `SupportsAgentRun.run(...)` for agent targets, `Workflow.run(...)` for workflow targets | -| Protocol response/event model | Channel package | core returns agent results; channel serializes them | -| ASGI server bootstrap | Host core convenience | `host.serve(...)` for default uvicorn path; `host.app` for custom hosting | - -### Channel session-carriage models - -Channels split into two families based on **who owns the session identifier across requests**. This distinction is invisible to the agent target, but it changes which host-side mechanisms are load-bearing for that channel. - -| Model | Examples | `ChannelSession.key` source | How a caller starts a new thread | -|---|---|---|---| -| **Caller-supplied session** | Responses (`previous_response_id` / `conversation_id`), Invocations, A2A, MCP — generally any HTTP/RPC-shaped channel | The wire payload carries it; the channel parses it into `ChannelSession.key`. `None` means "ephemeral / fresh thread". | Omit the previous id (or send a fresh one). The caller is in control. | -| **Host-tracked session** | Telegram, Activity Protocol via Azure Bot Service (Teams/Web Chat/Slack/…), WhatsApp — generally any chat surface whose protocol carries identity (`chat_id`, AAD oid, `from.id`) but no per-conversation key | The channel leaves `ChannelSession.key = None` and lets the host's per-`isolation_key` alias decide which `AgentSession` to resolve (rule #8 below). | The channel surfaces a `/new`-style command (a `ChannelCommand`) that calls `host.reset_session(isolation_key)`; the host's session-id alias rotates. There is no in-band way for the user to address a specific past thread. | - -Identity is an **orthogonal axis** (anonymous vs. identified). The realized cells in v1 are: - -| | Anonymous | Identified | -|---|---|---| -| **Caller-supplied session** | ✓ — bare `curl /responses` + `previous_response_id`. The id effectively *is* the identity (the resolver may project `previous_response_id` into the `isolation_key` for that turn). | ✓ — Responses + `safety_identifier`, or any caller-supplied channel behind a JWT/OAuth bearer that the resolver maps to an `isolation_key`. | -| **Host-tracked session** | n/a in v1 | ✓ — Telegram / Activity Protocol (Bot Service) / WhatsApp. The channel always authenticates; the resolver maps `(channel, native_id)` to `isolation_key`. | - -**Channel-author guidance.** When implementing a new channel: - -- If your upstream protocol carries a per-conversation identifier on every request, populate `ChannelSession.key` from it. You are a **caller-supplied** channel. `host.reset_session(...)` is **not** the right primitive for your `/new`-equivalent (your callers control that by simply omitting the previous id). Cross-channel linking via `IdentityLinker` is opt-in and depends on whether you also extract a stable identity (header, JWT, etc.) into `ChannelIdentity`. -- If your upstream protocol carries identity but **no** per-conversation key, leave `ChannelSession.key = None`. You are a **host-tracked** channel. To support "start a fresh thread", expose a channel-native command (Telegram `/new`, Teams adaptive-card button, …) that invokes `host.reset_session(isolation_key)` — the host alias rotation does the rest, and prior history remains addressable under its previous session id. You are the canonical case for cross-channel linking; populate `ChannelIdentity` faithfully so `IdentityLinker` and `ResponseTarget.active`/`.all_linked` can find your users. - -**Mixing on one host.** A single `AgentFrameworkHost` can mount channels of both families. A user can chat on Telegram (host-tracked) and have it linked via `IdentityLinker` to a Responses-channel session keyed by `previous_response_id`; in that case the linker's identity merge collapses both sides onto the same `isolation_key` and the host-tracked channel's alias becomes a peer of the caller-supplied `previous_response_id` for the same `AgentSession`. This is the v1 mechanism for "agent built on Responses, exposed to humans on Telegram, with continuity across both". - -### Session resolution rules - -1. If `ChannelRequest.session_mode == "disabled"`, the host bypasses session resolution and calls the target with `session=None`. -2. If `session_mode == "auto"`, the host resolves `ChannelSession.key` to an `AgentSession`, scoped by `isolation_key` when supplied. -3. If `session_mode == "auto"` and no key is supplied, the host may create an ephemeral session. -4. If `session_mode == "required"`, the host must resolve or create a usable session before invoking the target. -5. **Cross-channel resolution rule:** when two channels mounted on the same `AgentFrameworkHost` produce the same `isolation_key` (and either both omit `key` or both produce equivalent keys derived from `isolation_key`), the host resolves them to the **same** `AgentSession`. This is the v1 mechanism for cross-channel chat continuity (e.g. Telegram → Teams against the same conversation history). The **canonical** path for translating a channel's native per-channel identifier (Telegram `chat_id`, Teams AAD object id, …) into the stable `isolation_key` is the host-level `IdentityResolver` (per-channel `run_hook` mapping is supported as a lower-level alternative). When the channel-native identity is not yet linked, the `IdentityLinker` runs a connect ceremony (OAuth, MFA, signed one-time code) to associate it with an existing `isolation_key`. -6. The first spec does **not** standardize a cross-package storage API; cross-host/cross-process continuity is deferred to the pluggable session store (req #24), which also persists identity-link grants beyond the host process lifetime. -7. Responses and other conversation-aware channels may still own protocol-specific conversation/item storage above this layer. -8. **Session rotation (`reset_session`).** The host exposes `reset_session(isolation_key)` so **host-tracked** channels (see [Channel session-carriage models](#channel-session-carriage-models)) can implement "start a fresh thread" commands (e.g. Telegram `/new`). The default behavior **rotates the active session id alias** (`` → `#`) rather than deleting on-disk history: prior history remains addressable by its original session id while subsequent runs for that `isolation_key` resolve to a brand-new `AgentSession`. Apps that want destructive reset can layer that on top by calling into their own `HistoryProvider`. **Caller-supplied** channels do not call `reset_session`; their callers branch threads by sending a fresh / no `previous_response_id` (or equivalent) on the next request. - -### Channel metadata persisted onto stored messages - -When the host invokes the target, it does **not** pass the raw `ChannelRequest.input` directly. It first wraps the input into a `Message(role="user", contents=[...])` whose `additional_properties["hosting"]` carries an envelope describing where the message came from and where its response should go. This makes the resulting conversation history self-describing for any `HistoryProvider` (`FileHistoryProvider`, future Cosmos/Foundry providers, …) without that provider having to know anything channel-specific. - -```jsonc -{ - "channel": "telegram", // ChannelRequest.channel - "identity": { // populated from ChannelRequest.identity - "channel": "telegram", - "native_id": "", - "attributes": { /* channel-specific */ } - }, - "response_target": { // populated from ChannelRequest.response_target - "kind": "originating", - "targets": [] // [(channel, native_id), ...] for explicit targets - } -} -``` - -Round-trip is guaranteed by `Message.to_dict()` / `Message.from_dict()`. Future providers that key on protocol shape (e.g. a Responses `previous_response_id`-keyed store) can read this envelope to reconstruct cross-channel context without needing a separate channel-metadata sidecar. - -`FoundryHostedAgentHistoryProvider` round-trips the entire `additional_properties["hosting"]` namespace (and any other AF-side namespace) through the Foundry response store via a single opaque `agent_framework` container key written onto each `OutputItem`. Because the schema is now **intent-only** (no per-destination mutation after the initial write — see [Intended targets + durable delivery](#intended-targets--durable-delivery)), no service-side additions to the Foundry storage SDK are required for it to round-trip. - -### Intended targets + durable delivery - -The inbound envelope above captures the caller's **intent**. The assistant `Message` produced by the host carries a parallel envelope that records the *resolved destination set* — what the host actually intended to deliver to, after `ResponseTarget` resolution and `LinkPolicy` filtering. **This is a single write, never mutated.** Operational state for each push attempt (status, attempts, retries, last error, channel-issued id) lives in the [`DurableTaskRunner`](#durable-task-runner) — not on the message — because the runner is the component that performs and (when durable) retries the push. - -The shape of the fan-out — synchronous on the originating wire, scheduled via the runner for every non-originating destination — is the same in every multi-target scenario (`all_linked`, `active`, `channels([...])`, `identities([...])`): - -```mermaid -sequenceDiagram - autonumber - actor User - participant Tg as TelegramChannel
(originating) - participant Host - participant Target as Agent - participant Runner as DurableTaskRunner - participant Annot as _annotate_intended_targets - participant Act as ActivityChannel
(linked) - participant Resp as ResponsesChannel
(linked) - - User->>Tg: message - Tg->>Host: ChannelRequest(
identity=tg:12345,
response_target=all_linked) - Host->>Host: resolve isolation_key - Host->>Target: run - Target-->>Host: AgentResponse - Host->>Annot: hosting.intended_targets =
[tg, activity, responses] - - par originating — synchronous - Host->>Tg: response_hook → push (sync) - Tg-->>User: reply on Telegram - and non-originating — durable - Host->>Runner: schedule("hosting.push",
payload for activity) - Runner->>Host: _handle_push_task(payload) - Host->>Act: response_hook → push - Act-->>User: reply in Teams (or wherever) - and - Host->>Runner: schedule("hosting.push",
payload for responses) - Runner->>Host: _handle_push_task(payload) - Host->>Resp: response_hook → push - end -``` - -Schema on `Message.additional_properties["hosting"]` for a host-produced assistant message: - -```jsonc -{ - "originating": { // mirror of the inbound envelope above - "channel": "telegram", - "identity": { "channel": "telegram", "native_id": "12345", "attributes": {} }, - "response_target": { "kind": "all_linked", "targets": [] } - }, - "intended_targets": [ - { "destination": { "channel": "activity", "native_id": "29:abc..." } }, - { "destination": { "channel": "telegram", "native_id": "12345" } } - ], - "skipped_targets": [ // optional — present only when LinkPolicy excluded something - { - "destination": { "channel": "corp-only", "native_id": "..." }, - "reason": "link_policy" // link_policy | no_push_capability - } - ] -} -``` - -Lifecycle the host follows: - -1. After `ResponseTarget` resolution and `LinkPolicy` filtering, the host writes the assistant `Message` **once**, with the resolved `intended_targets[]` (every destination it will attempt) and an optional `skipped_targets[]` for destinations dropped at resolution time (so audit can show *why* a resolved-by-`ResponseTarget` destination did not receive the message — `link_policy` or `no_push_capability`). This write is immutable. -2. For each non-originating destination, the host schedules a `"hosting.push"` task via the configured [`DurableTaskRunner`](#durable-task-runner). The runner is responsible for attempting, retrying per its `RetryPolicy`, and (for durable runners) surviving host restarts. The push handler resolves the channel, runs the channel's `response_hook`, and calls `ChannelPush.push(...)`. -3. Operational delivery state — attempt count, last error, success timestamp, channel-issued message id — lives in the runner's own log. Replay across host restarts is a property of the runner (native for durable runners; not supported for the in-process runner). Operators who want a queryable delivery dashboard can read it from their runner backend's observability surface (TaskHub, Foundry durable tasks, …) — the host does not project it back onto the message. - -The originating destination (when `ResponseTarget` includes it) is **not** routed through the runner. It is rendered synchronously on the originating channel's wire; the host-internal `_deliver_response` helper returns `bool` (`True` if any push was scheduled / delivered, `False` otherwise) for the channel's own bookkeeping. Per-destination delivery outcomes are not collated back to the caller — durable runners surface them in their own logs / dashboards, and the in-process runner logs failures with structured fields. See [Built-in routes](#built-in-routes) for the synchronous return contract. - -> **Why intent-only on the message, with operational state in the runner?** A single immutable write keeps the message store as the source of truth for "what the host intended", without requiring providers to implement in-place mutation (no `SupportsDeliveryTracking` capability, no Foundry `update_item` service ask). Per-destination retry, replay, and failure surfacing become responsibilities of the runner, which is the right component because it owns the work queue. Operators who already use a durable runner (TaskHub, Foundry durable tasks) get observability through the runner's existing tooling rather than through a parallel ETL on the message store. - -### Durable task runner - -The host delegates non-originating push fan-out — and, in v1 fast-follow, background runs — to a pluggable `DurableTaskRunner`. The runner is the component that owns "this work needs to happen; retry on failure; survive (or don't survive) restarts depending on which runner you chose". Channel packages never see it directly; they just implement `ChannelPush.push(...)`. - -```python -from typing import Protocol, Callable, Awaitable, Mapping, Any, Literal -from dataclasses import dataclass - -@dataclass(frozen=True) -class RetryPolicy: - max_attempts: int = 5 - initial_backoff_seconds: float = 1.0 - backoff_multiplier: float = 2.0 - max_backoff_seconds: float = 60.0 - -@dataclass(frozen=True) -class TaskHandle: - task_id: str # opaque, runner-issued - name: str # the registered handler name - -TaskStatus = Literal["scheduled", "running", "succeeded", "failed", "cancelled"] - -class DurableTaskRunner(Protocol): - def register( - self, - name: str, - handler: Callable[[Mapping[str, Any]], Awaitable[None]], - ) -> None: ... - - async def schedule( - self, - name: str, - payload: Mapping[str, Any], - *, - retry_policy: RetryPolicy | None = None, - ) -> TaskHandle: ... - - async def get(self, handle: TaskHandle) -> TaskStatus | None: ... -``` - -The host registers an internal handler `"hosting.push"` at startup. Each non-originating destination becomes a single `runner.schedule("hosting.push", payload)` call. The handler: - -1. Resolves the channel from `payload["channel_id"]`. -2. Clones the `HostedRunResult` and runs the channel's `response_hook` (if any). -3. Calls `ChannelPush.push(identity, shaped_result)`. -4. Returns normally on success. On exception, the runner records the failure and either schedules a retry per `RetryPolicy` or marks the task `failed` (terminal). - -Built-in runner shipped in core: - -| Runner | Persistence | Replay across restarts | Default for | -|---|---|---|---| -| `InProcessTaskRunner` | None — `asyncio.create_task` + in-process retry | No (in-flight tasks lost on process death) | `runtime_mode="long_running"` | - -Adapter packages (deferred to v1 Fast Follow; no runtime dep from core): - -| Package | Backend | Notes | -|---|---|---| -| `agent-framework-hosting-durabletask` | `agent-framework-durabletask` (gRPC TaskHub) | Suits `ephemeral` deployments that already run a Durable Task sidecar. | -| `agent-framework-hosting-foundry` (extension) | Foundry durable-task API | Deferred until the FHA durable-task surface is finalized. | -| (possibly) SQLite-outbox runner | SQLite under the existing `HostStateStore` root | Lowest-dep "survives single-node restart" option for ephemeral hosts without an external sidecar. | - -Default selection follows [Runtime modes](#runtime-modes). `long_running` defaults to `InProcessTaskRunner`. `ephemeral` is **strict**: if `durable_task_runner` is not configured and `allow_in_process_runner=True` is not opted in, the host raises `RuntimeError` at construction — falling back to the in-process runner in an ephemeral environment would silently drop in-flight pushes on the next scale-to-zero. The `allow_in_process_runner=True` escape hatch is intentionally noisy (warning) and meant for local dev / smoke tests. - -#### Codec contract for durable serialisation - -When a `DurableTaskRunner` is configured for a deployment that uses out-of-process scheduling (e.g. a sidecar / gRPC TaskHub), task payloads must be **JSON-serialisable** end to end. Two pieces of the contract enforce this: - -- **`DurableTaskRunner.payload_mode`** — a class-level attribute declared by each runner implementation: - - `OBJECT` — the in-process runner; payloads pass Python objects by reference. No serialisation required. - - `JSON` — out-of-process runners; payloads must round-trip through JSON. -- **`ChannelPushCodec`** — a Protocol exposed by push-capable channels whose payloads are not natively JSON-serialisable. The codec defines `encode(payload) -> Mapping[str, Any]` / `decode(envelope) -> Any` so the channel owns the over-the-wire shape of its push payloads. Channels without exotic payloads can leave the codec unset and rely on the host's default `dataclasses.asdict`-style encode. - -At construction the host runs `_validate_runner_codec_pairing`: if the configured runner declares `payload_mode == JSON` and any push-capable channel does not expose a codec, the host raises `ChannelConfigurationError` so the misconfiguration is caught before traffic. On the consumer side `_handle_push_task` accepts both `OBJECT`-mode (in-memory object) and `JSON`-mode (`{"type": "push", ...}` envelope) shapes so the same handler serves both runner backends. - -```mermaid -sequenceDiagram - autonumber - participant Host - participant Codec as ChannelPushCodec
(on the push channel) - participant Runner as Durable runner
(payload_mode=JSON) - participant Worker as Runner worker
(may run after host scaled to zero) - participant Channel as Push channel - participant External as External service
(Telegram / Bot Framework / …) - - Note over Host,Runner: construction-time:
_validate_runner_codec_pairing
(refuse JSON runner + codec-less channel) - - Host->>Codec: encode(payload) → JSON-safe Mapping - Host->>Runner: schedule("hosting.push",
{"type": "push", "channel": "tg",
"payload": }) - Runner->>Runner: persist task - Runner-->>Host: TaskHandle - Host-->>Host: synchronous return path
(originating already delivered) - - Note over Worker: ... host may scale to zero ... - - Worker->>Runner: dequeue task - Worker->>Host: invoke "hosting.push" handler
(JSON envelope) - Host->>Host: _handle_push_task
detect envelope shape (OBJECT or JSON) - Host->>Codec: decode(payload) → in-memory object - Host->>Channel: ChannelPush.push(identity, result) - Channel->>External: native API call - alt success - External-->>Channel: ok - Channel-->>Worker: handler returns - Worker->>Runner: mark task succeeded - else transient failure - External-->>Channel: 5xx - Channel-->>Worker: raise - Worker->>Runner: retry per RetryPolicy - else terminal failure - Worker->>Runner: max_attempts → mark failed
(log only) - end -``` - -#### In-process runner shutdown drain - -`InProcessTaskRunner` ships a two-phase shutdown driven by `shutdown_grace_seconds` (default `5.0`): - -1. After lifespan shutdown signals, in-flight `"hosting.push"` tasks are given the grace period to finish — during which retries keep happening — so a clean Ctrl-C does not abandon work that is one network call away from completing. -2. When the grace expires, remaining tasks are cancelled and their `CancelledError` is swallowed (not logged as a failure — it is the expected shutdown shape). - -This is purely operational hygiene for the `long_running` default; durable adapters get this behaviour for free from their backends. - -#### Echo idempotency on retry - -When `ResponseTarget.channel(name, echo_input=True)` is set, the host packages an echo (`role="user"`) push *and* the agent reply (`role="assistant"`) into the same `"hosting.push"` task per non-originating destination. The handler tracks an `echo_done` cursor on the task state and short-circuits the echo phase on retry: a retry that fires after the echo succeeded but before the response push completed will not double-echo the user's message. The cursor lives on the runner-owned task state, not the message — same principle as the broader "intent only on the message, operational state in the runner" rule. - -```mermaid -sequenceDiagram - autonumber - participant Runner - participant Host - participant Channel - participant External - - Runner->>Host: _handle_push_task(
echo, response,
state={echo_done: False}) - Host->>Host: read echo_done → False - Host->>Channel: response_hook(echo, is_echo=True) - Channel->>External: push user message - External-->>Channel: ok - Host->>Runner: persist state.echo_done = True - - Host->>Channel: response_hook(response, is_echo=False) - Channel->>External: push assistant reply - External-->>Channel: 5xx (transient) - Channel-->>Host: raise - - Runner->>Runner: retry per RetryPolicy
(backoff) - - Runner->>Host: _handle_push_task(
echo, response,
state={echo_done: True}) - Host->>Host: read echo_done → True (skip echo) - Host->>Channel: response_hook(response, is_echo=False) - Channel->>External: push assistant reply - External-->>Channel: ok - Host-->>Runner: handler returns → succeeded -``` - -## Reference and Parity Plan - -The new core sits **below** the conceptual boundary of today's top-level Responses/Invocations host wrappers but is implemented in Agent Framework-owned code. Existing top-level `agentserver` hosts inform behavior, naming, and parity targets — **without** becoming runtime dependencies of the hosting core. Individual channel packages MAY consume lower-level building blocks shipped in `azure.ai.agentserver` (e.g. `FoundryHostedAgentHistoryProvider` builds on the Foundry response-store SDK). - -| Existing code area | Proposed treatment | Why | -|---|---|---| -| `SupportsAgentRun.run(..., session=..., stream=...)` | Reuse directly in core for agent targets | Already the correct Python execution seam | -| `Workflow.run(...)` and workflow streaming events | Reuse directly in core for workflow targets; normalize outputs into `HostedRunResult`/`HostedStreamResult` | Lets channels stay target-agnostic | -| Session resolution logic in current hosting layers | Implement in core, using current behavior as reference | Host behavior, not protocol behavior | -| Starlette app assembly and route aggregation | Implement in core, referencing current servers | Needed by every channel | -| PR #5393 Telegram `BOT_COMMANDS`, `CommandHandler(...)`, `set_my_commands(...)` | Reference for the generic `ChannelCommand` capability | Clearest current prior art for native command catalogs + runtime dispatch | -| `agent_framework_foundry_hosting._to_chat_options` | Inspiration for Responses channel-owned mapping | Still protocol-specific | -| `agent_framework_foundry_hosting._items_to_messages` / `_output_item_to_message` | Inspiration / parity reference in Responses channel codec | Useful, not generic hosting | -| `agent_framework_foundry_hosting._to_outputs` and `ResponseEventStream` | Inspiration for Responses event mapping; the new Responses channel owns its own AF-native serialization rather than reusing top-level `agentserver` host wrappers | Responses-specific serialization | -| `azure.ai.agentserver.responses.ResponseContext.get_history()` + `Store` | Folded into the agent's normal core `HistoryProvider` flow. The Responses channel projects `previous_response_id` / `conversation_id` into `ChannelSession.key`; the agent's `HistoryProvider` does the load / append exactly as for any other session. No Responses-specific history Protocol. | One uniform history seam across channels — the developer chooses where to store, and may compose multiple providers under the standard "single `load_messages=True`" rule. | -| `azure.ai.agentserver.responses.store._foundry_provider.FoundryStorageProvider` (HTTP-backed Foundry storage with `IsolationContext` user/chat headers) | Wrapped by a native `FoundryHostedAgentHistoryProvider` in `agent-framework-foundry-hosting` that **builds on top of** the SDK and exposes the standard core `HistoryProvider` Protocol. Agents attach it the same way they attach `FileHistoryProvider`. | Lets the Foundry response store back conversations driven through the new host, while keeping the channel agnostic to the storage backend. The provider owns a runtime dependency on `azure.ai.agentserver` (for the storage SDK) so it stays aligned with the SDK's wire contract, auth, and isolation headers without duplication. Same provider also works for non-Responses channels (Telegram, Invocations, …) so the choice is "where do I want history persisted" rather than "which channel am I exposing". | -| `agent_framework_foundry_hosting._invocations.InvocationsHostServer._sessions` (in-process `dict[str, AgentSession]`) | Replace with the host's normal `ChannelSession.key → AgentSession` resolution; agent history flows through its own (optional) core `HistoryProvider(load_messages=True)` | Invocations does **not** need a protocol-shaped history seam — confirmed by today's foundry hosting which keeps no `Store` on the Invocations side | -| `ResponsesAgentServerHost` / `InvocationAgentServerHost` top-level wrappers | Conceptual prior art only | Sit too high; encode protocol ownership | -| Workflow checkpoint behavior in current Responses hosting | Defer; reference only for future work | Needs separate design if it becomes shared | - -## Dependencies & Commitment Status - -| Dependency | Team | DRI | Status | -|---|---|---|---| -| `SupportsAgentRun` execution seam | Agent Framework Core (Python) | TBD | Committed (existing) | -| `Workflow` execution seam | Agent Framework Core (Python) | TBD | Committed (existing); host wraps workflow outputs into `HostedRunResult`/`HostedStreamResult` | -| `AgentSession` / conversation primitives | Agent Framework Core (Python) | TBD | Committed (existing); cross-package storage standardization deferred | -| Starlette | External (BSD-licensed) | n/a | Committed; required runtime dep of `agent-framework-hosting` | -| Uvicorn | External (BSD-licensed) | n/a | Open Question — required dep vs optional extra (see Open Questions) | -| `agent-framework-foundry-hosting` parity reference | Agent Framework Hosting | TBD | Reference-only, no runtime dependency | -| `FoundryHostedAgentHistoryProvider` (in `agent-framework-foundry-hosting`, built on `azure.ai.agentserver.responses.store._foundry_provider.FoundryStorageProvider`) | Agent Framework Foundry | TBD | Proposed v1 deliverable so Foundry-defined (and any other) agents can use Foundry's response store as a `HistoryProvider` through the new host. Implements the standard core `HistoryProvider` Protocol — usable from any channel, no Responses-specific Protocol. Owns a runtime dep on `azure.ai.agentserver` for the storage SDK. | -| PR #5393 Telegram sample (commands, polling/webhook patterns) | Agent Framework | PR author | Reference-only; informs `ChannelCommand` and `TelegramChannel` design | -| Telegram Bot API SDK | External | n/a | Committed (runtime dep of `agent-framework-hosting-telegram`) | -| `microsoft/teams.py` SDK (`microsoft-teams-apps`, `microsoft-teams-api`, `microsoft-teams-cards`) | External (MIT, Microsoft) | n/a | Proposed runtime dep of `agent-framework-hosting-teams` (req #29). The SDK already ships a "Build an agent using Microsoft Agent Framework" guide and a pluggable `HttpServerAdapter`, so the hosting package mounts the SDK's `App` into the host's Starlette app and reuses its Adaptive Cards / Streaming / Citations / Feedback / Suggested-prompts / Dialogs / Message-Extensions / SSO surface instead of re-implementing them. | -| `agent-framework-ag-ui`, `-a2a`, `-devui` | Agent Framework | various | Out of scope for first implementation; future convergence kept as a possibility | - -## Open Questions - -| # | Question | On Point | Notes | -|---|---|---|---| -| 5 | How much of the Responses Conversations API should the Responses channel own vs a future shared conversation utility? | Eng / PM | Tied to whether session storage gets standardized. | -| 6 | Should a later phase define a pluggable session store interface? | Eng | Needs to be designed **holistically across all storage axes** — sessions, messages, identity links, run-state / continuation tokens, workflow checkpoints — rather than per-axis. Tracked as v1 fast-follow / requirement #23. | -| 8 | Should command scopes / projection metadata become first-class — e.g. private-chat-only vs group-chat-visible commands, or per-locale descriptions? | Eng / PM | Telegram's `BotCommandScope` and `language_code` would need to be representable cross-channel. | -| 10 | Is "Channel" the GA name? "Head" was used interchangeably during design discussions. | PM | "Channel" chosen for the spec; confirm before public docs. | -| 12 | Should `ChannelRequest.session_mode` grow additional values (e.g. `"shared"` for multi-channel session sharing) or stay closed at three? | Eng | The taxonomy needs a **dedicated design exercise** covering all known channel session-shape patterns; revisit after that exercise. | -| 14 | Where do issued link grants live — short-lived in-memory state on the host, the same pluggable session store (#24), or a separate identity store? | Eng | Resolved as part of the **`HostStateStore`** seam (see [Host state storage](#host-state-storage)). Link grants live alongside continuation tokens and last-seen records in the v1 file-based default (`FileHostStateStore` → `link_grants/` namespace, 15min TTL). Pluggable Cosmos / SQL / Redis adapters tracked in req #24. **→ Move to Resolved Questions in next pass.** | -| 17 | Should `ResponseTarget.active` honor a configurable **time window** (last seen within N minutes) and what is the fallback when the window has expired before the response is ready — `originating`, `all_linked`, drop with `ContinuationToken` `status="failed"`? | PM / Eng | Likely yes with sensible default (e.g. 24h fall back to `originating`); per-request override via the run hook. | -| 22 | For the Responses WebSocket transport, what subprotocol identifier (if any) should be advertised on the `Upgrade` and how is auth conveyed — `Authorization` header on the upgrade, a `Sec-WebSocket-Protocol` token, or a query-string-bound short-lived token? | Eng / PM | Aligning with whatever OpenAI ships for Responses WS is preferable; keep the codec swappable so the channel can track upstream changes without breaking the host contract. | - -### Resolved Questions (decisions log) - -Original numbering preserved so external references (checkpoints, ADR cross-links) still resolve. Decisions captured here may imply spec-body changes elsewhere — see [Decisions-driven follow-ups](#decisions-driven-follow-ups) below. - -| # | Question | Decision | -|---|---|---| -| 1 | Final distribution package names? | `agent-framework-hosting` with suffixes (`-responses`, `-invocations`, `-telegram`, …). Public imports stay at `agent_framework.hosting`. | -| 2 | `uvicorn` required vs optional extra? | Use **hypercorn** instead of uvicorn; the `serve` extra remains optional. `host.app` is still the canonical server-agnostic ASGI surface. | -| 3 | Keep `HostedRunResult` wrapper or return `AgentResponse` directly? | **Keep `HostedRunResult`,** now shaped as a **generic typed envelope `HostedRunResult[TResult]`** (see Q31). It wraps both `AgentResponse` *and* `WorkflowRunResult`, and carries host-run metadata (resolved `session`) alongside the full-fidelity target output. | -| 4 | Where do generic auth helpers live? | Only the **mechanisms** live in core. Concrete implementations sit in their own packages when they pull dependencies; dep-free helpers may live in `hosting`. | -| 7 | `protocol_request` typed (`Any`) or typed kwargs? | **Keep `Any`.** | -| 9 | Allow nested routers / `path=""`? | **Yes.** The host developer is responsible for ensuring routes do not overlap. | -| 11 | Should the host support multiple targets? | **No** — final. Solve a layer above (an external router that owns multiple single-target hosts). | -| 13 | Which identity linkers ship in phase 1? | **Entra linker** (in the Entra package) + **one-time-code linker** (in core). Drop MFA for now; investigating additional linkers tracked as a follow-up. | -| 15 | Identity resolver invoked once on host vs per channel? | **Once on the host** with `ChannelIdentity(channel, native_id, ...)`. | -| 16 | Should `IdentityLinker` and `Channel` share a base `Contributor` protocol? | **A linker *is* a Channel — specialised.** Use the single Channel-shaped contract; collapse `IdentityLinker` into a Channel specialisation. | -| 18 | Contract for `ChannelPush` failures? | **The `DurableTaskRunner` owns retry and final-failure semantics**, per its `RetryPolicy`. Push handler exceptions are caught by the runner, which retries with backoff and ultimately marks the task `failed` when `max_attempts` is exhausted. Downstream push outcomes live in the runner's own log — there is no per-destination status surfaced on the message and no synchronous failure object returned to the caller. The host's internal `_deliver_response` helper returns `bool` (whether any work was scheduled) for the originating channel; observability for downstream pushes comes from the runner backend (TaskHub, Foundry durable tasks, log fields on `InProcessTaskRunner`). The earlier `DeliveryReport` value type has been removed. See [Intended targets + durable delivery](#intended-targets--durable-delivery) and [Durable task runner](#durable-task-runner). | -| 19 | `host.run_in_background(...)` `notify` callback? | Programmatic non-channel delivery will be expressed via the **`continuation_token`** mechanism (see Q20), not a separate `notify` callback. | -| 20 | Storage / TTL of `ContinuationToken`s? | **Done in this revision.** `ContinuationToken` is the type, with an opaque `token: str` field that channels surface to callers; equivalent continuation-token support is added to the **Invocations channel** alongside the existing Responses behaviour. Push-capable channels can still use it; default behaviour remains "push on completion", but the developer can choose other UX (poll-after-push, hybrid, …). Persistence is the **`HostStateStore`** seam — v1 default is **`FileHostStateStore`** (atomic JSON writes, 24h TTL on completed entries), so background runs survive host restarts. | -| 21 | Partial-failure surfacing for `all_linked`? | **Runner-only.** Originating-destination outcome is rendered synchronously on the originating channel's wire; the host's `_deliver_response` helper returns `bool` for the channel's own bookkeeping. Non-originating destinations are scheduled as `"hosting.push"` tasks on the `DurableTaskRunner`; per-task outcome (success / retried / terminal-failure) is observable via the runner's backend (TaskHub, Foundry durable tasks, structured log fields on `InProcessTaskRunner`). The host does not collate per-destination status back onto the message and no longer emits a `DeliveryReport`. | -| 23 | Share one backing store contract for host-level vs `ContextProvider`? | **Stay separate protocols** (current draft direction confirmed). A deployment may still bind both onto the same physical backend. | -| 24 | Where does the Foundry history provider live? | Tentative name **`FoundryHostedAgentHistoryProvider`**, in the **`foundry-hosting`** package (shares the dependency). Confirm with Foundry package owners before launch. | -| 25 | `Channel.confidentiality_tier` opaque vs enum? | Keep as `str?` for now; can revisit before Release. | -| 26 | Where does the delivery-replay mechanism live? | **In the `DurableTaskRunner`.** Durable adapters (TaskHub, Foundry durable tasks) provide retry-with-backoff and survive host restarts natively — replay is "the runner keeps retrying until `max_attempts` is exhausted or the push succeeds". The built-in `InProcessTaskRunner` retries within the process but does **not** survive restarts (in-flight tasks are lost). Operator-driven replay (`host.replay(task_handle)`) is out of scope for v1; the runner's own surface is sufficient for the common case. | -| 28 | Should the host collapse agent / workflow output to text? | **No.** `HostedRunResult[TResult]` carries the target output **unchanged** — full `AgentResponse` (with its multi-modal `messages`, `value`, `usage_details`) for agent targets, full `WorkflowRunResult` (with its `get_outputs()` / `get_final_state()`) for workflow targets. Channels decide what subset their wire renders; a `response_hook` may rebind `result` (e.g. project a workflow output into an `AgentResponse` for a text-only wire) via `HostedRunResult.replace(result=...)`. The host never loses fidelity it has, and never restricts modality. | -| 29 | How do channels do per-destination post-processing (text flattening, card rendering, citation attachment) without breaking the `Channel` Protocol? | **Channels expose a `response_hook` instance attribute** (callable accepting `(result, *, context: ChannelResponseContext) -> HostedRunResult[Any] \| Awaitable[HostedRunResult[Any]]`). The host duck-types this attribute and applies it on a per-destination clone of the `HostedRunResult` envelope before push. The `Channel` Protocol stays a small `name / path / contribute` contract — adding hook support to a new channel does not require Protocol changes. | -| 30 | Should non-originating destinations also see the user's input message, not just the agent reply? | **Opt-in via `ResponseTarget.channel(name, echo_input=True)`** (and the same kwarg on `.channels([...])` / `.identities([...])`). The host synthesises a `HostedRunResult[AgentResponse]` wrapping the user's input as a `role="user"` message and bundles it into the same scheduled push task as the agent reply per non-originating destination; the echo is dispatched first inside the task and an echo-push failure is logged and swallowed so the response push on the same destination is still attempted. Channels can transform or drop echoes via their `response_hook` (which receives `is_echo=True` for the echo phase). | -| 31 | Should `HostedRunResult` be flattened (text / messages) or carry the full target output? | **Carry the full target output, generically typed.** `HostedRunResult[TResult]` exposes a single `result: TResult` field — `AgentResponse` for agent targets, `WorkflowRunResult` for workflow targets — plus an optional `session: AgentSession \| None`. Earlier drafts carried a flattened `messages: list[Message]` projection alongside `raw_response`; this lost workflow-specific affordances (`get_outputs()`, `get_final_state()`, structured per-executor payloads) and forced the host to pre-shape data only some channels needed. The generic envelope keeps the host modality-agnostic, lets channels read the canonical accessor on the underlying type (`result.messages`, `result.value`, `result.get_outputs()`, …), and gives channel authors static typing where they want it. | -| 32 | 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 \| Literal["inherit"] \| None = "inherit"`)** plus named `AuthPolicy` factories for the three common combinations. A single enum collapses `require_link` and `allowlist` into one axis and cannot express the Mixed profile (`AnyOfAllowlists(NativeIdAllowlist, LinkedClaimAllowlist)` with `require_link=False` — native ids bypass auth, everyone else is funneled into linking) without re-introducing per-value sub-parameters that would defeat the point. Composition is built on a **tri-state `AllowlistDecision` (`ALLOW` / `DENY` / `ABSTAIN`)** rather than a boolean, because boolean composition cannot distinguish "claim allowlist denies you" from "claim allowlist hasn't seen any claims yet" — a critical distinction for the Mixed profile. `LinkedClaimAllowlist` is rejected at host startup if no source of verified claims is available (config validator, fail-fast), preventing the silent-deny-everyone footgun. Group-chat denials apply the same DM-redirect pattern as `LinkChallenge` (short generic refusal in-room, fuller `user_message` in DM, structured `log_details` only in logs). Shipping in two waves: the Protocol + `NativeIdAllowlist` + config validator ship with the next core PR; full `host.authorize(...)` pipeline + `LinkedClaimAllowlist` enforcement land with the `IdentityLinker` core PR. See [Authorization profiles and the IdentityAllowlist seam](#authorization-profiles-and-the-identityallowlist-seam). | -| 33 | How does the host decide whether it is running long-running vs ephemeral? | **Single `runtime_mode` parameter on `AgentFrameworkHost`**, defaulting to `None` for auto-detection. Auto-detect 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 is **advisory** — it drives *defaults* for `HostStateStore`, `DurableTaskRunner`, identity-link state, and similar seams, but every individual choice remains overridable. Detected mode is logged at startup so misdetection is visible. See [Runtime modes](#runtime-modes). | -| 34 | How does delivery to non-originating destinations actually happen — synchronously in the originating request handler, or out-of-band? | **Out-of-band via a `DurableTaskRunner`.** The host registers an internal handler `"hosting.push"` at startup; each non-originating destination becomes a single `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. See [Durable task runner](#durable-task-runner). | -| 35 | 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 previous `deliveries[]` status state machine (`pending`/`delivered`/`failed`/`skipped`), the `SupportsDeliveryTracking` provider capability, and the Foundry `update_item` service ask. See [Intended targets + durable delivery](#intended-targets--durable-delivery). | -| 36 | What happens when `runtime_mode="ephemeral"` and no `durable_task_runner` is configured? | **Raise at construction.** Silently falling back to `InProcessTaskRunner` in an ephemeral environment would drop every in-flight push on the next scale-to-zero — a footgun. The host raises `RuntimeError` unless `allow_in_process_runner=True` is opted in (warning logged). The opt-in is intended for local-dev / smoke tests where the developer accepts the in-flight loss. See [Durable task runner](#durable-task-runner). | -| 37 | What is the wire contract for push payloads under a durable (out-of-process) runner? | **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). Channels that ship non-JSON-native payloads expose a `ChannelPushCodec` (`encode` / `decode`). At construction the host runs `_validate_runner_codec_pairing` and refuses a `JSON`-mode runner paired with codec-less push channels. The push handler accepts both `OBJECT` and `JSON` envelope shapes so the same handler serves both runner backends. See [Codec contract for durable serialisation](#codec-contract-for-durable-serialisation). | -| 38 | Should `DeliveryReport` remain as a per-destination return value? | **No — removed.** Operational state lives in the runner; observability comes from the runner's backend (TaskHub, Foundry durable tasks, structured log fields on `InProcessTaskRunner`). The host's internal `_deliver_response` helper now returns `bool` (whether any work was scheduled / delivered) for the originating channel's own bookkeeping. Removing the value type collapses the public surface and removes a coupling point that would have needed a "schedule-time failure" subtype to round-trip durable failures back to the caller — failures live where they originate (the runner), not on a parallel object passed back through the synchronous return. | -| 39 | How is double-echo avoided when a push task retries after the echo phase succeeded but the response phase failed? | **An `echo_done` cursor on the runner-owned task state.** When `echo_input=True`, the `"hosting.push"` handler packages both the echo (`role="user"`) and the assistant reply into the same task; on the first attempt the handler dispatches the echo, sets `echo_done=True` on the task state, and then dispatches the reply. A retry that fires after the echo succeeded but the reply failed reads the cursor and short-circuits the echo phase. The cursor lives in the runner — same principle as the broader "intent only on the message, operational state in the runner" rule. See [Echo idempotency on retry](#echo-idempotency-on-retry). | -| 40 | What happens to in-flight `"hosting.push"` tasks on a clean `InProcessTaskRunner` shutdown? | **Two-phase drain.** A `shutdown_grace_seconds` window (default `5.0`) lets in-flight retries finish; remaining tasks are then cancelled and `CancelledError` is swallowed (not logged as a failure — it is the expected shutdown shape). Operators with longer worst-case retry chains can extend the grace via the constructor. Durable adapters get equivalent behaviour from their backends. See [In-process runner shutdown drain](#in-process-runner-shutdown-drain). | - -### Decisions-driven follow-ups - -The following resolutions imply prose / API edits elsewhere in the spec body (not just the table above). Captured here so they aren't lost; the edits themselves are deferred to a separate pass. - -- **Q2** — Switch all install / `host.serve()` references from `uvicorn` to `hypercorn`. -- **Q3** — ✅ Done. `HostedRunResult[TResult]` is now generic over the target output type; see Q31 below for the rationale. -- **Q11** — Strip any remaining "multi-target hedge" language from the spec body. -- **Q13** — Update the linker catalogue: Entra (in Entra package) + one-time-code (in core); remove MFA references. -- **Q16** — Collapse `IdentityLinker` into a Channel specialisation in the spec body (architecture diagrams, contracts, examples). -- **Q20** — ✅ Done. `ContinuationToken` type carries an opaque `token: str`; routes use `/{continuation_token}`; Invocations channel gets equivalent continuation-token support; persistence via `HostStateStore` (v1 default file-based). -- **Q32** — Spec text added (see [Authorization profiles and the IdentityAllowlist seam](#authorization-profiles-and-the-identityallowlist-seam) and req #22). The core PR includes `IdentityAllowlist` Protocol, `AllowlistDecision` enum, `AuthorizationContext`, `AllowAll` / `NativeIdAllowlist` / `LinkedClaimAllowlist` / `AnyOfAllowlists` / `AllOfAllowlists` / `CallableAllowlist` built-ins, `IdentityLinker` Protocol, `LinkedIdentity`, `LinkChallenge`, `AuthPolicy` factories, `Allowed` / `LinkRequired` / `Denied` outcomes, `Host(default_allowlist=..., identity_linker=...)` + per-channel `allowlist` parameter, construction-time validator (rules #1 + #2 + #3 — `require_link=True` without `identity_linker` now raises), and `host.authorize(...)` for open, native-id, and linked-claim profiles. Provider-specific linkers (for example Entra OAuth helpers) are separate channel/helper packages. -- **Q36 / Q37 / Q38 / Q39 / Q40** — Spec text added: strict-ephemeral default + `allow_in_process_runner` opt-in in §[Durable task runner](#durable-task-runner); new sub-sections [Codec contract for durable serialisation](#codec-contract-for-durable-serialisation), [In-process runner shutdown drain](#in-process-runner-shutdown-drain), [Echo idempotency on retry](#echo-idempotency-on-retry); `DeliveryReport` references purged from §[Intended targets + durable delivery](#intended-targets--durable-delivery) and Qs 18 / 21. Code lands in this core PR: `DurableTaskPayloadMode` + `ChannelPushCodec` + `PushPayloadNotSerializable` exception in `_types.py`; `_validate_runner_codec_pairing` + dual-mode `_handle_push_task` + `_build_push_payload` + `echo_done` cursor + `_annotate_intended_targets` in `_host.py`; `shutdown_grace_seconds` + 2-phase drain in `_runner.py`. -- **Q33 / Q34 / Q35** — Spec text added: new top-level §[Runtime modes](#runtime-modes), rewritten §[Intended targets + durable delivery](#intended-targets--durable-delivery), new §[Durable task runner](#durable-task-runner). Code lands in this core PR: `DurableTaskRunner` Protocol + `InProcessTaskRunner` + `runtime_mode` constructor parameter + auto-detection. Durable runner adapters (`agent-framework-hosting-durabletask`, Foundry adapter) are separate follow-up packages tracked under §[Decisions-driven follow-ups](#decisions-driven-follow-ups). Bumping req #14 (background runs) to share the same runner is a non-goal of this PR — the `ContinuationToken` machinery and the runner can be wired together in a later pass without re-shaping either contract. +- a sample uses one `AgentFrameworkHost` with multiple channels and no manual Starlette route composition, +- each current channel has contract tests for route contribution, lifecycle, request parsing, hooks, and originating response rendering, +- session tests prove shared `isolation_key` values share an `AgentSession` and `reset_session` rotates it, +- workflow tests or samples use explicit `checkpoint_location`, +- Foundry isolation middleware is covered by integration or contract tests, +- no v1 package exposes the removed linking, multicast, durable-runner, or continuation APIs, and +- this spec and ADR-0027 remain aligned. diff --git a/python/packages/hosting-activity-protocol/agent_framework_hosting_activity_protocol/_channel.py b/python/packages/hosting-activity-protocol/agent_framework_hosting_activity_protocol/_channel.py index 4fbca0003a..0c7e623790 100644 --- a/python/packages/hosting-activity-protocol/agent_framework_hosting_activity_protocol/_channel.py +++ b/python/packages/hosting-activity-protocol/agent_framework_hosting_activity_protocol/_channel.py @@ -90,14 +90,10 @@ from agent_framework_hosting import ( ChannelContribution, ChannelIdentity, ChannelRequest, - ChannelResponseContext, ChannelResponseHook, ChannelRunHook, ChannelSession, - ChannelStreamTransformHook, - HostedRunResult, - apply_response_hook, - apply_run_hook, + ChannelStreamUpdateHook, logger, ) from azure.core.credentials_async import AsyncTokenCredential @@ -151,11 +147,6 @@ class _OutboundError(RuntimeError): """Marker for transient outbound failures that should produce 502/retry.""" -def _text_result(text: str) -> HostedRunResult[AgentResponse]: - """Wrap plain text in a ``HostedRunResult`` for streaming fan-out delivery.""" - return HostedRunResult(AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(text=text)])])) - - def _parse_activity(activity: Mapping[str, Any]) -> Message: """Translate one Bot Framework ``message`` Activity into an Agent Framework Message. @@ -231,7 +222,7 @@ class ActivityProtocolChannel: When ``stream=True`` (default), the channel sends an initial placeholder activity, then edits it in place as the agent emits ``AgentResponseUpdate`` chunks (``PUT /v3/conversations/{id}/activities/{id}``). When ``stream=False`` - it just sends the final reply. A ``stream_transform_hook`` can rewrite or + it just sends the final reply. A ``stream_update_hook`` can rewrite or drop individual updates before they hit the wire. """ @@ -253,7 +244,7 @@ class ActivityProtocolChannel: response_hook: ChannelResponseHook | None = None, send_typing_action: bool = True, stream: bool = True, - stream_transform_hook: ChannelStreamTransformHook | None = None, + stream_update_hook: ChannelStreamUpdateHook | None = None, stream_edit_min_interval: float = 0.7, inbound_auth_validator: InboundAuthValidator | None = None, service_url_allowed_hosts: tuple[str, ...] = _DEFAULT_SERVICE_URL_HOSTS, @@ -290,17 +281,15 @@ class ActivityProtocolChannel: Unknown ``/foo`` text falls through to the agent. Handlers reply via ``ChannelCommandContext.reply``; surface them to users with a Teams manifest ``commandLists`` entry. - run_hook: Optional rewrite of ``ChannelRequest`` before invocation. + run_hook: Optional rewrite of ``ChannelRequest`` before invocation; + the host owns invocation of this hook. response_hook: Optional rewrite of the :class:`HostedRunResult` before the originating Activity - reply is serialized. The host also invokes this hook when - delivering to this channel as a non-originating push - destination. + reply is serialized; the host owns invocation of this hook. send_typing_action: Whether to send ``typing`` activities while the agent runs. - stream: Whether to stream by default. ``run_hook`` can flip per - request. - stream_transform_hook: Optional rewrite of each + stream: Whether to stream by default. + stream_update_hook: Optional rewrite of each ``AgentResponseUpdate`` before it hits the wire. stream_edit_min_interval: Seconds between successive in-place edits. Teams is more rate-sensitive than Telegram, so default @@ -341,7 +330,7 @@ class ActivityProtocolChannel: self.response_hook = response_hook self._send_typing_action = send_typing_action self._stream_default = stream - self._stream_transform_hook = stream_transform_hook + self._stream_update_hook = stream_update_hook self._stream_edit_min_interval = stream_edit_min_interval self._inbound_auth_validator = inbound_auth_validator self._service_url_allowed_hosts = tuple(h.lower().lstrip(".") for h in service_url_allowed_hosts) @@ -557,11 +546,10 @@ class ActivityProtocolChannel: return parsed = _parse_activity(activity) - # Store a Bot Framework conversation reference on the identity so the - # host can proactively ``push`` to this conversation later (fan-out - # from another channel). Recording the identity also registers this - # channel under the isolation key so ``ResponseTarget.all_linked`` / - # ``.active`` can resolve it. + # Store a Bot Framework conversation reference on the identity so + # channel hooks and command handlers can inspect it. Cross-channel + # proactive delivery is a follow-up enhancement outside the v1 host + # contract. identity = ChannelIdentity( channel=self.name, native_id=conversation_id, @@ -591,14 +579,6 @@ class ActivityProtocolChannel: metadata={"reply_to_id": activity.get("id"), "recipient": activity.get("recipient")}, stream=self._stream_default, ) - if self._hook is not None: - channel_request = await apply_run_hook( - self._hook, - channel_request, - target=self._ctx.target, - protocol_request=activity, - ) - await self._dispatch(activity, channel_request) async def _invoke_command( @@ -647,13 +627,6 @@ class ActivityProtocolChannel: }, metadata={"reply_to_id": activity.get("id"), "recipient": activity.get("recipient")}, ) - if self._hook is not None: - request = await apply_run_hook( - self._hook, - request, - target=self._ctx.target, - protocol_request=activity, - ) async def _reply(body: str) -> None: await self._send_message(activity, body) @@ -679,33 +652,26 @@ class ActivityProtocolChannel: await self._send_typing(inbound) if not request.stream: - result = await self._ctx.run(request) - include_originating = await self._ctx.deliver_response(request, result) - if include_originating: - result = await self._apply_response_hook(result, request) - text = getattr(result.result, "text", None) or "(no response)" - await self._send_message(inbound, text) + result = await self._ctx.run( + request, + run_hook=self._hook, + protocol_request=inbound, + response_hook=self.response_hook, + channel_name=self.name, + ) + text = getattr(result.result, "text", None) or "(no response)" + await self._send_message(inbound, text) return - stream = self._ctx.run_stream(request) - await self._stream_to_conversation(inbound, request, stream) - - async def _apply_response_hook( - self, - result: HostedRunResult[Any], - request: ChannelRequest, - ) -> HostedRunResult[Any]: - """Apply the channel-level response hook for an originating reply.""" - if self.response_hook is None: - return result - context = ChannelResponseContext( - request=request, + stream = await self._ctx.run_stream( + request, + run_hook=self._hook, + protocol_request=inbound, + stream_update_hook=self._stream_update_hook, + response_hook=self.response_hook, channel_name=self.name, - destination_identity=None, - originating=True, - is_echo=False, ) - return await apply_response_hook(self.response_hook, result, context=context) + await self._stream_to_conversation(inbound, request, stream) async def _stream_to_conversation( self, @@ -799,13 +765,6 @@ class ActivityProtocolChannel: try: async for update in stream: - if self._stream_transform_hook is not None: - transformed = self._stream_transform_hook(update) - if isinstance(transformed, Awaitable): - transformed = await transformed - if transformed is None: - continue - update = transformed chunk = getattr(update, "text", None) if chunk: accumulated += chunk @@ -821,39 +780,28 @@ class ActivityProtocolChannel: logger.exception("Activity edit worker crashed") try: - await stream.get_final_response() + final = await stream.get_final_response() except Exception: # pragma: no cover logger.exception("Stream finalize failed") - - # Fan the final reply out to any non-originating linked destinations - # (e.g. ``ResponseTarget.all_linked``) and learn whether this channel - # should still render on its own wire. For the default - # ``ResponseTarget.originating`` this is a no-op that returns True. - # Always consult the host even when nothing streamed so that - # ``ResponseTarget.none`` is honoured and non-originating targets are - # still fanned out for empty replies. - include_originating = True - if self._ctx is not None: - include_originating = await self._ctx.deliver_response(request, _text_result(accumulated)) - if not include_originating: - return + final = None + final_text = getattr(final, "text", None) or accumulated # Final flush — make sure the user sees everything that arrived after # the worker's last edit. If the placeholder failed, or the channel # turned out not to support edits (405), POST a fresh activity here # with whatever accumulated rather than PUT-editing the placeholder. if not placeholder_ok or edit_unsupported: - text = accumulated or "(no response)" + text = final_text or "(no response)" try: await self._send_message(inbound, text) except Exception: # pragma: no cover logger.exception("Activity fallback final send failed") - elif activity_id is not None and accumulated and accumulated != last_sent: + elif activity_id is not None and final_text and final_text != last_sent: try: - await self._update_activity(inbound, activity_id, accumulated) + await self._update_activity(inbound, activity_id, final_text) except Exception: # pragma: no cover logger.exception("Activity final edit failed") - elif not accumulated and activity_id is not None: + elif not final_text and activity_id is not None: # No text streamed — replace the placeholder with a stub so the # user isn't left staring at "…". try: @@ -875,19 +823,12 @@ class ActivityProtocolChannel: ``PUT /v3/conversations/{id}/activities/{id}``, so the progressive in-place edit cannot be used; we buffer the stream and ``POST`` a single message at the end. Mirrors the non-streaming path's - fan-out + response-hook semantics so behaviour is consistent - regardless of whether the target streamed. + response-hook semantics so behaviour is consistent regardless of + whether the target streamed. """ accumulated = "" try: async for update in stream: - if self._stream_transform_hook is not None: - transformed = self._stream_transform_hook(update) - if isinstance(transformed, Awaitable): - transformed = await transformed - if transformed is None: - continue - update = transformed chunk = getattr(update, "text", None) if chunk: accumulated += chunk @@ -895,23 +836,11 @@ class ActivityProtocolChannel: logger.exception("Activity streaming consumption failed") try: - await stream.get_final_response() + final = await stream.get_final_response() except Exception: # pragma: no cover logger.exception("Stream finalize failed") - - # Fan the final reply out to any non-originating linked destinations - # and learn whether this channel should still render on its own wire. - # Always consult the host even when nothing streamed so that - # ``ResponseTarget.none`` is honoured and non-originating targets are - # still fanned out for empty replies. - include_originating = True - if self._ctx is not None: - include_originating = await self._ctx.deliver_response(request, _text_result(accumulated)) - if not include_originating: - return - - result = await self._apply_response_hook(_text_result(accumulated), request) - text = getattr(result.result, "text", None) or "(no response)" + final = None + text = getattr(final, "text", None) or accumulated or "(no response)" try: await self._send_message(inbound, text) except Exception: # pragma: no cover @@ -998,56 +927,5 @@ class ActivityProtocolChannel: except Exception: # pragma: no cover - non-critical UX logger.exception("Teams typing send failed") - # -- ChannelPush -------------------------------------------------------- # - - async def push(self, identity: ChannelIdentity, payload: HostedRunResult[Any]) -> None: - """Proactively deliver an out-of-band message into a Bot Framework conversation. - - Implements :class:`host.ChannelPush` so this channel can be a - non-originating destination for ``ChannelRequest.response_target`` - (e.g. ``ResponseTarget.all_linked`` fan-out from Telegram/Discord, or - ``echo_input`` replay). The conversation reference is reconstructed - from ``identity.attributes`` captured on the inbound activity: - ``service_url``, ``conversation``, ``bot`` (outbound ``from``), - ``user`` (outbound ``recipient``), and ``channel_id``. - - Echo payloads (the user's mirrored input) carry ``role="user"`` - messages; Bot Service channels can only send AS the bot, so the text - is delivered as a normal bot message. - """ - if self._http is None: - raise RuntimeError("ActivityProtocolChannel.push called before startup") - attrs = identity.attributes - service_url = str(attrs.get("service_url") or "").rstrip("/") - conversation = dict(attrs.get("conversation") or {"id": identity.native_id}) - conversation_id = conversation.get("id") or identity.native_id - if not service_url: - raise ValueError("ActivityProtocolChannel.push requires 'service_url' in identity attributes") - # Re-validate the persisted ``service_url`` against the allow-list. The - # identity may have been recorded hours earlier (push runs out-of-band), - # so the allow-list could have narrowed or the store been tampered with - # since; never send a bearer token to a now-disallowed host. - if not self._is_service_url_allowed(service_url): - raise ValueError(f"ActivityProtocolChannel.push: service_url {service_url!r} is not in the allowed hosts") - - text = getattr(payload.result, "text", None) or "(no response)" - activity = { - "type": "message", - "from": dict(attrs.get("bot") or {}), - "recipient": dict(attrs.get("user") or {}), - "conversation": conversation, - "channelId": attrs.get("channel_id"), - "serviceUrl": attrs.get("service_url"), - "text": text, - "textFormat": "markdown", - } - if attrs.get("locale"): - activity["locale"] = attrs["locale"] - - url = f"{service_url}/v3/conversations/{conversation_id}/activities" - token = await self._get_token() - response = await self._http.post(url, json=activity, headers=self._auth_headers(token)) - response.raise_for_status() - __all__ = ["ActivityProtocolChannel", "activity_protocol_isolation_key"] diff --git a/python/packages/hosting-activity-protocol/tests/test_channel.py b/python/packages/hosting-activity-protocol/tests/test_channel.py index 0f3d1bf0bd..002003faf3 100644 --- a/python/packages/hosting-activity-protocol/tests/test_channel.py +++ b/python/packages/hosting-activity-protocol/tests/test_channel.py @@ -9,7 +9,7 @@ streaming edits and certificate paths are out of scope here. from __future__ import annotations -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -18,15 +18,13 @@ from agent_framework_hosting import ( AgentFrameworkHost, ChannelCommand, ChannelCommandContext, - ChannelIdentity, ChannelRequest, - ChannelSession, HostedRunResult, ) from starlette.testclient import TestClient from agent_framework_hosting_activity_protocol import ActivityProtocolChannel, activity_protocol_isolation_key -from agent_framework_hosting_activity_protocol._channel import _command_text, _parse_activity, _text_result +from agent_framework_hosting_activity_protocol._channel import _command_text, _parse_activity def test_activity_protocol_isolation_key_format() -> None: @@ -139,6 +137,26 @@ class _FakeAgentResponse: text: str +@dataclass +class _FakeUpdate: + text: str + + +class _FakeStream: + def __init__(self, chunks: list[str]) -> None: + self._chunks = chunks + + def __aiter__(self) -> Any: + async def gen() -> Any: + for chunk in self._chunks: + yield _FakeUpdate(chunk) + + return gen() + + async def get_final_response(self) -> _FakeAgentResponse: + return _FakeAgentResponse(text="".join(self._chunks)) + + class _FakeAgent: def __init__(self, reply: str = "ok") -> None: self._reply = reply @@ -149,6 +167,8 @@ class _FakeAgent: def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any: self.runs.append({"messages": messages, "stream": stream, "kwargs": kwargs}) + if stream: + return _FakeStream([self._reply]) async def _coro() -> _FakeAgentResponse: return _FakeAgentResponse(text=self._reply) @@ -183,9 +203,7 @@ _VALID_ACTIVITY: dict[str, Any] = { "serviceUrl": "https://smba.trafficmanager.net/amer/", } -# Minimal request envelope for direct ``_stream_to_conversation`` calls. The -# channel only consults it for cross-channel fan-out, which is skipped when -# ``_ctx`` is unset (as in these unit tests). +# Minimal request envelope for direct ``_stream_to_conversation`` calls. _VALID_REQUEST = ChannelRequest(channel="activity", operation="message.create", input=[]) @@ -214,10 +232,10 @@ class TestTeamsWebhook: assert agent.runs, "expected the agent to be invoked" def test_response_hook_can_rewrite_originating_reply(self) -> None: - contexts: list[Any] = [] + seen_kwargs: list[dict[str, Any]] = [] def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult: - contexts.append(kwargs["context"]) + seen_kwargs.append(dict(kwargs)) return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session) ch, agent = _make_teams() @@ -231,9 +249,8 @@ class TestTeamsWebhook: assert ch._http is not None body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined] assert body["text"] == "HI THERE" - assert contexts - assert contexts[0].channel_name == "activity" - assert contexts[0].originating is True + assert seen_kwargs + assert seen_kwargs[0]["channel_name"] == "activity" def test_non_message_activities_are_acked(self) -> None: ch, agent = _make_teams() @@ -346,10 +363,7 @@ class TestCommands: assert r.status_code == 200 assert not agent.runs - def test_run_hook_applied_to_command_request(self) -> None: - def hook(request: ChannelRequest, **_: Any) -> ChannelRequest: - return replace(request, session=ChannelSession(isolation_key="resolved-key")) - + def test_command_request_uses_activity_session(self) -> None: captured: list[str] = [] async def handle(ctx: ChannelCommandContext) -> None: @@ -358,7 +372,6 @@ class TestCommands: agent = _FakeAgent("hi") ch = ActivityProtocolChannel(send_typing_action=False, commands=[ChannelCommand("todos", "x", handle)]) - ch._hook = hook fake_http = MagicMock() response_mock = MagicMock() response_mock.raise_for_status = MagicMock() @@ -370,7 +383,7 @@ class TestCommands: with TestClient(host.app) as client: r = client.post("/activity/messages", json=dict(_VALID_ACTIVITY, text="/todos")) assert r.status_code == 200 - assert captured == ["resolved-key"] + assert captured == [activity_protocol_isolation_key("19:meeting_xyz@thread.v2")] class TestOutbound: @@ -385,79 +398,9 @@ class TestOutbound: assert body["text"] == "hi" -class TestPush: - """The channel implements ``host.ChannelPush`` so it can be a - non-originating destination for cross-channel fan-out / echo replay.""" - - def test_is_channel_push_instance(self) -> None: - from agent_framework_hosting import ChannelPush - - ch, _agent = _make_teams() - assert isinstance(ch, ChannelPush) - - def _identity(self) -> ChannelIdentity: - return ChannelIdentity( - channel="activity", - native_id="19:meeting_xyz@thread.v2", - attributes={ - "service_url": "https://smba.trafficmanager.net/amer/", - "conversation": {"id": "19:meeting_xyz@thread.v2"}, - "bot": {"id": "bot-1"}, - "user": {"id": "user-1"}, - "channel_id": "msteams", - "locale": "en-US", - }, - ) - - async def test_push_posts_proactive_activity(self) -> None: - ch, _agent = _make_teams() - await ch.push(self._identity(), _text_result("broadcast hello")) - assert ch._http is not None - ch._http.post.assert_called() # type: ignore[attr-defined] - url = ch._http.post.call_args[0][0] # type: ignore[attr-defined] - assert url == ("https://smba.trafficmanager.net/amer/v3/conversations/19:meeting_xyz@thread.v2/activities") - body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined] - assert body["text"] == "broadcast hello" - # Outbound activity speaks AS the bot: inbound recipient -> from, - # inbound from -> recipient. - assert body["from"] == {"id": "bot-1"} - assert body["recipient"] == {"id": "user-1"} - assert body["conversation"] == {"id": "19:meeting_xyz@thread.v2"} - - async def test_push_requires_service_url(self) -> None: - ch, _agent = _make_teams() - identity = ChannelIdentity( - channel="activity", - native_id="conv-x", - attributes={"conversation": {"id": "conv-x"}}, - ) - with pytest.raises(ValueError, match="service_url"): - await ch.push(identity, _text_result("hi")) - - async def test_push_rejects_disallowed_service_url(self) -> None: - # ``push`` runs out-of-band against a persisted identity, so it must - # re-validate the service_url against the allow-list rather than trust - # the value captured (possibly hours) earlier. - ch, _agent = _make_teams() - identity = ChannelIdentity( - channel="activity", - native_id="conv-x", - attributes={ - "service_url": "https://attacker.example.com/", - "conversation": {"id": "conv-x"}, - "bot": {"id": "bot-1"}, - "user": {"id": "user-1"}, - }, - ) - with pytest.raises(ValueError, match="not in the allowed hosts"): - await ch.push(identity, _text_result("hi")) - assert ch._http is not None - ch._http.post.assert_not_called() # type: ignore[attr-defined] - - class TestIdentityRecording: """``_process_activity`` must stamp the inbound conversation reference - onto ``ChannelRequest.identity`` so the host can record it for fan-out.""" + onto ``ChannelRequest.identity`` so hooks and commands can inspect it.""" async def test_inbound_sets_request_identity(self) -> None: ch, agent = _make_teams() @@ -793,56 +736,6 @@ class TestStreaming: body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined] assert body["text"] == "(no response)" - async def test_buffer_empty_stream_consults_host_and_can_suppress(self) -> None: - # Empty streamed replies must still consult the host so that - # ``ResponseTarget.none`` (deliver_response -> False) suppresses the - # originating message instead of posting "(no response)". - ch, _agent = _make_teams(stream=True) - webchat_activity = {**_VALID_ACTIVITY, "channelId": "directline"} - ctx = MagicMock() - ctx.deliver_response = AsyncMock(return_value=False) - ch._ctx = ctx - - class _EmptyStream: - def __aiter__(self) -> Any: - async def gen() -> Any: - if False: - yield None # type: ignore[unreachable] - - return gen() - - async def get_final_response(self) -> Any: - return _FakeAgentResponse(text="") - - ch._stream_edit_min_interval = 0.0 - await ch._stream_to_conversation(webchat_activity, _VALID_REQUEST, _EmptyStream()) # type: ignore[arg-type] - assert ch._http is not None - ctx.deliver_response.assert_awaited_once() - ch._http.post.assert_not_called() # type: ignore[attr-defined] - ch._http.put.assert_not_called() # type: ignore[attr-defined] - - async def test_edit_empty_stream_consults_host_and_can_suppress(self) -> None: - # Same contract for the edit-capable (Teams) progressive path. - ch, _agent = _make_teams(stream=True) - ctx = MagicMock() - ctx.deliver_response = AsyncMock(return_value=False) - ch._ctx = ctx - - class _EmptyStream: - def __aiter__(self) -> Any: - async def gen() -> Any: - if False: - yield None # type: ignore[unreachable] - - return gen() - - async def get_final_response(self) -> Any: - return _FakeAgentResponse(text="") - - ch._stream_edit_min_interval = 0.0 - await ch._stream_to_conversation(_VALID_ACTIVITY, _VALID_REQUEST, _EmptyStream()) # type: ignore[arg-type] - ctx.deliver_response.assert_awaited_once() - async def test_edit_405_falls_back_to_single_post(self) -> None: # Defensive: a channel advertised as edit-capable that nonetheless # rejects the PUT with 405 must stop editing and POST the final diff --git a/python/packages/hosting-discord/agent_framework_hosting_discord/_channel.py b/python/packages/hosting-discord/agent_framework_hosting_discord/_channel.py index 18da136c0c..f0297898b0 100644 --- a/python/packages/hosting-discord/agent_framework_hosting_discord/_channel.py +++ b/python/packages/hosting-discord/agent_framework_hosting_discord/_channel.py @@ -9,11 +9,11 @@ import json import logging import re import time -from collections.abc import Awaitable, Callable, Coroutine, Mapping, Sequence +from collections.abc import Callable, Coroutine, Mapping, Sequence from typing import Any, cast import httpx -from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message, ResponseStream +from agent_framework import AgentResponse, AgentResponseUpdate, ResponseStream from agent_framework_hosting import ( ChannelCommand, ChannelCommandContext, @@ -24,10 +24,8 @@ from agent_framework_hosting import ( ChannelResponseHook, ChannelRunHook, ChannelSession, - ChannelStreamTransformHook, + ChannelStreamUpdateHook, HostedRunResult, - apply_channel_response_hook, - apply_run_hook, ) from nacl.exceptions import BadSignatureError from nacl.signing import VerifyKey @@ -75,11 +73,6 @@ def _default_isolation_key(interaction: DiscordInteraction) -> str: return discord_isolation_key(guild_id, channel_id, user_id) -def _text_result(text: str) -> HostedRunResult[AgentResponse]: - """Build a host delivery payload from text accumulated by this channel.""" - return HostedRunResult(AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(text=text)])])) - - class DiscordChannel: """Discord channel backed by signed HTTP Interactions.""" @@ -100,7 +93,7 @@ class DiscordChannel: commands: Sequence[ChannelCommand] | None = None, run_hook: ChannelRunHook | None = None, response_hook: ChannelResponseHook | None = None, - stream_transform_hook: ChannelStreamTransformHook | None = None, + stream_update_hook: ChannelStreamUpdateHook | None = None, streaming: bool = False, isolation_key_factory: DiscordIsolationKeyFactory | None = None, skip_signature_verification: bool = False, @@ -133,7 +126,7 @@ class DiscordChannel: it reaches the host. response_hook: Optional hook that can rewrite the hosted result before the originating Discord response is serialized. - stream_transform_hook: Optional per-update transform hook applied + stream_update_hook: Optional per-update hook applied while streaming. streaming: Whether the agent command should call ``run_stream`` and edit the original interaction response as deltas arrive. @@ -163,7 +156,7 @@ class DiscordChannel: self._command_by_name = {command.name: command for command in self._commands} self._run_hook = run_hook self.response_hook = response_hook - self._stream_transform_hook = stream_transform_hook + self._stream_update_hook = stream_update_hook self._streaming = streaming self._isolation_key_factory = isolation_key_factory or _default_isolation_key self._skip_signature_verification = skip_signature_verification @@ -190,25 +183,6 @@ class DiscordChannel: on_shutdown=[self._on_shutdown], ) - async def push(self, identity: ChannelIdentity, payload: HostedRunResult[Any]) -> None: - """Push a hosted result to a Discord channel. - - Args: - identity: Destination identity. ``identity.attributes`` must carry - ``channel_id``. - payload: Hosted run result to render as Discord message text. - - Raises: - RuntimeError: If the channel has no bot token for Discord REST. - ValueError: If ``channel_id`` is missing from the identity. - """ - channel_id = _string_or_none(identity.attributes.get("channel_id")) - if channel_id is None: - raise ValueError("Discord push requires identity.attributes['channel_id']") - if self.bot_token is None: - raise RuntimeError("DiscordChannel.push requires bot_token to send channel messages") - await self._send_channel_messages(channel_id, _payload_text(payload)) - async def _on_startup(self) -> None: """Open the Discord REST client and optionally register slash commands.""" self._ensure_http() @@ -297,23 +271,17 @@ class DiscordChannel: input_value=prompt, stream=self._streaming, ) - if self._run_hook is not None: - request = await apply_run_hook( - self._run_hook, - request, - target=self._ctx.target, - protocol_request=interaction, - ) if request.stream: - await self._run_streaming(request, token) + await self._run_streaming(request, token, protocol_request=interaction) return - result = await self._ctx.run(request) - include_originating = await self._ctx.deliver_response(request, result) - if include_originating: - result = await apply_channel_response_hook(self, result, request=request, originating=True) - await self._edit_original_with_result(token, result) - else: - await self._edit_original(token, "Sent.") + result = await self._ctx.run( + request=request, + run_hook=self._run_hook, + protocol_request=interaction, + response_hook=self.response_hook, + channel_name=self.name, + ) + await self._edit_original_with_result(token, result) async def _run_channel_command( self, @@ -333,23 +301,23 @@ class DiscordChannel: if not reply.sent: await self._edit_original(token, "Done.") - async def _run_streaming(self, request: ChannelRequest, token: str) -> None: + async def _run_streaming( + self, request: ChannelRequest, token: str, *, protocol_request: DiscordInteraction | None = None + ) -> None: if self._ctx is None: raise RuntimeError("DiscordChannel was not contributed to a host.") - stream: ResponseStream[AgentResponseUpdate, AgentResponse] = self._ctx.run_stream(request) + stream: ResponseStream[AgentResponseUpdate, AgentResponse] = await self._ctx.run_stream( + request, + run_hook=self._run_hook, + protocol_request=protocol_request, + stream_update_hook=self._stream_update_hook, + response_hook=self.response_hook, + channel_name=self.name, + ) accumulated: list[str] = [] last_edit = 0.0 async for update in stream: - transformed: AgentResponseUpdate | None = update - if self._stream_transform_hook is not None: - maybe = self._stream_transform_hook(update) - if isinstance(maybe, Awaitable): - transformed = await cast("Awaitable[AgentResponseUpdate | None]", maybe) - else: - transformed = maybe - if transformed is None: - continue - chunk = _update_text(transformed) + chunk = _update_text(update) if not chunk: continue accumulated.append(chunk) @@ -358,13 +326,8 @@ class DiscordChannel: await self._edit_original(token, _stream_preview_content("".join(accumulated))) last_edit = now - final = _text_result("".join(accumulated)) - include_originating = await self._ctx.deliver_response(request, final) - if include_originating: - final = await apply_channel_response_hook(self, final, request=request, originating=True) - await self._edit_original_with_result(token, final) - else: - await self._edit_original(token, "Sent.") + final_response = await stream.get_final_response() + await self._edit_original_with_result(token, HostedRunResult(final_response)) def _build_request( self, @@ -478,16 +441,6 @@ class DiscordChannel: ) _raise_for_discord_error(response, "send interaction follow-up") - async def _send_channel_messages(self, channel_id: str, content: str) -> None: - http = self._ensure_http() - for chunk in _split_content(content): - response = await http.post( - f"/channels/{channel_id}/messages", - headers=self._bot_headers(), - json={"content": chunk}, - ) - _raise_for_discord_error(response, "send channel message") - def _bot_headers(self) -> dict[str, str]: if self.bot_token is None: raise RuntimeError("Discord bot token is required for this operation") diff --git a/python/packages/hosting-discord/tests/discord/test_channel.py b/python/packages/hosting-discord/tests/discord/test_channel.py index 5addcd9a06..f402fde90f 100644 --- a/python/packages/hosting-discord/tests/discord/test_channel.py +++ b/python/packages/hosting-discord/tests/discord/test_channel.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Awaitable from typing import Any import httpx @@ -13,7 +13,6 @@ from agent_framework_hosting import ( ChannelCommand, ChannelCommandContext, ChannelRequest, - ChannelResponseContext, HostedRunResult, ) from nacl.signing import SigningKey @@ -60,39 +59,95 @@ def _headers(signing_key: SigningKey, body: bytes) -> dict[str, str]: class _FakeContext: - def __init__(self, *, text: str = "agent reply", include_originating: bool = True) -> None: + def __init__(self, *, text: str = "agent reply") -> None: self.target = object() self.text = text - self.include_originating = include_originating self.requests: list[ChannelRequest] = [] - self.delivered: list[tuple[ChannelRequest, HostedRunResult[Any]]] = [] - self.stream: _FakeStream | None = None + self.fake_stream: _FakeStream | None = None - async def run(self, request: ChannelRequest) -> HostedRunResult[AgentResponse]: + async def run( + self, + request: ChannelRequest, + *, + run_hook: Any | None = None, + protocol_request: Any | None = None, + response_hook: Any | None = None, + channel_name: str | None = None, + ) -> HostedRunResult[AgentResponse]: + if run_hook is not None: + maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request) + if isinstance(maybe_request, Awaitable): + request = await maybe_request + else: + request = maybe_request self.requests.append(request) - return _run_result(self.text) + result = _run_result(self.text) + if response_hook is not None: + maybe_result = response_hook(result, request=request, channel_name=channel_name or request.channel) + if isinstance(maybe_result, Awaitable): + return await maybe_result + return maybe_result + return result - def run_stream(self, request: ChannelRequest) -> _FakeStream: + async def run_stream( + self, + request: ChannelRequest, + *, + run_hook: Any | None = None, + protocol_request: Any | None = None, + stream_update_hook: Any | None = None, + response_hook: Any | None = None, + channel_name: str | None = None, + ) -> _FakeStream: + if run_hook is not None: + maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request) + if isinstance(maybe_request, Awaitable): + request = await maybe_request + else: + request = maybe_request self.requests.append(request) - if self.stream is None: - self.stream = _FakeStream(["a", "b"]) - return self.stream - - async def deliver_response(self, request: ChannelRequest, payload: HostedRunResult[Any]) -> bool: - self.delivered.append((request, payload)) - return self.include_originating + if self.fake_stream is None: + self.fake_stream = _FakeStream(["a", "b"]) + if stream_update_hook is not None: + self.fake_stream.transform = stream_update_hook + if response_hook is not None: + self.fake_stream.response_hook = response_hook + self.fake_stream.request = request + self.fake_stream.channel_name = channel_name or request.channel + return self.fake_stream class _FakeStream: def __init__(self, chunks: list[str]) -> None: self._chunks = chunks + self.transform: Any | None = None + self.response_hook: Any | None = None + self.request: ChannelRequest | None = None + self.channel_name: str | None = None def __aiter__(self) -> AsyncIterator[AgentResponseUpdate]: return self._iter() async def _iter(self) -> AsyncIterator[AgentResponseUpdate]: for chunk in self._chunks: - yield AgentResponseUpdate(contents=[Content.from_text(text=chunk)], role="assistant") + update = AgentResponseUpdate(contents=[Content.from_text(text=chunk)], role="assistant") + if self.transform is not None: + transformed = self.transform(update) + if isinstance(transformed, Awaitable): + transformed = await transformed + if transformed is None: + continue + update = transformed + yield update + + async def get_final_response(self) -> AgentResponse: + result = _run_result("".join(self._chunks)) + if self.response_hook is None: + return result.result + shaped = self.response_hook(result, request=self.request, channel_name=self.channel_name) + if isinstance(shaped, Awaitable): + shaped = await shaped + return shaped.result class _DiscordRecorder: @@ -212,7 +267,6 @@ async def test_agent_command_runs_host_and_edits_original_response() -> None: assert context.requests[0].identity is not None assert context.requests[0].identity.native_id == "user-1" assert context.requests[0].identity.attributes["channel_id"] == "channel-1" - assert len(context.delivered) == 1 assert recorder.requests[0].method == "PATCH" assert recorder.requests[0].url.path == "/webhooks/app-1/token/messages/@original" assert recorder.json_payloads[0] == {"content": "agent says hi"} @@ -232,7 +286,6 @@ async def test_run_hook_can_rewrite_agent_request() -> None: attributes=request.attributes, stream=request.stream, identity=request.identity, - response_target=request.response_target, ) channel = DiscordChannel( @@ -254,9 +307,9 @@ async def test_response_hook_rewrites_originating_reply() -> None: recorder = _DiscordRecorder() context = _FakeContext(text="original") - async def hook(result: HostedRunResult[Any], *, context: ChannelResponseContext) -> HostedRunResult[Any]: - assert context.originating is True + async def hook(result: HostedRunResult[Any], **kwargs: Any) -> HostedRunResult[Any]: assert result.result.text == "original" + assert kwargs["channel_name"] == "discord" return _run_result("rewritten") channel = DiscordChannel( @@ -274,23 +327,6 @@ async def test_response_hook_rewrites_originating_reply() -> None: assert recorder.json_payloads[-1] == {"content": "rewritten"} -async def test_deliver_response_false_acknowledges_without_originating_payload() -> None: - recorder = _DiscordRecorder() - context = _FakeContext(text="fanout only", include_originating=False) - channel = DiscordChannel( - application_id="app-1", - public_key=SigningKey.generate().verify_key.encode().hex(), - register_commands=False, - api_base_url="https://discord.test", - ) - channel.contribute(context) # type: ignore[arg-type] - channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) - - await channel._run_agent_command(_interaction(), "token") - - assert recorder.json_payloads[-1] == {"content": "Sent."} - - async def test_missing_prompt_edits_original_without_calling_host() -> None: recorder = _DiscordRecorder() context = _FakeContext(text="should not run") @@ -513,75 +549,6 @@ async def test_originating_reply_sends_followup_chunks() -> None: assert [len(payload["content"]) for payload in recorder.json_payloads] == [2000, 1] -async def test_push_requires_channel_id_and_sends_chunked_messages() -> None: - recorder = _DiscordRecorder() - channel = DiscordChannel( - application_id="app-1", - public_key=SigningKey.generate().verify_key.encode().hex(), - bot_token="bot-token", - register_commands=False, - api_base_url="https://discord.test", - ) - channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) - - await channel.push( - identity=channel._identity_from_interaction(_interaction()), # pyright: ignore[reportPrivateUsage] - payload=_run_result("a" * 2001), - ) - - assert [request.url.path for request in recorder.requests] == [ - "/channels/channel-1/messages", - "/channels/channel-1/messages", - ] - assert [len(payload["content"]) for payload in recorder.json_payloads] == [2000, 1] - - -async def test_push_renders_no_response_for_unknown_payload_shape() -> None: - recorder = _DiscordRecorder() - channel = DiscordChannel( - application_id="app-1", - public_key=SigningKey.generate().verify_key.encode().hex(), - bot_token="bot-token", - register_commands=False, - api_base_url="https://discord.test", - ) - channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) - - await channel.push( - identity=channel._identity_from_interaction(_interaction()), # pyright: ignore[reportPrivateUsage] - payload=HostedRunResult(object()), - ) - - assert recorder.json_payloads == [{"content": "(no response)"}] - - -async def test_push_requires_bot_token_and_channel_id() -> None: - identity = DiscordChannel( - application_id="app-1", - public_key=SigningKey.generate().verify_key.encode().hex(), - register_commands=False, - )._identity_from_interaction(_interaction()) # pyright: ignore[reportPrivateUsage] - no_bot_token = DiscordChannel( - application_id="app-1", - public_key=SigningKey.generate().verify_key.encode().hex(), - register_commands=False, - ) - no_channel_id = DiscordChannel( - application_id="app-1", - public_key=SigningKey.generate().verify_key.encode().hex(), - bot_token="bot-token", - register_commands=False, - ) - - with pytest.raises(RuntimeError, match="bot_token"): - await no_bot_token.push(identity=identity, payload=_run_result("hello")) - with pytest.raises(ValueError, match="channel_id"): - await no_channel_id.push( - identity=type(identity)(channel=identity.channel, native_id=identity.native_id, attributes={}), - payload=_run_result("hello"), - ) - - async def test_streaming_edits_original_and_delivers_final_response() -> None: recorder = _DiscordRecorder() context = _FakeContext() @@ -599,14 +566,12 @@ async def test_streaming_edits_original_and_delivers_final_response() -> None: await channel._run_agent_command(_interaction(), "token") assert [payload["content"] for payload in recorder.json_payloads] == ["a", "ab", "ab"] - assert len(context.delivered) == 1 - assert context.delivered[0][1].result.text == "ab" async def test_streaming_preview_is_limited_and_final_reply_is_chunked() -> None: recorder = _DiscordRecorder() context = _FakeContext() - context.stream = _FakeStream(["a" * 2001]) + context.fake_stream = _FakeStream(["a" * 2001]) channel = DiscordChannel( application_id="app-1", public_key=SigningKey.generate().verify_key.encode().hex(), @@ -622,12 +587,11 @@ async def test_streaming_preview_is_limited_and_final_reply_is_chunked() -> None assert [request.method for request in recorder.requests] == ["PATCH", "PATCH", "POST"] assert [len(payload["content"]) for payload in recorder.json_payloads] == [2000, 2000, 1] - assert len(context.delivered[0][1].result.text) == 2001 -async def test_stream_transform_hook_can_drop_updates_and_disable_originating_reply() -> None: +async def test_stream_update_hook_can_drop_updates() -> None: recorder = _DiscordRecorder() - context = _FakeContext(include_originating=False) + context = _FakeContext() async def hook(update: AgentResponseUpdate) -> AgentResponseUpdate | None: if update.text == "a": @@ -639,7 +603,7 @@ async def test_stream_transform_hook_can_drop_updates_and_disable_originating_re public_key=SigningKey.generate().verify_key.encode().hex(), register_commands=False, streaming=True, - stream_transform_hook=hook, + stream_update_hook=hook, edit_interval=0, api_base_url="https://discord.test", ) @@ -648,11 +612,10 @@ async def test_stream_transform_hook_can_drop_updates_and_disable_originating_re await channel._run_agent_command(_interaction(), "token") - assert [payload["content"] for payload in recorder.json_payloads] == ["b", "Sent."] - assert context.delivered[0][1].result.text == "b" + assert [payload["content"] for payload in recorder.json_payloads] == ["b", "ab"] -async def test_stream_transform_hook_can_synchronously_rewrite_updates() -> None: +async def test_stream_update_hook_can_synchronously_rewrite_updates() -> None: recorder = _DiscordRecorder() context = _FakeContext() @@ -664,7 +627,7 @@ async def test_stream_transform_hook_can_synchronously_rewrite_updates() -> None public_key=SigningKey.generate().verify_key.encode().hex(), register_commands=False, streaming=True, - stream_transform_hook=hook, + stream_update_hook=hook, edit_interval=0, api_base_url="https://discord.test", ) @@ -673,7 +636,7 @@ async def test_stream_transform_hook_can_synchronously_rewrite_updates() -> None await channel._run_agent_command(_interaction(), "token") - assert [payload["content"] for payload in recorder.json_payloads] == ["x", "xx", "xx"] + assert [payload["content"] for payload in recorder.json_payloads] == ["x", "xx", "ab"] async def _noop() -> None: diff --git a/python/packages/hosting-entra/LICENSE b/python/packages/hosting-entra/LICENSE deleted file mode 100644 index 9e841e7a26..0000000000 --- a/python/packages/hosting-entra/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ - MIT License - - Copyright (c) Microsoft Corporation. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE diff --git a/python/packages/hosting-entra/README.md b/python/packages/hosting-entra/README.md deleted file mode 100644 index 6e0073812b..0000000000 --- a/python/packages/hosting-entra/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# agent-framework-hosting-entra - -Microsoft Entra (Azure AD) identity-linking sidecar channel for -[agent-framework-hosting](../hosting). Owns the OAuth 2.0 Authorization Code -flow that binds a per-channel id (e.g. a Telegram chat id) to the user's -Entra object id, so multiple non-Entra channels can share a single -`entra:` isolation key. - -## Usage - -```python -from pathlib import Path -from agent_framework_hosting import AgentFrameworkHost -from agent_framework_hosting_entra import ( - EntraIdentityLinkChannel, - EntraIdentityStore, -) - -store = EntraIdentityStore(Path("./identity_links.json")) - -host = AgentFrameworkHost( - target=my_agent, - channels=[ - EntraIdentityLinkChannel( - store=store, - tenant_id="", - client_id="", - client_secret="", - public_base_url="https://your.host", - ), - # ... other channels whose run hooks call store.lookup(...) - ], -) -host.serve() -``` - -For tenants that disallow client secrets, pass `certificate_path=` (and -optionally `certificate_password=`) instead of `client_secret`. The PEM -layout matches the one used by `agent-framework-hosting-teams`. diff --git a/python/packages/hosting-entra/agent_framework_hosting_entra/__init__.py b/python/packages/hosting-entra/agent_framework_hosting_entra/__init__.py deleted file mode 100644 index 6e1bba53b8..0000000000 --- a/python/packages/hosting-entra/agent_framework_hosting_entra/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Microsoft Entra (Azure AD) identity channel for :mod:`agent_framework_hosting`.""" - -from ._channel import ( - EntraIdentityLinkChannel, - EntraIdentityStore, - entra_isolation_key, -) - -__all__ = [ - "EntraIdentityLinkChannel", - "EntraIdentityStore", - "entra_isolation_key", -] diff --git a/python/packages/hosting-entra/agent_framework_hosting_entra/_channel.py b/python/packages/hosting-entra/agent_framework_hosting_entra/_channel.py deleted file mode 100644 index 7e6075638c..0000000000 --- a/python/packages/hosting-entra/agent_framework_hosting_entra/_channel.py +++ /dev/null @@ -1,505 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Microsoft Entra (Azure AD) identity-linking sidecar channel. - -Implements the OAuth 2.0 Authorization Code flow against Entra so users on -non-Entra channels (Telegram, Responses callers without a verified token, -etc.) can bind their per-channel id to a stable ``entra:`` isolation -key. Once the link is established, channel run-hooks can call -:meth:`EntraIdentityStore.lookup` and rewrite the request to use the Entra -key instead of the channel-native id. - -Two credential modes are supported: - -* ``client_secret`` — confidential-client secret. -* ``certificate_path`` — PEM bundle (private key + cert) for tenants that - disallow secrets. The Teams channel uses the same PEM layout; see - :mod:`agent_framework_hosting_teams` for the openssl recipe. -""" - -from __future__ import annotations - -import asyncio -import hashlib -import hmac -import html -import json -import secrets -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Any -from urllib.parse import urlencode, urlparse - -import httpx -import msal -from agent_framework_hosting import ( - ChannelContext, - ChannelContribution, - logger, -) -from cryptography import x509 -from cryptography.hazmat.primitives import hashes, serialization -from starlette.requests import Request -from starlette.responses import HTMLResponse, RedirectResponse, Response -from starlette.routing import Route - - -def entra_isolation_key(oid: str) -> str: - """Canonical isolation key for a user identified by Entra object id.""" - return f"entra:{oid}" - - -class EntraIdentityStore: - """Tiny JSON-backed mapping ``: → entra:``. - - Production deployments should swap this for a real KV store. Single-file - JSON is fine for samples because writes are infrequent (only during the - OAuth callback) and we serialize them under an asyncio lock. - """ - - def __init__(self, path: Path) -> None: - """Open an identity store backed by ``path``. - - Loads any existing JSON document; an unreadable or corrupt file is - logged and replaced with an empty in-memory map so callers always - get a usable store. - """ - self._path = path - self._lock = asyncio.Lock() - self._data: dict[str, str] = {} - if path.exists(): - try: - self._data = json.loads(path.read_text()) - except Exception: - logger.exception("identity store load failed; starting empty") - - def lookup(self, channel_key: str) -> str | None: - """Return the linked ``entra:`` key for a per-channel id, or ``None``.""" - return self._data.get(channel_key) - - async def link(self, channel_key: str, oid: str) -> None: - """Bind ``channel_key`` (e.g. ``telegram:123``) to the Entra ``oid`` and persist. - - Overwrites any existing mapping for ``channel_key`` and rewrites the - backing JSON file under the lock so concurrent callers cannot race. - """ - async with self._lock: - self._data[channel_key] = entra_isolation_key(oid) - self._path.write_text(json.dumps(self._data, indent=2, sort_keys=True)) - - async def unlink(self, channel_key: str) -> None: - """Remove the mapping for ``channel_key``; no-op if absent. - - The file is only rewritten when an entry actually existed so we - don't churn disk on idempotent unlink calls. - """ - async with self._lock: - if self._data.pop(channel_key, None) is not None: - self._path.write_text(json.dumps(self._data, indent=2, sort_keys=True)) - - -@dataclass -class _PendingAuth: - """In-memory record of an authorize redirect waiting for its OAuth callback.""" - - channel: str - channel_id: str - expires_at: float - return_to: str | None = None - - -def _link_html(body: str, *, status: int = 200) -> HTMLResponse: - """Wrap ``body`` in a minimal HTML shell suitable for browser link UIs.""" - return HTMLResponse( - f"{body}", - status_code=status, - ) - - -def _load_certificate_credential(certificate_path: str | Path, certificate_password: bytes | None) -> dict[str, str]: - """Build the ``msal`` certificate credential dict from a PEM bundle. - - Expects ``certificate_path`` to point at a single PEM containing the - private key followed by the X.509 certificate (the layout produced by - ``cat key.pem cert.pem > combined.pem``). - """ - pem_bytes = Path(certificate_path).read_bytes() - private_key = serialization.load_pem_private_key(pem_bytes, password=certificate_password) - cert = x509.load_pem_x509_certificate(pem_bytes) - - private_key_pem = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ).decode() - public_cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode() - # SHA-1 thumbprint is required by the Entra ``client_assertion`` spec for cert auth — not a security choice. - thumbprint = cert.fingerprint(hashes.SHA1()).hex() # noqa: S303 - return { - "private_key": private_key_pem, - "thumbprint": thumbprint, - "public_certificate": public_cert_pem, - } - - -class EntraIdentityLinkChannel: - """Sidecar Channel exposing ``GET /auth/start`` and ``GET /auth/callback``. - - Demonstrates that ``Channel`` is a general extensibility point — not just - for chat surfaces. Owns the Entra OAuth Authorization Code flow used to - bind a per-channel id (e.g. Telegram chat id) to the user's Entra object - id. - - Two credential modes are supported (mutually exclusive): - - * ``client_secret`` — classic confidential-client secret. - * ``certificate_path`` — PEM bundle (private key + certificate) for - tenants that disallow secrets. See ``teams.py`` module docstring for - an ``openssl`` recipe; the same PEM works here. - - Flow (OAuth 2.0 Authorization Code, confidential client): - - 1. ``GET /auth/start?channel=&id=`` mints a one-shot - ``state`` token and 302s to the Entra ``authorize`` endpoint. - 2. User signs in; Entra calls ``GET /auth/callback?code=...&state=...``. - 3. We exchange the code for a token (via ``msal`` so secret + cert auth - look identical at the call site), call Microsoft Graph ``/me`` to - read ``id`` (oid), persist ``: → entra:``, and - respond with a friendly HTML page (or 302 to ``return_to``). - - Tokens never leave the host process; only the ``oid`` claim is stored. - """ - - name = "identity" - path = "/auth" - - _AUTHORITY_TEMPLATE = "https://login.microsoftonline.com/{tenant}" - _GRAPH_ME = "https://graph.microsoft.com/v1.0/me" - _PENDING_TTL_SECONDS = 600 # 10 minutes - - def __init__( - self, - *, - store: EntraIdentityStore, - tenant_id: str, - client_id: str, - public_base_url: str, - client_secret: str | None = None, - certificate_path: str | Path | None = None, - certificate_password: bytes | None = None, - scope: str = "openid profile User.Read", - link_token_secret: str | None = None, - link_token_ttl_seconds: int = 600, - ) -> None: - if bool(client_secret) == bool(certificate_path): - raise ValueError("IdentityLinkChannel: pass exactly one of client_secret or certificate_path.") - if certificate_path is not None: - credential: str | dict[str, str] = _load_certificate_credential(certificate_path, certificate_password) - self._auth_kind = "certificate" - else: - credential = client_secret # type: ignore[assignment] - self._auth_kind = "client_secret" - - self._store = store - self._tenant_id = tenant_id - self._client_id = client_id - self._public_base_url = public_base_url.rstrip("/") - self._scopes = [s for s in scope.split() if s and s.lower() not in {"openid", "profile", "offline_access"}] - # MSAL ConfidentialClientApplication is sync; we wrap blocking calls - # in ``asyncio.to_thread`` because token endpoint calls do real I/O. - self._msal_app = msal.ConfidentialClientApplication( - client_id=client_id, - authority=self._AUTHORITY_TEMPLATE.format(tenant=tenant_id), - client_credential=credential, - ) - self._pending: dict[str, _PendingAuth] = {} - self._http: httpx.AsyncClient | None = None - # ``link_token_secret`` is the HMAC key that gates ``/auth/start``. - # Without it any open-internet caller can mint a binding for an - # arbitrary ``(channel, channel_id)`` pair and IDOR the victim's - # isolation key (see PR review on 0026 for the threat model). - # Optional only so dev-mode samples without the integration in - # place don't have to scramble for a secret; unsigned mode logs - # a loud warning at startup and wire-time. - self._link_token_secret = link_token_secret.encode("utf-8") if link_token_secret else None - self._link_token_ttl = link_token_ttl_seconds - # Allowed redirect-back hosts: relative paths and same-origin only. - # ``return_to`` from the unauthenticated /start query string is - # otherwise an open redirect (auth-host phishing vector). - parsed = urlparse(self._public_base_url) - self._allowed_return_host = parsed.netloc.lower() if parsed.netloc else None - - @property - def redirect_uri(self) -> str: - """The fully-qualified OAuth redirect URI registered with Entra ID. - - Computed from ``public_base_url`` plus the channel's mount path so - operators can copy it straight into the app registration's reply URLs. - """ - return f"{self._public_base_url}{self.path}/callback" - - def contribute(self, context: "ChannelContext") -> "ChannelContribution": - """Mount the ``/start`` and ``/callback`` routes plus lifecycle hooks.""" - return ChannelContribution( - routes=[ - Route("/start", self._handle_start, methods=["GET"]), - Route("/callback", self._handle_callback, methods=["GET"]), - ], - on_startup=[self._on_startup], - on_shutdown=[self._on_shutdown], - ) - - async def _on_startup(self) -> None: - """Open the shared HTTP client used for Microsoft Graph calls.""" - self._http = httpx.AsyncClient(timeout=15.0) - if self._link_token_secret is None: - logger.warning( - "EntraIdentityLinkChannel running WITHOUT link_token_secret. " - "GET /auth/start accepts unauthenticated (channel, id) pairs, " - "which means any open-internet caller can bind their Entra " - "account to a victim's per-channel id (IDOR on the identity " - "store). Pass link_token_secret=, mint URLs via " - "mint_start_url(...), and gate /start in front of the " - "channel that issues those URLs." - ) - logger.info( - "IdentityLinkChannel ready (auth=%s, signed_start=%s); redirect_uri=%s", - self._auth_kind, - self._link_token_secret is not None, - self.redirect_uri, - ) - - async def _on_shutdown(self) -> None: - """Close the Graph HTTP client; safe to call when never started.""" - if self._http is not None: - await self._http.aclose() - - # -- link-token helpers ----------------------------------------------- # - - def _sign_link_token(self, channel: str, channel_id: str, expires_at: int) -> str: - """Sign ``(channel, channel_id, expires_at)`` with HMAC-SHA256.""" - if self._link_token_secret is None: # pragma: no cover - guarded by callers - raise RuntimeError("link_token_secret is required to mint link tokens") - msg = f"{channel}|{channel_id}|{expires_at}".encode() - return hmac.new(self._link_token_secret, msg, hashlib.sha256).hexdigest() - - def _verify_link_token(self, channel: str, channel_id: str, expires_at: int, signature: str) -> bool: - """Constant-time verify the link-token signature and TTL.""" - if self._link_token_secret is None: # pragma: no cover - guarded by callers - return False - if expires_at < int(time.time()): - return False - expected = self._sign_link_token(channel, channel_id, expires_at) - return hmac.compare_digest(expected, signature) - - def mint_start_url(self, channel: str, channel_id: str, return_to: str | None = None) -> str: - """Return a one-shot signed URL for ``GET /auth/start``. - - Required when ``link_token_secret`` is set. Channels that issue - these URLs (e.g. a Telegram ``/link`` command after verifying the - inbound webhook signature) call this helper so the resulting URL - proves the caller authorised the ``(channel, channel_id)`` binding. - - Without this layer ``GET /auth/start`` is an IDOR vector: any - anonymous caller can bind a victim's per-channel id to their own - Entra ``oid``. - """ - if self._link_token_secret is None: - raise RuntimeError("mint_start_url requires link_token_secret in the constructor") - if return_to is not None: - self._validate_return_to(return_to) # fail fast at mint time - expires_at = int(time.time()) + self._link_token_ttl - sig = self._sign_link_token(channel, str(channel_id), expires_at) - params = { - "channel": channel, - "id": str(channel_id), - "exp": str(expires_at), - "sig": sig, - } - if return_to: - params["return_to"] = return_to - return f"{self._public_base_url}{self.path}/start?{urlencode(params)}" - - def _validate_return_to(self, return_to: str) -> None: - """Reject open-redirect targets. - - Allows: relative paths starting with ``/``, or absolute URLs whose - host equals the configured ``public_base_url``'s host. Rejects - everything else with ``ValueError``. - """ - if return_to.startswith("/") and not return_to.startswith("//"): - return # relative path, safe. - parsed = urlparse(return_to) - if not parsed.netloc: - return - if self._allowed_return_host and parsed.netloc.lower() == self._allowed_return_host: - return - raise ValueError( - f"return_to must be a relative path or same-origin URL " - f"(public_base_url host={self._allowed_return_host!r}); got {return_to!r}" - ) - - def authorize_url_for(self, channel: str, channel_id: str, return_to: str | None = None) -> str: - """Mint a one-shot authorize URL the user can visit to bind their account.""" - state = secrets.token_urlsafe(24) - self._gc_pending() - self._pending[state] = _PendingAuth( - channel=channel, - channel_id=str(channel_id), - expires_at=time.monotonic() + self._PENDING_TTL_SECONDS, - return_to=return_to, - ) - return str( - self._msal_app.get_authorization_request_url( - scopes=self._scopes, - redirect_uri=self.redirect_uri, - state=state, - prompt="select_account", - ) - ) - - def _gc_pending(self) -> None: - """Drop expired pending-auth entries so the in-memory map cannot grow unbounded.""" - now = time.monotonic() - for key, entry in list(self._pending.items()): - if entry.expires_at < now: - self._pending.pop(key, None) - - async def _handle_start(self, request: Request) -> Response: - """``GET /start?channel=&id=&return_to=&exp=&sig=`` — redirect to Entra to sign in. - - **Security model.** When ``link_token_secret`` is set the - request must include ``exp`` + ``sig`` — an HMAC over - ``(channel, channel_id, expires_at)`` minted by - :meth:`mint_start_url`. Without that gate, any open-internet - caller can bind a victim's per-channel id (e.g. - ``telegram:``) to their own Entra ``oid``: the - callback would persist - ``"telegram:" -> "entra:"`` and any - future inbound message from the victim would resolve to the - attacker's isolation key. We make the unsigned mode opt-in - with a loud startup warning so the dev-mode default doesn't - ship to production. - - ``return_to`` is validated against the configured - ``public_base_url`` host (or restricted to relative paths) to - prevent open-redirect phishing on a successful sign-in. - """ - channel = request.query_params.get("channel") - channel_id = request.query_params.get("id") - return_to = request.query_params.get("return_to") - if not channel or not channel_id: - return _link_html("Missing 'channel' or 'id' query parameter.", status=400) - - if self._link_token_secret is not None: - sig = request.query_params.get("sig") - exp_raw = request.query_params.get("exp") - try: - exp = int(exp_raw) if exp_raw else 0 - except ValueError: - exp = 0 - if not sig or not exp or not self._verify_link_token(channel, channel_id, exp, sig): - logger.warning( - "EntraIdentityLinkChannel /start rejected: missing/invalid signed link-token (channel=%s, id=%s)", - channel, - channel_id, - ) - return _link_html("Invalid or expired sign-in link.", status=403) - else: - # See _on_startup warning. Logged on every wire access so - # operators can't miss the IDOR exposure in their access logs. - logger.warning( - "EntraIdentityLinkChannel /start accepted UNSIGNED request " - "for (channel=%s, id=%s) — set link_token_secret to require " - "HMAC-signed link tokens minted via mint_start_url().", - channel, - channel_id, - ) - if return_to is not None: - try: - self._validate_return_to(return_to) - except ValueError as exc: - logger.warning("EntraIdentityLinkChannel /start invalid return_to: %s", exc) - return _link_html("Invalid return_to URL.", status=400) - url = self.authorize_url_for(channel, channel_id, return_to=return_to) - return RedirectResponse(url, status_code=302) - - async def _handle_callback(self, request: Request) -> Response: - """``GET /callback`` — finish the OAuth flow and persist the link. - - Exchanges the authorization code for a token, reads the user's - ``id``/``userPrincipalName`` from Microsoft Graph, then stores the - ``channel:channel_id -> entra:`` mapping in the identity store. - Renders a small HTML page so a browser-based flow has something to - show; if ``return_to`` was supplied (and validated at /start time - against the same-origin allowlist) it appears as a deep link. - - All values that flow into HTML output (``error``, ``error_description``, - ``channel_key``, ``upn``) are passed through :func:`html.escape` to - avoid reflected XSS — both the OAuth-error path and the - sign-in-success body would otherwise execute attacker-controlled - markup on the auth host's origin. - """ - if self._http is None: # pragma: no cover - guarded by lifecycle - raise RuntimeError("entra identity channel not started") - if error := request.query_params.get("error"): - description = request.query_params.get("error_description", "") - return _link_html( - f"Sign-in failed: {html.escape(error)}
{html.escape(description)}", - status=400, - ) - - code = request.query_params.get("code") - state = request.query_params.get("state") - pending = self._pending.pop(state or "", None) - if not code or pending is None or pending.expires_at < time.monotonic(): - return _link_html("Invalid or expired sign-in state. Please retry.", status=400) - - # MSAL handles client_secret vs client_assertion (cert) under the hood. - result: dict[str, Any] = await asyncio.to_thread( - self._msal_app.acquire_token_by_authorization_code, - code, - scopes=self._scopes, - redirect_uri=self.redirect_uri, - ) - if "access_token" not in result: - logger.warning("Entra token exchange failed: %s", result) - err_text = result.get("error_description") or result.get("error") or "unknown error" - return _link_html( - f"Token exchange failed: {html.escape(str(err_text))}", - status=502, - ) - access_token = result["access_token"] - - me = await self._http.get(self._GRAPH_ME, headers={"Authorization": f"Bearer {access_token}"}) - if me.status_code != 200: - return _link_html("Could not read user profile from Microsoft Graph.", status=502) - profile = me.json() - oid = profile.get("id") - upn = profile.get("userPrincipalName") or profile.get("displayName") or oid - if not oid: - return _link_html("Profile response missing 'id'.", status=502) - - channel_key = f"{pending.channel}:{pending.channel_id}" - await self._store.link(channel_key, oid) - logger.info("Linked %s → entra:%s (%s)", channel_key, oid, upn) - - if pending.return_to: - # ``return_to`` was already validated at /start time against - # the allowlist (relative path or same-origin only). Re-check - # defensively to harden against any future code path that - # bypasses the /start gate. - try: - self._validate_return_to(pending.return_to) - return RedirectResponse(pending.return_to, status_code=302) - except ValueError: - logger.warning( - "EntraIdentityLinkChannel /callback dropping invalid return_to: %s", - pending.return_to, - ) - return _link_html( - f"

Linked

{html.escape(channel_key)} is now bound to " - f"{html.escape(str(upn))}.

" - "

You can close this window and return to your chat.

" - ) diff --git a/python/packages/hosting-entra/pyproject.toml b/python/packages/hosting-entra/pyproject.toml deleted file mode 100644 index 45264741d5..0000000000 --- a/python/packages/hosting-entra/pyproject.toml +++ /dev/null @@ -1,108 +0,0 @@ -[project] -name = "agent-framework-hosting-entra" -description = "Microsoft Entra (Azure AD) OAuth-based identity-linking channel for agent-framework-hosting." -authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] -readme = "README.md" -requires-python = ">=3.10" -version = "1.0.0a260424" -license-files = ["LICENSE"] -urls.homepage = "https://aka.ms/agent-framework" -urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" -urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" -urls.issues = "https://github.com/microsoft/agent-framework/issues" -classifiers = [ - "License :: OSI Approved :: MIT License", - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", - "Typing :: Typed", -] -dependencies = [ - "agent-framework-core>=1.2.0,<2", - "agent-framework-hosting==1.0.0a260424", - "httpx>=0.27,<1", - "msal>=1.28,<2", - "cryptography>=42", -] - -[tool.uv] -prerelease = "if-necessary-or-explicit" -environments = [ - "sys_platform == 'darwin'", - "sys_platform == 'linux'", - "sys_platform == 'win32'" -] - -[tool.uv-dynamic-versioning] -fallback-version = "0.0.0" - -[tool.pytest.ini_options] -testpaths = 'tests' -addopts = "-ra -q -r fEX" -asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "function" -filterwarnings = [] -timeout = 120 -markers = [ - "integration: marks tests as integration tests that require external services", -] - -[tool.ruff] -extend = "../../pyproject.toml" - -[tool.coverage.run] -omit = [ - "**/__init__.py" -] - -[tool.pyright] -extends = "../../pyproject.toml" -include = ["agent_framework_hosting_entra"] -exclude = ['tests'] -# Bot Framework activities arrive as loosely-typed JSON-ish maps. Strict -# ``Unknown`` reporting on every ``.get(...)`` adds noise without catching -# real bugs — narrowing happens via runtime isinstance checks instead. -reportUnknownArgumentType = "none" -reportUnknownMemberType = "none" -reportUnknownVariableType = "none" -reportUnknownLambdaType = "none" -reportOptionalMemberAccess = "none" - -[tool.mypy] -plugins = ['pydantic.mypy'] -strict = true -python_version = "3.10" -ignore_missing_imports = true -disallow_untyped_defs = true -no_implicit_optional = true -check_untyped_defs = true -warn_return_any = true -show_error_codes = true -warn_unused_ignores = false -disallow_incomplete_defs = true -disallow_untyped_decorators = true - -[tool.bandit] -targets = ["agent_framework_hosting_entra"] -exclude_dirs = ["tests"] - -[tool.poe] -executor.type = "uv" -include = "../../shared_tasks.toml" - -[tool.poe.tasks.mypy] -help = "Run MyPy for this package." -cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_entra" - -[tool.poe.tasks.test] -help = "Run the default unit test suite for this package." -cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_entra --cov-report=term-missing:skip-covered tests' - -[build-system] -requires = ["flit-core >= 3.11,<4.0"] -build-backend = "flit_core.buildapi" diff --git a/python/packages/hosting-entra/tests/__init__.py b/python/packages/hosting-entra/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/python/packages/hosting-entra/tests/test_channel.py b/python/packages/hosting-entra/tests/test_channel.py deleted file mode 100644 index 19aa43d9b0..0000000000 --- a/python/packages/hosting-entra/tests/test_channel.py +++ /dev/null @@ -1,464 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for :mod:`agent_framework_hosting_entra`. - -The MSAL ``ConfidentialClientApplication`` and Microsoft Graph calls are -mocked out so no network access is required. Live OAuth, certificate auth, -and full webhook flow are out of scope here. -""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from starlette.applications import Starlette -from starlette.testclient import TestClient - -from agent_framework_hosting_entra import ( - EntraIdentityLinkChannel, - EntraIdentityStore, - entra_isolation_key, -) - - -def test_entra_isolation_key_format() -> None: - assert entra_isolation_key("abc123") == "entra:abc123" - - -class TestEntraIdentityStore: - async def test_link_writes_entra_namespaced_value(self, tmp_path: Path) -> None: - store = EntraIdentityStore(tmp_path / "links.json") - await store.link("telegram:42", "oid-xyz") - assert store.lookup("telegram:42") == "entra:oid-xyz" - # Persisted to disk. - saved = json.loads((tmp_path / "links.json").read_text()) - assert saved == {"telegram:42": "entra:oid-xyz"} - - async def test_unlink_removes_entry(self, tmp_path: Path) -> None: - store = EntraIdentityStore(tmp_path / "links.json") - await store.link("telegram:42", "oid") - await store.unlink("telegram:42") - assert store.lookup("telegram:42") is None - assert json.loads((tmp_path / "links.json").read_text()) == {} - - async def test_unlink_unknown_is_noop(self, tmp_path: Path) -> None: - store = EntraIdentityStore(tmp_path / "links.json") - await store.unlink("telegram:never") # must not raise - assert not (tmp_path / "links.json").exists() - - def test_loads_existing_file(self, tmp_path: Path) -> None: - path = tmp_path / "links.json" - path.write_text(json.dumps({"telegram:1": "entra:abc"})) - store = EntraIdentityStore(path) - assert store.lookup("telegram:1") == "entra:abc" - - def test_corrupt_file_starts_empty(self, tmp_path: Path) -> None: - path = tmp_path / "links.json" - path.write_text("not-json") - store = EntraIdentityStore(path) - assert store.lookup("anything") is None - - -class TestEntraIdentityLinkChannelConfig: - def test_rejects_neither_credential(self, tmp_path: Path) -> None: - with pytest.raises(ValueError, match="exactly one"): - EntraIdentityLinkChannel( - store=EntraIdentityStore(tmp_path / "x.json"), - tenant_id="t", - client_id="c", - public_base_url="https://example.com", - ) - - def test_rejects_both_credentials(self, tmp_path: Path) -> None: - with pytest.raises(ValueError, match="exactly one"): - EntraIdentityLinkChannel( - store=EntraIdentityStore(tmp_path / "x.json"), - tenant_id="t", - client_id="c", - public_base_url="https://example.com", - client_secret="s", - certificate_path="/tmp/does-not-exist.pem", - ) - - def test_redirect_uri_strips_trailing_slash(self, tmp_path: Path) -> None: - with patch( - "agent_framework_hosting_entra._channel.msal.ConfidentialClientApplication", - MagicMock(), - ): - ch = EntraIdentityLinkChannel( - store=EntraIdentityStore(tmp_path / "x.json"), - tenant_id="t", - client_id="c", - public_base_url="https://example.com/", - client_secret="s", - ) - assert ch.redirect_uri == "https://example.com/auth/callback" - - -class TestEntraIdentityLinkChannelRoutes: - def _make_channel(self, tmp_path: Path, msal_app: MagicMock) -> tuple[EntraIdentityLinkChannel, EntraIdentityStore]: - store = EntraIdentityStore(tmp_path / "links.json") - with patch( - "agent_framework_hosting_entra._channel.msal.ConfidentialClientApplication", - return_value=msal_app, - ): - ch = EntraIdentityLinkChannel( - store=store, - tenant_id="tenant-1", - client_id="client-1", - public_base_url="https://example.com", - client_secret="s", - ) - return ch, store - - def _mount_app(self, ch: EntraIdentityLinkChannel) -> Starlette: - # We don't depend on AgentFrameworkHost here — wire the routes - # directly so we can exercise the channel in isolation. - from starlette.routing import Mount - - contribution = ch.contribute(MagicMock()) - return Starlette(routes=[Mount(ch.path, routes=contribution.routes)]) - - def test_start_missing_params_returns_400(self, tmp_path: Path) -> None: - msal_app = MagicMock() - ch, _ = self._make_channel(tmp_path, msal_app) - with TestClient(self._mount_app(ch)) as client: - r = client.get("/auth/start", follow_redirects=False) - assert r.status_code == 400 - - def test_start_redirects_to_authorize_url(self, tmp_path: Path) -> None: - msal_app = MagicMock() - msal_app.get_authorization_request_url.return_value = ( - "https://login.microsoftonline.com/tenant-1/oauth2/v2.0/authorize?state=X" - ) - ch, _ = self._make_channel(tmp_path, msal_app) - with TestClient(self._mount_app(ch)) as client: - r = client.get( - "/auth/start", - params={"channel": "telegram", "id": "42"}, - follow_redirects=False, - ) - assert r.status_code == 302 - assert "login.microsoftonline.com" in r.headers["location"] - - def test_callback_invalid_state_returns_400(self, tmp_path: Path) -> None: - msal_app = MagicMock() - ch, _ = self._make_channel(tmp_path, msal_app) - ch._http = MagicMock(aclose=AsyncMock()) - with TestClient(self._mount_app(ch)) as client: - r = client.get("/auth/callback", params={"code": "c", "state": "unknown"}) - assert r.status_code == 400 - - def test_callback_links_oid_on_success(self, tmp_path: Path) -> None: - msal_app = MagicMock() - msal_app.get_authorization_request_url.return_value = ( - "https://login.microsoftonline.com/tenant-1/authorize?state=X" - ) - msal_app.acquire_token_by_authorization_code.return_value = {"access_token": "t"} - ch, store = self._make_channel(tmp_path, msal_app) - - # Fake the Graph /me call. - graph_response = MagicMock() - graph_response.status_code = 200 - graph_response.json = MagicMock(return_value={"id": "oid-xyz", "userPrincipalName": "user@x"}) - ch._http = MagicMock() - ch._http.get = AsyncMock(return_value=graph_response) - ch._http.aclose = AsyncMock() - - # Mint a real state via the public API so the pending dict is populated. - ch.authorize_url_for("telegram", "42") - state = next(iter(ch._pending.keys())) - - with TestClient(self._mount_app(ch)) as client: - r = client.get("/auth/callback", params={"code": "abc", "state": state}) - assert r.status_code == 200 - assert store.lookup("telegram:42") == "entra:oid-xyz" - - def test_callback_token_failure_returns_502(self, tmp_path: Path) -> None: - msal_app = MagicMock() - msal_app.get_authorization_request_url.return_value = "https://x" - msal_app.acquire_token_by_authorization_code.return_value = { - "error": "invalid_grant", - "error_description": "expired", - } - ch, store = self._make_channel(tmp_path, msal_app) - ch._http = MagicMock(aclose=AsyncMock()) - ch.authorize_url_for("telegram", "42") - state = next(iter(ch._pending.keys())) - with TestClient(self._mount_app(ch)) as client: - r = client.get("/auth/callback", params={"code": "c", "state": state}) - assert r.status_code == 502 - assert store.lookup("telegram:42") is None - - -# --------------------------------------------------------------------------- # -# Round-2 security hardening # -# --------------------------------------------------------------------------- # - - -class TestSignedLinkToken: - """`/auth/start` must reject unsigned/forged requests when secret is set.""" - - def _make_signed_channel( - self, tmp_path: Path, msal_app: MagicMock, *, secret: str = "test-secret" - ) -> EntraIdentityLinkChannel: - store = EntraIdentityStore(tmp_path / "links.json") - with patch( - "agent_framework_hosting_entra._channel.msal.ConfidentialClientApplication", - return_value=msal_app, - ): - return EntraIdentityLinkChannel( - store=store, - tenant_id="tenant-1", - client_id="client-1", - public_base_url="https://example.com", - client_secret="s", - link_token_secret=secret, - ) - - def _mount(self, ch: EntraIdentityLinkChannel) -> Starlette: - from starlette.routing import Mount - - contribution = ch.contribute(MagicMock()) - return Starlette(routes=[Mount(ch.path, routes=contribution.routes)]) - - def test_start_rejects_unsigned_request_when_secret_set(self, tmp_path: Path) -> None: - msal_app = MagicMock() - ch = self._make_signed_channel(tmp_path, msal_app) - with TestClient(self._mount(ch)) as client: - r = client.get( - "/auth/start", - params={"channel": "telegram", "id": "42"}, - follow_redirects=False, - ) - assert r.status_code == 403 - - def test_start_rejects_forged_signature(self, tmp_path: Path) -> None: - msal_app = MagicMock() - ch = self._make_signed_channel(tmp_path, msal_app) - with TestClient(self._mount(ch)) as client: - r = client.get( - "/auth/start", - params={ - "channel": "telegram", - "id": "42", - "exp": "9999999999", - "sig": "deadbeef", - }, - follow_redirects=False, - ) - assert r.status_code == 403 - - def test_start_accepts_valid_signed_url(self, tmp_path: Path) -> None: - msal_app = MagicMock() - msal_app.get_authorization_request_url.return_value = ( - "https://login.microsoftonline.com/tenant-1/authorize?state=X" - ) - ch = self._make_signed_channel(tmp_path, msal_app) - url = ch.mint_start_url("telegram", "42") - # Strip the host prefix to call via the in-process client. - path_and_query = url.split("https://example.com", 1)[1] - with TestClient(self._mount(ch)) as client: - r = client.get(path_and_query, follow_redirects=False) - assert r.status_code == 302 - - def test_start_rejects_expired_signed_url(self, tmp_path: Path) -> None: - import time as time_module - from urllib.parse import urlencode - - msal_app = MagicMock() - ch = self._make_signed_channel(tmp_path, msal_app) - # Hand-craft an expired-but-otherwise-valid token. - expired = int(time_module.time()) - 60 - sig = ch._sign_link_token("telegram", "42", expired) # type: ignore[attr-defined] # pyright: ignore[reportPrivateUsage] - params = {"channel": "telegram", "id": "42", "exp": str(expired), "sig": sig} - with TestClient(self._mount(ch)) as client: - r = client.get(f"/auth/start?{urlencode(params)}", follow_redirects=False) - assert r.status_code == 403 - - def test_mint_start_url_requires_secret(self, tmp_path: Path) -> None: - import pytest - - msal_app = MagicMock() - store = EntraIdentityStore(tmp_path / "links.json") - with patch( - "agent_framework_hosting_entra._channel.msal.ConfidentialClientApplication", - return_value=msal_app, - ): - ch = EntraIdentityLinkChannel( - store=store, - tenant_id="tenant-1", - client_id="client-1", - public_base_url="https://example.com", - client_secret="s", - ) - with pytest.raises(RuntimeError, match="link_token_secret"): - ch.mint_start_url("telegram", "42") - - def test_unsigned_mode_logs_warning_at_startup(self, tmp_path: Path, caplog: Any) -> None: - import asyncio as asyncio_mod - import logging - - msal_app = MagicMock() - store = EntraIdentityStore(tmp_path / "links.json") - with patch( - "agent_framework_hosting_entra._channel.msal.ConfidentialClientApplication", - return_value=msal_app, - ): - ch = EntraIdentityLinkChannel( - store=store, - tenant_id="tenant-1", - client_id="client-1", - public_base_url="https://example.com", - client_secret="s", - ) - with caplog.at_level(logging.WARNING, logger="agent_framework.hosting"): - asyncio_mod.run(ch._on_startup()) # pyright: ignore[reportPrivateUsage] - asyncio_mod.run(ch._on_shutdown()) # pyright: ignore[reportPrivateUsage] - assert any("WITHOUT link_token_secret" in r.message for r in caplog.records) - - -class TestXssEscaping: - """All inbound query/profile values must be HTML-escaped before output.""" - - def _setup(self, tmp_path: Path) -> tuple[EntraIdentityLinkChannel, EntraIdentityStore, MagicMock]: - store = EntraIdentityStore(tmp_path / "links.json") - msal_app = MagicMock() - msal_app.get_authorization_request_url.return_value = "https://x" - with patch( - "agent_framework_hosting_entra._channel.msal.ConfidentialClientApplication", - return_value=msal_app, - ): - ch = EntraIdentityLinkChannel( - store=store, - tenant_id="tenant-1", - client_id="client-1", - public_base_url="https://example.com", - client_secret="s", - ) - return ch, store, msal_app - - def _mount(self, ch: EntraIdentityLinkChannel) -> Starlette: - from starlette.routing import Mount - - contribution = ch.contribute(MagicMock()) - return Starlette(routes=[Mount(ch.path, routes=contribution.routes)]) - - def test_callback_error_param_is_escaped(self, tmp_path: Path) -> None: - ch, _, _ = self._setup(tmp_path) - ch._http = MagicMock(aclose=AsyncMock()) - with TestClient(self._mount(ch)) as client: - r = client.get( - "/auth/callback", - params={ - "error": "", - "error_description": "", - }, - ) - assert r.status_code == 400 - assert "@x"} - ) - ch._http = MagicMock(aclose=AsyncMock()) - ch._http.get = AsyncMock(return_value=graph_response) - # Mint a binding via authorize_url_for (channel-side trusted call). - ch.authorize_url_for("", "42") - state = next(iter(ch._pending.keys())) - with TestClient(self._mount(ch)) as client: - r = client.get("/auth/callback", params={"code": "abc", "state": state}) - assert r.status_code == 200 - assert "
messaging app] - - subgraph Host[AgentFrameworkHost] - direction TB - ASGI[Starlette app] - Router[Channel router] - Parse{parse →
command or
message?} - Auth[host.authorize] - Resolver[IdentityResolver] - Delivery[_deliver_response] - Push[_handle_push_task] - end - - Channels[Channels
Responses · Invocations ·
Telegram · Activity ·
IdentityLinker] - CmdHandler[CommandHandler
via ChannelCommandContext] - Target[(Agent or Workflow)] - Runner[DurableTaskRunner] - StateStore[(HostStateStore)] - - Caller --> ASGI - ASGI --> Router - Router --> Parse - Parse -- /command --> CmdHandler - Parse -- message --> Auth - CmdHandler -- ctx.run --> Auth - CmdHandler -- local reply --> Channels - Auth --> Resolver - Resolver --> StateStore - Auth --> Target - Target --> Delivery - Delivery -- originating sync --> Channels - Delivery -- non-originating --> Runner - Runner --> Push - Push --> Channels - Channels --> ASGI -``` - -For a richer set of flow diagrams — identity linking, multi-channel -fan-out, server-side relays, background runs, durable-runner codec -envelopes, echo idempotency, workflow targets — see the -[Python hosting spec](https://github.com/microsoft/agent-framework/blob/main/docs/specs/002-python-hosting-channels.md). +| `agent-framework-hosting-activity-protocol` | Bot Framework Activity Protocol | +| `agent-framework-hosting-discord` | Discord HTTP Interactions | ## Install ```bash pip install agent-framework-hosting agent-framework-hosting-responses -# or with uvicorn pre-installed for the demo `host.serve(...)` helper +# or with Hypercorn pre-installed for the demo `host.serve(...)` helper pip install "agent-framework-hosting[serve]" agent-framework-hosting-responses -# add the [disk] extra to opt in to on-disk persistence (see below) +# add the [disk] extra to persist reset-session aliases pip install "agent-framework-hosting[disk]" ``` @@ -109,84 +58,46 @@ host = AgentFrameworkHost(target=agent, channels=channels) host.serve(port=8000) ``` -See the [hosting samples](https://github.com/microsoft/agent-framework/tree/main/python/samples/04-hosting/af-hosting) -for richer multi-channel apps (Telegram + Teams + Responses fan-out, -identity linking, `ResponseTarget` routing, etc.). +## Session state and workflow checkpoints -## Optional disk persistence (`state_dir`) +By default the host keeps live `AgentSession` objects and reset-session aliases +in memory. Channels opt into continuity by setting +`ChannelRequest.session = ChannelSession(isolation_key=...)`; requests with the +same isolation key reuse the same host-created session. -By default the host keeps everything in memory: the durable-task runner's -pending push queue, the per-isolation-key session aliases, the active-channel -map, and the per-channel `ChannelIdentity` map. That is the right shape for -**ephemeral** runtimes (Foundry Hosted Agents et al.) where the host is -restarted per request and persistence lives behind a service like the Foundry -response store, and for short-lived local dev. - -For **long-running** deployments (an always-on container, a local dev server -you restart often, a single-VM bot) opt in to disk persistence by passing -`state_dir` to `AgentFrameworkHost`. The runner queue and the session -bookkeeping use [`diskcache`](https://grantjenks.com/docs/diskcache/) -(installed via the `[disk]` extra) protected by an OS-level advisory file -lock so two hosts pointed at the same directory can't double-execute -scheduled pushes. Workflow checkpoints (when the target is a `Workflow`) -use the framework's `FileCheckpointStorage` — no extra dependency. The -identity-link store path is offered to linkers that implement -`SupportsLinkStorePath`; linkers that manage persistence themselves should -be configured directly. +For long-running deployments that need `reset_session(...)` aliases to survive +restart, pass `state_dir`: ```python -from agent_framework_hosting import AgentFrameworkHost - -# Single path → host auto-derives `runner/`, `sessions/`, `links/`, and -# (for workflow targets) `checkpoints/` subpaths. host = AgentFrameworkHost( target=agent, channels=channels, state_dir="./.host-state", ) +``` -# Or route components to different roots — use the HostStatePaths TypedDict -# (or a plain dict with the same keys) for editor autocomplete on the keys. -# Omit a key to opt that component out of persistence. +This creates `./.host-state/sessions/` and stores only lightweight alias +bookkeeping. Live `AgentSession` objects are still rehydrated lazily by the +configured history provider on the next turn. + +For workflow targets, `checkpoint_location=...` is the clearest way to enable +checkpoint persistence. As a convenience, `state_dir="./.host-state"` also +derives `./.host-state/checkpoints/` for workflow targets. Use the mapping form +when you want only one component: + +```python from agent_framework_hosting import HostStatePaths host = AgentFrameworkHost( target=workflow, channels=channels, state_dir=HostStatePaths( - runner="/var/lib/myapp/tasks", - sessions="/var/lib/myapp/state", + sessions="/var/lib/myapp/sessions", checkpoints="/var/lib/myapp/checkpoints", - links="/var/lib/myapp/links", ), ) ``` -What survives a restart: - -- **Pending durable-task records** — scheduled but not-yet-completed push - deliveries replay on the next host startup via `runner.resume()`. Records - that crashed mid-attempt resume with their already-consumed retry budget. -- **`_session_aliases`** — per-isolation-key session-id rewrites (via the - reset-session command). -- **`_active`** — the most recently active channel for each isolation key - (consumed by `ResponseTarget.active`). -- **`_identities`** — channel-native `ChannelIdentity` rows used by - `ResponseTarget.channels([...])` / `.all_linked` fan-out. -- **Workflow checkpoints** — when the target is a `Workflow`, the host wraps - the `checkpoints` path in a per-isolation-key `FileCheckpointStorage` - (equivalent to passing `checkpoint_location=...` directly; the explicit - parameter takes precedence and emits a warning when both are set). -- **Identity-link store** — when the configured linker implements - `SupportsLinkStorePath`, the host passes the `links` path to it so pending - challenges, linked identities, and verified claims can survive restarts. - -What doesn't: - -- Live `AgentSession` objects (rehydrated lazily by the history provider on the - next turn). -- The `ContinuationToken` store (separate concern, plug in your own). - -Unpicklable push payloads raise `PushPayloadNotPicklable` *eagerly* from -`schedule()` so issues surface at the call site, not on the next restart. - +Cross-channel identity linking, multicast delivery, background runs, +continuation tokens, and durable delivery runners are follow-up enhancements, +not part of this v1 host contract. diff --git a/python/packages/hosting/agent_framework_hosting/__init__.py b/python/packages/hosting/agent_framework_hosting/__init__.py index aa91f654b0..ff03530dd4 100644 --- a/python/packages/hosting/agent_framework_hosting/__init__.py +++ b/python/packages/hosting/agent_framework_hosting/__init__.py @@ -13,30 +13,7 @@ they need. import importlib.metadata -from ._authorization import ( - AllOfAllowlists, - AllowAll, - Allowed, - AllowlistDecision, - AnyOfAllowlists, - AuthorizationContext, - AuthorizationOutcome, - AuthPolicy, - CallableAllowlist, - ChannelConfigurationError, - ClaimValue, - Denied, - IdentityAllowlist, - IdentityLinker, - LinkChallenge, - LinkedClaimAllowlist, - LinkedIdentity, - LinkRequired, - LinkResolution, - NativeIdAllowlist, - SupportsLinkStorePath, -) -from ._host import AgentFrameworkHost, ChannelContext, RuntimeMode, logger +from ._host import AgentFrameworkHost, ChannelContext, logger from ._isolation import ( ISOLATION_HEADER_CHAT, ISOLATION_HEADER_USER, @@ -45,35 +22,19 @@ from ._isolation import ( reset_current_isolation_keys, set_current_isolation_keys, ) -from ._runner import InProcessTaskRunner from ._types import ( Channel, ChannelCommand, ChannelCommandContext, ChannelContribution, ChannelIdentity, - ChannelPush, - ChannelPushCodec, ChannelRequest, - ChannelResponseContext, ChannelResponseHook, ChannelRunHook, ChannelSession, - ChannelStreamTransformHook, - DurableTaskPayloadMode, - DurableTaskRunner, + ChannelStreamUpdateHook, HostedRunResult, HostStatePaths, - PushPayloadNotPicklable, - PushPayloadNotSerializable, - ResponseTarget, - ResponseTargetKind, - RetryPolicy, - TaskHandle, - TaskStatus, - apply_channel_response_hook, - apply_response_hook, - apply_run_hook, ) try: @@ -85,59 +46,21 @@ __all__ = [ "ISOLATION_HEADER_CHAT", "ISOLATION_HEADER_USER", "AgentFrameworkHost", - "AllOfAllowlists", - "AllowAll", - "Allowed", - "AllowlistDecision", - "AnyOfAllowlists", - "AuthPolicy", - "AuthorizationContext", - "AuthorizationOutcome", - "CallableAllowlist", "Channel", "ChannelCommand", "ChannelCommandContext", - "ChannelConfigurationError", "ChannelContext", "ChannelContribution", "ChannelIdentity", - "ChannelPush", - "ChannelPushCodec", "ChannelRequest", - "ChannelResponseContext", "ChannelResponseHook", "ChannelRunHook", "ChannelSession", - "ChannelStreamTransformHook", - "ClaimValue", - "Denied", - "DurableTaskPayloadMode", - "DurableTaskRunner", + "ChannelStreamUpdateHook", "HostStatePaths", "HostedRunResult", - "IdentityAllowlist", - "IdentityLinker", - "InProcessTaskRunner", "IsolationKeys", - "LinkChallenge", - "LinkRequired", - "LinkResolution", - "LinkedClaimAllowlist", - "LinkedIdentity", - "NativeIdAllowlist", - "PushPayloadNotPicklable", - "PushPayloadNotSerializable", - "ResponseTarget", - "ResponseTargetKind", - "RetryPolicy", - "RuntimeMode", - "SupportsLinkStorePath", - "TaskHandle", - "TaskStatus", "__version__", - "apply_channel_response_hook", - "apply_response_hook", - "apply_run_hook", "get_current_isolation_keys", "logger", "reset_current_isolation_keys", diff --git a/python/packages/hosting/agent_framework_hosting/_authorization.py b/python/packages/hosting/agent_framework_hosting/_authorization.py deleted file mode 100644 index 882dad18cc..0000000000 --- a/python/packages/hosting/agent_framework_hosting/_authorization.py +++ /dev/null @@ -1,485 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Authorization seam — :class:`IdentityAllowlist`, :class:`IdentityLinker`, and outcomes. - -Channels that emit a :class:`ChannelIdentity` compose authorization from -two **orthogonal** parameters set per channel: - -- ``require_link: bool`` — "identity must be linked to an IdP claim". The - host delegates this to the configured :class:`IdentityLinker`; pairing - ``require_link=True`` with no linker is rejected at construction - (silent-deny-everyone is the worst possible default). -- ``allowlist: IdentityAllowlist | Literal["inherit"] | None`` — "identity - is on the accept list". The host evaluates the allowlist on every - inbound message via :func:`AgentFrameworkHost.authorize`. - -The two axes compose into the three named profiles **open** (no gate), -**forced-link** (any authenticated identity), and **allowlist** (only -listed identities, keyed either on the channel-native id pre-link or on -a verified IdP claim post-link). See -``docs/specs/002-python-hosting-channels.md`` § -"Authorization profiles and the IdentityAllowlist seam". - -This module ships the channel-neutral core pieces. Provider-specific -linking channels (for example Entra OAuth helpers) can implement -:class:`IdentityLinker` without the core package taking a dependency on -their transport or identity-provider SDKs. -""" - -from __future__ import annotations - -import os -from collections.abc import Awaitable, Callable, Collection, Mapping, Sequence -from dataclasses import dataclass, field -from datetime import datetime -from enum import Enum -from typing import Any, Literal, Protocol, TypeAlias, runtime_checkable - -from ._types import ChannelIdentity - - -class AllowlistDecision(str, Enum): - """Tri-state allowlist evaluation outcome. - - ``ABSTAIN`` is **not** a denial — it means "this allowlist has no - information yet" (typically a claim-based allowlist evaluated at - ``pre_link``). The host's :meth:`AgentFrameworkHost.authorize` - pipeline is what turns an all-``ABSTAIN`` outcome into the next - step (allow when open, escalate to a link ceremony when the config - calls for one). Boolean composition cannot distinguish "claim - allowlist denies you" from "claim allowlist hasn't seen any claims - yet" — a critical distinction for the **Mixed** profile. - """ - - ALLOW = "allow" - DENY = "deny" - ABSTAIN = "abstain" - - -ClaimValue: TypeAlias = str | Sequence[str] -"""Verified claim value shape understood by :class:`LinkedClaimAllowlist`.""" - - -def _empty_claim_mapping() -> Mapping[str, ClaimValue]: - return {} - - -def _empty_any_mapping() -> Mapping[str, Any]: - return {} - - -@dataclass(frozen=True) -class AuthorizationContext: - """Inputs to a single :meth:`IdentityAllowlist.evaluate` call.""" - - identity: ChannelIdentity - phase: Literal["pre_link", "post_link"] - isolation_key: str | None = None - verified_claims: Mapping[str, ClaimValue] = field(default_factory=_empty_claim_mapping) - claim_source: Literal["linker", "channel", "none"] = "none" - - -@runtime_checkable -class IdentityAllowlist(Protocol): - """Per-channel accept/deny gate evaluated by the host. - - ``requires_linked_claims`` declares that this allowlist's - :meth:`evaluate` cannot ``ALLOW`` until verified claims are - available — the host's construction-time validator rejects - configurations that would silently deny everyone (e.g. a - :class:`LinkedClaimAllowlist` on a channel that neither has - ``require_link=True`` nor natively emits verified claims). - """ - - requires_linked_claims: bool - - async def evaluate(self, context: AuthorizationContext) -> AllowlistDecision: ... - - -class AllowAll: - """Explicit "open" sentinel. - - Useful for tests, sample code, and for **overriding** a host-level - ``default_allowlist`` on a specific channel that should be public - inside an otherwise locked-down host. - """ - - requires_linked_claims: bool = False - - async def evaluate(self, context: AuthorizationContext) -> AllowlistDecision: - return AllowlistDecision.ALLOW - - -class NativeIdAllowlist: - """Accept only listed channel-native ids. - - Telegram ``chat_id``, WhatsApp number, Slack user id, etc. The - list can be a plain collection or an async loader so allowlist - sources can be config files, secret stores, or feature flags. - Pre-link and post-link behaviour is identical — native-id - allowlists do not depend on link state. - - When ``channel`` is set, the allowlist participates in - :class:`AnyOfAllowlists` composition by returning ``ABSTAIN`` for - requests from other channels — this lets per-channel native lists - coexist under a single combinator without one channel's ``DENY`` - masking another channel's ``ALLOW``. - - Keyword Args: - native_ids: A static collection of ids, or an async loader. - channel: When set, only requests whose - ``ChannelIdentity.channel`` matches participate; others - ``ABSTAIN``. - """ - - requires_linked_claims: bool = False - - def __init__( - self, - native_ids: Collection[str] | Callable[[], Awaitable[Collection[str]]], - *, - channel: str | None = None, - ) -> None: - self._native_ids: Collection[str] | None - self._loader: Callable[[], Awaitable[Collection[str]]] | None - if callable(native_ids): - self._native_ids = None - self._loader = native_ids - else: - self._native_ids = frozenset(native_ids) - self._loader = None - self.channel = channel - - async def _resolve(self) -> Collection[str]: - if self._native_ids is not None: - return self._native_ids - loader = self._loader - if loader is None: # pragma: no cover - defensive - raise RuntimeError("NativeIdAllowlist: loader missing after cache miss") - loaded = await loader() - # Cache the resolved set so subsequent calls avoid re-loading. - self._native_ids = frozenset(loaded) - self._loader = None - return self._native_ids - - async def evaluate(self, context: AuthorizationContext) -> AllowlistDecision: - if self.channel is not None and context.identity.channel != self.channel: - return AllowlistDecision.ABSTAIN - ids = await self._resolve() - if context.identity.native_id in ids: - return AllowlistDecision.ALLOW - return AllowlistDecision.DENY - - -class LinkedClaimAllowlist: - """Accept only identities whose verified IdP claim is on the list. - - ``evaluate`` returns ``ABSTAIN`` at ``pre_link`` (no claims yet) - and ``ALLOW``/``DENY`` at ``post_link``. Claim values may be plain - strings or a sequence of strings (for multi-valued claims such as - group ids); any intersection with ``values`` allows the identity. - - Keyword Args: - claim: The verified-claim key to inspect (e.g. ``"oid"``, - ``"tid"``, ``"groups"``). - values: Accepted values. - """ - - requires_linked_claims: bool = True - - def __init__(self, claim: str, values: Collection[str]) -> None: - self.claim = claim - self.values = frozenset(values) - - async def evaluate(self, context: AuthorizationContext) -> AllowlistDecision: - if context.phase == "pre_link": - return AllowlistDecision.ABSTAIN - value = context.verified_claims.get(self.claim) - if value is None: - return AllowlistDecision.DENY - if isinstance(value, str): - return AllowlistDecision.ALLOW if value in self.values else AllowlistDecision.DENY - return AllowlistDecision.ALLOW if any(item in self.values for item in value) else AllowlistDecision.DENY - - -class AnyOfAllowlists: - """Combinator: any child ``ALLOW`` wins; ``DENY`` only if all children ``DENY``. - - Use this for the **Mixed** profile (native id OR linked claim). - Returns ``ABSTAIN`` when no child decides. - """ - - def __init__(self, *allowlists: IdentityAllowlist) -> None: - self._children = allowlists - self.requires_linked_claims = any(getattr(a, "requires_linked_claims", False) for a in allowlists) - - async def evaluate(self, context: AuthorizationContext) -> AllowlistDecision: - any_abstain = False - all_deny = True - for child in self._children: - decision = await child.evaluate(context) - if decision is AllowlistDecision.ALLOW: - return AllowlistDecision.ALLOW - if decision is AllowlistDecision.ABSTAIN: - any_abstain = True - all_deny = False - # DENY contributes to all_deny without short-circuit. - if all_deny and self._children: - return AllowlistDecision.DENY - if any_abstain: - return AllowlistDecision.ABSTAIN - # No children — treat as ABSTAIN to avoid surprise DENY. - return AllowlistDecision.ABSTAIN - - -class AllOfAllowlists: - """Combinator: any child ``DENY`` wins; ``ALLOW`` only if all children ``ALLOW``. - - Use this to require multiple conditions (e.g. tenancy - **and** group membership). Returns ``ABSTAIN`` when no child - denies but at least one ``ABSTAIN``s. - """ - - def __init__(self, *allowlists: IdentityAllowlist) -> None: - self._children = allowlists - self.requires_linked_claims = any(getattr(a, "requires_linked_claims", False) for a in allowlists) - - async def evaluate(self, context: AuthorizationContext) -> AllowlistDecision: - any_abstain = False - for child in self._children: - decision = await child.evaluate(context) - if decision is AllowlistDecision.DENY: - return AllowlistDecision.DENY - if decision is AllowlistDecision.ABSTAIN: - any_abstain = True - if not self._children: - return AllowlistDecision.ABSTAIN - if any_abstain: - return AllowlistDecision.ABSTAIN - return AllowlistDecision.ALLOW - - -class CallableAllowlist: - """Escape hatch: wrap an arbitrary async function as an allowlist. - - Recommended only after exhausting the structured variants — - composition is harder to reason about with opaque callables. - """ - - def __init__( - self, - fn: Callable[[AuthorizationContext], Awaitable[AllowlistDecision]], - *, - requires_linked_claims: bool = False, - ) -> None: - self._fn = fn - self.requires_linked_claims = requires_linked_claims - - async def evaluate(self, context: AuthorizationContext) -> AllowlistDecision: - return await self._fn(context) - - -# --------------------------------------------------------------------------- # -# Outcome types # -# --------------------------------------------------------------------------- # - - -@dataclass(frozen=True) -class LinkChallenge: - """Challenge a channel can render to complete an identity link. - - Attributes: - challenge_id: Opaque linker-owned id for correlating the challenge - with the later completion callback. - url: Optional URL (OAuth authorization URL, device-flow URL, etc.) - the user should open. - expires_at: Optional challenge expiry time. - message: Optional safe text a channel may render with the challenge. - attributes: Linker-specific structured metadata. Channels should - only use keys documented by the concrete linker they integrate. - """ - - challenge_id: str - url: str | None = None - expires_at: datetime | None = None - message: str | None = None - attributes: Mapping[str, Any] = field(default_factory=_empty_any_mapping) - - -@dataclass(frozen=True) -class LinkedIdentity: - """Resolved IdP-backed identity returned by :class:`IdentityLinker`. - - Attributes: - isolation_key: Stable key the host should use for the linked user. - verified_claims: Claims verified by the linker or by a channel that - natively authenticates the user. - claim_source: Where the claims came from. - """ - - isolation_key: str - verified_claims: Mapping[str, ClaimValue] = field(default_factory=_empty_claim_mapping) - claim_source: Literal["linker", "channel"] = "linker" - - -LinkResolution: TypeAlias = LinkedIdentity | LinkChallenge -"""Result returned by :meth:`IdentityLinker.resolve`.""" - - -class IdentityLinker(Protocol): - """Resolve a channel-native identity or return a challenge to link it. - - Concrete linker packages own the storage, OAuth/device-code routes, and - provider-specific claim mapping. The core host only consumes the single - resolution call so authorization can be a one-round-trip decision. - """ - - async def resolve(self, identity: ChannelIdentity) -> LinkResolution: - """Return a linked identity or the challenge needed to create one.""" - ... - - -@runtime_checkable -class SupportsLinkStorePath(Protocol): - """Optional protocol for linkers that accept host-provided persistence. - - When ``AgentFrameworkHost(state_dir=...)`` derives a ``links`` path, the - host calls this hook on identity linkers that implement it. Linkers that - manage their own persistence can ignore this protocol and should be - configured directly by the application. - """ - - def configure_link_store_path(self, path: str | os.PathLike[str]) -> None: - """Configure where the linker should persist its link store.""" - ... - - -@dataclass(frozen=True) -class Allowed: - """The identity is authorized; ``isolation_key`` is its stable key.""" - - isolation_key: str - verified_claims: Mapping[str, ClaimValue] = field(default_factory=_empty_claim_mapping) - claim_source: Literal["linker", "channel", "none"] = "none" - - -@dataclass(frozen=True) -class LinkRequired: - """The identity must complete the link ceremony before proceeding. - - Channels render ``challenge`` through their native UX (the same - path the ``link`` command uses). - """ - - challenge: LinkChallenge - - -@dataclass(frozen=True) -class Denied: - """The identity is rejected. - - Attributes: - reason_code: Stable, machine-readable token (e.g. - ``"allowlist_denied_pre_link"``). Never echoed to end - users. - user_message: Safe to render publicly (group-chat-safe); - ``None`` falls back to a bland default ("You don't have - access to this bot."). - log_details: Structured payload for audit/observability; - **never** shown to users. - """ - - reason_code: str - user_message: str | None = None - log_details: Mapping[str, Any] = field(default_factory=_empty_any_mapping) - - -AuthorizationOutcome = Allowed | LinkRequired | Denied -"""Result of :func:`AgentFrameworkHost.authorize`. Channels render -each variant through their native UX.""" - - -class AuthPolicy: - """Factory helpers for common authorization policies. - - These helpers are thin wrappers over the concrete allowlist types; they - exist so application code can describe authorization intent without - importing each building block separately. - """ - - @staticmethod - def open() -> AllowAll: - """Allow every identity.""" - return AllowAll() - - @staticmethod - def native_ids( - native_ids: Collection[str] | Callable[[], Awaitable[Collection[str]]], - *, - channel: str | None = None, - ) -> NativeIdAllowlist: - """Allow listed channel-native ids.""" - return NativeIdAllowlist(native_ids, channel=channel) - - @staticmethod - def linked_claim(claim: str, values: Collection[str]) -> LinkedClaimAllowlist: - """Allow identities whose verified claim matches one of ``values``.""" - return LinkedClaimAllowlist(claim, values) - - @staticmethod - def any_of(*allowlists: IdentityAllowlist) -> AnyOfAllowlists: - """Allow when any child allowlist allows.""" - return AnyOfAllowlists(*allowlists) - - @staticmethod - def all_of(*allowlists: IdentityAllowlist) -> AllOfAllowlists: - """Allow only when every child allowlist allows.""" - return AllOfAllowlists(*allowlists) - - @staticmethod - def custom( - fn: Callable[[AuthorizationContext], Awaitable[AllowlistDecision]], - *, - requires_linked_claims: bool = False, - ) -> CallableAllowlist: - """Wrap a custom async allowlist function.""" - return CallableAllowlist(fn, requires_linked_claims=requires_linked_claims) - - -# --------------------------------------------------------------------------- # -# Configuration error # -# --------------------------------------------------------------------------- # - - -class ChannelConfigurationError(ValueError): - """Raised at host construction for authorization config that would deny all users. - - The host validator runs three rules (see spec §"Configuration - validation"); any failure is reported here rather than letting - the misconfigured host start up and reject every request. - """ - - -__all__ = [ - "AllOfAllowlists", - "AllowAll", - "Allowed", - "AllowlistDecision", - "AnyOfAllowlists", - "AuthPolicy", - "AuthorizationContext", - "AuthorizationOutcome", - "CallableAllowlist", - "ChannelConfigurationError", - "ClaimValue", - "Denied", - "IdentityAllowlist", - "IdentityLinker", - "LinkChallenge", - "LinkRequired", - "LinkResolution", - "LinkedClaimAllowlist", - "LinkedIdentity", - "NativeIdAllowlist", - "SupportsLinkStorePath", -] diff --git a/python/packages/hosting/agent_framework_hosting/_host.py b/python/packages/hosting/agent_framework_hosting/_host.py index d90a9df29c..c0a6a8e461 100644 --- a/python/packages/hosting/agent_framework_hosting/_host.py +++ b/python/packages/hosting/agent_framework_hosting/_host.py @@ -2,23 +2,21 @@ """The :class:`AgentFrameworkHost` and its :class:`ChannelContext` bridge. -The host is a tiny Starlette wrapper: +The host is a small Starlette wrapper: - ``__init__`` accepts a hostable target (``SupportsAgentRun`` agent or ``Workflow``) and a sequence of channels. - :meth:`AgentFrameworkHost.app` lazily builds a Starlette app by calling every channel's ``contribute`` and mounting the returned routes under the channel's ``path`` (empty path → mount at the app root). -- :class:`ChannelContext` exposes ``run`` / ``run_stream`` / - ``deliver_response`` for channels to invoke; the host handles - per-``isolation_key`` session caching, identity tracking, and - :class:`ResponseTarget` fan-out. +- :class:`ChannelContext` exposes ``run`` / ``run_stream`` for channels to + invoke; the host handles hook invocation and per-``isolation_key`` session + caching. Per SPEC-002 (and ADR-0026), the host is intentionally thin so the bulk of channel-specific behaviour stays in the channel package. Identity -linking, link policies, response targets, background runs, and the like -are pluggable extensions that the future identity/foundry packages will -contribute on top of this surface. +linking, multicast delivery, background runs, and durable delivery are +follow-up enhancements layered outside this v1 host contract. """ from __future__ import annotations @@ -30,7 +28,7 @@ import uuid from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence from contextlib import AbstractContextManager, ExitStack, asynccontextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, cast from agent_framework import ( AgentResponse, @@ -51,20 +49,6 @@ from starlette.responses import PlainTextResponse from starlette.routing import BaseRoute, Mount, Route, WebSocketRoute from starlette.types import ASGIApp, Receive, Scope, Send -from ._authorization import ( - Allowed, - AllowlistDecision, - AuthorizationContext, - AuthorizationOutcome, - ChannelConfigurationError, - ClaimValue, - Denied, - IdentityAllowlist, - IdentityLinker, - LinkChallenge, - LinkRequired, - SupportsLinkStorePath, -) from ._isolation import ( ISOLATION_HEADER_CHAT, ISOLATION_HEADER_USER, @@ -73,21 +57,15 @@ from ._isolation import ( set_current_isolation_keys, ) from ._persistence import normalize_state_dir -from ._runner import InProcessTaskRunner -from ._state_store import SessionsStateStore, build_session_dicts +from ._state_store import SessionsStateStore, build_session_aliases from ._types import ( Channel, - ChannelIdentity, - ChannelPush, - ChannelPushCodec, ChannelRequest, - DurableTaskPayloadMode, - DurableTaskRunner, + ChannelResponseHook, + ChannelRunHook, + ChannelStreamUpdateHook, HostedRunResult, HostStatePaths, - PushPayloadNotSerializable, - ResponseTargetKind, - apply_channel_response_hook, ) if TYPE_CHECKING: @@ -96,20 +74,6 @@ if TYPE_CHECKING: logger = logging.getLogger("agent_framework.hosting") -# Environment markers that auto-detect ``runtime_mode="ephemeral"``. Order -# matters only for telemetry — the first match wins and is logged at -# startup. Adding a new marker is a non-breaking change; consumers can -# always override via the ``runtime_mode`` constructor parameter. -_EPHEMERAL_RUNTIME_MARKERS: tuple[str, ...] = ( - "FOUNDRY_HOSTING_ENVIRONMENT", - "AZURE_FUNCTIONS_ENVIRONMENT", - "AWS_LAMBDA_FUNCTION_NAME", -) - - -RuntimeMode = Literal["long_running", "ephemeral"] - - def _exact_path_route(path: str, route: BaseRoute) -> BaseRoute | None: """Clone a root route so ``Mount('/x', Route('/'))`` also handles ``/x`` without a redirect.""" if isinstance(route, Route) and route.path == "/": @@ -125,46 +89,6 @@ def _exact_path_route(path: str, route: BaseRoute) -> BaseRoute | None: return None -def _detect_runtime_mode(env: Mapping[str, str] | None = None) -> tuple[RuntimeMode, str | None]: - """Inspect deployment markers and return ``(mode, matched_marker_or_None)``. - - Pure / side-effect-free so the host can call it once at construction - and tests can pass a synthetic env. ``env`` defaults to - :data:`os.environ`. Returns ``"long_running"`` when nothing matches — - that's the sensible default for local dev and always-on container - deployments. - """ - source = env if env is not None else os.environ - for marker in _EPHEMERAL_RUNTIME_MARKERS: - if source.get(marker): - return ("ephemeral", marker) - return ("long_running", None) - - -# Internal name the host uses when registering the push handler on the -# durable task runner. Exposed as a module constant so adapter packages -# (and the future background-run wiring under req #14) can use the same -# name for cross-runner observability. -HOSTING_PUSH_TASK_NAME = "hosting.push" - - -def _flatten_allowlists(allowlist: IdentityAllowlist) -> tuple[IdentityAllowlist, ...]: - """Walk an allowlist tree to expose nested :class:`IdentityAllowlist` instances. - - Used by :meth:`AgentFrameworkHost._validate_channel_authorization` - to inspect every leaf so type-checks like - ``NativeIdAllowlist(channel=)`` can be detected even - when buried inside :class:`AnyOfAllowlists` / :class:`AllOfAllowlists`. - """ - children = getattr(allowlist, "_children", None) - if children: - flat: list[IdentityAllowlist] = [allowlist] - for child in children: - flat.extend(_flatten_allowlists(child)) - return tuple(flat) - return (allowlist,) - - def _checkpoint_path_for_isolation_key(root: Path, isolation_key: str) -> Path: r"""Return ``root / isolation_key`` after rejecting path-traversal patterns. @@ -236,6 +160,34 @@ def _workflow_output_to_text(value: Any) -> str: return str(value) +async def _apply_run_hook( + hook: ChannelRunHook, + request: ChannelRequest, + *, + target: SupportsAgentRun | Workflow, + protocol_request: Any | None, +) -> ChannelRequest: + """Invoke a run hook with the host-owned calling convention.""" + result = hook(request, target=target, protocol_request=protocol_request) + if isinstance(result, Awaitable): + return await result + return result + + +async def _apply_response_hook( + hook: ChannelResponseHook, + result: HostedRunResult[Any], + *, + request: ChannelRequest, + channel_name: str | None, +) -> HostedRunResult[Any]: + """Invoke a response hook with the host-owned calling convention.""" + out = hook(result, request=request, channel_name=channel_name or request.channel) + if isinstance(out, Awaitable): + return await out + return out + + def _workflow_event_to_update(event: WorkflowEvent[Any]) -> AgentResponseUpdate | None: """Map a :class:`WorkflowEvent` to a channel-friendly :class:`AgentResponseUpdate`. @@ -272,7 +224,7 @@ def _workflow_event_to_update(event: WorkflowEvent[Any]) -> AgentResponseUpdate @asynccontextmanager -async def _suppress_already_consumed() -> AsyncIterator[None]: +async def _suppress_already_consumed() -> AsyncIterator[None]: # noqa: RUF029 """Yield, swallowing finalizer failures so consumer cleanup never crashes the host. The bridge stream calls ``get_final_response()`` after iterating the @@ -386,6 +338,63 @@ class _BoundResponseStream: return getattr(self._inner, name) +class _HostResponseStream: + """Adapter that applies host-owned stream and final-response hooks.""" + + def __init__( + self, + inner: Any, + *, + request: ChannelRequest, + stream_update_hook: ChannelStreamUpdateHook | None = None, + response_hook: ChannelResponseHook | None = None, + channel_name: str | None = None, + ) -> None: + self._inner = inner + self._request = request + self._stream_update_hook = stream_update_hook + self._response_hook = response_hook + self._channel_name = channel_name + + def __await__(self) -> Any: + return self.get_final_response().__await__() + + def __aiter__(self) -> AsyncIterator[Any]: + return self._wrap() + + async def _wrap(self) -> AsyncIterator[Any]: + async for update in self._inner: + if self._stream_update_hook is None: + yield update + continue + transformed = self._stream_update_hook(update) + if isinstance(transformed, Awaitable): + transformed = await transformed + if transformed is None: + continue + yield transformed + + async def get_final_response(self) -> Any: + result = await self._inner.get_final_response() + if self._response_hook is None: + return result + shaped = await _apply_response_hook( + self._response_hook, + HostedRunResult(result), + request=self._request, + channel_name=self._channel_name, + ) + return shaped.result + + async def aclose(self) -> None: + close = getattr(self._inner, "aclose", None) + if close is not None: + await close() + + def __getattr__(self, name: str) -> Any: + return getattr(self._inner, name) + + class ChannelContext: """Host-owned bridge that channels call to invoke the target.""" @@ -393,8 +402,8 @@ class ChannelContext: """Bind the context to its owning :class:`AgentFrameworkHost`. The host instance is the source of truth for the target, registered - channels, identity stores, sessions, and lifecycle state. Channels - only ever receive a context; they never see the host directly. + channels, sessions, and lifecycle state. Channels only ever receive a + context; they never see the host directly. """ self._host = host @@ -403,7 +412,15 @@ class ChannelContext: """The hostable target the channel should invoke.""" return self._host.target - async def run(self, request: ChannelRequest) -> HostedRunResult[Any]: + async def run( + self, + request: ChannelRequest, + *, + run_hook: ChannelRunHook | None = None, + protocol_request: Any | None = None, + response_hook: ChannelResponseHook | None = None, + channel_name: str | None = None, + ) -> HostedRunResult[Any]: """Invoke the target for ``request`` and return a channel-neutral result. For agent targets the return type narrows to @@ -412,45 +429,78 @@ class ChannelContext: as ``HostedRunResult[Any]`` because :class:`ChannelContext` is agnostic to which target shape the host was constructed with; channels narrow at the call site if they need it. - """ - return await self._host._invoke(request) # pyright: ignore[reportPrivateUsage] - def run_stream(self, request: ChannelRequest) -> ResponseStream[AgentResponseUpdate, AgentResponse]: - """Invoke the target with ``stream=True`` and return the agent's ResponseStream. + Args: + request: The channel-built request envelope. + + Keyword Args: + run_hook: Optional channel-supplied hook the host applies before + invoking the target. + protocol_request: Raw channel-native payload passed to + ``run_hook``. + response_hook: Optional channel-supplied hook the host applies to + the completed result before returning it. + channel_name: Channel name passed to ``response_hook``. Defaults + to ``request.channel``. + """ + prepared = await self._host._apply_run_hook( # pyright: ignore[reportPrivateUsage] + request, + hook=run_hook, + protocol_request=protocol_request, + ) + result = await self._host._invoke(prepared) # pyright: ignore[reportPrivateUsage] + return await self._host._apply_response_hook( # pyright: ignore[reportPrivateUsage] + result, + request=prepared, + hook=response_hook, + channel_name=channel_name, + ) + + async def run_stream( + self, + request: ChannelRequest, + *, + run_hook: ChannelRunHook | None = None, + protocol_request: Any | None = None, + stream_update_hook: ChannelStreamUpdateHook | None = None, + response_hook: ChannelResponseHook | None = None, + channel_name: str | None = None, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: + """Apply host-owned hooks and invoke the target with ``stream=True``. Channels iterate the stream directly (it acts like an AsyncGenerator) and are responsible for delivering updates to their wire protocol. - Apply per-channel ``transform_hook`` callables during iteration to - rewrite or drop individual updates before they hit the wire. + When ``stream_update_hook`` is supplied, the host applies it during + iteration to rewrite or drop individual updates before they hit the wire. + + Args: + request: The channel-built request envelope. + + Keyword Args: + run_hook: Optional channel-supplied hook the host applies before + opening the target stream. + protocol_request: Raw channel-native payload passed to + ``run_hook``. + stream_update_hook: Optional host-applied update transform. + response_hook: Optional host-applied final-response transform. + channel_name: Channel name passed to ``response_hook``. Defaults + to ``request.channel``. """ - return self._host._invoke_stream(request) # pyright: ignore[reportPrivateUsage] - - async def deliver_response( - self, - request: ChannelRequest, - payload: HostedRunResult[Any], - ) -> bool: - """Resolve ``request.response_target`` and push ``payload`` to each destination. - - Returns ``True`` when the originating channel should render the - agent reply on its own wire (i.e. the resolved target included - the originating channel — explicitly via - ``ResponseTarget.originating``, implicitly via - ``ResponseTarget.channels(["originating", ...])``, or as the - host's "every destination dropped, fall back to originating" - recovery path). Returns ``False`` when the reply is fanned out - purely to non-originating destinations (or - :data:`ResponseTarget.none` suppresses the reply entirely) — in - which case the originating channel typically responds with a - bare ack. - - Per-destination push outcomes (scheduled, retried, terminally - failed) live in the durable task runner's own log; this method - emits structured log entries for every resolution-time skip and - every schedule-time outage so operators have a single grep - anchor for "where did my reply go?". - """ - return await self._host._deliver_response(request, payload) # pyright: ignore[reportPrivateUsage] + prepared = await self._host._apply_run_hook( # pyright: ignore[reportPrivateUsage] + request, + hook=run_hook, + protocol_request=protocol_request, + ) + stream = self._host._invoke_stream(prepared) # pyright: ignore[reportPrivateUsage] + if stream_update_hook is None and response_hook is None: + return stream + return _HostResponseStream( + stream, + request=prepared, + stream_update_hook=stream_update_hook, + response_hook=response_hook, + channel_name=channel_name, + ) # type: ignore[return-value] class _FoundryIsolationASGIMiddleware: @@ -502,11 +552,6 @@ class AgentFrameworkHost: channels: Sequence[Channel], debug: bool = False, checkpoint_location: str | os.PathLike[str] | CheckpointStorage | None = None, - runtime_mode: RuntimeMode | None = None, - durable_task_runner: DurableTaskRunner | None = None, - allow_in_process_runner: bool = False, - default_allowlist: IdentityAllowlist | None = None, - identity_linker: IdentityLinker | None = None, state_dir: str | os.PathLike[str] | HostStatePaths | Mapping[str, str | os.PathLike[str]] | None = None, ) -> None: """Create a host for ``target`` and its channels. @@ -544,73 +589,21 @@ class AgentFrameworkHost: ``state_dir['checkpoints']`` (or the auto-derived ``state_dir/checkpoints/`` subfolder); a warning surfaces the double-configuration. - runtime_mode: Hint that drives the *defaults* for runtime-shape - dependent components (currently the durable task runner, - and — by extension — anything that wants to know whether - the process is expected to outlive a single request). - ``"long_running"`` (containers, OpenClaw-style always-on - deployments, local dev) → in-process / in-memory defaults. - ``"ephemeral"`` (Foundry Hosted Agents, Azure Functions, - AWS Lambda) → the host expects a durable runner to be - supplied via ``durable_task_runner`` and logs a warning - otherwise. ``None`` (the default) auto-detects from - deployment environment markers (currently - ``FOUNDRY_HOSTING_ENVIRONMENT``, ``AZURE_FUNCTIONS_ENVIRONMENT``, - ``AWS_LAMBDA_FUNCTION_NAME``); falls back to - ``"long_running"``. - durable_task_runner: The runner used to dispatch - non-originating push fan-out. Defaults to a process-local - :class:`InProcessTaskRunner` (asyncio + bounded retry, no - persistence) — appropriate for ``runtime_mode="long_running"`` - deployments. Ephemeral deployments should pass a durable - adapter (e.g. ``agent-framework-hosting-durabletask``, - or a Foundry-native adapter once available) so scheduled - pushes survive process restarts. - allow_in_process_runner: Opt-in escape hatch that allows - ``runtime_mode="ephemeral"`` to be paired with the - default in-process runner. Without this flag, the host - refuses to start in ephemeral mode without an explicit - ``durable_task_runner`` because the failure mode — - non-originating pushes silently lost on process recycle — - is the worst class of production bug (works in light - testing, drops work under load / lifecycle events). - Useful for local dev that wants to exercise ephemeral - code paths without standing up a durable backend; **not** - appropriate for production. - default_allowlist: Host-level fallback applied to every - channel that leaves ``allowlist="inherit"``. ``None`` - (the default) means the channel is open unless it sets - its own ``allowlist``. Channels can opt out of the host - default by setting ``allowlist=None`` explicitly. - identity_linker: Optional :class:`IdentityLinker` used to - resolve channel-native identities into verified IdP-backed - identities, or to return a :class:`LinkChallenge` the - channel can render when the user still needs to sign in. - Channels with ``require_link=True`` require this to be - configured unless they provide their own native verified - claims. state_dir: Opt-in disk persistence for host-managed state. - When set, the host writes the in-process task runner's - pending queue and the session-related dicts - (``_session_aliases``, ``_active``, ``_identities``) to - a :mod:`diskcache`-backed store under ``state_dir`` and - replays the runner queue on next startup. When the - target is a :class:`Workflow`, the auto-derived + When set, the host writes session aliases created by + :meth:`reset_session` to a :mod:`diskcache`-backed store + under ``state_dir``. When the target is a + :class:`Workflow`, the auto-derived ``state_dir/checkpoints/`` subfolder (or the ``checkpoints`` key of the mapping form) is also used as the workflow checkpoint location (equivalent to - passing ``checkpoint_location`` directly). The - auto-derived ``state_dir/links/`` subfolder (or the - ``links`` key of the mapping form) is offered to - identity linkers that implement - :class:`SupportsLinkStorePath`. Accepts: + passing ``checkpoint_location`` directly). Accepts: * ``None`` (default) — everything stays in memory; the process owns its state and loses it on exit. Matches today's behaviour exactly. * ``str`` / :class:`os.PathLike` — the host derives - default subpaths ``state_dir/runner/``, - ``state_dir/sessions/``, ``state_dir/links/``, and + default subpaths ``state_dir/sessions/`` and (for workflow targets) ``state_dir/checkpoints/``. Recommended for most long-running-host deployments — one path, no extra @@ -621,50 +614,32 @@ class AgentFrameworkHost: * :class:`HostStatePaths` typed dict / plain ``Mapping`` — per-component overrides for callers that want each component on a different volume (fast local - SSD for the runner, network-attached volume for - sessions, …). Components missing from the mapping - fall back to in-memory (or, for ``checkpoints``, to - no checkpoint persistence). Unknown keys raise + SSD for checkpoints, network-attached volume for + sessions, …). Components missing from the mapping fall + back to in-memory (or, for ``checkpoints``, to no + checkpoint persistence). Unknown keys raise ``ValueError`` to surface typos early. - The ``runner`` and ``sessions`` components require the + The ``sessions`` component requires the optional ``diskcache`` dependency (install with ``pip install 'agent-framework-hosting[disk]'``); ``checkpoints`` uses the core :class:`~agent_framework.FileCheckpointStorage` and has - no extra dependency. Each disk-cache-backed component - acquires an OS-level advisory lock on its directory; a - second host pointed at the same paths raises + no extra dependency. The disk-cache-backed sessions + component acquires an OS-level advisory lock on its + directory; a second host pointed at the same path raises :class:`RuntimeError` at construction so two processes - do not double-execute queued tasks. When - ``durable_task_runner`` is supplied explicitly, the - ``runner`` sub-path is ignored — the caller owns the - runner's persistence story. When ``checkpoint_location`` - is supplied explicitly, the ``checkpoints`` sub-path is - ignored. When an ``identity_linker`` does not implement - :class:`SupportsLinkStorePath`, the ``links`` sub-path is - ignored and the linker must be configured directly. + do not race session-alias writes. When + ``checkpoint_location`` is supplied explicitly, the + ``checkpoints`` sub-path is ignored. """ self.target: SupportsAgentRun | Workflow = target self._is_workflow = isinstance(target, Workflow) self.channels = list(channels) self._debug = debug self._app: Starlette | None = None - # Disk persistence — normalise the per-component map up front so - # the runner, session-store, and checkpoint paths are resolved - # before any consumer (including ``checkpoint_location``) is - # built. ``None`` (default) means everything stays in memory. self._state_paths: dict[str, Path | None] = normalize_state_dir(state_dir) - # Track whether the user passed the mapping form so we can - # distinguish "auto-derived from single path" (silent ignore for - # non-workflow targets) from "explicit mapping key" (warn for - # non-workflow targets, since that's almost certainly dead config). checkpoints_explicit_in_mapping = isinstance(state_dir, Mapping) and "checkpoints" in state_dir - links_explicit_in_mapping = isinstance(state_dir, Mapping) and "links" in state_dir - # Resolve the effective workflow checkpoint location: the - # explicit ``checkpoint_location`` argument wins; otherwise we - # fall back to ``state_dir['checkpoints']`` (single-path form - # auto-derives ``state_dir/checkpoints/``). derived_checkpoint_path = self._state_paths.get("checkpoints") self._checkpoint_location: Path | CheckpointStorage | None = None effective_checkpoint_source: str | os.PathLike[str] | CheckpointStorage | None = checkpoint_location @@ -685,7 +660,7 @@ class AgentFrameworkHost: "(state_dir['checkpoints']=%s); the explicit checkpoint_location " "takes precedence and the state_dir sub-path is ignored. " "Use the HostStatePaths mapping form and omit 'checkpoints' to " - "configure runner/sessions persistence without also enabling " + "configure session-alias persistence without also enabling " "host-managed workflow checkpointing.", derived_checkpoint_path, ) @@ -713,114 +688,19 @@ class AgentFrameworkHost: # ``CheckpointStorage`` is a non-runtime-checkable Protocol, # so we cannot ``isinstance``-check it directly. self._checkpoint_location = effective_checkpoint_source - # Runtime mode + durable task runner. We resolve mode first - # because the warning-on-ephemeral-without-runner only fires - # when both are at their defaults. - if runtime_mode is None: - resolved_mode, matched_marker = _detect_runtime_mode() - self._runtime_mode: RuntimeMode = resolved_mode - self._runtime_mode_source: str = ( - f"auto-detected from {matched_marker}" if matched_marker is not None else "auto-detected default" - ) - else: - self._runtime_mode = runtime_mode - self._runtime_mode_source = "explicit" - if durable_task_runner is None: - if self._runtime_mode == "ephemeral" and not allow_in_process_runner: - raise RuntimeError( - "AgentFrameworkHost is running in ephemeral runtime mode " - f"({self._runtime_mode_source}) without a durable_task_runner. " - "Non-originating push deliveries would be lost on process " - "recycle. Pass `durable_task_runner=...` (e.g. an " - "agent-framework-hosting-durabletask runner) for production, " - "or set `allow_in_process_runner=True` to opt out of this " - "check (e.g. for local dev exercising ephemeral code paths)." - ) - # When state_dir["runner"] is set, the default in-process - # runner persists its queue to disk so a long-running host - # can replay in-flight pushes after a crash / restart. - self._durable_task_runner: DurableTaskRunner = InProcessTaskRunner( - state_dir=self._state_paths.get("runner"), - ) - self._owns_runner = True - if self._runtime_mode == "ephemeral": - logger.warning( - "AgentFrameworkHost is running in ephemeral runtime mode " - "with the default InProcessTaskRunner (allow_in_process_runner=True). " - "Non-originating push deliveries will be lost if the process is " - "recycled mid-flight — this configuration is intended for local dev only." - ) - else: - self._durable_task_runner = durable_task_runner - self._owns_runner = False - if self._state_paths.get("runner") is not None: - # The caller supplied both a runner and a runner state - # path. The path would only have applied to the default - # in-process runner; surface the misconfig so it doesn't - # silently become a no-op. - logger.warning( - "state_dir['runner'] is set but a durable_task_runner was " - "supplied explicitly; the runner sub-path is ignored — " - "configure persistence on the runner instance directly." - ) - # Validate the runner / push-codec pairing eagerly: a JSON-mode - # durable runner cannot persist payloads for a push-capable - # channel that has no codec. Failing here makes the misconfig - # visible at process start rather than on first push. - self._validate_runner_codec_pairing() - # Register the internal push handler eagerly so it is available - # whether callers invoke ``_deliver_response`` directly (e.g. - # tests) or through the lifespan-managed ASGI app. Doing this - # in ``__init__`` is safe because runner handler registration - # has no I/O — it only associates a name with a callable. - self._durable_task_runner.register(HOSTING_PUSH_TASK_NAME, self._handle_push_task) - # Per-isolation_key session cache. The real spec backs this with a - # pluggable session store; this base host keeps it in-process. - # NOTE: live ``AgentSession`` objects are NOT persisted to disk - # — the history provider rehydrates them from its own store on - # the next turn. ``state_dir`` only persists the lightweight - # pickle-friendly bookkeeping below. self._sessions: dict[str, Any] = {} - # Open the disk-backed sessions store first when persistence is - # on; the three persisted dicts share the same cache + lock to - # minimise file handles and acquisition cost. sessions_path = self._state_paths.get("sessions") self._sessions_store: SessionsStateStore | None if sessions_path is not None: self._sessions_store = SessionsStateStore(sessions_path) - # ``isolation_key -> active session_id``. Normally identical to the - # isolation_key, but ``reset_session`` rotates this to a fresh id so - # the next turn starts a new ``AgentSession`` while the old history - # remains on disk under its original session_id. Persisted so a - # rotation survives a restart. - aliases_dict, active_dict, identities_dict = build_session_dicts(self._sessions_store) - self._session_aliases: dict[str, str] = aliases_dict - # (isolation_key -> last-seen channel name) for ResponseTarget.active. - self._active: dict[str, str] = active_dict - # Per-isolation_key identity registry: which channels we've seen this - # user on, and which native_id they used on each. Powers - # ResponseTarget.active / .channel(name) / .channels([...]) / - # .all_linked. - # Shape: { isolation_key: { channel_name: ChannelIdentity } }. - self._identities: dict[str, dict[str, ChannelIdentity]] = identities_dict + self._session_aliases: dict[str, str] = build_session_aliases(self._sessions_store) else: self._sessions_store = None self._session_aliases = {} - self._active = {} - self._identities = {} # Set by ``serve()`` so the lifespan startup handler doesn't # double-log the banner; remains ``False`` when callers mount # ``host.app`` under their own ASGI server. self._startup_logged: bool = False - # Authorization seam: allowlists, optional identity linker, and - # construction-time validation for fail-fast misconfigurations. - self._default_allowlist: IdentityAllowlist | None = default_allowlist - self._identity_linker: IdentityLinker | None = identity_linker - self._configure_identity_linker_state( - self._state_paths.get("links"), - explicit=links_explicit_in_mapping, - ) - self._validate_channel_authorization() @property def app(self) -> Starlette: @@ -829,340 +709,6 @@ class AgentFrameworkHost: self._app = self._build_app() return self._app - def _configure_identity_linker_state(self, links_path: Path | None, *, explicit: bool) -> None: - """Offer the derived ``state_dir['links']`` path to compatible linkers.""" - if links_path is None: - return - linker = self._identity_linker - if linker is None: - if explicit: - logger.warning("state_dir['links'] is set but no identity_linker is configured; ignoring.") - return - if isinstance(linker, SupportsLinkStorePath): - linker.configure_link_store_path(links_path) - return - logger.warning( - "state_dir['links'] is set but the configured identity_linker does not implement " - "SupportsLinkStorePath; configure link-store persistence on the linker directly." - ) - - def _validate_runner_codec_pairing(self) -> None: - """Refuse to start when a JSON-mode runner is paired with codec-less push channels. - - A JSON-mode durable runner (``payload_mode=JSON``) persists every - scheduled task's payload so it survives process restarts. The - host's ``hosting.push`` payload includes a - :class:`HostedRunResult` containing the full agent / workflow - output, which cannot be JSON-serialised without help from the - destination channel. Push-capable channels therefore must - declare a :class:`ChannelPushCodec` (a duck-typed - ``push_codec`` attribute on the channel) when paired with a - JSON-mode runner. - - Object-mode runners (the default in-process runner) accept live - Python references and skip this check. - """ - mode = getattr(self._durable_task_runner, "payload_mode", DurableTaskPayloadMode.OBJECT) - if mode != DurableTaskPayloadMode.JSON: - return - missing: list[str] = [] - for channel in self.channels: - if not isinstance(channel, ChannelPush): - # Channels that don't implement push are never scheduled, - # so a missing codec is fine. - continue - codec = getattr(channel, "push_codec", None) - if codec is None: - missing.append(channel.name) - if missing: - raise RuntimeError( - "Durable task runner declares payload_mode=JSON, but the following " - "push-capable channels have no `push_codec` attribute and cannot " - "be serialised for persistence: " - f"{', '.join(missing)}. Add a ChannelPushCodec to each channel " - "or switch to an object-mode runner (e.g. InProcessTaskRunner)." - ) - - def _resolve_channel_allowlist(self, channel: Channel) -> IdentityAllowlist | None: - """Apply the ``"inherit"`` / ``None`` / explicit semantics. - - - ``"inherit"`` (default) → host's ``default_allowlist``. - - ``None`` → explicitly open (carve-out inside a locked host). - - any other value → use as-is. - """ - raw: Any = getattr(channel, "allowlist", "inherit") - if raw == "inherit": - return self._default_allowlist - # ``None`` and concrete allowlists both pass through unchanged; - # the caller (``authorize``) treats ``None`` as "open". - return cast("IdentityAllowlist | None", raw) - - def _validate_channel_authorization(self) -> None: - """Reject configurations that would silently deny every user. - - Runs three rules (see spec § "Configuration validation"): - - 1. If a channel's resolved allowlist declares - ``requires_linked_claims=True``, the channel must either set - ``require_link=True`` or declare - ``emits_verified_claims=True`` — otherwise no verified - claims will ever reach :meth:`evaluate` and the allowlist - would always ``ABSTAIN`` / ``DENY``. - 2. If any channel has ``require_link=True``, an - ``identity_linker`` must be configured. Silent - deny-everyone is the worst possible default. - 3. ``NativeIdAllowlist(channel=)`` must reference a - channel name that exists on this host — typo-detection. - """ - known_channels = {c.name for c in self.channels} - for channel in self.channels: - allowlist = self._resolve_channel_allowlist(channel) - require_link = bool(getattr(channel, "require_link", False)) - emits_claims = bool(getattr(channel, "emits_verified_claims", False)) - # Rule #2: require_link without a linker. - if require_link and self._identity_linker is None: - raise ChannelConfigurationError( - f"Channel '{channel.name}' has require_link=True but no " - "identity_linker is configured on the host. Configure one or " - "remove require_link=True (silent deny-everyone is rejected)." - ) - if allowlist is None: - continue - # Rule #1: claim-dependent allowlist needs a claim source. - if getattr(allowlist, "requires_linked_claims", False) and not (require_link or emits_claims): - raise ChannelConfigurationError( - f"Channel '{channel.name}' has an allowlist that requires " - "verified IdP claims (requires_linked_claims=True) but the " - "channel neither sets require_link=True nor emits verified " - "claims natively. Configure a source of verified claims for " - "the allowlist (silent deny-everyone is rejected)." - ) - # Rule #3: native-id allowlists pointing at unknown channels. - for nested in _flatten_allowlists(allowlist): - target = getattr(nested, "channel", None) - if target is not None and target not in known_channels: - raise ChannelConfigurationError( - f"NativeIdAllowlist on channel '{channel.name}' references " - f"unknown channel '{target}'. Known channels: " - f"{sorted(known_channels)}." - ) - - async def authorize( - self, - identity: ChannelIdentity, - *, - require_link: bool = False, - allowlist: IdentityAllowlist | None = None, - verified_claims: Mapping[str, ClaimValue] | None = None, - ) -> AuthorizationOutcome: - """Evaluate authorization for ``identity`` against ``allowlist``. - - Channels should call this **before** producing a - :class:`ChannelRequest` so a denied identity never reaches the - agent. The host's run path also re-checks authorization for - defense-in-depth, but channels that surface :class:`Denied` or - :class:`LinkRequired` themselves can render the outcome - through their native UX (refusal message, link challenge) - rather than a generic error. - - Supports open, native-id allowlist, and verified-claim allowlist - profiles. ``require_link=True`` or claim-based allowlists use - the configured :class:`IdentityLinker`; channels that natively - authenticate users may pass ``verified_claims`` directly. - - Returns: - One of :class:`Allowed`, :class:`LinkRequired`, or - :class:`Denied`. - """ - claims: Mapping[str, ClaimValue] = verified_claims or {} - claim_source: Literal["linker", "channel", "none"] = "channel" if claims else "none" - auto_isolation_key = self._auto_issue_isolation_key(identity) - if allowlist is None: - # Open profile (or explicitly carved-out channel). - if require_link: - return await self._resolve_required_link(identity) - return Allowed(isolation_key=auto_isolation_key, verified_claims=claims, claim_source=claim_source) - pre_context = AuthorizationContext( - identity=identity, - phase="pre_link", - isolation_key=None, - verified_claims=claims, - claim_source=claim_source, - ) - decision = await allowlist.evaluate(pre_context) - if decision is AllowlistDecision.ALLOW: - if require_link: - return await self._resolve_required_link(identity) - return Allowed(isolation_key=auto_isolation_key, verified_claims=claims, claim_source=claim_source) - if decision is AllowlistDecision.DENY: - return Denied( - reason_code="allowlist_denied_pre_link", - user_message="You don't have access to this bot.", - log_details={ - "channel": identity.channel, - "phase": "pre_link", - }, - ) - # ABSTAIN: claim-dependent allowlists need a post-link / - # verified-claim evaluation. Non-claim allowlists can fall - # through to the open path, while still honoring require_link. - if getattr(allowlist, "requires_linked_claims", False): - if claims: - post_context = AuthorizationContext( - identity=identity, - phase="post_link", - isolation_key=auto_isolation_key, - verified_claims=claims, - claim_source="channel", - ) - post_decision = await allowlist.evaluate(post_context) - return self._authorization_outcome_from_post_link( - identity=identity, - isolation_key=auto_isolation_key, - claims=claims, - claim_source="channel", - decision=post_decision, - ) - return await self._resolve_and_evaluate_claim_allowlist(identity, allowlist) - if require_link: - return await self._resolve_required_link(identity) - return Allowed(isolation_key=auto_isolation_key, verified_claims=claims, claim_source=claim_source) - - async def _resolve_required_link(self, identity: ChannelIdentity) -> AuthorizationOutcome: - """Resolve ``identity`` through the configured linker or request linking.""" - linker = self._identity_linker - if linker is None: - # Defensive: the construction-time validator should catch this. - return Denied( - reason_code="link_required_without_linker", - user_message="Sign-in is not configured for this bot.", - log_details={"channel": identity.channel}, - ) - resolution = await linker.resolve(identity) - if isinstance(resolution, LinkChallenge): - return LinkRequired(challenge=resolution) - return Allowed( - isolation_key=resolution.isolation_key, - verified_claims=resolution.verified_claims, - claim_source=resolution.claim_source, - ) - - async def _resolve_and_evaluate_claim_allowlist( - self, - identity: ChannelIdentity, - allowlist: IdentityAllowlist, - ) -> AuthorizationOutcome: - """Resolve identity, then run a claim-dependent allowlist post-link.""" - linker = self._identity_linker - if linker is None: - return Denied( - reason_code="allowlist_requires_link", - user_message="Please link your account to continue.", - log_details={"channel": identity.channel, "phase": "pre_link"}, - ) - resolution = await linker.resolve(identity) - if isinstance(resolution, LinkChallenge): - return LinkRequired(challenge=resolution) - post_context = AuthorizationContext( - identity=identity, - phase="post_link", - isolation_key=resolution.isolation_key, - verified_claims=resolution.verified_claims, - claim_source=resolution.claim_source, - ) - post_decision = await allowlist.evaluate(post_context) - return self._authorization_outcome_from_post_link( - identity=identity, - isolation_key=resolution.isolation_key, - claims=resolution.verified_claims, - claim_source=resolution.claim_source, - decision=post_decision, - ) - - def _authorization_outcome_from_post_link( - self, - *, - identity: ChannelIdentity, - isolation_key: str, - claims: Mapping[str, ClaimValue], - claim_source: Literal["linker", "channel"], - decision: AllowlistDecision, - ) -> AuthorizationOutcome: - """Convert a post-link allowlist decision to a host outcome.""" - if decision is AllowlistDecision.ALLOW: - return Allowed(isolation_key=isolation_key, verified_claims=claims, claim_source=claim_source) - if decision is AllowlistDecision.DENY: - return Denied( - reason_code="allowlist_denied_post_link", - user_message="You don't have access to this bot.", - log_details={ - "channel": identity.channel, - "phase": "post_link", - "claim_source": claim_source, - }, - ) - return Denied( - reason_code="allowlist_abstained_post_link", - user_message="You don't have access to this bot.", - log_details={ - "channel": identity.channel, - "phase": "post_link", - "claim_source": claim_source, - }, - ) - - def _auto_issue_isolation_key(self, identity: ChannelIdentity) -> str: - """Auto-issue a stable isolation key for ``identity``. - - Returns the existing key when ``(channel, native_id)`` has - already been seen, or coins ``":"`` on - first contact. Configured :class:`IdentityLinker` instances can - return provider-backed isolation keys for flows that require - verified identity. - """ - # Look for an existing isolation_key that has already linked - # this (channel, native_id). Linear scan is fine for the - # in-process registry. Linker implementations can use their own - # indexed stores for provider-backed identities. - for isolation_key, by_channel in self._identities.items(): - existing = by_channel.get(identity.channel) - if existing is not None and existing.native_id == identity.native_id: - return isolation_key - # First contact — coin a deterministic key. - return f"{identity.channel}:{identity.native_id}" - - @property - def default_allowlist(self) -> IdentityAllowlist | None: - """Host-level fallback allowlist applied to channels with ``allowlist="inherit"``.""" - return self._default_allowlist - - @property - def runtime_mode(self) -> RuntimeMode: - """The resolved runtime mode for this host. - - Either ``"long_running"`` or ``"ephemeral"``. Resolved at - construction from the ``runtime_mode`` constructor argument or - — when unset — auto-detected from deployment environment - markers; see :func:`_detect_runtime_mode`. Advisory: the value - drives the *defaults* selected for runtime-shape-dependent - components (today, the durable task runner) and is logged at - startup for operator visibility. - """ - return self._runtime_mode - - @property - def durable_task_runner(self) -> DurableTaskRunner: - """The durable task runner used to dispatch non-originating pushes. - - Defaults to a process-local :class:`InProcessTaskRunner` when no - runner was supplied at construction. Adapter packages may - replace this with a durable backend (e.g. Foundry-native - scheduling, ``agent-framework-hosting-durabletask``); the host - itself only relies on the :class:`DurableTaskRunner` Protocol - surface so any conforming implementation is usable. - """ - return self._durable_task_runner - def serve( self, *, @@ -1257,9 +803,7 @@ class AgentFrameworkHost: Called from both :meth:`serve` (which knows the bind triple) and the ASGI lifespan ``startup`` phase (which does not — the host may be embedded under any caller-managed ASGI server). - Bind fields are omitted from the log line when unknown so - operators can still spot the runtime-mode banner under - externally-managed servers. + Bind fields are omitted from the log line when unknown. """ target_kind = "Workflow" if isinstance(self.target, Workflow) else type(self.target).__name__ target_name = getattr(self.target, "name", None) or target_kind @@ -1270,16 +814,12 @@ class AgentFrameworkHost: is_hosted = bool(os.environ.get("FOUNDRY_HOSTING_ENVIRONMENT")) bind = f"{host}:{port}" if host is not None and port is not None else "" logger.info( - "AgentFrameworkHost starting: target=%s (%s) bind=%s workers=%s hosted=%s " - "runtime_mode=%s (%s) runner=%s channels=[%s]", + "AgentFrameworkHost starting: target=%s (%s) bind=%s workers=%s hosted=%s channels=[%s]", target_name, target_kind, bind, workers if workers is not None else "", is_hosted, - self._runtime_mode, - self._runtime_mode_source, - type(self._durable_task_runner).__name__, channels_repr or "", ) @@ -1329,8 +869,7 @@ class AgentFrameworkHost: # logged it (it logs eagerly so the banner appears before # control passes to hypercorn); the lifespan still logs it # for callers that mount ``host.app`` directly under their - # own ASGI server — that path otherwise wouldn't get a - # runtime-mode banner at all. + # own ASGI server. if not self._startup_logged: self._log_startup() self._startup_logged = True @@ -1341,27 +880,7 @@ class AgentFrameworkHost: # Starlette / the ASGI server still aborts boot — and log # every other failure so operators can see them all in one # log scrape rather than discovering them turn-by-turn. - # (The hosting.push handler is registered eagerly in - # ``__init__`` rather than here, so ``_deliver_response`` - # can be called without first entering the lifespan — e.g. - # in tests, or by callers driving the host without an ASGI - # server.) startup_errors: list[tuple[str, BaseException]] = [] - # Replay any persisted pending tasks first so re-scheduled - # work runs alongside fresh traffic from the moment the - # host accepts requests. Only meaningful for the host-owned - # in-process runner with disk persistence on; caller-owned - # runners manage their own replay lifecycle. - if ( - self._owns_runner - and isinstance(self._durable_task_runner, InProcessTaskRunner) - and self._state_paths.get("runner") is not None - ): - try: - await self._durable_task_runner.resume() - except Exception as exc: - logger.exception("lifespan startup: durable task runner resume failed") - startup_errors.append(("InProcessTaskRunner.resume", exc)) for cb in on_startup: try: await cb() @@ -1394,21 +913,6 @@ class AgentFrameworkHost: name = getattr(cb, "__qualname__", repr(cb)) logger.exception("lifespan shutdown: callback %s failed", name) shutdown_errors.append((name, exc)) - # Drain the host-owned runner after channel shutdowns — - # channels may legitimately schedule a final push while - # tearing down (e.g. a goodbye message), and we want - # those tasks to get a chance to complete before we - # cancel pending work. For caller-supplied runners we - # leave lifecycle to the caller. - if self._owns_runner and isinstance(self._durable_task_runner, InProcessTaskRunner): - try: - await self._durable_task_runner.shutdown(timeout=5.0) - except Exception as exc: # pragma: no cover - defensive - logger.exception("lifespan shutdown: durable task runner shutdown failed") - shutdown_errors.append(("InProcessTaskRunner.shutdown", exc)) - # Close the persisted sessions store after the runner so - # any in-flight task that touches session state during - # shutdown can still write through. if self._sessions_store is not None: try: self._sessions_store.close() @@ -1426,17 +930,18 @@ class AgentFrameworkHost: ) raise first_exc + middleware = ( + [Middleware(_FoundryIsolationASGIMiddleware)] if os.environ.get("FOUNDRY_HOSTING_ENVIRONMENT") else [] + ) return Starlette( debug=self._debug, routes=routes, lifespan=lifespan, - middleware=[Middleware(_FoundryIsolationASGIMiddleware)], + middleware=middleware, ) def _build_run_kwargs(self, request: ChannelRequest) -> dict[str, Any]: - # The full spec resolves a ChannelSession into an AgentSession here, - # honors session_mode, and consults LinkPolicy / ResponseTarget. This - # base host keys a per-isolation_key AgentSession off the channel's + # The host keys a per-isolation_key AgentSession off the channel's # session hint so context providers (FileHistoryProvider, …) on the # target see one session per end user. session = None @@ -1471,6 +976,36 @@ class AgentFrameworkHost: run_kwargs["options"] = request.options return run_kwargs + async def _apply_run_hook( + self, + request: ChannelRequest, + *, + hook: ChannelRunHook | None, + protocol_request: Any | None, + ) -> ChannelRequest: + """Apply a channel-supplied run hook under host ownership.""" + if hook is None: + return request + return await _apply_run_hook( + hook, + request, + target=self.target, + protocol_request=protocol_request, + ) + + async def _apply_response_hook( + self, + result: HostedRunResult[Any], + *, + request: ChannelRequest, + hook: ChannelResponseHook | None, + channel_name: str | None, + ) -> HostedRunResult[Any]: + """Apply a channel-supplied response hook under host ownership.""" + if hook is None: + return result + return await _apply_response_hook(hook, result, request=request, channel_name=channel_name) + def _log_incoming(self, request: ChannelRequest, *, stream: bool) -> None: """Emit a structured INFO summary for every incoming target invocation. @@ -1554,7 +1089,6 @@ class AgentFrameworkHost: async def _invoke(self, request: ChannelRequest) -> HostedRunResult[AgentResponse]: self._log_incoming(request, stream=False) - self._record_identity(request) if self._is_workflow: # Workflow targets follow a separate path; the dedicated dispatch # is parameterised on ``WorkflowRunResult`` so the static return @@ -1579,7 +1113,6 @@ class AgentFrameworkHost: def _invoke_stream(self, request: ChannelRequest) -> ResponseStream[AgentResponseUpdate, AgentResponse]: self._log_incoming(request, stream=True) - self._record_identity(request) if self._is_workflow: return self._invoke_workflow_stream(request) run_kwargs = self._build_run_kwargs(request) @@ -1711,7 +1244,7 @@ class AgentFrameworkHost: Wraps the workflow's ``ResponseStream[WorkflowEvent, WorkflowRunResult]`` in a new ``ResponseStream[AgentResponseUpdate, AgentResponse]`` so channels can iterate it identically to an agent stream and apply - their ``stream_transform_hook`` callables. + their ``stream_update_hook`` callables. Mapping rules: @@ -1799,10 +1332,10 @@ class AgentFrameworkHost: Channels deliver inputs as plain text, a single ``Message``, or a list of ``Message`` (e.g. a Responses-API request that includes a ``system`` - instruction plus the user turn). To preserve channel provenance + - identity + ``response_target`` on the persisted history record (and - make it visible to context providers, evals, audits), we attach a - ``hosting`` block under ``additional_properties``. AF's + instruction plus the user turn). To preserve channel provenance and + optional identity metadata on the persisted history record (and make it + visible to context providers, evals, audits), we attach a ``hosting`` + block under ``additional_properties``. AF's ``Message.to_dict`` round-trips ``additional_properties`` through any ``HistoryProvider`` that serializes via ``to_dict`` (e.g. ``FileHistoryProvider``) and the framework explicitly does *not* @@ -1819,12 +1352,6 @@ class AgentFrameworkHost: "native_id": request.identity.native_id, "attributes": dict(request.identity.attributes) if request.identity.attributes else {}, } - target = request.response_target - hosting_meta["response_target"] = { - "kind": target.kind.value, - "targets": list(target.targets), - } - raw = request.input if isinstance(raw, Message): raw.additional_properties = {**(raw.additional_properties or {}), "hosting": hosting_meta} @@ -1842,531 +1369,5 @@ class AgentFrameworkHost: additional_properties={"hosting": hosting_meta}, ) - def _record_identity(self, request: ChannelRequest) -> None: - """Update the per-``isolation_key`` identity registry + active-channel hint. - - Called on every successful resolve. ``ResponseTarget.active`` - consumes ``self._active``; ``ResponseTarget.channel(name)`` / - ``.channels([...])`` / ``.all_linked`` consume ``self._identities``. - """ - if request.identity is None or request.session is None: - return - key = request.session.isolation_key - if not key: - return - self._identities.setdefault(key, {})[request.identity.channel] = request.identity - self._active[key] = request.identity.channel - - def _build_echo_payload(self, request: ChannelRequest) -> HostedRunResult[AgentResponse]: - """Build a ``HostedRunResult`` representing the originating user message. - - Used when ``ResponseTarget.echo_input`` is set so non-originating - destinations can mirror the user's turn before the agent reply - arrives. The user-facing payload is synthesised as a one-message - :class:`AgentResponse` (``role="user"``) so it flows through the - same delivery machinery as the agent's reply — channels handle - both via a single ``HostedRunResult[AgentResponse]`` shape. The - hosting metadata that ``_wrap_input`` attaches for agent - invocation is intentionally stripped: the echo is end-user-facing - and we don't leak host-internal bookkeeping onto another - channel's wire. - """ - raw = request.input - if isinstance(raw, Message): - user_messages: list[Message] = [ - Message(role="user", contents=list(raw.contents), author_name=raw.author_name), - ] - elif isinstance(raw, list) and raw and all(isinstance(m, Message) for m in raw): - user_messages = [ - Message(role="user", contents=list(m.contents), author_name=m.author_name) - for m in raw - if isinstance(m, Message) - ] - elif isinstance(raw, str): - user_messages = [Message(role="user", contents=[Content.from_text(text=raw)])] - elif isinstance(raw, Content): - user_messages = [Message(role="user", contents=[raw])] - else: - # AgentRunInputs allows other shapes (mapping, sequence of mixed - # str/Content); stringify as a defensive fallback. - user_messages = [Message(role="user", contents=[Content.from_text(text=str(raw))])] - return HostedRunResult(AgentResponse(messages=user_messages)) - - async def _deliver_payload_to_channel( - self, - channel: ChannelPush, - identity: ChannelIdentity, - payload: HostedRunResult[Any], - *, - request: ChannelRequest, - is_echo: bool, - ) -> HostedRunResult[Any]: - """Clone, run the channel's ``response_hook`` (if any), and push. - - The clone keeps fan-out free from cross-destination mutation: a - hook that rebinds ``result`` on one destination cannot leak into - the next push. Note that the clone is shallow — channels that - need to mutate ``result`` itself (rather than rebind it via - :meth:`HostedRunResult.replace`) are responsible for their own - deep copy. Returns the (possibly hook-shaped) payload so callers - can log post-hook diagnostics rather than the pre-hook ones. - - ``response_hook`` is duck-typed on the channel: any attribute - named ``response_hook`` that is callable participates. The - :class:`Channel` Protocol stays a small "name / path / contribute" - contract; richer surfaces stay attribute-level so adding hook - support to a new channel does not require updating the Protocol. - """ - shaped = await apply_channel_response_hook( - channel, - payload, - request=request, - destination_identity=identity, - originating=False, - is_echo=is_echo, - clone=True, - ) - await channel.push(identity, shaped) - return shaped - - async def _handle_push_task(self, payload: Mapping[str, Any]) -> None: - """Runner-side handler for ``hosting.push`` tasks. - - Unpacks a single per-destination push payload (one channel, one - identity) and runs the echo (when present) followed by the - response push. Echo failures are logged and swallowed — the - user-visible failure mode is "response delivered without - echo", *not* "no response at all". Response-push failures - re-raise so the runner can retry per the configured - :class:`RetryPolicy`. - - **Retry idempotency for the echo phase.** The payload includes a - mutable ``"echo_done"`` cursor (initialised to ``False`` at - schedule time). If a previous attempt already delivered the - echo but the response push then failed, the runner retries the - whole task; we observe ``echo_done == True`` and skip the - re-echo so end users on channels without server-side - deduplication don't see the same user-message echoed multiple - times. This is a best-effort guarantee for the in-process - runner — payload mutations don't survive process restarts. - Durable adapter packages SHOULD persist the cursor as part of - their task state (their replay machinery typically gives them - that primitive for free). - - Payload shape depends on the configured - :data:`DurableTaskRunner.payload_mode`: - - * Object mode (default) — live Python references: - ``channel_name``, ``identity``, ``result``, ``echo_result``, - ``echo_done``, ``request``. - * JSON mode — a single ``envelope`` produced by the - destination channel's :class:`ChannelPushCodec` plus - ``channel_name`` and ``echo_done``. The handler invokes - ``codec.decode(envelope)`` to recover the live references - before pushing. - """ - channel_name = cast(str, payload["channel_name"]) - echo_done = bool(payload.get("echo_done", False)) - - by_name = {ch.name: ch for ch in self.channels} - channel = by_name.get(channel_name) - if channel is None or not isinstance(channel, ChannelPush): - # Channel was validated at schedule time; if we ever land - # here it means the host's channel list mutated mid-flight, - # which we don't support. Log loudly and drop — re-raising - # would just cause the runner to retry forever. - logger.error( - "hosting.push: channel %r is no longer a ChannelPush; dropping task", - channel_name, - ) - return - push_channel = cast(ChannelPush, channel) - - # Recover the live references. Object-mode runners pass them - # through verbatim; JSON-mode runners persisted an envelope the - # channel's codec produced and we now ask the codec to decode - # it back. - envelope = payload.get("envelope") - if envelope is not None: - codec = cast("ChannelPushCodec | None", getattr(channel, "push_codec", None)) - if codec is None: - logger.error( - "hosting.push: channel %r received a JSON envelope but has no push_codec; dropping task", - channel_name, - ) - return - result, request, identity, echo_result = await codec.decode(envelope) - else: - identity = cast(ChannelIdentity, payload["identity"]) - result = cast(HostedRunResult[Any], payload["result"]) - echo_result = cast("HostedRunResult[Any] | None", payload.get("echo_result")) - request = cast(ChannelRequest, payload["request"]) - - if echo_result is not None and not echo_done: - try: - await self._deliver_payload_to_channel( - push_channel, - identity, - echo_result, - request=request, - is_echo=True, - ) - except Exception: - logger.exception( - "hosting.push: echo push failed for channel=%s native_id=%s", - channel_name, - identity.native_id, - ) - else: - # Mutate the payload mapping so a subsequent retry of - # this task (triggered by a failure in the response - # phase below) skips the echo. The in-process runner - # reuses the same mapping object across retries — see - # ``_run_with_retry``; durable adapters persist the - # cursor as part of their task state. - if isinstance(payload, dict): - payload["echo_done"] = True - logger.info( - "hosting.push: echoed user message", - extra={"channel": channel_name, "native_id": identity.native_id}, - ) - elif echo_result is not None and echo_done: - logger.debug( - "hosting.push: skipping echo on retry (already delivered)", - extra={"channel": channel_name, "native_id": identity.native_id}, - ) - - # Response phase — raise on failure so the runner retries per - # the configured retry policy. The runner is responsible for - # terminal-failure bookkeeping. - await self._deliver_payload_to_channel( - push_channel, - identity, - result, - request=request, - is_echo=False, - ) - logger.info( - "hosting.push: pushed agent response", - extra={"channel": channel_name, "native_id": identity.native_id}, - ) - - async def _deliver_response(self, request: ChannelRequest, payload: HostedRunResult[Any]) -> bool: - """Resolve ``request.response_target``, annotate audit metadata, and schedule pushes. - - Returns ``True`` when the originating channel should render the - agent reply on its own wire (the resolved target included the - originating channel either explicitly or via the host's "every - destination dropped, fall back to originating" recovery path). - Returns ``False`` when the reply is fanned out purely to - non-originating destinations (or :data:`ResponseTarget.none` - suppresses the reply entirely). - - Per SPEC-002 §"Intended targets + durable delivery": for any - non-``originating`` target, the originating channel returns an - acknowledgement and the actual agent reply is dispatched - **asynchronously** via the host's :class:`DurableTaskRunner` — - one scheduled task per destination, with the runner owning - retry / terminal-failure / replay semantics. - - **Immutable audit annotation.** Before scheduling, the host - annotates each resolved assistant ``Message`` in the payload - with the ``hosting.intended_targets`` list (and optionally - ``hosting.skipped_targets`` for destinations dropped at - resolution time). Persistence providers therefore observe the - host's *intent* from a single immutable write — mutable - per-destination delivery state is owned by the runner backend. - - When a destination cannot be resolved (no known native id), or - the destination channel doesn't implement :class:`ChannelPush`, - or no channel by that name is registered, it is dropped - synchronously and logged at WARNING. When the only resolved - destinations all drop at resolution time we fall back to - delivering on the originating channel so the user is never left - without a reply. - - When ``request.response_target.echo_input`` is True the echo - payload (the originating user message) is bundled into the - same per-destination task as the agent response — see - :meth:`_handle_push_task`. The echo is dispatched *before* the - response within that task; an echo failure does not abort the - response push, and a retried task skips an already-delivered - echo via the ``echo_done`` cursor. - - For JSON-mode runners the destination channel's - :class:`ChannelPushCodec` is called to project the in-memory - :class:`HostedRunResult` into a JSON-safe envelope before - scheduling. Codec failures - (:class:`PushPayloadNotSerializable`) abort the schedule for - that destination (logged and treated as skipped); other - destinations still get their chance. - - Each per-destination push (echo and response) goes through - :meth:`_deliver_payload_to_channel`, which clones the payload - and applies the channel's optional ``response_hook`` so - per-channel transforms (e.g. flatten multi-modal to text for a - text-only wire) can't leak across destinations. - """ - target = request.response_target - kind = target.kind - - # Fast paths for the trivial variants. - if kind == ResponseTargetKind.ORIGINATING: - return True - if kind == ResponseTargetKind.NONE: - # Background-only — drop the reply on the floor for now (no - # ContinuationToken in the prototype). - return False - - # Build the destination set. - include_originating = False - # Each entry is (channel_name, identity_override_or_None_to_lookup). - destinations: list[tuple[str, ChannelIdentity | None]] = [] - isolation_key = request.session.isolation_key if request.session is not None else None - known = self._identities.get(isolation_key or "", {}) - - if kind == ResponseTargetKind.ACTIVE: - active = self._active.get(isolation_key or "") - if active is None or active == request.channel: - # Fall back to originating when there's no other active - # channel known (matches the "first message" case). - self._annotate_intended_targets(payload, intended=(), skipped=()) - return True - destinations.append((active, known.get(active))) - - elif kind == ResponseTargetKind.ALL_LINKED: - for channel_name, identity in known.items(): - if channel_name == request.channel: - include_originating = True - continue - destinations.append((channel_name, identity)) - if not destinations and not include_originating: - # No links recorded yet — fall back. - self._annotate_intended_targets(payload, intended=(), skipped=()) - return True - - elif kind == ResponseTargetKind.IDENTITIES: - for ident in target.target_identities: - if ident.channel == request.channel: - # Pointing the originating channel at itself — fold - # into ``include_originating`` so the originating - # channel renders on its own wire rather than - # double-delivering via push. - include_originating = True - continue - destinations.append((ident.channel, ident)) - - elif kind == ResponseTargetKind.CHANNELS: - for entry in target.targets: - if entry == "originating": - include_originating = True - continue - if ":" in entry: - channel_name, _, native_id = entry.partition(":") - if channel_name == request.channel: - # Pointing the originating channel at itself with a - # specific native id — treat as "include - # originating" since the channel will reply on its - # own wire to that user anyway. - include_originating = True - continue - destinations.append((channel_name, ChannelIdentity(channel=channel_name, native_id=native_id))) - else: - if entry == request.channel: - include_originating = True - continue - destinations.append((entry, known.get(entry))) - - # Schedule per-destination push tasks via the durable runner. - by_name = {ch.name: ch for ch in self.channels} - runner_mode = getattr(self._durable_task_runner, "payload_mode", DurableTaskPayloadMode.OBJECT) - intended_tokens: list[str] = [] - skipped_tokens: list[str] = [] - echo_payload = self._build_echo_payload(request) if target.echo_input else None - for channel_name, dest_identity in destinations: - channel = by_name.get(channel_name) - token = f"{channel_name}:{dest_identity.native_id}" if dest_identity is not None else channel_name - if channel is None: - logger.warning("deliver_response: no channel named %r (target=%s)", channel_name, token) - skipped_tokens.append(token) - continue - if not isinstance(channel, ChannelPush): - logger.warning( - "deliver_response: channel %r does not implement ChannelPush (target=%s)", - channel_name, - token, - ) - skipped_tokens.append(token) - continue - if dest_identity is None: - logger.warning( - "deliver_response: no known identity for isolation_key=%s on channel=%s", - isolation_key, - channel_name, - ) - skipped_tokens.append(token) - continue - - # Build the runner payload. Object-mode runners get live - # references for speed; JSON-mode runners get a fully - # encoded envelope from the channel's push codec. - try: - task_payload = await self._build_push_payload( - channel=channel, - channel_name=channel_name, - identity=dest_identity, - request=request, - result=payload, - echo_payload=echo_payload, - runner_mode=runner_mode, - ) - except PushPayloadNotSerializable: - logger.exception( - "deliver_response: channel %r push codec refused payload (target=%s); skipping", - channel_name, - token, - ) - skipped_tokens.append(token) - continue - try: - await self._durable_task_runner.schedule(HOSTING_PUSH_TASK_NAME, task_payload) - except Exception: - # Schedule-time failures are a host-side outage (runner - # backend unreachable, configuration error). Log and - # treat the destination as skipped — the originating - # channel's fall-back-to-originating rule (below) keeps - # the user from being left without a reply when every - # destination dropped. - logger.exception("deliver_response: failed to schedule push for target=%s", token) - skipped_tokens.append(token) - continue - intended_tokens.append(token) - logger.info( - "deliver_response: scheduled push", - extra={"target": token, "channel": channel_name}, - ) - - if not intended_tokens and not include_originating: - # Spec policy: if every destination drops at resolution time - # (or scheduling fails universally) deliver to originating - # so the user gets a response. The runner backend still - # owns observability for any partial-failure case where at - # least one destination did get scheduled. - logger.warning("deliver_response: every destination dropped — falling back to originating") - include_originating = True - - self._annotate_intended_targets( - payload, - intended=tuple(intended_tokens), - skipped=tuple(skipped_tokens), - include_originating=include_originating, - originating_channel=request.channel, - ) - - return include_originating - - async def _build_push_payload( - self, - *, - channel: ChannelPush, - channel_name: str, - identity: ChannelIdentity, - request: ChannelRequest, - result: HostedRunResult[Any], - echo_payload: HostedRunResult[Any] | None, - runner_mode: DurableTaskPayloadMode, - ) -> dict[str, Any]: - """Assemble the runner payload for a single push destination. - - For object-mode runners (the default in-process runner) we - forward live references — no serialisation cost on the hot - path. For JSON-mode runners we invoke the channel's - :class:`ChannelPushCodec` once to produce a JSON-safe envelope - for the whole push triple; the codec is the only entity that - knows how to project a :class:`HostedRunResult` plus the - channel-side request/identity context for a specific channel's - wire format. - """ - if runner_mode == DurableTaskPayloadMode.OBJECT: - return { - "channel_name": channel_name, - "identity": identity, - "result": result, - "echo_result": echo_payload, - "echo_done": False, - "request": request, - } - # JSON mode — the startup validator guarantees every push-capable - # channel has a ``push_codec``. Use ``getattr`` for the same - # duck-typed lookup pattern the validator and decoder use. - codec = cast("ChannelPushCodec", getattr(channel, "push_codec")) # noqa: B009 - envelope = await codec.encode( - result=result, - request=request, - identity=identity, - echo_result=echo_payload, - ) - return { - "channel_name": channel_name, - "envelope": dict(envelope), - "echo_done": False, - } - - def _annotate_intended_targets( - self, - payload: HostedRunResult[Any], - *, - intended: tuple[str, ...], - skipped: tuple[str, ...], - include_originating: bool = False, - originating_channel: str | None = None, - ) -> None: - """Stamp ``additional_properties["hosting"]`` on every assistant message in the payload. - - The audit annotation is the spec's immutable record of the - host's delivery *intent* — persistence providers see what the - host meant to deliver from a single write, without ever - observing mutable per-destination state (the runner owns - that). Annotated fields: - - - ``intended_targets``: ``[[:], …]`` for - every non-originating destination whose push task was - scheduled successfully. - - ``skipped_targets``: destinations dropped at resolution time - (unknown channel, no ``ChannelPush``, no known identity, or - schedule-time outage). Useful for ops triage. - - ``includes_originating``: ``True`` when the originating - channel rendered (or will render) the reply on its own wire. - - Workflow targets producing arbitrary result objects with no - ``messages`` field are left untouched — the annotation is a - best-effort augmentation of conventional agent responses. - """ - result_obj = payload.result - messages_raw: Any = getattr(result_obj, "messages", None) - if not isinstance(messages_raw, list): - return - hosting_meta: dict[str, Any] = { - "intended_targets": list(intended), - "includes_originating": include_originating, - } - if skipped: - hosting_meta["skipped_targets"] = list(skipped) - if include_originating and originating_channel is not None: - hosting_meta["originating_channel"] = originating_channel - for entry in cast("list[Any]", messages_raw): # type: ignore[redundant-cast] - if not isinstance(entry, Message): - continue - message: Message = entry - if getattr(message, "role", None) != "assistant": - continue - existing = message.additional_properties or {} - existing_hosting = existing.get("hosting") if isinstance(existing, Mapping) else None - if isinstance(existing_hosting, Mapping): - merged_hosting: Mapping[str, Any] = {**existing_hosting, **hosting_meta} - else: - merged_hosting = hosting_meta - message.additional_properties = {**existing, "hosting": merged_hosting} - __all__ = ["AgentFrameworkHost", "ChannelContext", "logger"] diff --git a/python/packages/hosting/agent_framework_hosting/_persistence.py b/python/packages/hosting/agent_framework_hosting/_persistence.py index 0e2ca85438..9ecc4dd23e 100644 --- a/python/packages/hosting/agent_framework_hosting/_persistence.py +++ b/python/packages/hosting/agent_framework_hosting/_persistence.py @@ -2,25 +2,10 @@ """Shared persistence primitives for the hosting package. -The hosting core ships with an opt-in disk-persistence layer for the -in-process task runner and the host's session-related state. The -on-disk format is provided by the ``diskcache`` package (a small, -pure-Python, sqlite-backed dependency installed via the ``[disk]`` -optional extra). - -This module centralises: - -- :func:`load_diskcache` — lazy import that raises a helpful error when - the optional extra is missing. -- :func:`acquire_state_dir_lock` — single-owner file lock that fails - fast when a second process points at the same directory. -- :func:`normalize_state_dir` — turn the host-level ``state_dir`` - parameter (``str`` / ``PathLike`` / :class:`HostStatePaths` / - ``Mapping``) into a normalised ``dict[component_name -> Path | None]``. - -Everything in this module is internal — public callers should go -through :class:`AgentFrameworkHost` or -:class:`InProcessTaskRunner` directly. +The simplified hosting core keeps disk persistence only for session aliases +created by :meth:`AgentFrameworkHost.reset_session` and for workflow +checkpoint path derivation. The on-disk session-alias store uses the optional +``diskcache`` package installed via the ``[disk]`` extra. """ from __future__ import annotations @@ -35,29 +20,19 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from ._types import HostStatePaths -# Known component keys recognised by the host's ``state_dir`` normaliser. -# Adding a new component is a non-breaking change: extend this tuple and -# add the matching key to :class:`HostStatePaths` in ``_types.py``. -_KNOWN_COMPONENTS: tuple[str, ...] = ("runner", "sessions", "checkpoints", "links") +_KNOWN_COMPONENTS: tuple[str, ...] = ("sessions", "checkpoints") def load_diskcache() -> Any: - """Lazy-import :mod:`diskcache` with a helpful error when missing. - - The ``diskcache`` package is an optional dependency installed via - the ``agent-framework-hosting[disk]`` extra. Users that never set - ``state_dir`` never trigger the import. This wrapper produces a - single, consistent error message when the import is needed but the - extra was not installed. - """ + """Lazy-import :mod:`diskcache` with a helpful error when missing.""" try: import diskcache # type: ignore[import-untyped] except ImportError as exc: # pragma: no cover - exercised via tests by monkeypatching raise ImportError( - "agent-framework-hosting was asked to persist state to disk " - "(state_dir is set) but the optional `diskcache` dependency " + "agent-framework-hosting was asked to persist session aliases to disk " + "(state_dir['sessions'] is set) but the optional `diskcache` dependency " "is not installed. Install the disk extra: " - "`pip install 'agent-framework-hosting[disk]'`." + "`pip install 'agent-framework-hosting[disk]`." ) from exc return diskcache @@ -65,24 +40,11 @@ def load_diskcache() -> Any: def acquire_state_dir_lock(component_dir: Path) -> Any: """Acquire an exclusive single-owner lock on a component's state dir. - Two processes pointing at the same state directory would both scan - pending records on startup and could execute the same task twice; - we therefore enforce single-owner semantics with an OS-level - advisory lock. The lock file lives at ``/.lock`` and - is held for the lifetime of the returned file handle. Closing the - handle (or process exit) releases it. - - On Unix this uses :func:`fcntl.flock`. On Windows it uses - :func:`msvcrt.locking`. The lock is *advisory* — the OS will not - enforce it against processes that ignore it, but no - well-behaved component of this package will. - - Raises ``RuntimeError`` if another process already holds the lock. + Raises: + RuntimeError: If another process already holds the lock. """ component_dir.mkdir(parents=True, exist_ok=True) lock_path = component_dir / ".lock" - # Open in append mode so we don't truncate an existing lock file - # (some monitoring tools may inspect it). fh = open(lock_path, "a+", encoding="utf-8") # noqa: SIM115 - kept open for lifetime try: if sys.platform == "win32": @@ -94,8 +56,7 @@ def acquire_state_dir_lock(component_dir: Path) -> Any: fh.close() raise RuntimeError( f"Another process already holds the hosting state lock at {lock_path}. " - "Two hosts (or two runners) pointing at the same state directory would " - "double-execute scheduled tasks; point each host at its own state_dir." + "Point each host at its own state_dir." ) from exc else: import fcntl @@ -106,8 +67,7 @@ def acquire_state_dir_lock(component_dir: Path) -> Any: fh.close() raise RuntimeError( f"Another process already holds the hosting state lock at {lock_path}. " - "Two hosts (or two runners) pointing at the same state directory would " - "double-execute scheduled tasks; point each host at its own state_dir." + "Point each host at its own state_dir." ) from exc except RuntimeError: raise @@ -118,15 +78,10 @@ def acquire_state_dir_lock(component_dir: Path) -> Any: def release_state_dir_lock(handle: Any) -> None: - """Release a lock previously acquired by :func:`acquire_state_dir_lock`. - - Closing the file handle is sufficient to drop the lock on both - platforms, but we make the intent explicit so the caller doesn't - have to know which mechanism (``fcntl`` vs ``msvcrt``) is in use. - """ + """Release a lock previously acquired by :func:`acquire_state_dir_lock`.""" if handle is None: return - with contextlib.suppress(Exception): # close errors are not actionable + with contextlib.suppress(Exception): handle.close() @@ -135,40 +90,27 @@ def normalize_state_dir( ) -> dict[str, Path | None]: """Resolve the host-level ``state_dir`` parameter into a per-component map. - Accepts any of: - - - ``None`` → all components return ``None`` (fully in-memory; today's behavior). - - ``str`` / :class:`os.PathLike` → all components share a parent - directory and get an auto-allocated subfolder (``runner/``, - ``sessions/``, ``checkpoints/``, ``links/``). - - :class:`HostStatePaths` typed dict / plain ``Mapping`` → per-key - override. Components missing from the mapping fall back to ``None`` - (in-memory only). Unknown keys raise ``ValueError`` to surface - typos early. - - Returns a ``dict[component_name -> Path | None]`` covering every - component in :data:`_KNOWN_COMPONENTS`. + Accepts ``None``, a single root path, or a mapping with ``sessions`` and + ``checkpoints`` keys. Unknown keys raise ``ValueError`` so obsolete + ``runner`` / ``links`` configuration is rejected instead of silently + doing nothing. """ result: dict[str, Path | None] = {name: None for name in _KNOWN_COMPONENTS} if state_dir is None: return result - # Strings and PathLikes use the default subfolder layout. if isinstance(state_dir, (str, os.PathLike)): root = Path(os.fspath(state_dir)) for name in _KNOWN_COMPONENTS: result[name] = root / name return result - # Mappings (incl. TypedDict at runtime) get per-component overrides. if isinstance(state_dir, Mapping): unknown = [k for k in state_dir if k not in _KNOWN_COMPONENTS] if unknown: raise ValueError( f"state_dir mapping contains unknown component key(s): {unknown!r}. " - f"Known components are: {list(_KNOWN_COMPONENTS)!r}. " - "If you are trying to use a future component, upgrade " - "agent-framework-hosting to a version that supports it." + f"Known components are: {list(_KNOWN_COMPONENTS)!r}." ) for name in _KNOWN_COMPONENTS: raw_value: Any = state_dir.get(name) @@ -184,12 +126,3 @@ def normalize_state_dir( raise TypeError( f"state_dir must be a str, PathLike, HostStatePaths mapping, or None — got {type(state_dir).__name__}" ) - - -__all__ = [ - "_KNOWN_COMPONENTS", - "acquire_state_dir_lock", - "load_diskcache", - "normalize_state_dir", - "release_state_dir_lock", -] diff --git a/python/packages/hosting/agent_framework_hosting/_runner.py b/python/packages/hosting/agent_framework_hosting/_runner.py deleted file mode 100644 index d37fa19bc0..0000000000 --- a/python/packages/hosting/agent_framework_hosting/_runner.py +++ /dev/null @@ -1,751 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""In-process implementation of :class:`DurableTaskRunner`. - -This is the default runner the host wires in when the operator does not -supply one. It runs tasks via :func:`asyncio.create_task` with a bounded -retry loop following the supplied :class:`RetryPolicy`. - -Two modes: - -* **In-memory** (``state_dir=None``, default) — pending tasks live as - ``asyncio.Task`` references in process memory. In-flight tasks are - lost on process death. Cheap, zero dependencies, suitable for unit - tests and for long-running deployments where "the process dies, - queued pushes are lost" is an acceptable failure mode. -* **Disk-persistent** (``state_dir=``) — pending tasks are - pickled into a :mod:`diskcache`-backed sqlite store before the - ``asyncio.Task`` is created. On the next startup the host calls - :meth:`InProcessTaskRunner.resume` which re-schedules every - surviving ``"pending"`` record with its persisted attempt count. - Graceful shutdown cancellations leave records in ``"pending"`` so - they replay on the next boot. Suitable for ``runtime_mode="long_running"`` - deployments that survive container moves / OOMs. - -For ``runtime_mode="ephemeral"`` deployments (Foundry Hosted Agent, -Azure Functions, Lambda) plug in a durable adapter package -(``agent-framework-hosting-durabletask`` for the gRPC TaskHub backend, -a future Foundry adapter, …) — they all implement the same -:class:`DurableTaskRunner` Protocol. - -See ``docs/specs/002-python-hosting-channels.md`` § "Durable task runner". -""" - -from __future__ import annotations - -import asyncio -import contextlib -import logging -import os -import pickle # noqa: S403 # nosec B403 - used only to validate user payloads round-trip -import time -import uuid -from collections.abc import Awaitable, Callable, Mapping -from pathlib import Path -from typing import Any, cast - -from ._persistence import ( - acquire_state_dir_lock, - load_diskcache, - release_state_dir_lock, -) -from ._types import ( - DurableTaskPayloadMode, - DurableTaskRunner, - PushPayloadNotPicklable, - RetryPolicy, - TaskHandle, - TaskStatus, -) - -logger = logging.getLogger(__name__) - - -# Keys used inside the per-task on-disk record. Kept as module constants -# so the schema is documented once and refactors are mechanical. -_REC_HANDLER_NAME = "handler_name" -_REC_PAYLOAD = "payload" -_REC_RETRY_POLICY = "retry_policy" -_REC_ATTEMPTS = "attempts_completed" -_REC_STATUS = "status" -_REC_CREATED_AT = "created_at" -_REC_TERMINAL_AT = "terminal_at" -_REC_NAME = "name" - -# Deque key inside the cache holding terminal task ids in insertion order. -# Used for FIFO eviction of terminal records once the bounded cap is hit. -_TERMINAL_ORDER_KEY = "__terminal_order__" - - -class _PersistedPayloadDict(dict[str, Any]): - """Drop-in :class:`dict` that mirrors mutations back to disk. - - Used by :class:`InProcessTaskRunner` when ``state_dir`` is set so - handler-side cursors (``echo_done``) survive process restarts. The - handler interacts with this object exactly as it would with a plain - dict; the override on :meth:`__setitem__` is the only difference. - - Held weakly by the runner so handlers that capture the dict in - long-lived closures don't keep the runner alive past its natural - lifetime. - """ - - # Type annotation for the persist callback; the actual attribute is - # assigned via the __slots__-aware ``object.__setattr__`` dance - # below so PyPy doesn't reject the assignment on a ``dict`` subclass. - _persist_cb: Callable[[Mapping[str, Any]], None] - - __slots__ = ("_persist_cb",) - - def __init__( - self, - data: Mapping[str, Any], - persist_cb: Callable[[Mapping[str, Any]], None], - ) -> None: - super().__init__(data) - # Use object.__setattr__ to bypass the __slots__ checker on - # dict subclasses (CPython is liberal here but PyPy is strict). - object.__setattr__(self, "_persist_cb", persist_cb) - - def __setitem__(self, key: str, value: Any) -> None: - super().__setitem__(key, value) - # Re-serialise after each mutation. The cache stores opaque - # pickled values, so partial-field updates aren't possible — - # we send the whole payload mapping every time. Mutations on - # the runner's hot path are rare (just the ``echo_done`` - # cursor today) so this is fine. - self._persist_cb(dict(self)) - - -class InProcessTaskRunner(DurableTaskRunner): - """In-memory or disk-persistent :class:`DurableTaskRunner`. - - Schedules each task as an :func:`asyncio.create_task` coroutine and - retries on exception up to ``RetryPolicy.max_attempts`` times with - exponential backoff. Terminal status (``succeeded`` / ``failed`` / - ``cancelled``) is reported via :meth:`get`. - - Re-registration of the same handler name after :meth:`schedule` has - been called is rejected to avoid silent re-orderings of in-flight - work; the host registers all handlers at startup, before serving - traffic. - - Keyword Args: - default_retry_policy: Per-runner default :class:`RetryPolicy`; - overridable per-task at :meth:`schedule` call sites. - terminal_cache_size: Maximum number of terminal task records to - retain. Older entries are FIFO-evicted so a long-running - host can't accumulate unbounded status entries. - shutdown_grace_seconds: Window :meth:`shutdown` waits for - in-flight tasks to drain before cancelling stragglers. - state_dir: When set, the runner persists pending and terminal - task records under this directory (a :mod:`diskcache` - sqlite store at ``/cache.db`` and a single-owner - lock at ``/.lock``). Persisted pending records - survive process restarts and are replayed by :meth:`resume`. - When ``None`` (default) the runner is purely in-memory and - in-flight tasks are lost on process death. Requires the - optional ``diskcache`` dependency — install with - ``pip install 'agent-framework-hosting[disk]'``. - """ - - # Declared at class level so the ``DurableTaskRunner`` Protocol's - # ``payload_mode`` attribute resolves on instances without needing - # to assign it in ``__init__``. - payload_mode: DurableTaskPayloadMode = DurableTaskPayloadMode.OBJECT - - def __init__( - self, - *, - default_retry_policy: RetryPolicy | None = None, - terminal_cache_size: int = 1024, - shutdown_grace_seconds: float = 5.0, - state_dir: str | os.PathLike[str] | None = None, - ) -> None: - self._handlers: dict[str, Callable[[Mapping[str, Any]], Awaitable[None]]] = {} - self._default_retry_policy = default_retry_policy or RetryPolicy() - self._terminal_cache_size = terminal_cache_size - # How long ``shutdown()`` waits for in-flight tasks to finish on - # their own before cancelling them. Channels may legitimately - # schedule a final push during their own shutdown callback - # (goodbye message, telemetry flush), so the runner gives them - # this window to complete before cancellation kicks in. - self._shutdown_grace_seconds = shutdown_grace_seconds - - # Operational state. ``_pending`` holds asyncio tasks that are - # scheduled or running. ``_terminal`` is an in-memory mirror of - # the most recent terminal statuses (kept in-memory regardless of - # ``state_dir`` so ``get`` is fast and works before/without the - # cache being opened). - self._pending: dict[str, asyncio.Task[None]] = {} - self._terminal: dict[str, TaskStatus] = {} - self._terminal_order: list[str] = [] - - # Set to True on the first ``schedule``/``resume`` call so subsequent - # ``register`` calls fail loudly rather than silently swapping a - # handler out from under in-flight work. - self._started = False - - # Set to True when ``shutdown()`` starts so the retry loop's - # ``CancelledError`` handler distinguishes "the runner is going - # down, leave my record in 'pending' for resume()" from "this - # task was explicitly cancelled, mark it 'cancelled'". - self._shutting_down = False - - # Disk persistence — opt-in via ``state_dir``. ``None`` keeps - # the runner pure-memory (the default behaviour). - self._state_dir: Path | None = Path(os.fspath(state_dir)) if state_dir is not None else None - self._cache: Any = None - self._terminal_deque: Any = None - self._lock_handle: Any = None - if self._state_dir is not None: - self._open_cache() - - # ------------------------------------------------------------------ # - # Cache lifecycle - # ------------------------------------------------------------------ # - - def _open_cache(self) -> None: - """Open the disk cache and acquire the single-owner lock. - - Called from ``__init__`` when ``state_dir`` is set. Splitting it - out keeps the constructor body readable and gives tests a clean - seam for monkeypatching. - """ - if self._state_dir is None: # pragma: no cover - guarded by caller - raise RuntimeError("_open_cache called without state_dir") - diskcache = load_diskcache() - # Acquire the directory lock *before* opening the cache so two - # runners pointed at the same dir don't both try to initialise - # sqlite. The lock handle stays open for the runner's lifetime. - self._lock_handle = acquire_state_dir_lock(self._state_dir) - try: - self._cache = diskcache.Cache(str(self._state_dir)) - # Re-hydrate the in-memory terminal mirror so ``get`` works - # for task ids that completed in a prior process. Doing this - # here (rather than lazily) means the mirror is consistent - # the moment construction returns. - order: Any = self._cache.get(_TERMINAL_ORDER_KEY, default=[]) - if not isinstance(order, list): - # Defensive: a corrupted ordering list shouldn't take - # the host down. Reset and continue — at worst we lose - # ordering for FIFO eviction, not correctness. - logger.warning( - "InProcessTaskRunner: terminal-order entry in %s is not a list; resetting", self._state_dir - ) - order = [] - self._cache.set(_TERMINAL_ORDER_KEY, order) - self._terminal_order = [str(x) for x in cast(list[Any], order)] - for task_id in self._terminal_order: - rec_obj: Any - try: - rec_obj = self._cache.get(task_id) - except Exception: # pragma: no cover - exercised via corrupt-entry test - rec_obj = None - if not isinstance(rec_obj, dict): - continue - rec = cast(dict[str, Any], rec_obj) - status = rec.get(_REC_STATUS) - if status in {"succeeded", "failed", "cancelled"}: - self._terminal[task_id] = status - except Exception: - release_state_dir_lock(self._lock_handle) - self._lock_handle = None - raise - - # ------------------------------------------------------------------ # - # DurableTaskRunner Protocol - # ------------------------------------------------------------------ # - - def register( - self, - name: str, - handler: Callable[[Mapping[str, Any]], Awaitable[None]], - ) -> None: - if self._started: - raise RuntimeError( - f"InProcessTaskRunner.register({name!r}) called after the " - "runner started scheduling tasks — register all handlers at " - "host startup, before serving traffic, to avoid silently " - "reordering in-flight work." - ) - if name in self._handlers: - logger.warning("InProcessTaskRunner: replacing handler registered under %r", name) - self._handlers[name] = handler - - async def schedule( - self, - name: str, - payload: Mapping[str, Any], - *, - retry_policy: RetryPolicy | None = None, - ) -> TaskHandle: - if name not in self._handlers: - raise KeyError( - f"InProcessTaskRunner.schedule({name!r}): no handler " - "registered under this name. Call register(name, handler) " - "at host startup before scheduling." - ) - - self._started = True - policy = retry_policy or self._default_retry_policy - task_id = uuid.uuid4().hex - handle = TaskHandle(task_id=task_id, name=name) - - # Persist the record (when state_dir is set) BEFORE we spawn the - # asyncio task — if the persistence write fails we surface it as - # a synchronous error from ``schedule`` rather than silently - # downgrading to in-memory. - if self._cache is not None: - record = self._build_record(name, dict(payload), policy) - self._validate_picklable(record) - self._cache.set(task_id, record) - - # When persisted, wrap the payload so handler-side mutations - # (e.g. ``payload["echo_done"] = True``) flow back to disk. - runtime_payload: Mapping[str, Any] - if self._cache is not None: - captured_task_id = task_id - - def _persist_cb(new_payload: Mapping[str, Any]) -> None: - self._update_record_payload(captured_task_id, new_payload) - - runtime_payload = _PersistedPayloadDict(payload, _persist_cb) - else: - runtime_payload = payload - - handler = self._handlers[name] - task = asyncio.create_task( - self._run_with_retry(handle, handler, runtime_payload, policy), - name=f"hosting.task[{name}]:{task_id}", - ) - self._pending[task_id] = task - - def _on_done(_t: asyncio.Task[None], tid: str = task_id) -> None: - self._pending.pop(tid, None) - - task.add_done_callback(_on_done) - return handle - - async def get(self, handle: TaskHandle) -> TaskStatus | None: - if handle.task_id in self._pending: - task = self._pending[handle.task_id] - if task.cancelled(): - return "cancelled" - return "running" - # In-memory terminal mirror covers both pure-memory and - # disk-persistent runs (we re-hydrate on cache open). - if handle.task_id in self._terminal: - return self._terminal[handle.task_id] - # Disk fallback for very-aged task ids that left the in-memory - # mirror but still have a record on disk (extremely unlikely - # given that we re-hydrate all terminals at open, but defensive). - if self._cache is not None: - rec_obj: Any = self._cache.get(handle.task_id) - if isinstance(rec_obj, dict): - rec = cast(dict[str, Any], rec_obj) - status = rec.get(_REC_STATUS) - # Records on disk only live in one of four states: - # ``pending`` (queued or in-flight — resume picks these - # up) or one of the terminals. There is no transient - # ``running`` status; the in-flight asyncio task is - # observable via ``_pending`` only inside its own - # process. - if status in {"succeeded", "failed", "cancelled", "pending"}: - return cast(TaskStatus, status) - return None - - # ------------------------------------------------------------------ # - # Resume — replay persisted pending records on startup - # ------------------------------------------------------------------ # - - async def resume(self) -> int: - """Re-schedule pending tasks persisted by a previous process. - - Walks the cache for records in ``"pending"`` status, looks up - their handler in :attr:`_handlers`, and re-creates an - :class:`asyncio.Task` for each — preserving the persisted - attempt count so retry budgets resume mid-way through their - backoff schedule. - - Records whose handler is no longer registered are marked - ``"failed"`` with a clear reason in the log; they will not be - retried again. Records that fail to deserialise (corrupted - sqlite row, schema drift, …) are quarantined: their entry is - removed from the cache and the task id is logged. Both classes - of error are non-fatal — the host should boot even when a - small number of legacy records can't be replayed. - - Returns the number of records successfully re-scheduled. - - Called automatically from :class:`AgentFrameworkHost`'s lifespan - startup hook when the runner is host-owned. Callers driving the - runner directly (tests, bespoke ASGI setups) MUST call this - once after registering handlers and before serving traffic. - """ - if self._cache is None: - return 0 - - # Mark started so subsequent register() calls fail loudly — we - # don't want handler swaps after replay begins. - self._started = True - - replayed = 0 - # iterkeys returns a live view; we copy to a list because we may - # delete entries inside the loop (quarantine / drop-on-missing-handler). - task_ids: list[str] = [str(k) for k in self._cache.iterkeys() if k != _TERMINAL_ORDER_KEY] - for task_id in task_ids: - rec_obj: Any - try: - rec_obj = self._cache.get(task_id) - except Exception: - logger.exception("InProcessTaskRunner.resume: failed to read record %s; quarantining", task_id) - with contextlib.suppress(KeyError): - del self._cache[task_id] - continue - if not isinstance(rec_obj, dict) or _REC_STATUS not in rec_obj: - logger.warning("InProcessTaskRunner.resume: record %s is not a task dict; quarantining", task_id) - with contextlib.suppress(KeyError): - del self._cache[task_id] - continue - rec = cast(dict[str, Any], rec_obj) - status = rec[_REC_STATUS] - if status != "pending": - continue - - handler_name = rec.get(_REC_HANDLER_NAME) - if not isinstance(handler_name, str) or handler_name not in self._handlers: - logger.warning( - "InProcessTaskRunner.resume: no handler registered for record %s (handler=%r); marking failed", - task_id, - handler_name, - ) - self._mark_terminal(task_id, "failed") - continue - handler = self._handlers[handler_name] - - policy_value = rec.get(_REC_RETRY_POLICY) or self._default_retry_policy - if not isinstance(policy_value, RetryPolicy): - # Legacy / corrupt entry — fall back to the default rather - # than failing the whole resume. - policy_value = self._default_retry_policy - policy: RetryPolicy = policy_value - payload_value: Any = rec.get(_REC_PAYLOAD) or {} - payload: dict[str, Any] - if isinstance(payload_value, dict): - payload = cast(dict[str, Any], payload_value) - elif hasattr(payload_value, "keys"): - payload = dict(cast(Mapping[str, Any], payload_value)) - else: - payload = {} - - name_value = rec.get(_REC_NAME, handler_name) - handle = TaskHandle(task_id=task_id, name=str(name_value)) - attempts_value = rec.get(_REC_ATTEMPTS, 0) - attempts_completed = int(attempts_value or 0) - - def _make_resume_persist_cb(tid: str) -> Callable[[Mapping[str, Any]], None]: - def _cb(new_payload: Mapping[str, Any]) -> None: - self._update_record_payload(tid, new_payload) - - return _cb - - runtime_payload = _PersistedPayloadDict(payload, _make_resume_persist_cb(task_id)) - - task = asyncio.create_task( - self._run_with_retry(handle, handler, runtime_payload, policy, _resume_from_attempt=attempts_completed), - name=f"hosting.task[{handle.name}]:{task_id}(resumed)", - ) - self._pending[task_id] = task - - def _on_done(_t: asyncio.Task[None], tid: str = task_id) -> None: - self._pending.pop(tid, None) - - task.add_done_callback(_on_done) - replayed += 1 - - if replayed: - logger.info( - "InProcessTaskRunner.resume: re-scheduled %d pending task(s) from %s", replayed, self._state_dir - ) - return replayed - - # ------------------------------------------------------------------ # - # Lifecycle helper (the host calls this from ``on_shutdown``) - # ------------------------------------------------------------------ # - - async def shutdown(self, *, timeout: float | None = None) -> None: - """Wait briefly for pending tasks to drain, then cancel anything still running. - - Called by the host on ``on_shutdown`` so a graceful shutdown does - not orphan in-flight push retries. Channels may legitimately - schedule a final push from their own shutdown callback (e.g. a - goodbye message); the runner therefore *waits* up to - ``timeout`` seconds (default: the runner's - ``shutdown_grace_seconds`` configured at construction) for the - in-flight set to finish on its own before cancelling stragglers. - Tasks that don't honour cancellation within the same window are - abandoned — the runner makes no synchronous durability claim, - so cleanup is best-effort. - - When ``state_dir`` is set, tasks that didn't drain are left in - ``"pending"`` status on disk so the next process replays them - via :meth:`resume`. The disk cache is closed and the - single-owner lock is released regardless of drain outcome. - """ - self._shutting_down = True - try: - if self._pending: - grace = timeout if timeout is not None else self._shutdown_grace_seconds - tasks = list(self._pending.values()) - # Phase 1 — wait for natural completion within the grace window. - if grace > 0: - await asyncio.wait(tasks, timeout=grace) - # Phase 2 — cancel anything still pending, then wait briefly for - # cancellation to propagate. - still_pending = [t for t in tasks if not t.done()] - if still_pending: - logger.info( - "InProcessTaskRunner.shutdown: %d task(s) still running after %.2fs grace; cancelling", - len(still_pending), - grace, - ) - for task in still_pending: - task.cancel() - cancellation_window = max(grace, 1.0) - try: - await asyncio.wait_for( - asyncio.gather(*still_pending, return_exceptions=True), - timeout=cancellation_window, - ) - except (TimeoutError, asyncio.TimeoutError): - logger.warning( - "InProcessTaskRunner.shutdown: %d task(s) did not exit within %.2fs " - "of cancellation; abandoning", - sum(not t.done() for t in still_pending), - cancellation_window, - ) - finally: - # Release disk resources after the in-flight set has been - # given a chance to drain — tasks that mutate the payload - # mid-shutdown will fail to persist after this point, which - # is the correct behaviour (the next process will replay - # from whatever the last fully-committed state was). - if self._cache is not None: - try: - self._cache.close() - except Exception: # pragma: no cover - close errors aren't actionable - logger.exception("InProcessTaskRunner.shutdown: failed to close cache cleanly") - self._cache = None - if self._lock_handle is not None: - release_state_dir_lock(self._lock_handle) - self._lock_handle = None - - # ------------------------------------------------------------------ # - # Internals — retry loop - # ------------------------------------------------------------------ # - - async def _run_with_retry( - self, - handle: TaskHandle, - handler: Callable[[Mapping[str, Any]], Awaitable[None]], - payload: Mapping[str, Any], - policy: RetryPolicy, - *, - _resume_from_attempt: int = 0, - ) -> None: - delay = policy.initial_backoff_seconds - attempt = _resume_from_attempt - try: - while True: - attempt += 1 - # Persist the attempt counter BEFORE we invoke the - # handler so a crash mid-handler doesn't lose the fact - # that we tried — replay sees the bumped counter and - # respects the original retry budget. Trade-off: a - # crash before the external call is made still consumes - # one attempt (at-most-once semantics around the bump); - # we document this as best-effort across crashes. - self._update_record_attempts(handle.task_id, attempt) - - try: - await handler(payload) - except asyncio.CancelledError: - # On a graceful shutdown of a disk-persistent runner - # we deliberately *don't* mark the record terminal — - # ``resume()`` will pick it up on the next boot and - # replay it with the persisted attempt counter. For - # in-memory runners (no cache) there's nothing to - # resume from, so we still mark ``cancelled`` so - # callers holding the handle can observe the - # outcome. - if not (self._shutting_down and self._cache is not None): - self._mark_terminal(handle.task_id, "cancelled") - raise - except Exception as exc: - if attempt >= policy.max_attempts: - logger.exception( - "InProcessTaskRunner: task %s (%s) failed after %d attempts", - handle.name, - handle.task_id, - attempt, - ) - self._mark_terminal(handle.task_id, "failed") - return - logger.warning( - "InProcessTaskRunner: task %s (%s) attempt %d/%d failed (%s); retrying in %.2fs", - handle.name, - handle.task_id, - attempt, - policy.max_attempts, - exc, - delay, - ) - try: - await asyncio.sleep(delay) - except asyncio.CancelledError: - if not (self._shutting_down and self._cache is not None): - self._mark_terminal(handle.task_id, "cancelled") - raise - delay = min(delay * policy.backoff_multiplier, policy.max_backoff_seconds) - else: - self._mark_terminal(handle.task_id, "succeeded") - return - except asyncio.CancelledError: - # Propagate so the outer ``asyncio.Task`` records cancellation - # in its own state for any observer that holds the raw task. - return - - # ------------------------------------------------------------------ # - # Internals — record / disk helpers - # ------------------------------------------------------------------ # - - def _build_record( - self, - name: str, - payload: Mapping[str, Any], - policy: RetryPolicy, - ) -> dict[str, Any]: - """Construct the on-disk record dict for a freshly-scheduled task.""" - return { - _REC_HANDLER_NAME: name, - _REC_NAME: name, - _REC_PAYLOAD: dict(payload), - _REC_RETRY_POLICY: policy, - _REC_ATTEMPTS: 0, - _REC_STATUS: "pending", - _REC_CREATED_AT: time.time(), - } - - def _validate_picklable(self, record: Mapping[str, Any]) -> None: - """Pickle-probe a record at schedule time so misconfig is loud. - - We only do this when the cache is open (i.e. persistence is on). - The probe runs ``pickle.dumps`` on the record and raises a - framework-typed :class:`PushPayloadNotPicklable` if it fails. - Loud failure here is better than silent data loss after the - next restart. - """ - try: - pickle.dumps(record) # nosec B301 - dumps only, no untrusted load - except Exception as exc: - raise PushPayloadNotPicklable( - "InProcessTaskRunner: scheduled task payload is not picklable; " - "disk persistence (state_dir) requires payloads to round-trip " - "through pickle. Common causes: a user-supplied response that " - "embeds a live network client, asyncio.Lock, or generator. " - f"Underlying pickle error: {exc!r}" - ) from exc - - def _update_record_attempts(self, task_id: str, attempt: int) -> None: - """Bump the attempt counter on the persisted record (if any). - - Status stays ``"pending"`` while the task is in-flight — there - is no transient ``"running"`` status. This keeps the resume - contract simple: anything ``"pending"`` on disk is a candidate - for replay, whether it was never picked up or crashed mid-attempt. - """ - if self._cache is None: - return - rec = self._cache.get(task_id) - if not isinstance(rec, dict): - # Record was evicted / quarantined since schedule; nothing - # to persist. The asyncio task continues — it just won't - # be resumable on next boot. - return - rec[_REC_ATTEMPTS] = attempt - try: - self._cache.set(task_id, rec) - except Exception: # pragma: no cover - cache write failures aren't actionable - logger.exception("InProcessTaskRunner: failed to persist attempt counter for %s", task_id) - - def _update_record_payload(self, task_id: str, new_payload: Mapping[str, Any]) -> None: - """Persist a handler-side payload mutation back to disk. - - Called from :class:`_PersistedPayloadDict.__setitem__`. The whole - payload mapping is re-written (the cache stores opaque pickled - values, so partial-field updates aren't possible). Handler-side - mutations on the runner's hot path are rare (today: only the - ``echo_done`` cursor) so the extra write is acceptable. - """ - if self._cache is None: - return - rec = self._cache.get(task_id) - if not isinstance(rec, dict): - return - rec[_REC_PAYLOAD] = dict(new_payload) - try: - self._cache.set(task_id, rec) - except Exception: # pragma: no cover - cache write failures aren't actionable - logger.exception("InProcessTaskRunner: failed to persist payload mutation for %s", task_id) - - def _mark_terminal(self, task_id: str, status: TaskStatus) -> None: - """Move a task to a terminal status, updating both memory and disk. - - Records are first updated on disk (so a crash between the disk - write and the in-memory write doesn't lose the terminal status), - then mirrored to the in-memory cache, then FIFO-bounded. - """ - # Disk side first. - if self._cache is not None: - rec = self._cache.get(task_id) - if isinstance(rec, dict): - rec[_REC_STATUS] = status - rec[_REC_TERMINAL_AT] = time.time() - # Truncate heavy fields (payload, retry_policy) — once - # the task is terminal we never need them again, and - # keeping them around bloats disk on long-lived hosts. - rec[_REC_PAYLOAD] = None - rec[_REC_RETRY_POLICY] = None - try: - self._cache.set(task_id, rec) - except Exception: # pragma: no cover - logger.exception("InProcessTaskRunner: failed to persist terminal status for %s", task_id) - - # In-memory side. - if task_id not in self._terminal: - self._terminal_order.append(task_id) - self._terminal[task_id] = status - - # FIFO-evict from BOTH layers once we exceed the cap. - while len(self._terminal_order) > self._terminal_cache_size: - evicted = self._terminal_order.pop(0) - self._terminal.pop(evicted, None) - if self._cache is not None: - try: - del self._cache[evicted] - except KeyError: - pass - except Exception: # pragma: no cover - logger.exception("InProcessTaskRunner: failed to evict %s from disk cache", evicted) - - # Persist the new ordering list so a restart sees the same FIFO - # ordering for further eviction decisions. - if self._cache is not None: - try: - self._cache.set(_TERMINAL_ORDER_KEY, list(self._terminal_order)) - except Exception: # pragma: no cover - logger.exception("InProcessTaskRunner: failed to persist terminal-order list") - - -__all__ = ["InProcessTaskRunner"] diff --git a/python/packages/hosting/agent_framework_hosting/_state_store.py b/python/packages/hosting/agent_framework_hosting/_state_store.py index 1ca62a0a7d..817f9bd52e 100644 --- a/python/packages/hosting/agent_framework_hosting/_state_store.py +++ b/python/packages/hosting/agent_framework_hosting/_state_store.py @@ -1,49 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. -"""Disk-backed wrappers for the host's in-memory state dicts. +"""Disk-backed wrapper for the host's session-alias map. -The host keeps three in-process dictionaries that need to survive a -process restart when the operator opts in to disk persistence: - -- ``_session_aliases`` (``isolation_key -> active session_id``): rotated - by :meth:`AgentFrameworkHost.reset_session`; without persistence a - restart silently re-uses the pre-rotation session_id and the user sees - history they were supposed to have walked away from. -- ``_active`` (``isolation_key -> last-seen channel name``): drives - :class:`ResponseTarget` ``.active`` fan-out; losing it on restart makes - :class:`ResponseTarget.active` raise ``"no active channel"`` for every - user the host has previously talked to. -- ``_identities`` - (``isolation_key -> {channel_name -> ChannelIdentity}``): the per-user - channel registry that powers :class:`ResponseTarget` ``.channel(name)``, - ``.channels([...])`` and ``.all_linked``; losing it on restart turns - every linked-identity push target into a not-found. - -Both wrappers are :class:`dict` subclasses so the rest of the host code -doesn't need to know whether persistence is on or off; the only -difference is that mutations are mirrored back to a -:mod:`diskcache`-backed sqlite store. Reads stay fast because the -in-memory copy is the source of truth — disk is purely a backing -store for write-through and re-hydration. - -Layout under ``/sessions/`` (the ``sessions`` component -chosen because all three dicts share the same per-user-life cycle): - - /sessions/ - .lock # single-owner lock (advisory) - cache.db, … # diskcache sqlite files - keyed by: - "aliases:" -> str (session_id) - "active:" -> str (channel name) - "identities:" -> dict[channel_name, ChannelIdentity] - -Pickle is what diskcache uses by default; the wrappers do not impose -their own serialisation. :class:`ChannelIdentity` is a frozen dataclass -of plain scalars and so round-trips cleanly. - -Everything in this module is internal. Public consumers should use -:class:`AgentFrameworkHost(state_dir=...)` and let the host wire the -wrappers up. +``AgentFrameworkHost.reset_session(isolation_key)`` rotates future requests for +that isolation key onto a new session id. Persisting the alias map lets that +rotation survive a host restart without introducing cross-channel identity or +delivery state into the core host. """ from __future__ import annotations @@ -52,7 +14,7 @@ import logging import os from collections.abc import Mapping from pathlib import Path -from typing import Any, TypeVar, cast +from typing import Any, TypeVar from ._persistence import ( acquire_state_dir_lock, @@ -62,27 +24,12 @@ from ._persistence import ( logger = logging.getLogger(__name__) - _V = TypeVar("_V") - - -# Key prefixes inside the shared sessions cache. Three logical maps live -# in one diskcache so they share a single sqlite handle and a single -# directory lock — opening multiple diskcaches against the same -# directory is supported but doubles file-handle pressure and the -# per-open lock acquisition cost. _ALIASES_PREFIX = "aliases:" -_ACTIVE_PREFIX = "active:" -_IDENTITIES_PREFIX = "identities:" class SessionsStateStore: - """One disk cache + lock shared by every host-side persisted dict. - - The host constructs one of these per ``state_dir["sessions"]`` value - and threads it into each :class:`_PersistedDict` it creates. Closing - the store releases the lock and the cache handle. - """ + """One disk cache + lock for host-side session aliases.""" def __init__(self, sessions_dir: str | os.PathLike[str]) -> None: self._sessions_dir: Path = Path(os.fspath(sessions_dir)) @@ -97,22 +44,11 @@ class SessionsStateStore: @property def cache(self) -> Any: - """Return the underlying :mod:`diskcache` Cache. - - Intended for the wrapper classes in this module only. Callers - outside the module should go through the typed wrappers — direct - cache access bypasses the key-prefix discipline that keeps the - three maps from colliding. - """ + """Return the underlying :mod:`diskcache` Cache.""" return self._cache def close(self) -> None: - """Close the cache and release the directory lock. - - Safe to call multiple times. The host invokes this from its - lifespan shutdown hook so a second host can re-open the same - ``state_dir`` cleanly after the first exits. - """ + """Close the cache and release the directory lock.""" if self._cache is not None: try: self._cache.close() @@ -125,15 +61,7 @@ class SessionsStateStore: class _PersistedDict(dict[str, _V]): - """Drop-in :class:`dict` whose mutations mirror to a diskcache prefix. - - Used for the host's flat ``str -> V`` dicts (``_session_aliases`` - and ``_active``). The in-memory copy is the source of truth for - reads; writes update memory first and then mirror to disk so a - crash between the two leaves the in-memory state correct (which is - what subsequent reads will see anyway) and only loses the last - not-yet-flushed value on next restart. - """ + """Drop-in :class:`dict` whose mutations mirror to a diskcache prefix.""" def __init__( self, @@ -144,26 +72,20 @@ class _PersistedDict(dict[str, _V]): super().__init__() self._store = store self._prefix = key_prefix - # Rehydrate from disk into memory exactly once at construction. - # Doing this here (rather than lazily) keeps the in-memory dict - # behaviour consistent with the non-persisted code path — - # ``len(host._session_aliases)`` reflects all known users from - # the moment the host is constructed. cache: Any = store.cache for raw_key in cache.iterkeys(): if not isinstance(raw_key, str) or not raw_key.startswith(key_prefix): continue - value: Any try: - value = cache.get(raw_key) + value: Any = cache.get(raw_key) except Exception: logger.exception("SessionsStateStore: failed to rehydrate %s; skipping", raw_key) continue logical_key = raw_key[len(key_prefix) :] super().__setitem__(logical_key, value) if initial: - for k, v in initial.items(): - self[k] = v + for key, value in initial.items(): + self[key] = value def __setitem__(self, key: str, value: _V) -> None: super().__setitem__(key, value) @@ -182,9 +104,7 @@ class _PersistedDict(dict[str, _V]): logger.exception("SessionsStateStore: failed to evict %s%s", self._prefix, key) def pop(self, key: str, *args: Any) -> _V: - # ``dict.pop`` doesn't go through ``__delitem__``, so we mirror - # the disk side here explicitly. Forward the default sentinel - # only when present so we match ``dict.pop`` semantics exactly. + """Mirror ``dict.pop`` to disk.""" value: _V = super().pop(key, *args) try: del self._store.cache[self._prefix + key] @@ -195,16 +115,17 @@ class _PersistedDict(dict[str, _V]): return value def clear(self) -> None: + """Mirror ``dict.clear`` to disk.""" keys = list(self.keys()) super().clear() cache = self._store.cache - for k in keys: + for key in keys: try: - del cache[self._prefix + k] + del cache[self._prefix + key] except KeyError: pass except Exception: # pragma: no cover - logger.exception("SessionsStateStore: failed to evict %s%s during clear", self._prefix, k) + logger.exception("SessionsStateStore: failed to evict %s%s during clear", self._prefix, key) def update( # type: ignore[override] self, @@ -212,191 +133,14 @@ class _PersistedDict(dict[str, _V]): /, **kwargs: _V, ) -> None: - # Defer to __setitem__ so every entry is mirrored to disk; the - # default ``dict.update`` writes into the underlying storage - # directly and would skip our persistence hook. + """Mirror ``dict.update`` to disk one item at a time.""" if other is not None: - for k in other: - self[k] = other[k] - for k, v in kwargs.items(): - self[k] = v + for key in other: + self[key] = other[key] + for key, value in kwargs.items(): + self[key] = value -class _PersistedNestedDict(dict[str, dict[str, _V]]): - """Disk-backed wrapper for the per-isolation-key identity map. - - The host's ``_identities`` is a nested dict - ``isolation_key -> {channel_name -> ChannelIdentity}``. The whole - inner dict for a given isolation_key is small (one entry per channel - the user has appeared on), so we persist the inner dict as a single - cache value rather than per-channel — fewer cache hits, simpler - schema, no need for a separate sub-prefix. - - To make mutations of the inner dict mirror to disk, ``__getitem__`` - returns a ``_NestedInnerProxy`` that mutates the parent's cache slot - on each ``__setitem__`` / ``__delitem__``. The wrapper is purely - additive — callers that pass a plain dict in via ``__setitem__`` get - the same write-through behaviour for free. - """ - - def __init__( - self, - store: SessionsStateStore, - key_prefix: str = _IDENTITIES_PREFIX, - ) -> None: - super().__init__() - self._store = store - self._prefix = key_prefix - cache: Any = store.cache - for raw_key in cache.iterkeys(): - if not isinstance(raw_key, str) or not raw_key.startswith(key_prefix): - continue - value: Any - try: - value = cache.get(raw_key) - except Exception: - logger.exception("SessionsStateStore: failed to rehydrate %s; skipping", raw_key) - continue - if not isinstance(value, dict): - continue - inner_value = cast(dict[str, _V], value) - logical_key = raw_key[len(key_prefix) :] - # Wrap so caller-side mutations on the inner dict mirror back. - inner: _NestedInnerProxy[_V] = _NestedInnerProxy(self, logical_key, inner_value) - super().__setitem__(logical_key, inner) - - def __setitem__(self, key: str, value: dict[str, _V]) -> None: - # Wrap whatever the caller passes in so subsequent ``inner[ch] = ...`` - # mutations are mirrored to disk. We always wrap (even - # ``_NestedInnerProxy`` inputs) so the proxy's ``_outer`` link - # points at us rather than at any previous outer dict. - wrapped = _NestedInnerProxy(self, key, dict(value)) - super().__setitem__(key, wrapped) - self.persist_inner(key, dict(value)) - - def __delitem__(self, key: str) -> None: - super().__delitem__(key) - try: - del self._store.cache[self._prefix + key] - except KeyError: - pass - except Exception: # pragma: no cover - logger.exception("SessionsStateStore: failed to evict %s%s", self._prefix, key) - - def setdefault(self, key: str, default: dict[str, _V] | None = None) -> dict[str, _V]: # type: ignore[override] - if key in self: - return self[key] - if default is None: - default = {} - self[key] = default - return self[key] - - def persist_inner(self, isolation_key: str, snapshot: Mapping[str, _V]) -> None: - """Write the full inner dict for ``isolation_key`` back to disk. - - Called from :class:`_NestedInnerProxy` on every mutation and by - :meth:`__setitem__` when a new outer key is added. A single - write per change keeps the schema simple — there is no - partial-row update — and is fine for the access pattern - (mutations on the host's hot path are rare: identity registry - writes are once-per-channel-per-user). - """ - try: - self._store.cache.set(self._prefix + isolation_key, snapshot) - except Exception: # pragma: no cover - cache write failures aren't actionable - logger.exception( - "SessionsStateStore: failed to persist identities for %s%s", - self._prefix, - isolation_key, - ) - - -class _NestedInnerProxy(dict[str, _V]): - """Inner-dict proxy that mirrors mutations back to its outer. - - Returned by :class:`_PersistedNestedDict.__getitem__` (via the - rehydration / ``__setitem__`` wrap). When the channel-registry code - does ``self._identities[ik][channel_name] = identity``, the - ``__setitem__`` on this proxy fires and re-writes the whole inner - dict to disk via the parent's ``persist_inner``. Behavioural - identity with ``dict`` is preserved otherwise (``len``, iteration, - ``__contains__``, …). - """ - - _outer: _PersistedNestedDict[_V] - _key: str - - __slots__ = ("_key", "_outer") - - def __init__( - self, - outer: _PersistedNestedDict[_V], - key: str, - data: Mapping[str, _V], - ) -> None: - super().__init__(data) - # ``__slots__`` on a ``dict`` subclass requires the back-door — - # CPython is lenient, PyPy is strict. - object.__setattr__(self, "_outer", outer) - object.__setattr__(self, "_key", key) - - def __setitem__(self, key: str, value: _V) -> None: - super().__setitem__(key, value) - self._outer.persist_inner(self._key, dict(self)) - - def __delitem__(self, key: str) -> None: - super().__delitem__(key) - self._outer.persist_inner(self._key, dict(self)) - - def pop(self, key: str, *args: Any) -> _V: - value: _V = super().pop(key, *args) - self._outer.persist_inner(self._key, dict(self)) - return value - - def clear(self) -> None: - super().clear() - self._outer.persist_inner(self._key, dict(self)) - - def update( # type: ignore[override] - self, - other: Mapping[str, _V] | None = None, - /, - **kwargs: _V, - ) -> None: - if other is not None: - for k in other: - super().__setitem__(k, other[k]) - for k, v in kwargs.items(): - super().__setitem__(k, v) - self._outer.persist_inner(self._key, dict(self)) - - -def build_session_dicts( - store: SessionsStateStore, -) -> tuple[ - _PersistedDict[str], - _PersistedDict[str], - _PersistedNestedDict[Any], -]: - """Construct the three host-side persisted dicts against a single store. - - Returns ``(session_aliases, active, identities)`` in the order the - host assigns them, so the call site reads - ``self._session_aliases, self._active, self._identities = build_session_dicts(store)``. - """ - aliases: _PersistedDict[str] = _PersistedDict(store, _ALIASES_PREFIX) - active: _PersistedDict[str] = _PersistedDict(store, _ACTIVE_PREFIX) - identities: _PersistedNestedDict[Any] = _PersistedNestedDict(store) - return aliases, active, identities - - -# Re-export keys for tests / power users that want to inspect the cache. -__all__ = [ - "_ACTIVE_PREFIX", - "_ALIASES_PREFIX", - "_IDENTITIES_PREFIX", - "SessionsStateStore", - "_PersistedDict", - "_PersistedNestedDict", - "build_session_dicts", -] +def build_session_aliases(store: SessionsStateStore) -> dict[str, str]: + """Return the disk-backed session-alias map for ``store``.""" + return _PersistedDict[str](store, _ALIASES_PREFIX) diff --git a/python/packages/hosting/agent_framework_hosting/_types.py b/python/packages/hosting/agent_framework_hosting/_types.py index 93dcc437a0..23c172d9a7 100644 --- a/python/packages/hosting/agent_framework_hosting/_types.py +++ b/python/packages/hosting/agent_framework_hosting/_types.py @@ -11,12 +11,7 @@ These types form the boundary between the host and individual channels. A channel parses its native payload, builds a :class:`ChannelRequest`, and hands it to :class:`ChannelContext.run` (or ``run_stream``) on the host. -The host normalizes the request into a single agent invocation and either -returns the result to the originating channel or fans out via -:class:`ResponseTarget` to other channels that implement -:class:`ChannelPush`. - -See ``docs/specs/002-python-hosting-channels.md`` for the full design. +The channel owns rendering the result back onto its originating protocol. """ from __future__ import annotations @@ -24,16 +19,11 @@ from __future__ import annotations import os from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass, field -from enum import Enum -from typing import TYPE_CHECKING, Any, Generic, Literal, Protocol, TypedDict, TypeVar, cast, runtime_checkable +from typing import TYPE_CHECKING, Any, Generic, Protocol, TypedDict, TypeVar, runtime_checkable from agent_framework import ( - AgentResponse, AgentResponseUpdate, AgentRunInputs, - ResponseStream, - SupportsAgentRun, - Workflow, ) from starlette.routing import BaseRoute @@ -41,11 +31,6 @@ if TYPE_CHECKING: from ._host import ChannelContext -# --------------------------------------------------------------------------- # -# Channel-neutral request envelope -# --------------------------------------------------------------------------- # - - class ChannelSession: """Channel-supplied session hint. @@ -59,15 +44,12 @@ class ChannelSession: class ChannelIdentity: - """Channel-native identity the host sees on each request. + """Channel-native identity metadata observed on a request. - Consumed by the host's identity registry. The host uses it for two things: - - 1. Recording the active channel for an ``isolation_key`` so - ``ResponseTarget.active`` resolves correctly. - 2. Telling :class:`ChannelPush` ``push`` recipients **where** in their - native namespace to deliver — Telegram uses ``native_id`` as the - chat id, Teams as the conversation/AAD id, etc. + The simplified hosting core records this only on the persisted input + message's ``additional_properties["hosting"]`` block and forwards it + through run/response hooks. Cross-channel linking and recipient lookup are + follow-up concerns, not part of the v1 host contract. """ def __init__( @@ -81,176 +63,6 @@ class ChannelIdentity: self.attributes: Mapping[str, Any] = attributes if attributes is not None else dict() -class ResponseTargetKind(str, Enum): - """Discriminator for :class:`ResponseTarget` variants.""" - - ORIGINATING = "originating" - ACTIVE = "active" - CHANNELS = "channels" - ALL_LINKED = "all_linked" - IDENTITIES = "identities" - NONE = "none" - - -class ResponseTarget: - """Per-request directive controlling **where** the host delivers the agent reply. - - Independent of ``session_mode``. Construct via the classmethod helpers or - use the module-level singletons rather than touching ``kind`` directly. - Variants: - - - ``ResponseTarget.originating`` (default) — synchronous response on the - originating channel only. - - ``ResponseTarget.active`` — push to the channel most recently observed - for the resolved ``isolation_key``. - - ``ResponseTarget.channel("teams")`` / ``.channels([...])`` — push to - one or more named destinations. Each entry is either a bare channel - name (host resolves the native id from its identity registry) or a - ``"channel:native_id"`` token (used verbatim). The pseudo-name - ``"originating"`` includes the originating channel in the fan-out. - - ``ResponseTarget.identity(ChannelIdentity)`` / - ``.identities([ChannelIdentity, ...])`` — push to one or more - **fully-specified identities**. Preferred over the ``"channel:native_id"`` - string variant when the destination needs ``identity.attributes`` - preserved (Teams conversation/thread metadata, Slack channel+thread, - Bot Framework service-url, etc.). - - ``ResponseTarget.all_linked`` — push to every channel where the - resolved ``isolation_key`` has been observed. - - ``ResponseTarget.none`` — background-only; in the prototype this just - suppresses the originating reply (no ``ContinuationToken`` yet). - - Instances are intended to be treated as immutable; the singletons are - shared across the process. - """ - - def __init__( - self, - kind: ResponseTargetKind = ResponseTargetKind.ORIGINATING, - targets: tuple[str, ...] = (), - identities: tuple[ChannelIdentity, ...] = (), - *, - echo_input: bool = False, - ) -> None: - self.kind = kind - self.targets = targets - # Stored under a non-clashing name so the ``identities`` - # *classmethod* (the public builder) can coexist with the - # value accessor (the ``identities`` property below). At - # runtime instance attributes shadow class attributes anyway, - # but type checkers see the classmethod and reject reassignment. - self._target_identities: tuple[ChannelIdentity, ...] = tuple(identities) - # When True, the host first pushes the originating user message - # to every non-originating destination (so end-user apps observing - # those channels can keep their UI in sync) before pushing the - # agent response. Defaults to False — opt-in only, because not - # every channel knows how to render ``role="user"`` content - # gracefully on its own surface. - self.echo_input = echo_input - - @property - def target_identities(self) -> tuple[ChannelIdentity, ...]: - """Destination identities for ``kind == IDENTITIES`` targets. - - Public name distinct from the :meth:`identities` classmethod - builder. Empty for non-``IDENTITIES`` kinds. - """ - return self._target_identities - - # -- builders ---------------------------------------------------------- # - - @classmethod - def channel(cls, name: str, *, echo_input: bool = False) -> ResponseTarget: - """Target a single named destination channel.""" - return cls(kind=ResponseTargetKind.CHANNELS, targets=(name,), echo_input=echo_input) - - @classmethod - def channels(cls, names: Sequence[str], *, echo_input: bool = False) -> ResponseTarget: - """Target an explicit list of destination channels.""" - return cls(kind=ResponseTargetKind.CHANNELS, targets=tuple(names), echo_input=echo_input) - - @classmethod - def identity(cls, identity: ChannelIdentity, *, echo_input: bool = False) -> ResponseTarget: - """Target a single fully-specified :class:`ChannelIdentity`. - - Preferred over the ``"channel:native_id"`` string token in - :meth:`channels` when ``identity.attributes`` carries metadata the - destination channel needs (Teams conversation/thread ids and - service-url, Slack channel + thread, Bot Framework activity-locator - fields, etc.). The host pushes to the named identity verbatim - without consulting its own identity registry. - """ - return cls(kind=ResponseTargetKind.IDENTITIES, identities=(identity,), echo_input=echo_input) - - @classmethod - def identities(cls, identities: Sequence[ChannelIdentity], *, echo_input: bool = False) -> ResponseTarget: - """Target an explicit list of fully-specified :class:`ChannelIdentity` objects. - - See :meth:`identity` for the single-destination variant. - """ - return cls(kind=ResponseTargetKind.IDENTITIES, identities=tuple(identities), echo_input=echo_input) - - # -- value semantics --------------------------------------------------- # - # ``ResponseTarget`` is treated as immutable, so two instances with the - # same ``kind`` + ``targets`` + ``identities`` + ``echo_input`` are - # interchangeable. Tests and channel parsers compare instances with - # ``==`` and use them as dict keys. - - def __eq__(self, other: object) -> bool: - if not isinstance(other, ResponseTarget): - return NotImplemented - return ( - self.kind is other.kind - and self.targets == other.targets - and _identities_equal(self._target_identities, other._target_identities) - and self.echo_input == other.echo_input - ) - - def __hash__(self) -> int: - # ``ChannelIdentity`` is not itself hashable (mutable attributes - # mapping); fold the identifying triple so two ``identities`` - # tuples with the same channel/native_id/attributes content hash - # the same. - identities_key = tuple( - (i.channel, i.native_id, tuple(sorted(i.attributes.items()))) for i in self._target_identities - ) - return hash((self.kind, self.targets, identities_key, self.echo_input)) - - def __repr__(self) -> str: - suffix = ", echo_input=True" if self.echo_input else "" - if self.kind is ResponseTargetKind.CHANNELS: - return f"ResponseTarget.channels({list(self.targets)!r}{suffix})" - if self.kind is ResponseTargetKind.IDENTITIES: - return f"ResponseTarget.identities({list(self._target_identities)!r}{suffix})" - return f"ResponseTarget.{self.kind.value}{suffix}" - - -def _identities_equal(left: tuple[ChannelIdentity, ...], right: tuple[ChannelIdentity, ...]) -> bool: - """Structural-equality helper for ``ResponseTarget.identities`` comparisons. - - ``ChannelIdentity`` is a plain class without ``__eq__``, so ``tuple`` / - ``list`` comparisons fall back to identity equality which is too strict - for value-typed ``ResponseTarget`` callers (two equivalent identity - tuples produced independently would otherwise compare unequal). - """ - if len(left) != len(right): - return False - for a, b in zip(left, right, strict=True): - if a.channel != b.channel or a.native_id != b.native_id: - return False - if dict(a.attributes) != dict(b.attributes): - return False - return True - - -# Module-level singletons so callers can write ``ResponseTarget.originating`` -# (matching the spec's classmethod-style notation) without juggling Python's -# no-zero-arg-classmethod-property limitation. -ResponseTarget.originating = ResponseTarget(kind=ResponseTargetKind.ORIGINATING) # type: ignore[attr-defined] -ResponseTarget.active = ResponseTarget(kind=ResponseTargetKind.ACTIVE) # type: ignore[attr-defined] -ResponseTarget.all_linked = ResponseTarget(kind=ResponseTargetKind.ALL_LINKED) # type: ignore[attr-defined] -ResponseTarget.none = ResponseTarget(kind=ResponseTargetKind.NONE) # type: ignore[attr-defined] - - @dataclass class ChannelRequest: """Uniform invocation envelope every channel produces from its native payload. @@ -260,16 +72,15 @@ class ChannelRequest: """ channel: str - operation: str # e.g. "message.create", "command.invoke" + operation: str input: AgentRunInputs session: ChannelSession | None = None options: Mapping[str, Any] | None = None - session_mode: str = "auto" # "auto" | "required" | "disabled" + session_mode: str = "auto" metadata: Mapping[str, Any] = field(default_factory=lambda: {}) attributes: Mapping[str, Any] = field(default_factory=lambda: {}) stream: bool = False identity: ChannelIdentity | None = None - response_target: ResponseTarget = field(default_factory=lambda: ResponseTarget.originating) # type: ignore[attr-defined] class ChannelCommand: @@ -335,42 +146,11 @@ TResult = TypeVar("TResult") class HostedRunResult(Generic[TResult]): - r"""Channel-neutral envelope around the target's full-fidelity result. + """Channel-neutral envelope around the target's full-fidelity result. - Carries the underlying execution payload **unchanged** so channels - (and developer-supplied ``response_hook``\\s) can read everything the - target produced — full multi-modal contents, structured ``value``, - ``usage_details``, ``response_id``, workflow per-executor outputs, - final ``WorkflowRunState``, etc. - - ``result`` is generic in ``TResult`` so callers retain static typing: - - * Agent targets always produce - ``HostedRunResult[AgentResponse]`` — channels read - ``result.messages``, ``result.value``, ``result.usage_details``, … - directly. - * Workflow targets produce ``HostedRunResult[WorkflowRunResult]`` - today (``Workflow`` is not itself generic, so the static narrowing - is only as tight as ``Workflow.run``'s return). Channels iterate - ``result.get_outputs()`` and inspect ``result.get_final_state()`` - to render workflow-specific UX. When a host author drives the - workflow themselves and knows the final-output type, they may - narrow to ``HostedRunResult[MyOutput]`` in their own - ``response_hook`` signatures. - * The echo-input phase synthesises an ``HostedRunResult[AgentResponse]`` - wrapping the originating user turn so the same per-destination - delivery machinery applies. - - The optional ``session`` slot carries the resolved - :class:`~agent_framework.AgentSession` the host bound to this - invocation (``None`` for workflow targets, which do not own session - state in the agent sense). Channels that want to surface session - metadata (e.g. echo the resolved isolation key into a response - header) read it here. - - Treat instances as immutable: the host clones per-destination before - invoking a per-channel ``response_hook`` so one channel's transform - cannot perturb the payload another destination observes. + The host does not flatten or pre-shape the target output. Channels and + response hooks read the underlying result type directly and serialize the + subset their wire format can carry. """ def __init__( @@ -388,575 +168,45 @@ class HostedRunResult(Generic[TResult]): result: TResult | _Unset = _UNSET, session: Any | _Unset | None = _UNSET, ) -> HostedRunResult[TResult]: - """Return a shallow copy with the supplied fields overridden. - - Used by the host's delivery layer to clone the envelope before - applying a per-destination ``response_hook``, so one channel's - transform cannot mutate the payload another destination sees. - The clone is shallow — channels that need to mutate - ``result.messages`` (or any other nested mutable container) are - responsible for deep-cloning that container themselves. - """ + """Return a shallow copy with the supplied fields overridden.""" new: HostedRunResult[TResult] = HostedRunResult.__new__(HostedRunResult) # pyright: ignore[reportUnknownVariableType] new.result = self.result if isinstance(result, _Unset) else result new.session = self.session if isinstance(session, _Unset) else session return new -class DurableTaskPayloadMode(str, Enum): - """How a :class:`DurableTaskRunner` consumes scheduled-task payloads. - - Used by the host's startup validator to pair a runner's persistence - expectations with the channels' push-codec capabilities. Adapter packages - pick the right value for their backing store. - - * ``OBJECT`` — the runner accepts live Python objects in the payload. - No serialization is required; the host's - :class:`InProcessTaskRunner` is the canonical example. Suitable for - ``runtime_mode="long_running"`` deployments where the runner shares - address space with the producer. - * ``JSON`` — the runner persists the payload (database, durable queue, - Foundry scheduled-task store, …) and replays it after a process - restart. Payloads MUST be JSON-serializable, which constrains what - the host can put on the wire. The host validates at construction - that every push-capable channel exposes a - :class:`ChannelPushCodec` (so :class:`HostedRunResult` payloads can - be reduced to a JSON envelope before scheduling). - """ - - OBJECT = "object" - JSON = "json" - - -# A push-codec implementation reduces the ``(result, request, identity)`` -# triple a destination channel will receive into a JSON-safe envelope that -# a durable :class:`DurableTaskRunner` can persist, and reconstructs the -# rendering inputs on the consumer side. The host *invokes* the codec -# during scheduling; the destination channel implements it (the channel -# knows what shape of payload it can render). -# -# Channels with no push codec are usable only with object-mode runners -# (the default :class:`InProcessTaskRunner`) — the host validates this at -# construction so the mismatch surfaces eagerly rather than on first push. -class ChannelPushCodec(Protocol): - """Optional capability: serialise the push envelope for a durable task runner. - - Implementations live on the destination channel (alongside ``push``) - as a duck-typed ``push_codec`` attribute. The host's - :meth:`_deliver_response` invokes :meth:`encode` once per scheduled - push (in JSON-mode runner deployments) to produce a JSON-safe - envelope for the runner; the handler calls :meth:`decode` - immediately before invoking :meth:`ChannelPush.push`. Object-mode - runners (the default in-process runner) bypass the codec entirely - and pass live references through verbatim. - - Encoded envelopes MUST be JSON-serialisable - (``dict``/``list``/``str``/``int``/``float``/``bool``/``None``). - Channels that cannot satisfy this for some inputs (e.g. arbitrary - workflow result objects without a stable schema) SHOULD raise a - typed :class:`PushPayloadNotSerializable` from :meth:`encode` - rather than return a best-effort representation; the host surfaces - that as a schedule-time error and the destination is treated as - skipped (other destinations still get their chance). - """ - - async def encode( - self, - *, - result: HostedRunResult[Any], - request: ChannelRequest, - identity: ChannelIdentity, - echo_result: HostedRunResult[Any] | None, - ) -> Mapping[str, Any]: - """Project the in-memory push triple into a JSON-safe envelope.""" - ... - - async def decode( - self, - envelope: Mapping[str, Any], - ) -> tuple[HostedRunResult[Any], ChannelRequest, ChannelIdentity, HostedRunResult[Any] | None]: - """Reconstruct ``(result, request, identity, echo_result)`` from an envelope.""" - ... - - -class PushPayloadNotSerializable(RuntimeError): - """Raised by a :class:`ChannelPushCodec` when the payload cannot be serialised. - - Channels raise this from :meth:`ChannelPushCodec.encode` when the - inbound :class:`HostedRunResult` carries content the codec has no - JSON projection for (e.g. an arbitrary workflow result with no - declared schema). The host surfaces the error eagerly at schedule - time rather than letting the runner discover it after persisting - a half-formed envelope. - """ - - -class PushPayloadNotPicklable(RuntimeError): - """Raised when a disk-persistent runner cannot pickle a scheduled task payload. - - The in-process runner falls back to pickle when ``state_dir`` is set - so a long-running host can resume in-flight pushes across restarts. - Most :class:`HostedRunResult` payloads (frozen dataclasses wrapping - :class:`AgentResponse` or workflow output) pickle without issue, but - a user-supplied workflow result or response hook may embed an - unpickleable object (live network client, ``asyncio.Lock``, generator). - The runner raises this at schedule time so the misconfig is loud - rather than silently downgrading to no-persistence. - """ - - class HostStatePaths(TypedDict, total=False): """Per-component disk paths for host-managed state. - Pass an instance of this typed dict to - :class:`~agent_framework_hosting._host.AgentFrameworkHost`'s - ``state_dir`` parameter when you want to place individual components - on different volumes — for example, a fast local SSD for the runner - task queue and a network-attached durable volume for session state - that needs to survive container moves. - - All keys are optional (``total=False``): unset components fall back - to in-memory storage (or, for ``checkpoints``, to no checkpoint - persistence). Pass a single ``str``/``PathLike`` to ``state_dir`` - instead to get the default subfolder layout - (``state_dir/runner/``, ``state_dir/sessions/``, - ``state_dir/checkpoints/``, ``state_dir/links/``). - - Future components (continuations, ledger) will be added as additional - keys in subsequent releases. + Only session aliases and workflow checkpoints remain in the simplified + host. Linking stores, active-channel maps, identity registries, and runner + queues are follow-up concerns. """ - runner: str | os.PathLike[str] - """Where :class:`~agent_framework_hosting._runner.InProcessTaskRunner` - persists its pending-task queue and bounded terminal-status cache. - Required for in-flight push retries to survive process restarts.""" - sessions: str | os.PathLike[str] - """Where the host persists session aliases (from - :meth:`AgentFrameworkHost.reset_session`), the per-isolation-key - identity registry, and the last-active-channel map. Required for - ``ResponseTarget.active``/``.channel``/``.all_linked`` to find - destinations after a restart, and for ``reset_session`` rotations - to survive a restart.""" + """Where the host persists session aliases created by ``reset_session``.""" checkpoints: str | os.PathLike[str] - """Where the host persists workflow checkpoints for ``Workflow`` - targets. Equivalent to passing ``checkpoint_location=`` - directly: the host wraps it in a per-isolation-key - :class:`~agent_framework.FileCheckpointStorage`. Ignored when the - target is a ``SupportsAgentRun`` agent (a warning is emitted if you - set it explicitly via the mapping form). Pass the legacy - ``checkpoint_location`` parameter instead when you need to supply a - :class:`~agent_framework.CheckpointStorage` instance — it takes - precedence over this key.""" - - links: str | os.PathLike[str] - """Where identity-linker implementations persist their link store: - pending link challenges/grants, channel-native identity to linked - isolation-key mappings, and verified-claim metadata. The core host - does not impose a storage format; concrete :class:`IdentityLinker` - implementations that support host-provided persistence receive this - path via ``configure_link_store_path``. If a linker manages its own - persistence, omit this key or configure that linker directly.""" + """Where the host persists workflow checkpoints for ``Workflow`` targets.""" -# A transform hook runs over each AgentResponseUpdate as the channel consumes -# the stream. It can return a replacement update, ``None`` to drop the update, -# or be async. Channels apply it during iteration so that channel-specific -# concerns (e.g. masking, redaction, formatting for the wire) live close to -# the channel rather than on the agent. -ChannelStreamTransformHook = Callable[ +ChannelStreamUpdateHook = Callable[ [AgentResponseUpdate], "AgentResponseUpdate | Awaitable[AgentResponseUpdate | None] | None", ] -# --------------------------------------------------------------------------- # -# Channel run hook -# --------------------------------------------------------------------------- # - - -# Run hooks accept the channel-built ``ChannelRequest`` and return a -# (possibly modified) replacement. Channels invoke the hook with both the -# request and the channel-side context as keyword arguments — the call -# convention is ``await hook(request, target=..., protocol_request=...)``. -# -# The ergonomic minimum for a hook implementation is therefore a function -# accepting ``request`` positionally plus ``**kwargs`` and returning a -# (possibly mutated) :class:`ChannelRequest`. Hooks that need the agent -# target or the raw channel-native payload pull them off the keyword -# arguments by name (``target`` / ``protocol_request``). -# -# ``protocol_request`` is the raw, channel-native payload the channel -# parsed (the JSON body for Responses, the Telegram ``Update`` dict, the -# Bot Framework ``Activity`` for Teams). Use it when the hook needs a -# field the channel did not lift onto ``ChannelRequest`` (e.g. OpenAI's -# ``safety_identifier``, Teams' ``from.aadObjectId``, …). ChannelRunHook = Callable[..., "Awaitable[ChannelRequest] | ChannelRequest"] -async def apply_run_hook( - hook: ChannelRunHook, - request: ChannelRequest, - *, - target: SupportsAgentRun | Workflow, - protocol_request: Any | None, -) -> ChannelRequest: - """Channel-side helper to invoke a :data:`ChannelRunHook` with the standard kwargs. - - Channels call this rather than calling the hook directly so the - invocation convention (``request`` positional, ``target`` / - ``protocol_request`` keyword) is enforced in one place. - """ - result = hook(request, target=target, protocol_request=protocol_request) - if isinstance(result, Awaitable): - return await result - return result - - -# --------------------------------------------------------------------------- # -# Channel response hook -# --------------------------------------------------------------------------- # - - -class ChannelResponseContext: - """Per-destination context handed to a :data:`ChannelResponseHook`. - - Response hooks run on the *output* side of the host pipeline, after - the agent / workflow has produced a :class:`HostedRunResult` but - before the destination channel serialises it to its wire format. - Hooks may need to make decisions based on *where* the payload is - headed — e.g. flatten multi-modal output to text for a text-only - destination, or pick which content variant to deliver to a card- - capable channel. The context captures that information without - forcing hooks to parse stringly destination tokens. - """ - - def __init__( - self, - request: ChannelRequest, - channel_name: str, - destination_identity: ChannelIdentity | None, - originating: bool, - is_echo: bool = False, - ) -> None: - self.request = request - self.channel_name = channel_name - # ``None`` when the originating channel is rendering its own reply - # (no push identity needed for "respond on the wire you came in - # on") or when the destination is named without a known native id. - self.destination_identity = destination_identity - # True when this hook invocation is for the originating channel's - # synchronous reply. False for non-originating push targets. - self.originating = originating - # True when the payload being shaped is the user-message echo - # rather than the agent response (only happens when - # ``ResponseTarget.echo_input`` is set). - self.is_echo = is_echo - - -# Response hooks accept the :class:`HostedRunResult` the host has assembled -# and return a (possibly modified) replacement. Channels invoke the hook -# with both the payload and the per-destination -# :class:`ChannelResponseContext` as keyword arguments — the call -# convention is ``await hook(result, context=...)``. -# -# The ergonomic minimum for a hook implementation is a function accepting -# ``result`` positionally plus ``**kwargs`` and returning a (possibly -# rewritten) :class:`HostedRunResult`. Hooks that need to branch on the -# destination read it off the ``context`` keyword argument. -# -# ``HostedRunResult`` is generic in the underlying ``result`` type; the -# hook callable signature stays ``Any``-typed so a single -# ``response_hook`` attribute on a channel can serve both agent -# (``HostedRunResult[AgentResponse]``) and workflow -# (``HostedRunResult[WorkflowRunResult]``) payloads — channels narrow -# at hook entry if they need static checking. ChannelResponseHook = Callable[..., "Awaitable[HostedRunResult[Any]] | HostedRunResult[Any]"] -async def apply_response_hook( - hook: ChannelResponseHook, - result: HostedRunResult[Any], - *, - context: ChannelResponseContext, -) -> HostedRunResult[Any]: - """Channel-side helper to invoke a :data:`ChannelResponseHook` with the standard kwargs. - - Channels (and the host's delivery layer) call this rather than calling - the hook directly so the invocation convention (``result`` positional, - ``context`` keyword) is enforced in one place. - """ - out = hook(result, context=context) - if isinstance(out, Awaitable): - return await out - return out - - -# --------------------------------------------------------------------------- # -# Channel protocols -# --------------------------------------------------------------------------- # - - @runtime_checkable class Channel(Protocol): - """A pluggable adapter that exposes one transport on the host. - - Channels publish their routes, commands, and lifecycle callbacks via - :meth:`contribute`. The host mounts them under the channel's ``path`` - (or at the app root when ``path == ""``) and gives the channel a - :class:`ChannelContext` so it can call back into the host to invoke - the agent target and deliver responses. - """ + """A pluggable adapter that exposes one transport on the host.""" name: str - path: str # default endpoint path (e.g. "/responses"); use "" to mount contributed routes at the app root + path: str def contribute(self, context: ChannelContext) -> ChannelContribution: ... - - -@runtime_checkable -class ChannelPush(Protocol): - r"""Optional capability: a channel that can deliver outbound messages without a prior request. - - Per SPEC-002 (req #13), channels that can do proactive delivery - (Telegram bot proactive message, Teams proactive bot message, - webhook callbacks, SSE broadcasts) implement ``push`` on top of the - base :class:`Channel` protocol. Channels without push can only be - addressed as the ``originating`` :class:`ResponseTarget`. - - Distinguishing user echoes from agent replies - --------------------------------------------- - When the originating :class:`ResponseTarget` opts in to - ``echo_input=True``, the host pushes the user's input message to - each non-originating destination **before** the agent reply. Both - pushes go through the same ``push(identity, payload)`` entry point; - the channel distinguishes them by inspecting the role on the - payload's underlying :class:`~agent_framework.Message`\\(s): - - * ``payload.result.messages[i].role == "user"`` → the echo phase - (originating user's turn mirrored onto this destination so the - channel's UX can stay coherent with the user's actual prompt). - Channels that cannot impersonate the user (most chat bots can - only send AS the bot) typically render echoes as a quoted / - prefixed block, drop them, or skip them via a - ``response_hook`` — see below. - * ``payload.result.messages[i].role == "assistant"`` → the agent's - reply. - - Channels that want to branch on phase WITHOUT inspecting roles can - instead expose a ``response_hook`` attribute on the channel - instance: the host calls the hook with a - :class:`ChannelResponseContext` whose ``is_echo`` flag carries the - same phase information explicitly, and the hook returns a - (possibly rewritten) :class:`HostedRunResult` that the host then - hands to ``push``. The hook seam is duck-typed and intentionally - NOT part of this Protocol so adding hook support to an existing - channel never breaks its public contract. - """ - - name: str - - async def push(self, identity: ChannelIdentity, payload: HostedRunResult[Any]) -> None: ... - - -async def apply_channel_response_hook( - channel: Channel | ChannelPush, - result: HostedRunResult[Any], - *, - request: ChannelRequest, - originating: bool, - destination_identity: ChannelIdentity | None = None, - is_echo: bool = False, - clone: bool = False, -) -> HostedRunResult[Any]: - """Apply a channel's optional response hook with the standard context. - - Channels and the host call this helper when they need to shape a - :class:`HostedRunResult` for one destination. The helper centralizes the - response-hook convention: hooks are discovered from a duck-typed - ``response_hook`` attribute, called through :func:`apply_response_hook`, - and receive a :class:`ChannelResponseContext` that identifies the channel, - destination identity, originating-vs-push phase, and echo phase. - - Args: - channel: Channel whose ``response_hook`` attribute may shape the payload. - result: Hosted run result to pass to the hook. - request: Originating channel request. - originating: Whether this is the originating channel's synchronous reply. - destination_identity: Destination identity for non-originating pushes, or - ``None`` for originating replies. - is_echo: Whether the payload is an echo of the user input. - clone: Whether to shallow-clone ``result`` before applying the hook. - - Returns: - The original, cloned, or hook-shaped hosted run result. - """ - shaped = result.replace() if clone else result - hook = cast(ChannelResponseHook | None, getattr(channel, "response_hook", None)) - if not callable(hook): - return shaped - context = ChannelResponseContext( - request=request, - channel_name=channel.name, - destination_identity=destination_identity, - originating=originating, - is_echo=is_echo, - ) - return await apply_response_hook(hook, shaped, context=context) - - -# --------------------------------------------------------------------------- # -# Durable task runner — pluggable seam for non-originating push fan-out and -# (in v1 fast-follow) background runs. See spec §"Durable task runner". -# --------------------------------------------------------------------------- # - - -@dataclass(frozen=True) -class RetryPolicy: - """Retry contract a :class:`DurableTaskRunner` honours per scheduled task. - - Defaults are deliberately conservative — five attempts on a 1s/2x/60s - exponential backoff — so a transient channel outage (Telegram returning - 502, Activity Protocol token refresh) is rerouted to retry without the - operator wiring anything. Adapter backends (TaskHub, Foundry durable - tasks) MAY translate this into their native retry primitive; the - in-process runner implements it directly via ``asyncio.sleep``. - """ - - max_attempts: int = 5 - initial_backoff_seconds: float = 1.0 - backoff_multiplier: float = 2.0 - max_backoff_seconds: float = 60.0 - - -@dataclass(frozen=True) -class TaskHandle: - """Opaque, runner-issued handle for a scheduled task. - - Callers receive one of these from :meth:`DurableTaskRunner.schedule` and - pass it back to :meth:`DurableTaskRunner.get` to poll status. ``task_id`` - is opaque — its shape is implementation-defined (UUID for the in-process - runner, instance id for TaskHub, scheduled-task arn for Foundry). The - ``name`` mirrors the handler name supplied to :meth:`schedule` so the - caller does not have to track it separately. - """ - - task_id: str - name: str - - -TaskStatus = Literal["scheduled", "running", "succeeded", "failed", "cancelled"] - - -@runtime_checkable -class DurableTaskRunner(Protocol): - """Pluggable seam the host uses to schedule out-of-band work. - - The host registers a single internal handler — ``"hosting.push"`` — at - startup; each non-originating push destination becomes a - ``runner.schedule("hosting.push", payload)`` call. The handler resolves - the destination channel, runs its ``response_hook`` (if any), and calls - :meth:`ChannelPush.push`. Failures inside the handler are caught by the - runner, retried per the supplied :class:`RetryPolicy`, and ultimately - marked terminal-failed when ``max_attempts`` is exhausted. - - Two implementations ship in the framework: an in-process default - (``InProcessTaskRunner``, asyncio + bounded retry, no cross-restart - persistence) suitable for ``runtime_mode="long_running"`` deployments, - plus adapter packages (``agent-framework-hosting-durabletask``, a future - Foundry adapter) for ``runtime_mode="ephemeral"`` deployments that need - cross-restart durability. - - Adapters MUST publish their ``payload_mode`` so the host's startup - validator can pair runner persistence expectations with channel - push-codec capabilities. Object-mode runners accept live Python - references in the payload (the in-process default does this for - speed); JSON-mode runners persist payloads across process restarts - and therefore require every push-capable channel to expose a - :class:`ChannelPushCodec`. - """ - - # Adapter classes set this explicitly; the host inspects it at - # construction time. Default is conservative ("object") so a runner - # that omits the attribute is treated as in-process-only and does - # not silently impose a JSON requirement on channels. - payload_mode: DurableTaskPayloadMode - - def register( - self, - name: str, - handler: Callable[[Mapping[str, Any]], Awaitable[None]], - ) -> None: - """Register a named handler the runner will invoke when a task fires. - - Re-registering under the same name replaces the previous handler. - Implementations SHOULD raise :class:`RuntimeError` if called after - the runner has been started, to avoid silent reorderings of in-flight - work; the in-process runner enforces this. - """ - ... - - async def schedule( - self, - name: str, - payload: Mapping[str, Any], - *, - retry_policy: RetryPolicy | None = None, - ) -> TaskHandle: - """Schedule a previously-registered handler invocation. - - ``name`` MUST match a name previously passed to :meth:`register`. The - ``payload`` is forwarded verbatim to the handler; implementations - MUST treat it as opaque (no introspection, no normalization). - ``retry_policy`` overrides the runner's default for this task only; - ``None`` means "use the runner-wide default". - - Returns a :class:`TaskHandle` the caller may use with :meth:`get` to - poll status. Returning the handle MUST NOT wait for the task to run - — scheduling is fire-and-forget from the caller's perspective. - """ - ... - - async def get(self, handle: TaskHandle) -> TaskStatus | None: - """Return the current status of a scheduled task. - - Returns ``None`` if the runner no longer has any record of the task - (e.g. it was scheduled in a prior process and the runner has no - persistent backing). Otherwise one of the :data:`TaskStatus` values. - """ - ... - - -__all__ = [ - "AgentResponse", - "AgentResponseUpdate", - "Channel", - "ChannelCommand", - "ChannelCommandContext", - "ChannelContribution", - "ChannelIdentity", - "ChannelPush", - "ChannelPushCodec", - "ChannelRequest", - "ChannelResponseContext", - "ChannelResponseHook", - "ChannelRunHook", - "ChannelSession", - "ChannelStreamTransformHook", - "DurableTaskPayloadMode", - "DurableTaskRunner", - "HostStatePaths", - "HostedRunResult", - "PushPayloadNotPicklable", - "PushPayloadNotSerializable", - "ResponseStream", - "ResponseTarget", - "ResponseTargetKind", - "RetryPolicy", - "TaskHandle", - "TaskStatus", - "apply_channel_response_hook", - "apply_response_hook", - "apply_run_hook", -] diff --git a/python/packages/hosting/tests/test_authorization.py b/python/packages/hosting/tests/test_authorization.py deleted file mode 100644 index 9de3f6b5ca..0000000000 --- a/python/packages/hosting/tests/test_authorization.py +++ /dev/null @@ -1,580 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Tests for the authorization and identity-linking seam.""" - -from __future__ import annotations - -from collections.abc import Collection -from typing import Any - -import pytest - -from agent_framework_hosting import ( - AgentFrameworkHost, - AllOfAllowlists, - AllowAll, - Allowed, - AllowlistDecision, - AnyOfAllowlists, - AuthorizationContext, - AuthPolicy, - CallableAllowlist, - ChannelConfigurationError, - ChannelContext, - ChannelContribution, - ChannelIdentity, - Denied, - LinkChallenge, - LinkedClaimAllowlist, - LinkedIdentity, - LinkRequired, - NativeIdAllowlist, -) - -# --------------------------------------------------------------------------- # -# Fakes # -# --------------------------------------------------------------------------- # - - -class _ChannelStub: - name: str = "stub" - path: str = "/stub" - require_link: bool = False - allowlist: Any = "inherit" - emits_verified_claims: bool = False - - def __init__( - self, - *, - name: str = "stub", - require_link: bool = False, - allowlist: Any = "inherit", - emits_verified_claims: bool = False, - ) -> None: - self.name = name - self.path = f"/{name}" - self.require_link = require_link - self.allowlist = allowlist - self.emits_verified_claims = emits_verified_claims - - def contribute(self, context: ChannelContext) -> ChannelContribution: - return ChannelContribution(routes=[]) - - -class _AgentStub: - """Bare minimum target — the validators run during ``__init__``, - not on first request, so the target is never actually invoked.""" - - async def run(self, *args: Any, **kwargs: Any) -> Any: # pragma: no cover - raise NotImplementedError - - -class _StaticLinker: - """Test linker returning either a linked identity or a challenge.""" - - def __init__(self, result: LinkedIdentity | LinkChallenge) -> None: - self.result = result - self.calls: list[ChannelIdentity] = [] - - async def resolve(self, identity: ChannelIdentity) -> LinkedIdentity | LinkChallenge: - self.calls.append(identity) - return self.result - - -def _ctx_pre_link(channel: str = "telegram", native_id: str = "42") -> AuthorizationContext: - return AuthorizationContext( - identity=ChannelIdentity(channel=channel, native_id=native_id), - phase="pre_link", - ) - - -def _ctx_post_link(claims: dict[str, str] | None = None) -> AuthorizationContext: - return AuthorizationContext( - identity=ChannelIdentity(channel="telegram", native_id="42"), - phase="post_link", - isolation_key="alice", - verified_claims=claims or {}, - claim_source="linker", - ) - - -# --------------------------------------------------------------------------- # -# Built-in allowlists # -# --------------------------------------------------------------------------- # - - -class TestAllowAll: - async def test_allows_both_phases(self) -> None: - a = AllowAll() - assert await a.evaluate(_ctx_pre_link()) is AllowlistDecision.ALLOW - assert await a.evaluate(_ctx_post_link()) is AllowlistDecision.ALLOW - - def test_does_not_require_linked_claims(self) -> None: - assert AllowAll().requires_linked_claims is False - - -class TestNativeIdAllowlist: - async def test_allows_listed_id(self) -> None: - a = NativeIdAllowlist({"42", "99"}) - assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW - - async def test_denies_unlisted_id(self) -> None: - a = NativeIdAllowlist({"42"}) - assert await a.evaluate(_ctx_pre_link(native_id="99")) is AllowlistDecision.DENY - - async def test_channel_filter_abstains_for_other_channels(self) -> None: - # The native-id list is scoped to "telegram" — a request from - # another channel should ABSTAIN so a combinator can give a - # parallel allowlist a chance to ALLOW. - a = NativeIdAllowlist({"42"}, channel="telegram") - assert await a.evaluate(_ctx_pre_link(channel="slack", native_id="42")) is AllowlistDecision.ABSTAIN - - async def test_channel_filter_evaluates_matching_channel(self) -> None: - a = NativeIdAllowlist({"42"}, channel="telegram") - assert await a.evaluate(_ctx_pre_link(channel="telegram", native_id="42")) is AllowlistDecision.ALLOW - assert await a.evaluate(_ctx_pre_link(channel="telegram", native_id="99")) is AllowlistDecision.DENY - - async def test_async_loader_caches_after_first_call(self) -> None: - # The loader should run once; subsequent ``evaluate`` calls hit - # the cache so a slow / costly source isn't re-queried per - # message. - calls = {"n": 0} - - async def loader() -> Collection[str]: - calls["n"] += 1 - return {"42"} - - a = NativeIdAllowlist(loader) - assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW - assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW - assert calls["n"] == 1 - - -class TestLinkedClaimAllowlist: - """Claim allowlists abstain pre-link and decide once claims are available.""" - - def test_declares_requires_linked_claims(self) -> None: - a = LinkedClaimAllowlist("oid", ["abc"]) - assert a.requires_linked_claims is True - - async def test_pre_link_abstains(self) -> None: - a = LinkedClaimAllowlist("oid", ["abc"]) - assert await a.evaluate(_ctx_pre_link()) is AllowlistDecision.ABSTAIN - - async def test_post_link_allows_matching_claim(self) -> None: - a = LinkedClaimAllowlist("oid", ["abc"]) - assert await a.evaluate(_ctx_post_link({"oid": "abc"})) is AllowlistDecision.ALLOW - - async def test_post_link_allows_matching_multi_value_claim(self) -> None: - a = LinkedClaimAllowlist("groups", ["admins"]) - ctx = AuthorizationContext( - identity=ChannelIdentity(channel="telegram", native_id="42"), - phase="post_link", - isolation_key="alice", - verified_claims={"groups": ("users", "admins")}, - claim_source="linker", - ) - assert await a.evaluate(ctx) is AllowlistDecision.ALLOW - - async def test_post_link_denies_missing_or_nonmatching_claim(self) -> None: - a = LinkedClaimAllowlist("oid", ["abc"]) - assert await a.evaluate(_ctx_post_link({"oid": "def"})) is AllowlistDecision.DENY - assert await a.evaluate(_ctx_post_link({"tid": "abc"})) is AllowlistDecision.DENY - - -class TestAnyOfAllowlists: - async def test_any_allow_wins(self) -> None: - a = AnyOfAllowlists(NativeIdAllowlist({"42"}), NativeIdAllowlist({"99"})) - # native_id=42 → first ALLOWs, short-circuit. - assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW - - async def test_all_deny_yields_deny(self) -> None: - # Both lists deny native_id=7. - a = AnyOfAllowlists(NativeIdAllowlist({"42"}), NativeIdAllowlist({"99"})) - assert await a.evaluate(_ctx_pre_link(native_id="7")) is AllowlistDecision.DENY - - async def test_abstain_when_no_decision(self) -> None: - # Channel-scoped lists both ABSTAIN on a "slack" request. - a = AnyOfAllowlists( - NativeIdAllowlist({"42"}, channel="telegram"), - NativeIdAllowlist({"99"}, channel="teams"), - ) - assert await a.evaluate(_ctx_pre_link(channel="slack", native_id="42")) is AllowlistDecision.ABSTAIN - - async def test_empty_is_abstain(self) -> None: - # No children → ABSTAIN (not DENY) to avoid silent deny-all. - a = AnyOfAllowlists() - assert await a.evaluate(_ctx_pre_link()) is AllowlistDecision.ABSTAIN - - def test_propagates_requires_linked_claims(self) -> None: - a = AnyOfAllowlists(NativeIdAllowlist({"42"}), LinkedClaimAllowlist("oid", [])) - assert a.requires_linked_claims is True - - -class TestAllOfAllowlists: - async def test_any_deny_short_circuits(self) -> None: - a = AllOfAllowlists(NativeIdAllowlist({"42"}), NativeIdAllowlist({"99"})) - assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.DENY - - async def test_all_allow_yields_allow(self) -> None: - a = AllOfAllowlists(NativeIdAllowlist({"42"}), NativeIdAllowlist({"42", "99"})) - assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW - - async def test_abstain_when_no_deny_but_no_unanimous_allow(self) -> None: - a = AllOfAllowlists( - NativeIdAllowlist({"42"}, channel="telegram"), - NativeIdAllowlist({"42"}, channel="teams"), - ) - # ABSTAIN from teams (different channel), ALLOW from telegram → ABSTAIN. - assert await a.evaluate(_ctx_pre_link(channel="telegram", native_id="42")) is AllowlistDecision.ABSTAIN - - async def test_empty_is_abstain(self) -> None: - a = AllOfAllowlists() - assert await a.evaluate(_ctx_pre_link()) is AllowlistDecision.ABSTAIN - - -class TestCallableAllowlist: - async def test_wraps_async_fn(self) -> None: - async def fn(ctx: AuthorizationContext) -> AllowlistDecision: - if ctx.identity.native_id == "42": - return AllowlistDecision.ALLOW - return AllowlistDecision.DENY - - a = CallableAllowlist(fn) - assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW - assert await a.evaluate(_ctx_pre_link(native_id="99")) is AllowlistDecision.DENY - - def test_requires_linked_claims_passthrough(self) -> None: - async def fn(_: AuthorizationContext) -> AllowlistDecision: # pragma: no cover - return AllowlistDecision.ALLOW - - a = CallableAllowlist(fn, requires_linked_claims=True) - assert a.requires_linked_claims is True - - -class TestAuthPolicy: - async def test_factory_helpers_return_working_allowlists(self) -> None: - assert await AuthPolicy.open().evaluate(_ctx_pre_link()) is AllowlistDecision.ALLOW - assert await AuthPolicy.native_ids({"42"}).evaluate(_ctx_pre_link()) is AllowlistDecision.ALLOW - assert await AuthPolicy.linked_claim("oid", {"abc"}).evaluate(_ctx_post_link({"oid": "abc"})) is ( - AllowlistDecision.ALLOW - ) - - async def test_custom_factory(self) -> None: - async def fn(_: AuthorizationContext) -> AllowlistDecision: - return AllowlistDecision.ALLOW - - policy = AuthPolicy.custom(fn, requires_linked_claims=True) - assert policy.requires_linked_claims is True - assert await policy.evaluate(_ctx_pre_link()) is AllowlistDecision.ALLOW - - -# --------------------------------------------------------------------------- # -# Host configuration validator # -# --------------------------------------------------------------------------- # - - -class TestChannelAuthorizationValidator: - """The host's startup validator catches three classes of misconfig - so they fail at construction rather than silently denying every - user at runtime.""" - - def test_require_link_without_linker_raises(self) -> None: - # ``require_link=True`` with no linker would silently reject - # every request — caught at construction. - with pytest.raises(ChannelConfigurationError, match="identity_linker"): - AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub(require_link=True)], - ) - - def test_require_link_with_linker_passes(self) -> None: - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub(require_link=True)], - identity_linker=_StaticLinker(LinkedIdentity("alice", {"oid": "abc"})), - ) - assert host.runtime_mode == "long_running" - - def test_linked_claim_allowlist_without_claim_source_raises(self) -> None: - # The channel has no ``require_link=True`` AND doesn't emit - # claims natively → the allowlist would always DENY / ABSTAIN. - with pytest.raises(ChannelConfigurationError, match="verified IdP claims"): - AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub(allowlist=LinkedClaimAllowlist("oid", []))], - ) - - def test_linked_claim_allowlist_with_native_claim_source_passes(self) -> None: - # When the channel declares ``emits_verified_claims=True`` - # (e.g. Activity Protocol with AAD bearer) the validator - # accepts the LinkedClaimAllowlist without needing a linker. - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[ - _ChannelStub( - allowlist=LinkedClaimAllowlist("oid", ["abc"]), - emits_verified_claims=True, - ) - ], - ) - assert host.default_allowlist is None - - def test_linked_claim_allowlist_with_require_link_and_linker_passes(self) -> None: - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub(require_link=True, allowlist=LinkedClaimAllowlist("oid", ["abc"]))], - identity_linker=_StaticLinker(LinkedIdentity("alice", {"oid": "abc"})), - ) - assert host.runtime_mode == "long_running" - - def test_native_id_allowlist_unknown_channel_raises(self) -> None: - with pytest.raises(ChannelConfigurationError, match="unknown channel 'mystery'"): - AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub(allowlist=NativeIdAllowlist({"42"}, channel="mystery"))], - ) - - def test_native_id_allowlist_known_channel_passes(self) -> None: - # A channel-scoped native list pointing at a peer channel is - # the supported way to compose per-channel allowlists. - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[ - _ChannelStub(name="telegram", allowlist=NativeIdAllowlist({"42"}, channel="telegram")), - _ChannelStub(name="slack"), - ], - ) - assert host.runtime_mode == "long_running" - - def test_default_allowlist_applies_to_inheriting_channel(self) -> None: - # ``allowlist="inherit"`` (the default) picks up the host-level - # ``default_allowlist``. This is the "lock down a whole bot in - # one place" ergonomic. - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub(name="telegram")], - default_allowlist=NativeIdAllowlist({"42"}), - ) - # The default flowed through; channel sees the host's allowlist. - assert host.default_allowlist is not None - - def test_explicit_none_carve_out_overrides_default(self) -> None: - # ``allowlist=None`` on a channel explicitly opts out of the - # host default — useful for a public endpoint inside an - # otherwise locked-down host. - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub(name="public", allowlist=None)], - default_allowlist=NativeIdAllowlist({"42"}), - ) - # Construction succeeded; the validator did not raise. - assert host.default_allowlist is not None - - def test_combinator_with_unknown_nested_channel_raises(self) -> None: - # The validator walks ``AnyOfAllowlists`` / ``AllOfAllowlists`` - # so a typo'd channel name nested under a combinator is still - # caught at construction. - with pytest.raises(ChannelConfigurationError, match="unknown channel 'typo'"): - AgentFrameworkHost( - target=_AgentStub(), - channels=[ - _ChannelStub( - allowlist=AnyOfAllowlists( - NativeIdAllowlist({"42"}, channel="stub"), - NativeIdAllowlist({"99"}, channel="typo"), - ) - ) - ], - ) - - -# --------------------------------------------------------------------------- # -# host.authorize pipeline # -# --------------------------------------------------------------------------- # - - -class TestHostAuthorize: - """Host authorization pipeline across open, native-id, and linked-claim profiles.""" - - def _host(self) -> AgentFrameworkHost: - return AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()]) - - async def test_open_profile_returns_allowed_with_auto_isolation_key(self) -> None: - host = self._host() - outcome = await host.authorize(ChannelIdentity(channel="telegram", native_id="42")) - assert isinstance(outcome, Allowed) - assert outcome.isolation_key == "telegram:42" - - async def test_native_allowlist_allows_listed_id(self) -> None: - host = self._host() - outcome = await host.authorize( - ChannelIdentity(channel="telegram", native_id="42"), - allowlist=NativeIdAllowlist({"42"}), - ) - assert isinstance(outcome, Allowed) - assert outcome.isolation_key == "telegram:42" - - async def test_native_allowlist_denies_unlisted_id(self) -> None: - host = self._host() - outcome = await host.authorize( - ChannelIdentity(channel="telegram", native_id="99"), - allowlist=NativeIdAllowlist({"42"}), - ) - assert isinstance(outcome, Denied) - assert outcome.reason_code == "allowlist_denied_pre_link" - assert outcome.user_message is not None - # The bland default leaks neither tenant nor list size. - assert "telegram" not in (outcome.user_message or "") - - async def test_abstain_with_claim_requirement_yields_link_required_message(self) -> None: - # Without a linker and without channel-emitted claims, a claim-required - # allowlist cannot make progress and the host returns a safe denial. - async def abstain(_: AuthorizationContext) -> AllowlistDecision: - return AllowlistDecision.ABSTAIN - - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub(emits_verified_claims=True)], - ) - outcome = await host.authorize( - ChannelIdentity(channel="telegram", native_id="42"), - allowlist=CallableAllowlist(abstain, requires_linked_claims=True), - ) - assert isinstance(outcome, Denied) - assert outcome.reason_code == "allowlist_requires_link" - - async def test_abstain_without_claim_requirement_falls_through_to_allowed(self) -> None: - async def abstain(_: AuthorizationContext) -> AllowlistDecision: - return AllowlistDecision.ABSTAIN - - host = self._host() - outcome = await host.authorize( - ChannelIdentity(channel="telegram", native_id="42"), - allowlist=CallableAllowlist(abstain), - ) - assert isinstance(outcome, Allowed) - - async def test_auto_issue_returns_existing_key_when_known(self) -> None: - # When an identity has already been observed, the auto-issued - # key matches the existing one rather than coining a fresh - # token. This is the linker-free equivalent of identity resolution. - host = self._host() - host._identities["alice"] = {"telegram": ChannelIdentity(channel="telegram", native_id="42")} - outcome = await host.authorize(ChannelIdentity(channel="telegram", native_id="42")) - assert isinstance(outcome, Allowed) - assert outcome.isolation_key == "alice" - - async def test_verified_claims_propagate_to_context(self) -> None: - # Channels that natively carry verified claims (e.g. Activity - # Protocol bearer with AAD oid) pass them through to - # ``authorize`` — the allowlist sees them on the - # ``AuthorizationContext``. - seen: list[AuthorizationContext] = [] - - async def capture(ctx: AuthorizationContext) -> AllowlistDecision: - seen.append(ctx) - return AllowlistDecision.ALLOW - - host = self._host() - await host.authorize( - ChannelIdentity(channel="telegram", native_id="42"), - allowlist=CallableAllowlist(capture), - verified_claims={"oid": "abc"}, - ) - assert len(seen) == 1 - assert seen[0].claim_source == "channel" - assert dict(seen[0].verified_claims) == {"oid": "abc"} - - async def test_require_link_returns_challenge_when_unlinked(self) -> None: - challenge = LinkChallenge("c1", url="https://login.example/c1") - linker = _StaticLinker(challenge) - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub(require_link=True)], - identity_linker=linker, - ) - outcome = await host.authorize( - ChannelIdentity(channel="telegram", native_id="42"), - require_link=True, - ) - assert isinstance(outcome, LinkRequired) - assert outcome.challenge is challenge - assert [call.native_id for call in linker.calls] == ["42"] - - async def test_require_link_returns_linked_identity_when_resolved(self) -> None: - linked = LinkedIdentity("entra:abc", {"oid": "abc"}) - linker = _StaticLinker(linked) - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub(require_link=True)], - identity_linker=linker, - ) - outcome = await host.authorize( - ChannelIdentity(channel="telegram", native_id="42"), - require_link=True, - ) - assert isinstance(outcome, Allowed) - assert outcome.isolation_key == "entra:abc" - assert dict(outcome.verified_claims) == {"oid": "abc"} - assert outcome.claim_source == "linker" - # authorize() is decision-only; identity registry writes remain on - # the request execution path. - assert host._identities == {} - - async def test_linked_claim_allowlist_with_linker_allows_matching_claim(self) -> None: - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub(require_link=True, allowlist=LinkedClaimAllowlist("oid", ["abc"]))], - identity_linker=_StaticLinker(LinkedIdentity("entra:abc", {"oid": "abc"})), - ) - outcome = await host.authorize( - ChannelIdentity(channel="telegram", native_id="42"), - require_link=True, - allowlist=LinkedClaimAllowlist("oid", ["abc"]), - ) - assert isinstance(outcome, Allowed) - assert outcome.isolation_key == "entra:abc" - - async def test_linked_claim_allowlist_with_linker_denies_nonmatching_claim(self) -> None: - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub(require_link=True, allowlist=LinkedClaimAllowlist("oid", ["abc"]))], - identity_linker=_StaticLinker(LinkedIdentity("entra:def", {"oid": "def"})), - ) - outcome = await host.authorize( - ChannelIdentity(channel="telegram", native_id="42"), - require_link=True, - allowlist=LinkedClaimAllowlist("oid", ["abc"]), - ) - assert isinstance(outcome, Denied) - assert outcome.reason_code == "allowlist_denied_post_link" - - async def test_linked_claim_allowlist_with_linker_returns_challenge_when_unlinked(self) -> None: - challenge = LinkChallenge("c1") - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub(require_link=True, allowlist=LinkedClaimAllowlist("oid", ["abc"]))], - identity_linker=_StaticLinker(challenge), - ) - outcome = await host.authorize( - ChannelIdentity(channel="telegram", native_id="42"), - require_link=True, - allowlist=LinkedClaimAllowlist("oid", ["abc"]), - ) - assert isinstance(outcome, LinkRequired) - assert outcome.challenge is challenge - - async def test_linked_claim_allowlist_uses_channel_verified_claims_without_linker(self) -> None: - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub(emits_verified_claims=True, allowlist=LinkedClaimAllowlist("oid", ["abc"]))], - ) - outcome = await host.authorize( - ChannelIdentity(channel="activity", native_id="aad-user"), - allowlist=LinkedClaimAllowlist("oid", ["abc"]), - verified_claims={"oid": "abc"}, - ) - assert isinstance(outcome, Allowed) - assert outcome.isolation_key == "activity:aad-user" - assert outcome.claim_source == "channel" diff --git a/python/packages/hosting/tests/test_host.py b/python/packages/hosting/tests/test_host.py index cefdf5c848..f6b2d9ebfa 100644 --- a/python/packages/hosting/tests/test_host.py +++ b/python/packages/hosting/tests/test_host.py @@ -4,7 +4,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence +from collections.abc import AsyncIterator, Sequence from dataclasses import dataclass, field from typing import Any @@ -21,16 +21,9 @@ from agent_framework_hosting import ( ChannelContext, ChannelContribution, ChannelIdentity, - ChannelPush, ChannelRequest, ChannelSession, - DurableTaskPayloadMode, - DurableTaskRunner, HostedRunResult, - ResponseTarget, - RetryPolicy, - TaskHandle, - TaskStatus, ) @@ -75,27 +68,33 @@ class _FakeAgent: self.created_sessions.append(s) return s - async def run(self, messages: Any = None, *, stream: bool = False, session: Any = None, **kwargs: Any) -> Any: + def run(self, messages: Any = None, *, stream: bool = False, session: Any = None, **kwargs: Any) -> Any: self.calls.append({"messages": messages, "stream": stream, "session": session, "kwargs": kwargs}) - if stream: # pragma: no cover - not used by these tests + if stream: + updates = [AgentResponseUpdate(contents=[Content.from_text(text=self._reply)], role="assistant")] - async def _gen() -> AsyncIterator[Any]: - yield self._reply + async def _gen() -> AsyncIterator[AgentResponseUpdate]: + for update in updates: + yield update - return _gen() - return _FakeAgentResponse(text=self._reply) + async def _finalize(items: Sequence[AgentResponseUpdate]) -> AgentResponse: # noqa: RUF029 + return AgentResponse.from_updates(items) + + return ResponseStream[AgentResponseUpdate, AgentResponse](_gen(), finalizer=_finalize) + + async def _coro() -> _FakeAgentResponse: + return _FakeAgentResponse(text=self._reply) + + return _coro() class _RecordingChannel: - """Minimal :class:`Channel` + :class:`ChannelPush` for routing tests.""" + """Minimal :class:`Channel` for host tests.""" - def __init__(self, name: str = "fake", path: str = "/fake", supports_push: bool = True) -> None: + def __init__(self, name: str = "fake", path: str = "/fake") -> None: self.name = name self.path = path self.context: ChannelContext | None = None - self.pushes: list[tuple[ChannelIdentity, HostedRunResult[Any]]] = [] - self._push_raises: Exception | None = None - self._supports_push = supports_push # Provide a single trivial route so contribute() exercises the endpoint path. self._routes: Sequence[BaseRoute] = (Route("/ping", _ping),) @@ -103,88 +102,6 @@ class _RecordingChannel: self.context = context return ChannelContribution(routes=self._routes) - async def push(self, identity: ChannelIdentity, payload: HostedRunResult[Any]) -> None: - if self._push_raises is not None: - raise self._push_raises - self.pushes.append((identity, payload)) - - -class _NoPushChannel: - """A channel that does NOT implement :class:`ChannelPush`.""" - - def __init__(self, name: str = "nopush", path: str = "/nopush") -> None: - self.name = name - self.path = path - - def contribute(self, context: ChannelContext) -> ChannelContribution: - return ChannelContribution() - - -class _SyncTaskRunner(DurableTaskRunner): - """A :class:`DurableTaskRunner` that runs handlers inline. - - Tests of the delivery routing want deterministic, synchronous - behaviour. The real :class:`InProcessTaskRunner` schedules via - ``asyncio.create_task`` so push side effects only land *after* - the test has yielded control — awkward for assertions that read - a channel's recorded pushes immediately after - :meth:`ChannelContext.deliver_response` returns. - - Two knobs control failure handling: - - - ``schedule_raises``: when set, every call to :meth:`schedule` - raises this exception. Mimics a host-side outage (the durable - backend is unreachable). - - ``swallow_handler_errors`` (default ``True``): when the - handler raises, the error is recorded in - :attr:`handler_errors` but :meth:`schedule` still returns - successfully — matching the real durable contract that - "scheduled" is a separate signal from "delivered". Set to - ``False`` to surface handler exceptions through - :meth:`schedule` for the few tests that want to assert on - handler-raised failures inline. - """ - - def __init__(self, *, swallow_handler_errors: bool = True) -> None: - self._handlers: dict[str, Callable[[Mapping[str, Any]], Awaitable[None]]] = {} - self.scheduled: list[tuple[str, Mapping[str, Any]]] = [] - self.handler_errors: list[BaseException] = [] - self.schedule_raises: BaseException | None = None - self.swallow_handler_errors = swallow_handler_errors - - # Default object-mode matches the real ``InProcessTaskRunner`` — - # tests that want to exercise the JSON-mode path override this on - # the instance. - payload_mode = DurableTaskPayloadMode.OBJECT - - def register( - self, - name: str, - handler: Callable[[Mapping[str, Any]], Awaitable[None]], - ) -> None: - self._handlers[name] = handler - - async def schedule( - self, - name: str, - payload: Mapping[str, Any], - *, - retry_policy: RetryPolicy | None = None, - ) -> TaskHandle: - if self.schedule_raises is not None: - raise self.schedule_raises - self.scheduled.append((name, payload)) - try: - await self._handlers[name](payload) - except Exception as exc: - self.handler_errors.append(exc) - if not self.swallow_handler_errors: - raise - return TaskHandle(task_id=f"sync-{len(self.scheduled)}", name=name) - - async def get(self, handle: TaskHandle) -> TaskStatus | None: # pragma: no cover - unused - return "succeeded" - def _assistant_response(text: str) -> AgentResponse: """Build a one-message ``AgentResponse`` to use as a ``HostedRunResult.result``.""" @@ -227,7 +144,6 @@ class TestHostWiring: def test_channel_is_recognized(self) -> None: ch = _RecordingChannel() assert isinstance(ch, Channel) - assert isinstance(ch, ChannelPush) def test_app_mounts_channel_routes_under_path(self) -> None: agent = _FakeAgent() @@ -313,10 +229,6 @@ class TestHostInvoke: "native_id": "user:1", "attributes": {}, } - assert msg.additional_properties["hosting"]["response_target"] == { - "kind": "originating", - "targets": [], - } async def test_invoke_caches_session_per_isolation_key(self) -> None: agent = _FakeAgent() @@ -398,6 +310,56 @@ class TestHostInvoke: assert agent.calls[0]["kwargs"]["options"] == {"temperature": 0.4} +class TestHostOwnedHooks: + async def test_context_run_applies_run_hook_before_invocation(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + captured: dict[str, Any] = {} + + async def hook(request: ChannelRequest, **kwargs: Any) -> ChannelRequest: + captured["target"] = kwargs["target"] + captured["protocol_request"] = kwargs["protocol_request"] + return ChannelRequest( + channel=request.channel, + operation=request.operation, + input="rewritten", + session=request.session, + ) + + req = ChannelRequest(channel=ch.name, operation="op", input="original", session=ChannelSession("alice")) + await ch.context.run(req, run_hook=hook, protocol_request={"raw": True}) + + assert captured["target"] is agent + assert captured["protocol_request"] == {"raw": True} + assert agent.calls[0]["messages"].text == "rewritten" + + async def test_context_run_stream_applies_run_hook_before_opening_stream(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + def hook(request: ChannelRequest, **_: Any) -> ChannelRequest: + return ChannelRequest(channel=request.channel, operation=request.operation, input="streamed") + + stream = await ch.context.run_stream( + ChannelRequest(channel=ch.name, operation="op", input="original"), + run_hook=hook, + stream_update_hook=lambda update: AgentResponseUpdate( + contents=[Content.from_text(text=update.text.upper())], + role="assistant", + ), + ) + + chunks = [update.text async for update in stream] + assert chunks == ["OK"] + assert agent.calls[0]["messages"].text == "streamed" + + # --------------------------------------------------------------------------- # # Workflow target # # --------------------------------------------------------------------------- # @@ -436,7 +398,7 @@ class TestHostWorkflowTarget: assert ch.context is not None req = ChannelRequest(channel="fake", operation="message.create", input="hi") - stream = ch.context.run_stream(req) + stream = await ch.context.run_stream(req) updates: list[AgentResponseUpdate] = [] async for update in stream: @@ -464,7 +426,7 @@ class TestHostWorkflowTarget: assert ch.context is not None req = ChannelRequest(channel="fake", operation="message.create", input="x") - stream = ch.context.run_stream(req) + stream = await ch.context.run_stream(req) chunks: list[str] = [] async for update in stream: @@ -561,7 +523,7 @@ class TestHostWorkflowCheckpointing: input="hi", session=ChannelSession(isolation_key="bob"), ) - stream = ch.context.run_stream(req) + stream = await ch.context.run_stream(req) async for _ in stream: pass await stream.get_final_response() @@ -702,520 +664,6 @@ class TestHostWorkflowCheckpointingPathTraversal: assert list(tmp_path.iterdir()) == [] -# --------------------------------------------------------------------------- # -# Delivery routing # -# --------------------------------------------------------------------------- # - - -def _make_host_with_two_channels( - *, - runner: DurableTaskRunner | None = None, -) -> tuple[AgentFrameworkHost, _RecordingChannel, _RecordingChannel, ChannelContext, _SyncTaskRunner]: - agent = _FakeAgent() - a = _RecordingChannel(name="responses", path="/r") - b = _RecordingChannel(name="telegram", path="/t") - sync_runner = runner if isinstance(runner, _SyncTaskRunner) else _SyncTaskRunner() - host = AgentFrameworkHost( - target=agent, - channels=[a, b], - durable_task_runner=runner or sync_runner, - ) - _ = host.app - assert a.context is not None - return host, a, b, a.context, sync_runner - - -def _record_identity_on(host: AgentFrameworkHost, isolation_key: str, channel: str, native_id: str) -> None: - """Pre-seed the host's identity registry by running a request.""" - host._identities.setdefault(isolation_key, {})[channel] = ChannelIdentity(channel=channel, native_id=native_id) - host._active[isolation_key] = channel - - -class TestDeliverResponse: - """Delivery routing — the originating channel learns whether to render - on its own wire from the ``bool`` return; everything else - (scheduled tasks, schedule-time failures, skip reasons) lives in - the runner's own log. Tests assert the bool plus observable - state on the sync runner fake (``scheduled``, ``handler_errors``) - and on the destination channels (``pushes``).""" - - async def test_originating_returns_true(self) -> None: - _, _, _, ctx, runner = _make_host_with_two_channels() - req = ChannelRequest(channel="responses", operation="op", input="x") - include_originating = await ctx.deliver_response(req, _make_reply("reply")) - assert include_originating is True - assert runner.scheduled == [] - - async def test_none_suppresses_everything(self) -> None: - _, _, _, ctx, runner = _make_host_with_two_channels() - req = ChannelRequest( - channel="responses", - operation="op", - input="x", - response_target=ResponseTarget.none, # type: ignore[attr-defined] - ) - include_originating = await ctx.deliver_response(req, _make_reply("reply")) - assert include_originating is False - assert runner.scheduled == [] - - async def test_active_pushes_to_other_channel(self) -> None: - host, _a, b, ctx, runner = _make_host_with_two_channels() - # Alice was last seen on telegram. - _record_identity_on(host, "alice", "telegram", "42") - # Now she sends a message via responses; ResponseTarget.active should - # push to telegram, not back to responses. - req = ChannelRequest( - channel="responses", - operation="op", - input="x", - session=ChannelSession(isolation_key="alice"), - response_target=ResponseTarget.active, # type: ignore[attr-defined] - ) - include_originating = await ctx.deliver_response(req, _make_reply("reply")) - assert include_originating is False - assert len(runner.scheduled) == 1 - assert b.pushes and b.pushes[0][0].native_id == "42" - - async def test_active_falls_back_to_originating_when_self(self) -> None: - host, _a, _b, ctx, runner = _make_host_with_two_channels() - _record_identity_on(host, "alice", "responses", "user:1") - req = ChannelRequest( - channel="responses", - operation="op", - input="x", - session=ChannelSession(isolation_key="alice"), - response_target=ResponseTarget.active, # type: ignore[attr-defined] - ) - include_originating = await ctx.deliver_response(req, _make_reply("reply")) - assert include_originating is True - assert runner.scheduled == [] - - async def test_channels_with_unknown_identity_falls_back_to_originating(self) -> None: - _, _, _, ctx, runner = _make_host_with_two_channels() - # No prior identity seeded for telegram on alice. - req = ChannelRequest( - channel="responses", - operation="op", - input="x", - session=ChannelSession(isolation_key="alice"), - response_target=ResponseTarget.channel("telegram"), - ) - include_originating = await ctx.deliver_response(req, _make_reply("reply")) - # Skipped at resolution → fallback to originating so the user - # still gets a reply. - assert include_originating is True - assert runner.scheduled == [] - - async def test_channels_with_explicit_native_id_token(self) -> None: - _, _, b, ctx, runner = _make_host_with_two_channels() - req = ChannelRequest( - channel="responses", - operation="op", - input="x", - response_target=ResponseTarget.channel("telegram:99"), - ) - include_originating = await ctx.deliver_response(req, _make_reply("reply")) - assert include_originating is False - assert len(runner.scheduled) == 1 - assert b.pushes[0][0].native_id == "99" - - async def test_channels_originating_pseudo_includes_origin(self) -> None: - host, _a, _b, ctx, runner = _make_host_with_two_channels() - _record_identity_on(host, "alice", "telegram", "42") - req = ChannelRequest( - channel="responses", - operation="op", - input="x", - session=ChannelSession(isolation_key="alice"), - response_target=ResponseTarget.channels(["originating", "telegram"]), - ) - include_originating = await ctx.deliver_response(req, _make_reply("reply")) - assert include_originating is True - assert len(runner.scheduled) == 1 - - async def test_channels_unknown_channel_name_falls_back(self) -> None: - _, _, _, ctx, runner = _make_host_with_two_channels() - req = ChannelRequest( - channel="responses", - operation="op", - input="x", - response_target=ResponseTarget.channel("nope"), - ) - include_originating = await ctx.deliver_response(req, _make_reply("reply")) - assert include_originating is True # fallback - assert runner.scheduled == [] - - async def test_no_push_capability_falls_back(self) -> None: - agent = _FakeAgent() - a = _RecordingChannel(name="responses", path="/r") - b = _NoPushChannel(name="nopush", path="/n") - host = AgentFrameworkHost(target=agent, channels=[a, b]) - _ = host.app - assert a.context is not None - # Pre-seed identity on the no-push channel so we get past the - # identity check and hit the ChannelPush check. - host._identities.setdefault("alice", {})["nopush"] = ChannelIdentity(channel="nopush", native_id="42") - req = ChannelRequest( - channel="responses", - operation="op", - input="x", - session=ChannelSession(isolation_key="alice"), - response_target=ResponseTarget.channel("nopush"), - ) - include_originating = await a.context.deliver_response(req, _make_reply("reply")) - assert include_originating is True # fallback - - async def test_all_linked_pushes_to_every_other_channel(self) -> None: - host, _a, b, ctx, runner = _make_host_with_two_channels() - # Alice on responses (originating) and telegram. - host._identities.setdefault("alice", {}) - host._identities["alice"]["responses"] = ChannelIdentity(channel="responses", native_id="user:1") - host._identities["alice"]["telegram"] = ChannelIdentity(channel="telegram", native_id="42") - req = ChannelRequest( - channel="responses", - operation="op", - input="x", - session=ChannelSession(isolation_key="alice"), - response_target=ResponseTarget.all_linked, # type: ignore[attr-defined] - ) - include_originating = await ctx.deliver_response(req, _make_reply("reply")) - assert include_originating is True - assert len(runner.scheduled) == 1 - assert b.pushes and b.pushes[0][1].result.text == "reply" - - async def test_all_linked_no_other_channels_falls_back(self) -> None: - _host, _a, _b, ctx, runner = _make_host_with_two_channels() - req = ChannelRequest( - channel="responses", - operation="op", - input="x", - session=ChannelSession(isolation_key="alice"), - response_target=ResponseTarget.all_linked, # type: ignore[attr-defined] - ) - include_originating = await ctx.deliver_response(req, _make_reply("reply")) - assert include_originating is True - assert runner.scheduled == [] - - async def test_identities_variant_preserves_attributes(self) -> None: - """``ResponseTarget.identities([...])`` plumbs full - :class:`ChannelIdentity` objects through resolution, preserving - ``attributes`` for destination channels that need conversation/ - thread metadata (Teams, Slack, Bot Framework).""" - _, _, b, ctx, runner = _make_host_with_two_channels() - ident = ChannelIdentity( - channel="telegram", - native_id="42", - attributes={"thread_id": "t1", "service_url": "https://x"}, - ) - req = ChannelRequest( - channel="responses", - operation="op", - input="x", - response_target=ResponseTarget.identity(ident), - ) - include_originating = await ctx.deliver_response(req, _make_reply("reply")) - assert include_originating is False - assert len(runner.scheduled) == 1 - # The destination identity arrived at push with attributes intact. - pushed_identity = b.pushes[0][0] - assert pushed_identity.native_id == "42" - assert dict(pushed_identity.attributes) == {"thread_id": "t1", "service_url": "https://x"} - - async def test_identities_pointing_to_originating_includes_origin(self) -> None: - """An identity whose channel matches the originating channel - folds into ``include_originating`` rather than double-delivering - via push.""" - _, _, _, ctx, runner = _make_host_with_two_channels() - ident = ChannelIdentity(channel="responses", native_id="user:1") - req = ChannelRequest( - channel="responses", - operation="op", - input="x", - response_target=ResponseTarget.identities([ident]), - ) - include_originating = await ctx.deliver_response(req, _make_reply("reply")) - assert include_originating is True - assert runner.scheduled == [] - - async def test_handler_exception_does_not_change_return_value(self) -> None: - """When ``ChannelPush.push`` raises *inside the runner handler* - the originating channel still sees the same return value — - ``DurableTaskRunner.schedule`` accepted the work, and downstream - delivery outcome is owned by the runner (it logs and retries - per the configured ``RetryPolicy``).""" - host, _a, b, ctx, runner = _make_host_with_two_channels() - b._push_raises = RuntimeError("boom") # type: ignore[attr-defined] - host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42") - req = ChannelRequest( - channel="responses", - operation="op", - input="x", - session=ChannelSession(isolation_key="alice"), - response_target=ResponseTarget.channel("telegram"), - ) - include_originating = await ctx.deliver_response(req, _make_reply("reply")) - # Schedule succeeded → the return value is unaffected by a - # downstream handler failure. - assert include_originating is False - assert len(runner.scheduled) == 1 - # Handler raised — runner captured the error (the real runner - # would retry it; the sync fake records it). - assert runner.handler_errors and isinstance(runner.handler_errors[0], RuntimeError) - assert str(runner.handler_errors[0]) == "boom" - - async def test_schedule_exception_falls_back_to_originating(self) -> None: - """When :meth:`DurableTaskRunner.schedule` itself raises (the - runner backend is unreachable) the destination is treated as - skipped — same outcome as any other resolution-time drop. The - host's fall-back-to-originating rule then ensures the user - still gets a reply rather than being left without one.""" - host, _a, _b, ctx, runner = _make_host_with_two_channels() - runner.schedule_raises = RuntimeError("runner backend unreachable") - host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42") - req = ChannelRequest( - channel="responses", - operation="op", - input="x", - session=ChannelSession(isolation_key="alice"), - response_target=ResponseTarget.channel("telegram"), - ) - include_originating = await ctx.deliver_response(req, _make_reply("reply")) - # Schedule raised → no scheduled tasks, fall back to originating. - assert runner.scheduled == [] - assert include_originating is True - - async def test_echo_input_pushes_user_message_then_response(self) -> None: - """``echo_input=True`` triggers two pushes per destination, - bundled into the same scheduled task: the originating user - message first, then the agent reply. Channels downstream of a - workflow that emits to multiple channels need this to keep - their UI state coherent with the user's actual prompt.""" - host, _a, b, ctx, runner = _make_host_with_two_channels() - host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42") - req = ChannelRequest( - channel="responses", - operation="op", - input="hello there", - session=ChannelSession(isolation_key="alice"), - response_target=ResponseTarget.channel("telegram", echo_input=True), - ) - include_originating = await ctx.deliver_response(req, _make_reply("reply")) - assert include_originating is False - # One scheduled task per destination; the handler does echo then response inline. - assert len(runner.scheduled) == 1 - _, payload = runner.scheduled[0] - assert payload["echo_result"] is not None - # Two pushes landed on the channel: echo first, then response. - assert len(b.pushes) == 2 - echo_identity, echo_payload = b.pushes[0] - assert echo_identity.native_id == "42" - assert echo_payload.result.text == "hello there" - assert str(echo_payload.result.messages[0].role) == "user" - resp_identity, resp_payload = b.pushes[1] - assert resp_identity.native_id == "42" - assert resp_payload.result.text == "reply" - assert str(resp_payload.result.messages[0].role) == "assistant" - - async def test_echo_input_failure_does_not_block_response(self) -> None: - """An echo push that raises inside the handler is logged and - swallowed; the response push must still be attempted on the - same destination so the user-visible failure mode is - "response delivered without echo" rather than "no response at - all".""" - agent = _FakeAgent() - a = _RecordingChannel(name="responses", path="/r") - b = _RecordingChannel(name="telegram", path="/t") - runner = _SyncTaskRunner() - host = AgentFrameworkHost(target=agent, channels=[a, b], durable_task_runner=runner) - _ = host.app - assert a.context is not None - - host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42") - - # Make the FIRST push (echo) raise, but the SECOND (response) succeed. - calls = {"n": 0} - real_push = b.push - - async def flaky_push(identity: ChannelIdentity, payload: HostedRunResult[Any]) -> None: - calls["n"] += 1 - if calls["n"] == 1: - raise RuntimeError("echo down") - await real_push(identity, payload) - - b.push = flaky_push # type: ignore[method-assign] - - req = ChannelRequest( - channel="responses", - operation="op", - input="hi", - session=ChannelSession(isolation_key="alice"), - response_target=ResponseTarget.channel("telegram", echo_input=True), - ) - include_originating = await a.context.deliver_response(req, _make_reply("reply")) - # Schedule succeeded; handler swallowed the echo failure and - # the response push landed on the channel. - assert include_originating is False - assert b.pushes and b.pushes[0][1].result.text == "reply" - # Handler did not raise (echo failure was swallowed inside - # the handler), so the runner saw no error. - assert runner.handler_errors == [] - - async def test_echo_idempotent_on_retry(self) -> None: - """When the response push fails on a retried task, the handler - must NOT re-deliver the echo if a prior attempt already - succeeded. The ``echo_done`` cursor on the payload mapping is - the host's idempotency primitive; this test invokes the - handler directly twice with the same payload to exercise the - retry semantics.""" - host, _a, b, ctx, runner = _make_host_with_two_channels() - host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42") - req = ChannelRequest( - channel="responses", - operation="op", - input="hi", - session=ChannelSession(isolation_key="alice"), - response_target=ResponseTarget.channel("telegram", echo_input=True), - ) - # First scheduled invocation — echo + response both succeed. - await ctx.deliver_response(req, _make_reply("reply")) - assert len(b.pushes) == 2 # echo + response - # Simulate a retry: invoke the handler again with the same - # payload mapping (the in-process runner reuses the mapping - # across retries). After the first run ``echo_done`` was - # mutated to ``True``; the second run must skip the echo. - _, payload = runner.scheduled[0] - assert payload["echo_done"] is True - await host._handle_push_task(payload) - # Only one more push (the response) — the echo was skipped. - assert len(b.pushes) == 3 - assert str(b.pushes[2][1].result.messages[0].role) == "assistant" - - -# --------------------------------------------------------------------------- # -# Response hook + multi-modal payload + clone-on-fan-out # -# --------------------------------------------------------------------------- # - - -class TestResponseHookFanOut: - async def test_response_hook_applied_per_destination(self) -> None: - """Channels with a ``response_hook`` attribute see their hook - applied before push, with a ``ChannelResponseContext`` carrying - the destination identity, the originating request, and an - ``is_echo`` flag.""" - agent = _FakeAgent() - a = _RecordingChannel(name="responses", path="/r") - b = _RecordingChannel(name="telegram", path="/t") - - seen: list[tuple[str, str, bool]] = [] - - async def telegram_hook( - result: HostedRunResult[AgentResponse], - *, - context: Any, - **_: Any, - ) -> HostedRunResult[AgentResponse]: - seen.append((context.channel_name, context.destination_identity.native_id, context.is_echo)) - return result.replace( - result=AgentResponse( - messages=[Message(role="assistant", contents=[Content.from_text("[hooked] " + result.result.text)])] - ), - ) - - b.response_hook = telegram_hook # type: ignore[attr-defined] - host = AgentFrameworkHost(target=agent, channels=[a, b], durable_task_runner=_SyncTaskRunner()) - _ = host.app - assert a.context is not None - - host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42") - req = ChannelRequest( - channel="responses", - operation="op", - input="hi", - session=ChannelSession(isolation_key="alice"), - response_target=ResponseTarget.channel("telegram"), - ) - report = await a.context.deliver_response(req, _make_reply("reply")) - assert report is False - # The pushed payload reflects the hook's transform. - assert b.pushes[0][1].result.text == "[hooked] reply" - assert seen == [("telegram", "42", False)] - - async def test_response_hook_mutation_isolated_per_destination(self) -> None: - """A hook that rebinds ``result`` on its payload must NOT affect - the payload another destination sees. The host clones the - envelope before each hook invocation so a per-destination - :meth:`HostedRunResult.replace` cannot leak across destinations.""" - agent = _FakeAgent() - a = _RecordingChannel(name="responses", path="/r") - b = _RecordingChannel(name="telegram", path="/t") - c = _RecordingChannel(name="extra", path="/x") - - async def hook_that_rebinds(result: HostedRunResult[AgentResponse], **_: Any) -> HostedRunResult[AgentResponse]: - # Naughty hook: rebind ``result`` to a fresh AgentResponse. - # Host's per-destination clone via ``replace()`` makes this safe - # for sibling destinations. - return result.replace(result=AgentResponse(messages=[])) - - b.response_hook = hook_that_rebinds # type: ignore[attr-defined] - host = AgentFrameworkHost(target=agent, channels=[a, b, c], durable_task_runner=_SyncTaskRunner()) - _ = host.app - assert a.context is not None - - host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42") - host._identities["alice"]["extra"] = ChannelIdentity(channel="extra", native_id="9") - - original = _make_reply("reply") - original_result_snapshot = original.result - - req = ChannelRequest( - channel="responses", - operation="op", - input="hi", - session=ChannelSession(isolation_key="alice"), - response_target=ResponseTarget.channels(["telegram", "extra"]), - ) - report = await a.context.deliver_response(req, original) - assert report is False - # The rebind on the telegram clone must not have touched the - # original envelope, nor the extra channel's view. - assert original.result is original_result_snapshot - # ``extra`` channel saw the original-shaped payload. - extra_push = next(p for p in c.pushes) - assert extra_push[1].result.text == "reply" - - async def test_response_hook_fires_on_echo_with_is_echo_true(self) -> None: - """When ``echo_input`` is set, the channel's response_hook fires - TWICE per destination — once for the echo (is_echo=True), once - for the response (is_echo=False).""" - agent = _FakeAgent() - a = _RecordingChannel(name="responses", path="/r") - b = _RecordingChannel(name="telegram", path="/t") - - phases: list[bool] = [] - - async def telegram_hook( - result: HostedRunResult[AgentResponse], *, context: Any, **_: Any - ) -> HostedRunResult[AgentResponse]: - phases.append(context.is_echo) - return result - - b.response_hook = telegram_hook # type: ignore[attr-defined] - host = AgentFrameworkHost(target=agent, channels=[a, b], durable_task_runner=_SyncTaskRunner()) - _ = host.app - assert a.context is not None - - host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42") - req = ChannelRequest( - channel="responses", - operation="op", - input="hi", - session=ChannelSession(isolation_key="alice"), - response_target=ResponseTarget.channel("telegram", echo_input=True), - ) - await a.context.deliver_response(req, _make_reply("reply")) - assert phases == [True, False] - - # --------------------------------------------------------------------------- # # HostedRunResult — generic typed envelope # # --------------------------------------------------------------------------- # @@ -1522,7 +970,7 @@ class TestBindRequestContext: stream=True, attributes={"response_id": "resp_stream"}, ) - stream = ch.context.run_stream(req) + stream = await ch.context.run_stream(req) # As soon as run_stream returns, the binding must already be open # so any provider work that happens during iteration sees it. @@ -1574,7 +1022,7 @@ class TestBoundResponseStream: stream=True, attributes={"response_id": "resp_get_final"}, ) - stream = ch.context.run_stream(req) + stream = await ch.context.run_stream(req) # Skip iteration and go straight to ``get_final_response``; # the adapter must drain the inner stream itself and close # the binding in ``finally``. @@ -1599,7 +1047,7 @@ class TestBoundResponseStream: stream=True, attributes={"response_id": "resp_idem"}, ) - stream = ch.context.run_stream(req) + stream = await ch.context.run_stream(req) async for _u in stream: pass # Iteration's finally already closed; an explicit ``aclose`` @@ -1627,7 +1075,7 @@ class TestBoundResponseStream: stream=True, attributes={"response_id": "resp_abandon"}, ) - stream = ch.context.run_stream(req) + stream = await ch.context.run_stream(req) await stream.aclose() # type: ignore[attr-defined] # Binding released without iterating. @@ -1655,7 +1103,7 @@ class TestBoundResponseStream: stream=True, attributes={"response_id": "resp_getattr"}, ) - stream = ch.context.run_stream(req) + stream = await ch.context.run_stream(req) # ``with_result_hook`` is a real method on ``ResponseStream``; # if forwarding broke this would AttributeError. try: @@ -1682,7 +1130,7 @@ class TestBoundResponseStream: stream=True, attributes={"response_id": "resp_await"}, ) - stream = ch.context.run_stream(req) + stream = await ch.context.run_stream(req) final = await stream # exercises __await__ assert final.text == "chunk-1chunk-2" names = [n for n, _ in prov.events] diff --git a/python/packages/hosting/tests/test_host_disk.py b/python/packages/hosting/tests/test_host_disk.py index 5ed27b67aa..abcbf7397b 100644 --- a/python/packages/hosting/tests/test_host_disk.py +++ b/python/packages/hosting/tests/test_host_disk.py @@ -1,32 +1,19 @@ # Copyright (c) Microsoft. All rights reserved. -"""Tests for ``state_dir`` wired through :class:`AgentFrameworkHost`.""" +"""Tests for narrowed ``state_dir`` support in :class:`AgentFrameworkHost`.""" from __future__ import annotations -import asyncio from pathlib import Path from typing import Any import pytest -from agent_framework_hosting import ( - AgentFrameworkHost, - ChannelContext, - ChannelContribution, - ChannelIdentity, - LinkChallenge, -) +from agent_framework_hosting import AgentFrameworkHost, ChannelContext, ChannelContribution -# Skip the whole module when the optional disk extra isn't installed. pytest.importorskip("diskcache") -# --------------------------------------------------------------------------- # -# Test helpers # -# --------------------------------------------------------------------------- # - - class _AgentStub: """Bare-minimum SupportsAgentRun stub for host construction.""" @@ -42,65 +29,22 @@ class _ChannelStub: return ChannelContribution() -class _NonConfigurableLinker: - async def resolve(self, _identity: ChannelIdentity) -> LinkChallenge: - return LinkChallenge("link") - - -class _ConfigurableLinker: - def __init__(self) -> None: - self.configured_path: Path | None = None - - def configure_link_store_path(self, path: str | Path) -> None: - self.configured_path = Path(path) - - async def resolve(self, _identity: ChannelIdentity) -> LinkChallenge: - return LinkChallenge("link") - - def _close_host_disk(host: AgentFrameworkHost) -> None: - """Mirror the lifespan shutdown ordering for tests that simulate restart. - - The real shutdown order is ``runner.shutdown()`` → ``sessions_store.close()``; - both release their advisory file locks so a second host can take ownership. - """ - runner = host._durable_task_runner - try: - asyncio.get_event_loop().run_until_complete(runner.shutdown(timeout=1.0)) - except RuntimeError: - # No running loop; spin up a throw-away one. - asyncio.run(runner.shutdown(timeout=1.0)) + """Release any session-alias store held by ``host``.""" if host._sessions_store is not None: host._sessions_store.close() -# --------------------------------------------------------------------------- # -# state_dir=None preserves the in-memory contract # -# --------------------------------------------------------------------------- # - - -def test_state_dir_none_keeps_plain_dicts(tmp_path: Path) -> None: - """No store, no sessions persistence, no files written.""" +def test_state_dir_none_keeps_plain_alias_dict(tmp_path: Path) -> None: + """No store, no alias persistence, no files written.""" host = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()]) - try: - assert host._sessions_store is None - assert isinstance(host._session_aliases, dict) - assert isinstance(host._active, dict) - assert isinstance(host._identities, dict) - # No accidental disk writes anywhere under tmp_path. - assert list(tmp_path.iterdir()) == [] - finally: - # Nothing to close. - pass + assert host._sessions_store is None + assert isinstance(host._session_aliases, dict) + assert list(tmp_path.iterdir()) == [] -# --------------------------------------------------------------------------- # -# Single string state_dir creates default subfolders # -# --------------------------------------------------------------------------- # - - -def test_string_state_dir_creates_subfolders(tmp_path: Path) -> None: - """Passing a single path expands to ``runner/`` and ``sessions/``.""" +def test_string_state_dir_creates_sessions_subfolder_only(tmp_path: Path) -> None: + """Passing a single path expands to ``sessions/`` plus lazy checkpoint path.""" host = AgentFrameworkHost( target=_AgentStub(), channels=[_ChannelStub()], @@ -108,100 +52,42 @@ def test_string_state_dir_creates_subfolders(tmp_path: Path) -> None: ) try: assert host._sessions_store is not None - assert (tmp_path / "runner").is_dir() assert (tmp_path / "sessions").is_dir() + assert not (tmp_path / "runner").exists() + assert not (tmp_path / "links").exists() + # Checkpoint path is derived but not created for agent targets. + assert not (tmp_path / "checkpoints").exists() finally: _close_host_disk(host) -# --------------------------------------------------------------------------- # -# Per-component override via HostStatePaths-shaped dict # -# --------------------------------------------------------------------------- # - - -def test_per_component_paths(tmp_path: Path) -> None: - """Dict form lets the caller route components to different roots.""" - runner_dir = tmp_path / "tasks" +def test_per_component_session_path(tmp_path: Path) -> None: + """Dict form lets callers route session aliases to a specific root.""" sessions_dir = tmp_path / "state" host = AgentFrameworkHost( target=_AgentStub(), channels=[_ChannelStub()], - state_dir={"runner": runner_dir, "sessions": sessions_dir}, + state_dir={"sessions": sessions_dir}, ) try: - assert runner_dir.is_dir() assert sessions_dir.is_dir() - # Default subfolders should NOT exist when the caller provides - # explicit overrides. - assert not (tmp_path / "runner").is_dir() or runner_dir == (tmp_path / "runner") - assert not (tmp_path / "sessions").is_dir() or sessions_dir == (tmp_path / "sessions") + assert host._sessions_store is not None + assert host._checkpoint_location is None finally: _close_host_disk(host) -def test_unknown_component_key_raises(tmp_path: Path) -> None: - """Misspelled keys should fail loudly so the user catches typos.""" +@pytest.mark.parametrize("key", ["runner", "links", "active", "identities"]) +def test_removed_state_dir_component_keys_raise(tmp_path: Path, key: str) -> None: + """Obsolete follow-up components should fail loudly instead of becoming no-ops.""" with pytest.raises(ValueError, match="unknown"): AgentFrameworkHost( target=_AgentStub(), channels=[_ChannelStub()], - state_dir={"runnerr": tmp_path / "x"}, # type: ignore[dict-item] + state_dir={key: tmp_path / key}, # type: ignore[dict-item] ) -def test_links_state_path_configures_compatible_identity_linker(tmp_path: Path) -> None: - """``state_dir['links']`` is offered to linkers that accept host-owned persistence.""" - linker = _ConfigurableLinker() - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub()], - identity_linker=linker, - state_dir=tmp_path, - ) - try: - assert linker.configured_path == tmp_path / "links" - finally: - _close_host_disk(host) - - -def test_explicit_links_state_path_without_linker_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: - """Explicit ``links`` path with no linker is almost certainly dead config.""" - with caplog.at_level("WARNING", logger="agent_framework.hosting"): - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub()], - state_dir={"links": tmp_path / "links"}, - ) - try: - assert any( - "state_dir['links']" in rec.message and "no identity_linker" in rec.message for rec in caplog.records - ) - finally: - _close_host_disk(host) - - -def test_links_state_path_with_nonconfigurable_linker_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: - """A linker that owns its persistence directly gets a clear warning.""" - with caplog.at_level("WARNING", logger="agent_framework.hosting"): - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub()], - identity_linker=_NonConfigurableLinker(), - state_dir={"links": tmp_path / "links"}, - ) - try: - assert any( - "state_dir['links']" in rec.message and "SupportsLinkStorePath" in rec.message for rec in caplog.records - ) - finally: - _close_host_disk(host) - - -# --------------------------------------------------------------------------- # -# Session bookkeeping survives a host restart # -# --------------------------------------------------------------------------- # - - def test_session_aliases_survive_restart(tmp_path: Path) -> None: """Aliases written on host #1 must be visible to host #2.""" state_dir = tmp_path / "state" @@ -219,84 +105,6 @@ def test_session_aliases_survive_restart(tmp_path: Path) -> None: _close_host_disk(host2) -def test_active_channel_survives_restart(tmp_path: Path) -> None: - """``_active`` must round-trip through the store.""" - state_dir = tmp_path / "state" - - host1 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir) - host1._active["user-1"] = "telegram" - host1._active["user-2"] = "responses" - _close_host_disk(host1) - - host2 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir) - try: - assert host2._active["user-1"] == "telegram" - assert host2._active["user-2"] == "responses" - finally: - _close_host_disk(host2) - - -def test_identities_nested_mutation_survives_restart(tmp_path: Path) -> None: - """Setting ``self._identities[ik][channel] = identity`` must persist. - - This exercises the proxy-inner-dict ``__setitem__`` write-through path, - not just the outer-key replacement path. - """ - state_dir = tmp_path / "state" - - host1 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir) - ident_tg = ChannelIdentity("telegram", "tg-123", {"username": "alice"}) - ident_rsp = ChannelIdentity("responses", "rsp-456") - # Mirrors the host-internal path in ``_register_identity``. - host1._identities.setdefault("user-1", {})["telegram"] = ident_tg - host1._identities.setdefault("user-1", {})["responses"] = ident_rsp - host1._identities.setdefault("user-2", {})["telegram"] = ChannelIdentity("telegram", "tg-789") - _close_host_disk(host1) - - host2 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir) - try: - u1 = host2._identities["user-1"] - assert set(u1.keys()) == {"telegram", "responses"} - assert u1["telegram"].native_id == "tg-123" - assert u1["telegram"].attributes["username"] == "alice" - assert u1["responses"].native_id == "rsp-456" - assert host2._identities["user-2"]["telegram"].native_id == "tg-789" - finally: - _close_host_disk(host2) - - -# --------------------------------------------------------------------------- # -# Explicit durable_task_runner + state_dir['runner'] warns # -# --------------------------------------------------------------------------- # - - -def test_explicit_runner_with_runner_state_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: - """Caller-owned runner + state_dir['runner'] → ignore + warn.""" - from agent_framework_hosting import InProcessTaskRunner - - user_runner = InProcessTaskRunner() - try: - with caplog.at_level("WARNING"): - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub()], - durable_task_runner=user_runner, - allow_in_process_runner=True, - state_dir={"runner": tmp_path / "runner"}, - ) - assert any("state_dir['runner']" in rec.message for rec in caplog.records) - # Sessions store wasn't requested, so still None. - assert host._sessions_store is None - finally: - # user_runner has no disk state, so nothing else to clean up. - pass - - -# --------------------------------------------------------------------------- # -# Workflow checkpoint integration # -# --------------------------------------------------------------------------- # - - def _build_simple_workflow() -> Any: """Build a no-op workflow for checkpoint-wiring tests.""" from tests._workflow_fixtures import build_upper_workflow @@ -313,7 +121,6 @@ def test_single_path_state_dir_wires_workflow_checkpoints(tmp_path: Path) -> Non state_dir=tmp_path, ) try: - # Checkpoint location is derived from the single state_dir. assert host._checkpoint_location == tmp_path / "checkpoints" finally: _close_host_disk(host) @@ -330,7 +137,6 @@ def test_mapping_state_dir_checkpoints_key_wires_workflow_checkpoints(tmp_path: ) try: assert host._checkpoint_location == ckpt_dir - # No diskcache components were requested. assert host._sessions_store is None finally: _close_host_disk(host) @@ -342,9 +148,7 @@ def test_mapping_state_dir_omits_checkpoints_for_workflow(tmp_path: Path) -> Non host = AgentFrameworkHost( target=workflow, channels=[_ChannelStub()], - # No 'checkpoints' key → no checkpoint persistence even though - # other components are persisted. - state_dir={"runner": tmp_path / "r", "sessions": tmp_path / "s"}, + state_dir={"sessions": tmp_path / "s"}, ) try: assert host._checkpoint_location is None @@ -381,7 +185,6 @@ def test_state_dir_checkpoints_for_agent_target_silent_for_single_path(tmp_path: ) try: assert host._checkpoint_location is None - # ``checkpoints/`` subfolder is not eagerly created (no consumer). assert not (tmp_path / "checkpoints").exists() finally: _close_host_disk(host) @@ -390,7 +193,7 @@ def test_state_dir_checkpoints_for_agent_target_silent_for_single_path(tmp_path: def test_state_dir_checkpoints_for_agent_target_warns_when_explicit( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """Mapping form with ``checkpoints`` + agent target → warn (dead config).""" + """Mapping form with ``checkpoints`` + agent target → warn.""" with caplog.at_level("WARNING", logger="agent_framework.hosting"): host = AgentFrameworkHost( target=_AgentStub(), diff --git a/python/packages/hosting/tests/test_isolation.py b/python/packages/hosting/tests/test_isolation.py index 4dc029f07b..84fcd35e29 100644 --- a/python/packages/hosting/tests/test_isolation.py +++ b/python/packages/hosting/tests/test_isolation.py @@ -16,6 +16,7 @@ from __future__ import annotations import asyncio +import pytest from starlette.requests import Request from starlette.responses import JSONResponse from starlette.routing import BaseRoute, Route @@ -168,7 +169,22 @@ def _make_host_with_probe() -> tuple[object, _IsolationProbeChannel]: class TestIsolationMiddlewareEndToEnd: - def test_both_headers_lifted_into_contextvar(self) -> None: + def test_headers_ignored_outside_foundry_environment(self) -> None: + host, probe = _make_host_with_probe() + with TestClient(host.app) as client: # type: ignore[attr-defined] + r = client.get( + "/probe", + headers={ + ISOLATION_HEADER_USER: "alice-uid", + ISOLATION_HEADER_CHAT: "general-cid", + }, + ) + assert r.status_code == 200 + assert r.json() == {"user": None, "chat": None, "_present": False} + assert probe.captured == [None] + + def test_both_headers_lifted_into_contextvar(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") host, probe = _make_host_with_probe() with TestClient(host.app) as client: # type: ignore[attr-defined] r = client.get( @@ -186,15 +202,17 @@ class TestIsolationMiddlewareEndToEnd: assert captured.user_key == "alice-uid" assert captured.chat_key == "general-cid" - def test_only_user_header_lifted(self) -> None: + def test_only_user_header_lifted(self, monkeypatch: pytest.MonkeyPatch) -> None: """One-header-only branch: the middleware still binds (chat=None).""" + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") host, probe = _make_host_with_probe() with TestClient(host.app) as client: # type: ignore[attr-defined] r = client.get("/probe", headers={ISOLATION_HEADER_USER: "alice-uid"}) assert r.status_code == 200 assert r.json() == {"user": "alice-uid", "chat": None} - def test_only_chat_header_lifted(self) -> None: + def test_only_chat_header_lifted(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") host, probe = _make_host_with_probe() with TestClient(host.app) as client: # type: ignore[attr-defined] r = client.get("/probe", headers={ISOLATION_HEADER_CHAT: "general-cid"}) @@ -213,9 +231,10 @@ class TestIsolationMiddlewareEndToEnd: assert r.json() == {"user": None, "chat": None, "_present": False} assert probe.captured == [None] - def test_empty_header_value_treated_as_absent(self) -> None: + def test_empty_header_value_treated_as_absent(self, monkeypatch: pytest.MonkeyPatch) -> None: """A header that's present but empty must not bind an empty key — ``IsolationContext`` rejects empty strings on the read side.""" + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") host, probe = _make_host_with_probe() with TestClient(host.app) as client: # type: ignore[attr-defined] r = client.get( @@ -229,10 +248,11 @@ class TestIsolationMiddlewareEndToEnd: # Empty user header decodes to None; chat key stays bound. assert r.json() == {"user": None, "chat": "general-cid"} - def test_contextvar_resets_after_request(self) -> None: + def test_contextvar_resets_after_request(self, monkeypatch: pytest.MonkeyPatch) -> None: """The middleware must call ``reset_current_isolation_keys`` in a ``finally`` so per-request state never leaks across requests or back into the calling thread's context.""" + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") host, probe = _make_host_with_probe() with TestClient(host.app) as client: # type: ignore[attr-defined] r1 = client.get("/probe", headers={ISOLATION_HEADER_USER: "alice-uid"}) @@ -245,9 +265,10 @@ class TestIsolationMiddlewareEndToEnd: r2 = client.get("/probe") assert r2.json() == {"user": None, "chat": None, "_present": False} - def test_concurrent_requests_get_isolated_contextvars(self) -> None: + def test_concurrent_requests_get_isolated_contextvars(self, monkeypatch: pytest.MonkeyPatch) -> None: """Different requests run in different async contexts; binding from request A must NOT leak into a concurrent request B.""" + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") host, probe = _make_host_with_probe() async def _drive() -> None: diff --git a/python/packages/hosting/tests/test_runner.py b/python/packages/hosting/tests/test_runner.py deleted file mode 100644 index bee7e097b0..0000000000 --- a/python/packages/hosting/tests/test_runner.py +++ /dev/null @@ -1,333 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Tests for :class:`InProcessTaskRunner` and runtime-mode auto-detection.""" - -from __future__ import annotations - -import asyncio -from collections.abc import Mapping -from typing import Any - -import pytest - -from agent_framework_hosting import ( - AgentFrameworkHost, - ChannelContext, - ChannelContribution, - DurableTaskPayloadMode, - InProcessTaskRunner, - RetryPolicy, - TaskHandle, -) -from agent_framework_hosting._host import _detect_runtime_mode - -# --------------------------------------------------------------------------- # -# Test helpers # -# --------------------------------------------------------------------------- # - - -class _AgentStub: - """Bare-minimum SupportsAgentRun stub for host construction.""" - - async def run(self, *_args: Any, **_kwargs: Any) -> None: # pragma: no cover - unused - return None - - -class _ChannelStub: - name = "stub" - path = "/stub" - - def contribute(self, _context: ChannelContext) -> ChannelContribution: - return ChannelContribution() - - -# --------------------------------------------------------------------------- # -# Runtime-mode auto-detection # -# --------------------------------------------------------------------------- # - - -class TestRuntimeModeDetection: - """``_detect_runtime_mode`` is pure: tests pass a synthetic env so - they never depend on the test runner's environment. Auto-detected - mode + matched marker drive the per-host startup banner so operators - can confirm the host is running in the expected shape.""" - - def test_no_markers_defaults_to_long_running(self) -> None: - mode, marker = _detect_runtime_mode(env={}) - assert mode == "long_running" - assert marker is None - - def test_foundry_marker_selects_ephemeral(self) -> None: - mode, marker = _detect_runtime_mode(env={"FOUNDRY_HOSTING_ENVIRONMENT": "production"}) - assert mode == "ephemeral" - assert marker == "FOUNDRY_HOSTING_ENVIRONMENT" - - def test_azure_functions_marker_selects_ephemeral(self) -> None: - mode, marker = _detect_runtime_mode(env={"AZURE_FUNCTIONS_ENVIRONMENT": "Development"}) - assert mode == "ephemeral" - assert marker == "AZURE_FUNCTIONS_ENVIRONMENT" - - def test_lambda_marker_selects_ephemeral(self) -> None: - mode, marker = _detect_runtime_mode(env={"AWS_LAMBDA_FUNCTION_NAME": "my-fn"}) - assert mode == "ephemeral" - assert marker == "AWS_LAMBDA_FUNCTION_NAME" - - def test_empty_marker_value_ignored(self) -> None: - # Empty-string env var should not count as "set" — Foundry's - # template uses unset-or-empty as "not deployed". - mode, marker = _detect_runtime_mode(env={"FOUNDRY_HOSTING_ENVIRONMENT": ""}) - assert mode == "long_running" - assert marker is None - - -class TestHostRuntimeMode: - """``runtime_mode`` ctor argument overrides auto-detect; ``None`` - triggers auto-detect. The detected mode is exposed via the - ``runtime_mode`` property for operator inspection (and is logged at - startup via ``_log_startup``).""" - - def test_explicit_long_running(self) -> None: - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub()], - runtime_mode="long_running", - ) - assert host.runtime_mode == "long_running" - - def test_explicit_ephemeral_with_default_runner_raises(self) -> None: - # Default runner is in-process and not durable. Ephemeral - # deployments would silently lose pushes on scale-to-zero, so - # the host refuses the combination at construction unless the - # operator opts in explicitly via ``allow_in_process_runner``. - with pytest.raises(RuntimeError, match="ephemeral"): - AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub()], - runtime_mode="ephemeral", - ) - - def test_explicit_ephemeral_with_in_process_opt_in_warns(self, caplog: pytest.LogCaptureFixture) -> None: - # The opt-in escape hatch keeps the old warn-and-proceed - # behaviour for local-dev / smoke-test scenarios that genuinely - # want ephemeral runtime semantics without a real durable - # backend. - with caplog.at_level("WARNING", logger="agent_framework.hosting"): - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub()], - runtime_mode="ephemeral", - allow_in_process_runner=True, - ) - assert host.runtime_mode == "ephemeral" - assert any("ephemeral" in r.getMessage() and "InProcessTaskRunner" in r.getMessage() for r in caplog.records) - - def test_explicit_ephemeral_with_supplied_runner_does_not_warn(self, caplog: pytest.LogCaptureFixture) -> None: - runner = InProcessTaskRunner() - with caplog.at_level("WARNING", logger="agent_framework.hosting"): - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub()], - runtime_mode="ephemeral", - durable_task_runner=runner, - ) - # No warning — operator opted into a specific runner. - assert host.runtime_mode == "ephemeral" - assert host.durable_task_runner is runner - assert not any("ephemeral" in r.getMessage() for r in caplog.records) - - def test_auto_detect_ephemeral_raises_without_opt_in(self, monkeypatch: pytest.MonkeyPatch) -> None: - # Auto-detected ephemeral flows through the same strict gate. - monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "production") - with pytest.raises(RuntimeError, match="ephemeral"): - AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()]) - - def test_auto_detect_ephemeral_with_opt_in_proceeds(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "production") - host = AgentFrameworkHost( - target=_AgentStub(), - channels=[_ChannelStub()], - allow_in_process_runner=True, - ) - assert host.runtime_mode == "ephemeral" - - def test_default_runner_is_in_process_task_runner(self) -> None: - host = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()]) - assert isinstance(host.durable_task_runner, InProcessTaskRunner) - - -# --------------------------------------------------------------------------- # -# InProcessTaskRunner # -# --------------------------------------------------------------------------- # - - -class TestInProcessTaskRunner: - async def test_schedule_runs_handler_and_records_succeeded(self) -> None: - runner = InProcessTaskRunner() - seen: list[Mapping[str, Any]] = [] - - async def handler(payload: Mapping[str, Any]) -> None: - seen.append(payload) - - runner.register("ping", handler) - handle = await runner.schedule("ping", {"x": 1}) - # ``schedule`` returns immediately; the task runs on the loop. - # Drain explicitly via ``shutdown`` to flush in-flight work, - # then assert. - await _drain(runner, handle) - assert seen == [{"x": 1}] - assert await runner.get(handle) == "succeeded" - - async def test_unknown_handler_raises_keyerror(self) -> None: - runner = InProcessTaskRunner() - with pytest.raises(KeyError): - await runner.schedule("missing", {}) - - async def test_register_after_start_raises(self) -> None: - runner = InProcessTaskRunner() - - async def noop(_p: Mapping[str, Any]) -> None: - return None - - runner.register("x", noop) - handle = await runner.schedule("x", {}) - await _drain(runner, handle) - # Re-registering after the runner has started scheduling is - # rejected so in-flight tasks can't have their handler swapped - # out from under them. - with pytest.raises(RuntimeError, match="register"): - runner.register("y", noop) - - async def test_handler_retried_then_succeeds(self) -> None: - runner = InProcessTaskRunner() - attempts = {"n": 0} - - async def flaky(_p: Mapping[str, Any]) -> None: - attempts["n"] += 1 - if attempts["n"] < 3: - raise RuntimeError(f"attempt {attempts['n']}") - - runner.register("flaky", flaky) - # Tight retry policy so the test doesn't sleep visibly. - policy = RetryPolicy(max_attempts=5, initial_backoff_seconds=0.001, max_backoff_seconds=0.005) - handle = await runner.schedule("flaky", {}, retry_policy=policy) - await _drain(runner, handle) - assert attempts["n"] == 3 - assert await runner.get(handle) == "succeeded" - - async def test_handler_failure_records_failed_after_max_attempts(self) -> None: - runner = InProcessTaskRunner() - - async def always_fails(_p: Mapping[str, Any]) -> None: - raise RuntimeError("nope") - - runner.register("doomed", always_fails) - policy = RetryPolicy(max_attempts=2, initial_backoff_seconds=0.001) - handle = await runner.schedule("doomed", {}, retry_policy=policy) - await _drain(runner, handle) - assert await runner.get(handle) == "failed" - - async def test_shutdown_cancels_pending_tasks(self) -> None: - runner = InProcessTaskRunner() - started = asyncio.Event() - cancelled = asyncio.Event() - - async def long_running(_p: Mapping[str, Any]) -> None: - started.set() - try: - # Sleep longer than the test wait so shutdown can cancel. - await asyncio.sleep(5) - except asyncio.CancelledError: - cancelled.set() - raise - - runner.register("long", long_running) - handle = await runner.schedule("long", {}) - await asyncio.wait_for(started.wait(), timeout=1.0) - await runner.shutdown(timeout=1.0) - assert cancelled.is_set() - assert await runner.get(handle) == "cancelled" - - async def test_shutdown_grace_drain_does_not_cancel_finishing_tasks(self) -> None: - """A short-lived task that completes within the grace window - must NOT receive a cancellation. The grace-period drain is the - graceful-shutdown contract — channels with goodbye-message - flushes rely on it.""" - runner = InProcessTaskRunner() - cancelled = asyncio.Event() - completed = asyncio.Event() - - async def quick(_p: Mapping[str, Any]) -> None: - try: - await asyncio.sleep(0.05) - except asyncio.CancelledError: - cancelled.set() - raise - completed.set() - - runner.register("quick", quick) - handle = await runner.schedule("quick", {}) - # Shutdown with a generous grace window relative to the task duration. - await runner.shutdown(timeout=1.0) - assert completed.is_set() - assert not cancelled.is_set() - assert await runner.get(handle) == "succeeded" - - async def test_get_returns_none_for_unknown_handle(self) -> None: - runner = InProcessTaskRunner() - handle = TaskHandle(task_id="never-scheduled", name="x") - assert await runner.get(handle) is None - - async def test_terminal_cache_evicts_oldest(self) -> None: - # Cache size of 2: drain three tasks in sequence, the first - # should age out by the time the third's terminal lands. - runner = InProcessTaskRunner(terminal_cache_size=2) - - async def noop(_p: Mapping[str, Any]) -> None: - return None - - runner.register("noop", noop) - h1 = await runner.schedule("noop", {}) - await _drain(runner, h1) - h2 = await runner.schedule("noop", {}) - await _drain(runner, h2) - h3 = await runner.schedule("noop", {}) - await _drain(runner, h3) - # Oldest handle's terminal status should be evicted by now. - assert await runner.get(h1) is None - assert await runner.get(h2) == "succeeded" - assert await runner.get(h3) == "succeeded" - - async def test_shutdown_is_safe_when_no_tasks_pending(self) -> None: - runner = InProcessTaskRunner() - # No-op shouldn't raise. - await runner.shutdown() - - def test_payload_mode_defaults_to_object(self) -> None: - # The in-process runner passes live Python references through - # the payload — the host wires this attribute into its codec - # validator at startup. Durable adapters that persist payloads - # must override this to ``JSON`` so the host refuses to ship - # un-serialisable references. - runner = InProcessTaskRunner() - assert runner.payload_mode == DurableTaskPayloadMode.OBJECT - - -# --------------------------------------------------------------------------- # -# Helpers # -# --------------------------------------------------------------------------- # - - -async def _drain(runner: InProcessTaskRunner, handle: TaskHandle, *, timeout: float = 1.0) -> None: - """Wait for ``handle`` to reach a terminal state. - - Polls ``get`` rather than reaching into runner internals so we exercise the - public surface from the test side too. - """ - deadline = asyncio.get_event_loop().time() + timeout - while True: - status = await runner.get(handle) - if status in ("succeeded", "failed", "cancelled"): - return - if asyncio.get_event_loop().time() > deadline: - raise AssertionError(f"task {handle.task_id} did not reach terminal in {timeout}s; status={status}") - await asyncio.sleep(0.01) diff --git a/python/packages/hosting/tests/test_runner_disk.py b/python/packages/hosting/tests/test_runner_disk.py deleted file mode 100644 index db566e7e3c..0000000000 --- a/python/packages/hosting/tests/test_runner_disk.py +++ /dev/null @@ -1,278 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Tests for :class:`InProcessTaskRunner` disk persistence (``state_dir``).""" - -from __future__ import annotations - -import asyncio -from collections.abc import Mapping -from pathlib import Path -from typing import Any - -import pytest - -from agent_framework_hosting import ( - InProcessTaskRunner, - PushPayloadNotPicklable, - RetryPolicy, -) - -# Skip the whole module if the optional diskcache dependency isn't installed. -pytest.importorskip("diskcache") - - -# --------------------------------------------------------------------------- # -# state_dir=None preserves today's purely in-memory contract # -# --------------------------------------------------------------------------- # - - -async def test_state_dir_none_is_pure_memory(tmp_path: Path) -> None: - """No directory creation / no lock file when state_dir is omitted.""" - runner = InProcessTaskRunner() - calls: list[Mapping[str, Any]] = [] - - async def handler(payload: Mapping[str, Any]) -> None: - calls.append(payload) - - runner.register("echo", handler) - handle = await runner.schedule("echo", {"k": "v"}) - - # Wait for completion. - for _ in range(50): - if (await runner.get(handle)) == "succeeded": - break - await asyncio.sleep(0.01) - assert calls == [{"k": "v"}] - assert await runner.get(handle) == "succeeded" - # Confirm we didn't accidentally write to disk. - assert not (tmp_path / ".lock").exists() - - await runner.shutdown() - - -# --------------------------------------------------------------------------- # -# Lock contention — two runners on the same dir refuse to coexist # -# --------------------------------------------------------------------------- # - - -async def test_two_runners_one_state_dir_raise(tmp_path: Path) -> None: - """Second runner construction must fail loudly, not silently corrupt.""" - state_dir = tmp_path / "runner" - first = InProcessTaskRunner(state_dir=state_dir) - try: - with pytest.raises(RuntimeError, match="state lock"): - InProcessTaskRunner(state_dir=state_dir) - finally: - await first.shutdown() - - -# --------------------------------------------------------------------------- # -# Pickle failure raises eagerly, never silently downgrades # -# --------------------------------------------------------------------------- # - - -async def test_unpickleable_payload_raises(tmp_path: Path) -> None: - """Schedule must refuse payloads that can't survive a restart.""" - runner = InProcessTaskRunner(state_dir=tmp_path / "runner") - - async def handler(_: Mapping[str, Any]) -> None: ... - - runner.register("echo", handler) - # Local lambdas / closures are the canonical unpicklable values. - with pytest.raises(PushPayloadNotPicklable): - await runner.schedule("echo", {"callback": lambda: None}) - await runner.shutdown() - - -# --------------------------------------------------------------------------- # -# Resume — pending records replay on next process # -# --------------------------------------------------------------------------- # - - -async def test_pending_record_replays_on_resume(tmp_path: Path) -> None: - """Simulate a crash: first runner schedules but never starts running.""" - state_dir = tmp_path / "runner" - - # Process 1 — schedule a task, then "die" before the asyncio loop runs it. - runner1 = InProcessTaskRunner(state_dir=state_dir) - blocked = asyncio.Event() - - async def slow(_: Mapping[str, Any]) -> None: - # Sleep so the task is observably still in flight when we shutdown. - await blocked.wait() - - runner1.register("slow", slow) - handle = await runner1.schedule("slow", {"work": 1}) - # Force a hard shutdown — leaves the in-flight task in 'pending' on disk. - await runner1.shutdown(timeout=0.1) - - # Process 2 — fresh runner against same state_dir, register the handler, - # call resume. We expect the persisted record to be re-scheduled. - runner2 = InProcessTaskRunner(state_dir=state_dir) - seen: list[Mapping[str, Any]] = [] - - async def slow_resumed(payload: Mapping[str, Any]) -> None: - seen.append(dict(payload)) - - runner2.register("slow", slow_resumed) - replayed = await runner2.resume() - assert replayed == 1 - - # Give the resumed task time to run. - for _ in range(50): - if seen: - break - await asyncio.sleep(0.01) - assert seen == [{"work": 1}] - # Status is observable via the original handle. - assert await runner2.get(handle) == "succeeded" - - await runner2.shutdown() - - -# --------------------------------------------------------------------------- # -# echo_done cursor survives restart # -# --------------------------------------------------------------------------- # - - -async def test_payload_mutation_survives_restart(tmp_path: Path) -> None: - """Handler-side payload mutations (echo_done) round-trip through disk.""" - state_dir = tmp_path / "runner" - runner1 = InProcessTaskRunner(state_dir=state_dir) - - # Handler sets echo_done and then blocks forever (simulating mid-flight crash). - handler_progress = asyncio.Event() - - async def half_done(payload: Mapping[str, Any]) -> None: - # Mutate the payload to mark first phase complete. - payload["echo_done"] = True # type: ignore[index] - handler_progress.set() - # Sleep indefinitely so the asyncio task is still running at shutdown. - await asyncio.Event().wait() - - runner1.register("two_phase", half_done) - handle = await runner1.schedule("two_phase", {"echo_done": False, "k": "v"}) - await handler_progress.wait() - await runner1.shutdown(timeout=0.1) - - # Process 2 — replay; the handler now sees echo_done=True from disk. - runner2 = InProcessTaskRunner(state_dir=state_dir) - observed: list[bool] = [] - - async def two_phase_resumed(payload: Mapping[str, Any]) -> None: - observed.append(bool(payload.get("echo_done"))) - - runner2.register("two_phase", two_phase_resumed) - await runner2.resume() - - for _ in range(50): - if observed: - break - await asyncio.sleep(0.01) - assert observed == [True] - # And the resumed task ran to completion. - assert await runner2.get(handle) == "succeeded" - - await runner2.shutdown() - - -# --------------------------------------------------------------------------- # -# Resume gracefully handles missing handler / corrupt entries # -# --------------------------------------------------------------------------- # - - -async def test_resume_with_missing_handler_marks_failed(tmp_path: Path) -> None: - """A persisted record whose handler is no longer registered is marked failed.""" - state_dir = tmp_path / "runner" - - runner1 = InProcessTaskRunner(state_dir=state_dir) - - async def will_be_removed(_: Mapping[str, Any]) -> None: - await asyncio.Event().wait() - - runner1.register("ghost", will_be_removed) - handle = await runner1.schedule("ghost", {}) - await runner1.shutdown(timeout=0.1) - - # Process 2 — never registers "ghost". - runner2 = InProcessTaskRunner(state_dir=state_dir) - replayed = await runner2.resume() - assert replayed == 0 - # The record is moved to terminal 'failed'. - assert await runner2.get(handle) == "failed" - await runner2.shutdown() - - -async def test_resume_quarantines_corrupt_entries(tmp_path: Path) -> None: - """A non-dict on-disk entry must be quarantined, not crash resume.""" - import diskcache # noqa: PLC0415 - lazy import to keep module-import cheap - - state_dir = tmp_path / "runner" - state_dir.mkdir(parents=True, exist_ok=True) - # Pre-populate the cache with a junk entry. - cache = diskcache.Cache(str(state_dir)) - cache.set("bad-task-id", "this is not a dict") - cache.close() - - runner = InProcessTaskRunner(state_dir=state_dir) - # resume() must not raise even with a corrupt entry on disk. - replayed = await runner.resume() - assert replayed == 0 - await runner.shutdown() - - # The corrupt entry should have been removed. - cache2 = diskcache.Cache(str(state_dir)) - assert "bad-task-id" not in cache2 - cache2.close() - - -# --------------------------------------------------------------------------- # -# Retry attempt counter persists across resume # -# --------------------------------------------------------------------------- # - - -async def test_attempt_counter_persists_across_resume(tmp_path: Path) -> None: - """A handler that crashes mid-attempt resumes with the consumed budget.""" - state_dir = tmp_path / "runner" - policy = RetryPolicy(max_attempts=3, initial_backoff_seconds=0.01, backoff_multiplier=1.0) - - # Process 1 — schedule, fail once, shutdown before retry settles. - runner1 = InProcessTaskRunner(state_dir=state_dir, default_retry_policy=policy) - attempts_seen_p1 = 0 - - async def flaky(_: Mapping[str, Any]) -> None: - nonlocal attempts_seen_p1 - attempts_seen_p1 += 1 - raise RuntimeError("boom-1") - - runner1.register("flaky", flaky) - handle = await runner1.schedule("flaky", {}) - # Let it attempt twice (waste 2 of 3 budgeted retries), then crash-shutdown. - for _ in range(50): - if attempts_seen_p1 >= 2: - break - await asyncio.sleep(0.01) - await runner1.shutdown(timeout=0.05) - - # Process 2 — resume; only 1 attempt left in the budget. Confirm we don't - # re-grant the full retry budget. - runner2 = InProcessTaskRunner(state_dir=state_dir, default_retry_policy=policy) - attempts_seen_p2 = 0 - - async def flaky_resumed(_: Mapping[str, Any]) -> None: - nonlocal attempts_seen_p2 - attempts_seen_p2 += 1 - raise RuntimeError("boom-2") - - runner2.register("flaky", flaky_resumed) - await runner2.resume() - # Wait for the resumed task to consume its remaining attempts and fail terminally. - for _ in range(100): - if (await runner2.get(handle)) == "failed": - break - await asyncio.sleep(0.01) - assert await runner2.get(handle) == "failed" - # Original consumed 2 attempts; we should have allowed at most max_attempts-2=1 - # more in process 2. - assert attempts_seen_p2 <= 1 - await runner2.shutdown() diff --git a/python/packages/hosting/tests/test_types.py b/python/packages/hosting/tests/test_types.py index 3253c50905..2c77cbee6b 100644 --- a/python/packages/hosting/tests/test_types.py +++ b/python/packages/hosting/tests/test_types.py @@ -4,63 +4,13 @@ from __future__ import annotations -from typing import Any - from agent_framework_hosting import ( - ChannelContribution, ChannelIdentity, ChannelRequest, - ChannelResponseContext, ChannelSession, - DurableTaskPayloadMode, - HostedRunResult, - ResponseTarget, - ResponseTargetKind, - apply_channel_response_hook, - apply_run_hook, ) -class TestResponseTarget: - def test_originating_default_singleton(self) -> None: - target = ResponseTarget.originating # type: ignore[attr-defined] - assert target.kind is ResponseTargetKind.ORIGINATING - assert target.targets == () - - def test_active_singleton(self) -> None: - target = ResponseTarget.active # type: ignore[attr-defined] - assert target.kind is ResponseTargetKind.ACTIVE - assert target.targets == () - - def test_all_linked_singleton(self) -> None: - target = ResponseTarget.all_linked # type: ignore[attr-defined] - assert target.kind is ResponseTargetKind.ALL_LINKED - - def test_none_singleton(self) -> None: - target = ResponseTarget.none # type: ignore[attr-defined] - assert target.kind is ResponseTargetKind.NONE - - def test_channel_builder_single(self) -> None: - target = ResponseTarget.channel("teams") - assert target.kind is ResponseTargetKind.CHANNELS - assert target.targets == ("teams",) - - def test_channels_builder_list(self) -> None: - target = ResponseTarget.channels(["teams", "telegram", "originating"]) - assert target.kind is ResponseTargetKind.CHANNELS - assert target.targets == ("teams", "telegram", "originating") - - def test_channels_builder_accepts_tuple(self) -> None: - target = ResponseTarget.channels(("a", "b")) - assert target.targets == ("a", "b") - - def test_target_is_hashable(self) -> None: - # Plain class — hashing falls back to identity, which is fine here: - # the two keys below are different instances (singleton vs builder). - d = {ResponseTarget.originating: 1, ResponseTarget.channel("t"): 2} # type: ignore[attr-defined] - assert len(d) == 2 - - class TestChannelRequest: def test_required_fields_only(self) -> None: req = ChannelRequest(channel="responses", operation="message.create", input="hi") @@ -74,17 +24,6 @@ class TestChannelRequest: assert req.attributes == {} assert req.stream is False assert req.identity is None - # Default response target is the originating singleton. - assert req.response_target.kind is ResponseTargetKind.ORIGINATING - - def test_default_response_target_is_originating_singleton(self) -> None: - # Every new request shares the module-level ``originating`` singleton - # by default — instances are intended to be treated as immutable, so - # sharing is safe and avoids per-request allocation. - a = ChannelRequest(channel="a", operation="op", input="x") - b = ChannelRequest(channel="b", operation="op", input="y") - assert a.response_target is ResponseTarget.originating # type: ignore[attr-defined] - assert a.response_target is b.response_target def test_with_session_and_identity(self) -> None: req = ChannelRequest( @@ -93,14 +32,12 @@ class TestChannelRequest: input="hi", session=ChannelSession(isolation_key="user:42"), identity=ChannelIdentity(channel="telegram", native_id="42"), - response_target=ResponseTarget.active, # type: ignore[attr-defined] ) assert req.session is not None assert req.session.isolation_key == "user:42" assert req.identity is not None assert req.identity.channel == "telegram" assert req.identity.native_id == "42" - assert req.response_target.kind is ResponseTargetKind.ACTIVE class TestChannelIdentity: @@ -111,228 +48,3 @@ class TestChannelIdentity: def test_attributes_passthrough(self) -> None: ident = ChannelIdentity(channel="teams", native_id="abc", attributes={"role": "user"}) assert dict(ident.attributes) == {"role": "user"} - - -class _DummyTarget: - """Stand-in for the ``SupportsAgentRun | Workflow`` arg `apply_run_hook` forwards. - - `apply_run_hook` doesn't introspect the target — it just forwards - it as a kwarg to the user's hook — so a bare class is enough. - """ - - -class _DummyChannel: - name = "dummy" - path = "/dummy" - - def contribute(self, _context: Any) -> ChannelContribution: - return ChannelContribution() - - -class TestApplyChannelResponseHook: - async def test_originating_hook_receives_standard_context(self) -> None: - request = ChannelRequest(channel="discord", operation="message.create", input="hi") - payload = HostedRunResult("original") - captured: list[ChannelResponseContext] = [] - - async def hook( - result: HostedRunResult[Any], - *, - context: ChannelResponseContext, - ) -> HostedRunResult[Any]: - captured.append(context) - return result.replace(result="hooked") - - channel = _DummyChannel() - channel.response_hook = hook # type: ignore[attr-defined] - - shaped = await apply_channel_response_hook(channel, payload, request=request, originating=True) - - assert shaped.result == "hooked" - assert captured[0].request is request - assert captured[0].channel_name == "dummy" - assert captured[0].destination_identity is None - assert captured[0].originating is True - assert captured[0].is_echo is False - - async def test_non_originating_hook_can_clone_before_shaping(self) -> None: - request = ChannelRequest(channel="responses", operation="message.create", input="hi") - identity = ChannelIdentity(channel="dummy", native_id="user-1") - payload = HostedRunResult("original") - seen_payloads: list[HostedRunResult[Any]] = [] - seen_contexts: list[ChannelResponseContext] = [] - - def hook( - result: HostedRunResult[Any], - *, - context: ChannelResponseContext, - ) -> HostedRunResult[Any]: - seen_payloads.append(result) - seen_contexts.append(context) - return result.replace(result="hooked") - - channel = _DummyChannel() - channel.response_hook = hook # type: ignore[attr-defined] - - shaped = await apply_channel_response_hook( - channel, - payload, - request=request, - destination_identity=identity, - originating=False, - is_echo=True, - clone=True, - ) - - assert seen_payloads[0] is not payload - assert shaped.result == "hooked" - assert seen_contexts[0].destination_identity is identity - assert seen_contexts[0].originating is False - assert seen_contexts[0].is_echo is True - - async def test_missing_hook_returns_payload_or_clone(self) -> None: - request = ChannelRequest(channel="responses", operation="message.create", input="hi") - payload = HostedRunResult("original") - channel = _DummyChannel() - - same = await apply_channel_response_hook(channel, payload, request=request, originating=True) - cloned = await apply_channel_response_hook(channel, payload, request=request, originating=True, clone=True) - - assert same is payload - assert cloned is not payload - assert cloned.result == payload.result - - -class TestApplyRunHook: - """`apply_run_hook` is the channel-side helper that invokes a - `ChannelRunHook` with the standard kwargs (`request` positional, - `target` / `protocol_request` keyword). Channels call this rather - than calling the hook directly so the convention is enforced in - one place. Cover both branching paths (sync vs async hook return) - and assert kwargs forwarding so a regression that drops `target` - or `protocol_request` is caught.""" - - async def test_sync_hook_returning_modified_request(self) -> None: - captured: dict[str, Any] = {} - - def hook(request: ChannelRequest, **kwargs: Any) -> ChannelRequest: - # Snapshot the kwargs for the assertion below, then return a - # NEW request so we also verify the helper passes the - # replacement straight through (no merging / mutation). - captured["target"] = kwargs.get("target") - captured["protocol_request"] = kwargs.get("protocol_request") - return ChannelRequest(channel=request.channel, operation="HOOK_TOUCHED", input=request.input) - - original = ChannelRequest(channel="responses", operation="op", input="hi") - target = _DummyTarget() - proto = {"raw": "payload"} - - result = await apply_run_hook(hook, original, target=target, protocol_request=proto) - - assert result is not original - assert result.operation == "HOOK_TOUCHED" - assert captured["target"] is target - assert captured["protocol_request"] is proto - - async def test_async_hook_returning_modified_request(self) -> None: - captured: dict[str, Any] = {} - - async def hook(request: ChannelRequest, **kwargs: Any) -> ChannelRequest: - captured["target"] = kwargs.get("target") - captured["protocol_request"] = kwargs.get("protocol_request") - # Return an awaitable result to exercise the async branch - # (`isinstance(result, Awaitable) → await it`). - return ChannelRequest(channel=request.channel, operation="ASYNC_HOOK", input=request.input) - - original = ChannelRequest(channel="telegram", operation="op", input="hi") - target = _DummyTarget() - proto = {"update_id": 42} - - result = await apply_run_hook(hook, original, target=target, protocol_request=proto) - - assert result.operation == "ASYNC_HOOK" - assert captured["target"] is target - assert captured["protocol_request"] is proto - - async def test_protocol_request_can_be_none(self) -> None: - """Channels that don't have a raw protocol payload (e.g. CLI / test - harness invocations) pass ``protocol_request=None``; the helper - forwards it as-is so hooks can ``if protocol_request is None`` to - gate channel-specific logic.""" - captured: dict[str, Any] = {} - - async def hook(request: ChannelRequest, **kwargs: Any) -> ChannelRequest: - captured["protocol_request"] = kwargs.get("protocol_request") - captured["protocol_request_in_kwargs"] = "protocol_request" in kwargs - return request - - await apply_run_hook( - hook, - ChannelRequest(channel="x", operation="op", input="hi"), - target=_DummyTarget(), - protocol_request=None, - ) - - assert captured["protocol_request"] is None - assert captured["protocol_request_in_kwargs"] is True - - -class TestDurableTaskPayloadMode: - """``DurableTaskPayloadMode`` distinguishes object-mode (in-process, - live references) from JSON-mode (durable persistence, channel codec - required) runners. The host's startup validator uses the value to - refuse misconfigured deployments.""" - - def test_enum_values(self) -> None: - assert DurableTaskPayloadMode.OBJECT.value == "object" - assert DurableTaskPayloadMode.JSON.value == "json" - # Both members; no surprise additions until we ship a third - # adapter style. - assert set(DurableTaskPayloadMode) == {DurableTaskPayloadMode.OBJECT, DurableTaskPayloadMode.JSON} - - -class TestResponseTargetIdentities: - """``ResponseTarget.identity``/``.identities`` carry full - :class:`ChannelIdentity` objects (incl. attributes) so destination - channels that need conversation/thread metadata (Teams, Slack, Bot - Framework) don't have to encode it through string tokens.""" - - def test_identity_single(self) -> None: - ident = ChannelIdentity(channel="teams", native_id="user@contoso", attributes={"tenant_id": "abc"}) - target = ResponseTarget.identity(ident) - assert target.kind is ResponseTargetKind.IDENTITIES - assert len(target.target_identities) == 1 - assert target.target_identities[0].channel == "teams" - assert target.target_identities[0].native_id == "user@contoso" - assert dict(target.target_identities[0].attributes) == {"tenant_id": "abc"} - - def test_identities_list_preserves_attributes(self) -> None: - ident_a = ChannelIdentity(channel="teams", native_id="u1", attributes={"thread": "t1"}) - ident_b = ChannelIdentity(channel="slack", native_id="u2", attributes={"channel_id": "c2"}) - target = ResponseTarget.identities([ident_a, ident_b]) - assert target.kind is ResponseTargetKind.IDENTITIES - assert len(target.target_identities) == 2 - assert dict(target.target_identities[0].attributes) == {"thread": "t1"} - assert dict(target.target_identities[1].attributes) == {"channel_id": "c2"} - - def test_identity_value_equality_matches_on_attributes(self) -> None: - # Two ``ResponseTarget.identity`` values built independently - # compare equal when the underlying ``ChannelIdentity`` content - # matches — important because tests and channel parsers use - # ``==`` on targets. - ident_a = ChannelIdentity(channel="teams", native_id="u1", attributes={"thread": "t1"}) - ident_b = ChannelIdentity(channel="teams", native_id="u1", attributes={"thread": "t1"}) - assert ResponseTarget.identity(ident_a) == ResponseTarget.identity(ident_b) - # Different attributes → not equal. - ident_c = ChannelIdentity(channel="teams", native_id="u1", attributes={"thread": "t2"}) - assert ResponseTarget.identity(ident_a) != ResponseTarget.identity(ident_c) - - def test_identity_repr_includes_targets(self) -> None: - ident = ChannelIdentity(channel="teams", native_id="u1") - rep = repr(ResponseTarget.identity(ident)) - assert "ResponseTarget.identities" in rep - - def test_identity_echo_input_flag(self) -> None: - ident = ChannelIdentity(channel="teams", native_id="u1") - target = ResponseTarget.identity(ident, echo_input=True) - assert target.echo_input is True diff --git a/python/pyproject.toml b/python/pyproject.toml index 1c695ec8a9..4804fff87c 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -89,7 +89,6 @@ agent-framework-hosting = { workspace = true } agent-framework-hosting-invocations = { workspace = true } agent-framework-hosting-telegram = { workspace = true } agent-framework-hosting-activity-protocol = { workspace = true } -agent-framework-hosting-entra = { workspace = true } agent-framework-hosting-discord = { workspace = true } agent-framework-hyperlight = { workspace = true } agent-framework-lab = { workspace = true } @@ -217,7 +216,6 @@ executionEnvironments = [ { root = "packages/hosting-invocations/tests", reportPrivateUsage = "none" }, { root = "packages/hosting-telegram/tests", reportPrivateUsage = "none" }, { root = "packages/hosting-activity-protocol/tests", reportPrivateUsage = "none" }, - { root = "packages/hosting-entra/tests", reportPrivateUsage = "none" }, { root = "packages/lab/gaia/tests", reportPrivateUsage = "none" }, { root = "packages/lab/lightning/tests", reportPrivateUsage = "none" }, { root = "packages/lab/tau2/tests", reportPrivateUsage = "none" }, diff --git a/python/samples/04-hosting/af-hosting/README.md b/python/samples/04-hosting/af-hosting/README.md index ad368b9d44..6c812a997c 100644 --- a/python/samples/04-hosting/af-hosting/README.md +++ b/python/samples/04-hosting/af-hosting/README.md @@ -8,7 +8,7 @@ The general hosting plumbing lives in its own package (`agent-framework-hosting-responses`, `agent-framework-hosting-invocations`, `agent-framework-hosting-telegram`, `agent-framework-hosting-activity-protocol`, -`agent-framework-hosting-entra`). +`agent-framework-hosting-discord`). | Sample | What it shows | Packaging | |---|---|---| @@ -16,8 +16,7 @@ its own package (`agent-framework-hosting-responses`, | [`local_responses_workflow/`](./local_responses_workflow) | A 4-step `Workflow` (typed `SloganBrief` intake → writer → legal → formatter) hosted behind **both** the Responses and Invocations channels via a shared `run_hook` that parses inbound text/JSON into the workflow's typed input. The host writes per-conversation checkpoints via `checkpoint_location=…`. Demonstrates workflow targets + structured input adaptation + multi-channel + resume-across-turns. Includes a `call_server.rest` file with REST examples for both endpoints. | **Local only.** | | [`foundry_hosted_agent/`](./foundry_hosted_agent) | One Foundry agent, **Responses + Invocations only** — the minimal shape that is **runtime-compatible with the Foundry Hosted Agents platform**. | Ships with `Dockerfile` + `agent.yaml` + `agent.manifest.yaml` + `azure.yaml` so the same image runs locally **or** as a Foundry Hosted Agent (`azd up`). | | [`foundry_telegram_invocations_weather/`](./foundry_telegram_invocations_weather) | Experimental Telegram weather bot that mounts `TelegramChannel` at `POST /invocations`, registers the Foundry Hosted Agents Invocations URL as the Telegram webhook, and uses `FoundryHostedAgentHistoryProvider` for storage. | Ships with `Dockerfile` + `agent.yaml` + `agent.manifest.yaml` + `azure.yaml`; used to validate whether a non-Responses channel can run under Foundry Invocations. | -| [`local_telegram/`](./local_telegram) | Adds Telegram, a `@tool`, `FileHistoryProvider`, run hooks (per-user / per-chat session keying), extra Telegram commands, and `ResponseTarget` multicast. Runs under Hypercorn with multiple workers. | **Local only.** No Dockerfile / Foundry packaging. | -| [`local_identity_link/`](./local_identity_link) | Everything in `local_telegram/` plus Teams and the Entra identity-link sidecar (`/auth/start` + `/auth/callback`). Demonstrates linking a Telegram chat to an Entra user so multiple non-Entra channels can share one isolation key. | **Local only.** No Dockerfile / Foundry packaging. | +| [`local_telegram/`](./local_telegram) | Adds Telegram, a `@tool`, `FileHistoryProvider`, run hooks (per-user / per-chat session keying), and extra Telegram commands. Runs under Hypercorn with multiple workers. | **Local only.** No Dockerfile / Foundry packaging. | Each sample is fully self-contained — its own `pyproject.toml`, `uv.lock`, server `app.py`, calling script(s), and `storage/` directory. Every @@ -40,9 +39,9 @@ involved**. | Aspect | `af-hosting/` (this directory) | `foundry-hosted-agents/` | |---|---|---| -| Server stack | `agent-framework-hosting` + per-channel packages (`-responses`, `-invocations`, `-telegram`, `-activity-protocol`, `-entra`) | `agent-framework-hosted` only — the Foundry Hosted Agents runtime owns the HTTP surface | -| Channels other than Responses / Invocations | Yes — Telegram, Activity Protocol (Teams), Entra identity-linking | No — the platform exposes Responses + Invocations only | -| Run target | Local Hypercorn (`local_responses/`, `local_telegram/`, `local_identity_link/`); Hosted Agents *or* local (`foundry_hosted_agent/`) | Hosted Agents *or* local container; targets the Hosted Agents platform contract | +| Server stack | `agent-framework-hosting` + per-channel packages (`-responses`, `-invocations`, `-telegram`, `-activity-protocol`, `-discord`) | `agent-framework-hosted` only — the Foundry Hosted Agents runtime owns the HTTP surface | +| Channels other than Responses / Invocations | Yes — Telegram, Activity Protocol (Teams), Discord | No — the platform exposes Responses + Invocations only | +| Run target | Local Hypercorn (`local_responses/`, `local_telegram/`); Hosted Agents *or* local (`foundry_hosted_agent/`) | Hosted Agents *or* local container; targets the Hosted Agents platform contract | | When to pick this | You need extra channels (Telegram/Teams via Activity Protocol/…), custom hosting middleware, or want to run outside the Foundry runtime | You only need Responses/Invocations and want zero hosting boilerplate, leveraging the Foundry-managed surface | `foundry_hosted_agent/` is the bridge sample: it uses the diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/README.md b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/README.md index 2fb766ae3b..97e174455d 100644 --- a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/README.md +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/README.md @@ -25,10 +25,8 @@ is unset) it transparently falls back to an in-memory store, so the same code runs in dev. Writes are a no-op — Foundry persists Responses turns authoritatively as the runtime executes them. -For richer scenarios (custom tools, history providers, run hooks, -multicast, Telegram, Teams, identity linking) see -[`../local_telegram`](../local_telegram) and -[`../local_identity_link`](../local_identity_link). +For richer local scenarios (custom tools, history providers, run hooks, +Telegram, and Activity Protocol) see [`../local_telegram`](../local_telegram). ## Layout diff --git a/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/app.py b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/app.py index a7c4ef4007..8c5ebf44cf 100644 --- a/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/app.py +++ b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/app.py @@ -162,7 +162,6 @@ def build_host() -> AgentFrameworkHost: # 3. Register Telegram at /invocations and keep Responses available for sanity checks. return AgentFrameworkHost( target=agent, - allow_in_process_runner=True, channels=[ ResponsesChannel(response_id_factory=foundry_response_id), TelegramChannel( diff --git a/python/samples/04-hosting/af-hosting/local_identity_link/README.md b/python/samples/04-hosting/af-hosting/local_identity_link/README.md deleted file mode 100644 index 33a706386d..0000000000 --- a/python/samples/04-hosting/af-hosting/local_identity_link/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# local_identity_link — every channel, plus identity linking - -The full surface: Responses + Invocations + Telegram + Activity Protocol (Teams) + the Entra -identity-link sidecar. The Entra channel exposes -`/auth/start` + `/auth/callback` so users on Telegram (or any non-Entra -channel) can bind their per-channel id to a stable `entra:` isolation -key. Channel run-hooks then rewrite incoming requests to use the linked -key, so a chat started on Telegram and a chat started on Teams that both -resolve to the same Entra user share one history. - -## Run - -```bash -export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com -export FOUNDRY_MODEL=gpt-4o -export TELEGRAM_BOT_TOKEN=... -# Entra app registration (confidential client): -export ENTRA_TENANT_ID=... -export ENTRA_CLIENT_ID=... -export ENTRA_CLIENT_SECRET=... # or: -# export ENTRA_CERTIFICATE_PATH=./teams-bot.pem -export PUBLIC_BASE_URL=https:// # used to mint redirect_uri -# Teams (optional — same tenant): -export TEAMS_APP_ID=... -export TEAMS_APP_PASSWORD=... - -az login - -uv sync -uv run hypercorn app:app \ - --bind 0.0.0.0:8000 \ - --workers 4 -``` - -## Identity link - -Register `https:///auth/callback` as the redirect URI on your -Entra app, then visit (replace ```` with the Telegram numeric -chat id): - -``` -https:///auth/start?channel=telegram&id= -``` - -After sign-in, subsequent Telegram messages from that chat resolve to the -linked Entra user. - -## Call locally - -```bash -uv sync --group dev - -# Default: post a Responses request as `local-dev`. -uv run python call_server.py "What is the weather in Tokyo?" - -# Resume any session by id, including a Telegram one (works because -# the Telegram run-hook writes sessions under telegram:): -uv run python call_server.py --previous-response-id telegram:8741188429 "What did we discuss?" - -# Multicast to a Telegram chat in parallel with the local response: -uv run python call_server.py --telegram-chat-id 8741188429 "Heads up." -``` - -> This sample is **local-only** — it shows the `agent-framework-hosting` -> server stack as a standalone process. For a Foundry-Hosted-Agents-compatible -> packaging (Dockerfile + `agent.yaml` + `azure.yaml`), see -> [`foundry_hosted_agent/`](../foundry_hosted_agent). diff --git a/python/samples/04-hosting/af-hosting/local_identity_link/app.py b/python/samples/04-hosting/af-hosting/local_identity_link/app.py deleted file mode 100644 index a8bba348ee..0000000000 --- a/python/samples/04-hosting/af-hosting/local_identity_link/app.py +++ /dev/null @@ -1,395 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Complete multi-channel hosting sample with unified Entra ID identity. - -Wires every built-in channel onto a single ``AgentFrameworkHost`` and -demonstrates a pattern for collapsing per-channel identifiers into a single -**Microsoft Entra ID** (object id) key so a user's history follows them -across surfaces. - -Identity resolution -------------------- -Each request is bucketed under one ``isolation_key`` for ``FileHistoryProvider``: - -- **Teams** is the source of truth. Inbound activities carry the user's - ``aadObjectId``; we promote it to ``entra:`` in the Teams ``run_hook``. -- **Telegram** has no built-in OAuth identity. Users link their chat to - their Entra ID by sending ``/link``; the bot replies with a one-shot - authorize URL served by the host's ``EntraIdentityLinkChannel``. After the - OAuth callback the mapping ``telegram: → entra:`` is - persisted to ``identity_links.json`` and every later Telegram turn is - bucketed under the user's Entra key. -- **Responses API** callers can pass ``entra_oid`` directly (top-level or - in ``metadata``), or pass ``safety_identifier`` and rely on the same - store (``responses: → entra:``). Otherwise we fall back - to ``responses:``. - -Required environment --------------------- -- ``FOUNDRY_PROJECT_ENDPOINT`` / ``FOUNDRY_MODEL`` — agent backing. -- ``TELEGRAM_BOT_TOKEN`` — required to enable the Telegram channel. -- ``TEAMS_APP_ID`` / ``TEAMS_APP_PASSWORD`` — optional; without them the - Teams channel runs in dev mode (Bot Framework Emulator only). -- ``ENTRA_TENANT_ID`` / ``ENTRA_CLIENT_ID`` plus **either** - ``ENTRA_CLIENT_SECRET`` **or** ``ENTRA_CERT_PATH`` - (+ optional ``ENTRA_CERT_PASSWORD``) — required to enable the ``/link`` - flow. The app's redirect URI must be registered as - ``{PUBLIC_BASE_URL}/auth/callback`` in your Entra app. -- ``PUBLIC_BASE_URL`` — externally reachable base of this host (e.g. - ``https://my-host.example.com``). Defaults to ``http://localhost:8000``. - -Run ---- -This module exposes ``app`` as the canonical ASGI surface. Recommended -production launch is **Hypercorn**:: - - hypercorn app:app --bind 0.0.0.0:8000 --workers 4 - -The ``__main__`` block below uses ``host.serve(...)`` (single-process -Hypercorn) as a local-dev fallback. -""" - -from __future__ import annotations - -import logging -import os -from collections.abc import Mapping -from dataclasses import replace -from pathlib import Path -from typing import Annotated, Any - -from agent_framework import Agent, FileHistoryProvider, tool -from agent_framework_foundry import FoundryChatClient -from agent_framework_hosting import ( - AgentFrameworkHost, - Channel, - ChannelCommand, - ChannelCommandContext, - ChannelRequest, - ChannelSession, -) -from agent_framework_hosting_activity_protocol import ActivityProtocolChannel -from agent_framework_hosting_entra import ( - EntraIdentityLinkChannel, - EntraIdentityStore, - entra_isolation_key, -) -from agent_framework_hosting_invocations import InvocationsChannel -from agent_framework_hosting_responses import ResponsesChannel -from agent_framework_hosting_telegram import TelegramChannel -from azure.identity.aio import DefaultAzureCredential - -logger = logging.getLogger("agent_framework.hosting.complete_app") - -SESSIONS_DIR = Path(__file__).resolve().parent / "storage" / "sessions" -SESSIONS_DIR.mkdir(parents=True, exist_ok=True) -IDENTITY_STORE_PATH = Path(__file__).resolve().parent / "storage" / "identity_links.json" - - -# --------------------------------------------------------------------------- # -# Tools -# --------------------------------------------------------------------------- # - - -@tool(approval_mode="never_require") -def lookup_weather( - location: Annotated[str, "The city to look up weather for."], -) -> str: - """Return a deterministic weather report for a city.""" - reports = { - "Seattle": "Seattle is rainy with a high of 13°C.", - "Amsterdam": "Amsterdam is cloudy with a high of 16°C.", - "Tokyo": "Tokyo is clear with a high of 22°C.", - } - return reports.get(location, f"{location} is sunny with a high of 20°C.") - - -# --------------------------------------------------------------------------- # -# Run hooks: collapse per-channel identifiers down to a single Entra ID key -# --------------------------------------------------------------------------- # - - -def _replace_session(request: ChannelRequest, isolation_key: str) -> ChannelRequest: - return replace(request, session=ChannelSession(isolation_key=isolation_key)) - - -def make_activity_hook() -> Any: - """Promote ``aadObjectId`` from the inbound Activity to ``entra:``. - - The Activity Protocol channel is treated as the **primary** identity - source for Teams traffic: every authenticated Teams user has an Entra - object id, and we trust it directly without consulting the link store. - """ - - def _hook( - request: ChannelRequest, - *, - protocol_request: Mapping[str, Any] | None = None, - **_: object, - ) -> ChannelRequest: - activity = protocol_request or {} - from_ = activity.get("from") if isinstance(activity, Mapping) else None - oid = from_.get("aadObjectId") if isinstance(from_, Mapping) else None - if oid: - return _replace_session(request, entra_isolation_key(oid)) - # Unauthenticated channels (web chat, emulator) — fall back to the - # per-conversation key the channel already set. - return request - - return _hook - - -def make_telegram_hook(store: EntraIdentityStore) -> Any: - """Resolve identity then bump reasoning effort. - - The reasoning bump applies to **every** Telegram request — linked or - not — so the high-effort preset isn't silently lost the moment a - user runs ``/link`` (which is the headline feature of this sample). - Identity resolution and option mutation are separate concerns: we - swap the session if a link exists, then upgrade the options on the - way out either way. - """ - - def _hook(request: ChannelRequest, **_: object) -> ChannelRequest: - chat_id = request.attributes.get("chat_id") - if chat_id is not None: - linked = store.lookup(f"telegram:{chat_id}") - if linked is not None: - request = _replace_session(request, linked) - # Bump reasoning effort regardless of identity (linked or not). - options = dict(request.options or {}) - options["reasoning"] = {"effort": "high", "summary": "detailed"} - return replace(request, options=options) - - return _hook - - -def make_responses_hook(store: EntraIdentityStore) -> Any: - """Same identity resolution as Telegram/Teams, plus the usual option scrub. - - Resolution order: - 1. Body ``entra_oid`` (top-level or in ``metadata``) — a caller already - knows the user's Entra id. - 2. ``safety_identifier`` (or legacy ``user``) looked up in the link - store as ``responses:``. - 3. Fallback ``responses:``. - - .. WARNING:: - DEV ONLY. The ``entra_oid`` shortcut treats a client-supplied - identity claim as authoritative with **no token verification**: - any Responses caller can claim to be any user and read that - user's history bucket. Production deployments must either: - - - Drop this shortcut entirely and rely on ``safety_identifier`` - + the link store (i.e. force every caller through the OAuth - identity-link flow), or - - Add a JWT validator that verifies an inbound Authorization - header, extracts the verified ``oid`` claim, and feeds *that* - into ``entra_isolation_key`` — never trust a body field for - identity in a multi-tenant deployment. - - This shortcut exists only so the sample's smoke tests can pin - an isolation key without spinning up an Entra app registration. - """ - - def _hook( - request: ChannelRequest, - *, - protocol_request: Mapping[str, Any] | None = None, - **_: object, - ) -> ChannelRequest: - options = dict(request.options or {}) - options.pop("temperature", None) - options.pop("store", None) - - body = protocol_request or {} - metadata = body.get("metadata") if isinstance(body.get("metadata"), dict) else {} - - # WARNING (DEV ONLY): client-supplied entra_oid is trusted with - # NO verification. Production code must verify a JWT instead. - explicit_oid = body.get("entra_oid") or metadata.get("entra_oid") - safety_id = body.get("safety_identifier") or body.get("user") or "anonymous" - - if explicit_oid: - isolation_key = entra_isolation_key(explicit_oid) - else: - isolation_key = store.lookup(f"responses:{safety_id}") or f"responses:{safety_id}" - - return replace( - request, - session=ChannelSession(isolation_key=isolation_key), - options=options or None, - ) - - return _hook - - -# --------------------------------------------------------------------------- # -# Telegram commands -# --------------------------------------------------------------------------- # - - -def make_commands( - host_ref: dict[str, AgentFrameworkHost], - store: EntraIdentityStore, - linker_ref: dict[str, EntraIdentityLinkChannel | None], -) -> list[ChannelCommand]: - def _telegram_key(ctx: ChannelCommandContext) -> str: - chat_id = ctx.request.attributes.get("chat_id") - return f"telegram:{chat_id}" - - def _isolation_for(ctx: ChannelCommandContext) -> str: - # Honour any existing link so /new resets the right bucket. - return store.lookup(_telegram_key(ctx)) or _telegram_key(ctx) - - async def handle_start(ctx: ChannelCommandContext) -> None: - await ctx.reply( - "Hi! I'm a multi-channel agent.\nCommands: /link, /unlink, /new, /whoami, /weather , /help." - ) - - async def handle_help(ctx: ChannelCommandContext) -> None: - await ctx.reply( - "/link — bind this chat to your Entra ID for shared history\n" - "/unlink — unbind this chat\n" - "/new — start a fresh conversation\n" - "/whoami — show your isolation key\n" - "/weather — call the weather tool directly\n" - "/help — this message" - ) - - async def handle_link(ctx: ChannelCommandContext) -> None: - linker = linker_ref.get("linker") - if linker is None: - await ctx.reply( - "Identity linking is not configured on this host. " - "Set ENTRA_TENANT_ID, ENTRA_CLIENT_ID, and either " - "ENTRA_CLIENT_SECRET or ENTRA_CERTIFICATE_PATH." - ) - return - chat_id = ctx.request.attributes.get("chat_id") - if chat_id is None: - # Without a chat_id we'd format "telegram:None" into the - # authorize URL, OAuth would complete, and the store would - # gain a poisoned `telegram:None` entry that any later - # chat_id-less message would collapse onto. Refuse instead. - await ctx.reply("Couldn't determine your Telegram chat id; please retry from a 1:1 chat with the bot.") - return - url = linker.authorize_url_for("telegram", str(chat_id)) - await ctx.reply("Open this link to bind this chat to your Microsoft account:\n" + url) - - async def handle_unlink(ctx: ChannelCommandContext) -> None: - await store.unlink(_telegram_key(ctx)) - await ctx.reply("This chat is no longer linked. New messages will use the chat-only key.") - - async def handle_new(ctx: ChannelCommandContext) -> None: - host_ref["host"].reset_session(_isolation_for(ctx)) - await ctx.reply("New session started. Previous history is cleared.") - - async def handle_whoami(ctx: ChannelCommandContext) -> None: - key = _isolation_for(ctx) - if key.startswith("entra:"): - await ctx.reply(f"This chat is linked. Isolation key: {key}") - else: - await ctx.reply(f"This chat is not linked to an Entra ID. Isolation key: {key}\nSend /link to bind it.") - - async def handle_weather(ctx: ChannelCommandContext) -> None: - command_text = ctx.request.input if isinstance(ctx.request.input, str) else "" - _, _, location = command_text.partition(" ") - location = location.strip() or "Seattle" - await ctx.reply(lookup_weather(location=location)) - - return [ - ChannelCommand("start", "Introduce the bot", handle_start), - ChannelCommand("help", "List available commands", handle_help), - ChannelCommand("link", "Bind this chat to your Microsoft account", handle_link), - ChannelCommand("unlink", "Unbind this chat from any Microsoft account", handle_unlink), - ChannelCommand("new", "Start a new session for this chat", handle_new), - ChannelCommand("whoami", "Show the isolation key for this chat", handle_whoami), - ChannelCommand("weather", "Call the weather tool: /weather ", handle_weather), - ] - - -# --------------------------------------------------------------------------- # -# Host wiring -# --------------------------------------------------------------------------- # - - -def build_host() -> AgentFrameworkHost: - agent = Agent( - client=FoundryChatClient(credential=DefaultAzureCredential()), - name="WeatherAgent", - instructions=( - "You are a friendly weather assistant. Use the lookup_weather tool " - "for any weather question and answer in one short sentence." - ), - tools=[lookup_weather], - context_providers=[FileHistoryProvider(SESSIONS_DIR)], - default_options={"store": False}, - ) - - store = EntraIdentityStore(IDENTITY_STORE_PATH) - - # Optional Entra-OAuth identity linker. Pick exactly one credential mode: - # ENTRA_CLIENT_SECRET *or* ENTRA_CERT_PATH (+ optional ENTRA_CERT_PASSWORD). - # When unconfigured, /link tells the user the feature is disabled and the - # host runs without a linker. - tenant_id = os.environ.get("ENTRA_TENANT_ID") - client_id = os.environ.get("ENTRA_CLIENT_ID") - client_secret = os.environ.get("ENTRA_CLIENT_SECRET") - cert_path = os.environ.get("ENTRA_CERTIFICATE_PATH") - cert_password_env = os.environ.get("ENTRA_CERTIFICATE_PASSWORD") - public_base_url = os.environ.get("PUBLIC_BASE_URL", "http://localhost:8000") - - linker: EntraIdentityLinkChannel | None = None - if tenant_id and client_id and (client_secret or cert_path): - linker = EntraIdentityLinkChannel( - store=store, - tenant_id=tenant_id, - client_id=client_id, - client_secret=client_secret, - certificate_path=cert_path, - certificate_password=cert_password_env.encode() if cert_password_env else None, - public_base_url=public_base_url, - ) - - host_ref: dict[str, AgentFrameworkHost] = {} - linker_ref: dict[str, EntraIdentityLinkChannel | None] = {"linker": linker} - - channels: list[Channel] = [ - ResponsesChannel(run_hook=make_responses_hook(store)), - InvocationsChannel(), - ActivityProtocolChannel( - app_id=os.environ.get("TEAMS_APP_ID"), - tenant_id=os.environ.get("TEAMS_TENANT_ID", "botframework.com"), - # Use either a client secret OR a certificate. Cert is required - # for tenants that disallow secrets — see the package README for - # an `openssl` recipe to generate one. - app_password=os.environ.get("TEAMS_APP_PASSWORD"), - certificate_path=os.environ.get("TEAMS_CERT_PATH"), - certificate_password=( - os.environ["TEAMS_CERT_PASSWORD"].encode() if os.environ.get("TEAMS_CERT_PASSWORD") else None - ), - run_hook=make_activity_hook(), - ), - TelegramChannel( - bot_token=os.environ["TELEGRAM_BOT_TOKEN"], - webhook_url=os.environ.get("TELEGRAM_WEBHOOK_URL"), - secret_token=os.environ.get("TELEGRAM_WEBHOOK_SECRET"), - parse_mode="Markdown", - commands=make_commands(host_ref, store, linker_ref), - run_hook=make_telegram_hook(store), - ), - ] - if linker is not None: - channels.append(linker) - - host = AgentFrameworkHost(target=agent, channels=channels, debug=True) - host_ref["host"] = host - return host - - -app = build_host().app - - -if __name__ == "__main__": - build_host().serve(host="0.0.0.0", port=int(os.environ.get("PORT", "8000"))) diff --git a/python/samples/04-hosting/af-hosting/local_identity_link/call_server.py b/python/samples/04-hosting/af-hosting/local_identity_link/call_server.py deleted file mode 100644 index 766714a650..0000000000 --- a/python/samples/04-hosting/af-hosting/local_identity_link/call_server.py +++ /dev/null @@ -1,72 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Local client for the **complete** server (``app.py`` in this folder). - -Demonstrates the two most distinctive flows the complete sample adds on top -of the advanced sample: - -1. **Identity-linked Telegram resume.** Pass ``--previous-response-id - telegram:`` to resume a Telegram chat's history through the - Responses endpoint — this only works once the user has linked their - Telegram chat to their Entra account via the - ``EntraIdentityLinkChannel`` (visit ``/auth/start?channel=telegram&id=...`` - in the browser first). -2. **Multicast via ``response_target``.** Pass ``--telegram-chat-id`` to - have the host fan out the agent reply to a Telegram chat in addition - to returning it on the local wire. Drop ``--include-originating`` to - send only to Telegram and have the local response reduced to a small - acknowledgement. - -Start the server first (in another shell):: - - cd local_identity_link && uv run python app.py - -Then:: - - python call_server.py "What is the weather in Tokyo?" - python call_server.py --previous-response-id telegram:8741188429 "What did we discuss?" - python call_server.py --telegram-chat-id 8741188429 "Heads up, sending to your phone too." -""" - -from __future__ import annotations - -import argparse - -from openai import OpenAI - -BASE_URL = "http://127.0.0.1:8000" - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--safety-identifier", default="local-dev") - parser.add_argument("--previous-response-id", default=None) - parser.add_argument("--telegram-chat-id", default=None) - parser.add_argument("--include-originating", action="store_true", default=True) - parser.add_argument("prompt", nargs="*") - args = parser.parse_args() - - prompt = " ".join(args.prompt) or "What is the weather in Seattle?" - - extra_body: dict[str, object] = {} - if args.telegram_chat_id is not None: - targets: list[str] = [] - if args.include_originating: - targets.append("originating") - targets.append(f"telegram:{args.telegram_chat_id}") - extra_body["response_target"] = targets - - client = OpenAI(base_url=BASE_URL, api_key="not-needed") - response = client.responses.create( - model="agent", - input=prompt, - safety_identifier=args.safety_identifier, - previous_response_id=args.previous_response_id, - extra_body=extra_body or None, - ) - print(f"User: {prompt}") - print(f"Agent: {response.output_text}") - - -if __name__ == "__main__": - main() diff --git a/python/samples/04-hosting/af-hosting/local_identity_link/pyproject.toml b/python/samples/04-hosting/af-hosting/local_identity_link/pyproject.toml deleted file mode 100644 index 6e8a4d5505..0000000000 --- a/python/samples/04-hosting/af-hosting/local_identity_link/pyproject.toml +++ /dev/null @@ -1,32 +0,0 @@ -[project] -name = "agent-framework-hosting-sample-complete" -version = "0.0.1" -description = "Complete multi-channel hosting sample (Responses + Invocations + Telegram + Activity Protocol + Entra identity-link)." -requires-python = ">=3.10" -dependencies = [ - "agent-framework-foundry", - "agent-framework-hosting", - "agent-framework-hosting-activity-protocol", - "agent-framework-hosting-entra", - "agent-framework-hosting-invocations", - "agent-framework-hosting-responses", - "agent-framework-hosting-telegram", - "azure-identity", - "hypercorn>=0.17", - "httpx>=0.27", - "aiohttp>=3.13.5", -] - -[dependency-groups] -dev = ["openai>=1.99"] - -[tool.uv] -package = false - -[tool.uv.sources] -agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" } -agent-framework-hosting-activity-protocol = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-activity-protocol" } -agent-framework-hosting-entra = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-entra" } -agent-framework-hosting-invocations = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-invocations" } -agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" } -agent-framework-hosting-telegram = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-telegram" } diff --git a/python/samples/04-hosting/af-hosting/local_telegram/README.md b/python/samples/04-hosting/af-hosting/local_telegram/README.md index 939c905561..727d332342 100644 --- a/python/samples/04-hosting/af-hosting/local_telegram/README.md +++ b/python/samples/04-hosting/af-hosting/local_telegram/README.md @@ -1,4 +1,4 @@ -# local_telegram — `@tool`, file-backed history, hooks, multicast +# local_telegram — `@tool`, file-backed history, hooks, Telegram Builds on `foundry_hosted_agent/` with the hooks and config most real apps need: @@ -11,9 +11,6 @@ Builds on `foundry_hosted_agent/` with the hooks and config most real apps need: do not share history. - A `telegram_hook` that keys per-chat sessions via `telegram_isolation_key`. - Two extra Telegram commands (`/new`, `/whoami`). -- `ResponseTarget` multicast: a Responses request can fan out the agent - reply to a Telegram chat by passing - `extra_body={"response_target": ["originating", "telegram:"]}`. `app:app` is a module-level Starlette ASGI app, so this sample runs under Hypercorn (multi-process). @@ -49,8 +46,6 @@ uv run python call_server.py "What is the weather in Tokyo?" # Resume an existing session by AgentSession id (works across channels): uv run python call_server.py --previous-response-id telegram:8741188429 "What did we discuss?" -# Multicast: keep the reply on the local wire AND push it to Telegram. -uv run python call_server_multicast.py --telegram-chat-id 8741188429 "Heads up." ``` > This sample is **local-only** — it shows the `agent-framework-hosting` diff --git a/python/samples/04-hosting/af-hosting/local_telegram/call_server_multicast.py b/python/samples/04-hosting/af-hosting/local_telegram/call_server_multicast.py deleted file mode 100644 index 123b16502e..0000000000 --- a/python/samples/04-hosting/af-hosting/local_telegram/call_server_multicast.py +++ /dev/null @@ -1,92 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Local client demonstrating server-side ``ResponseTarget`` fan-out. - -Posts one request to ``/responses`` with -``extra_body={"response_target": ["originating", "telegram:"]}``. -The server invokes the agent once and the host's -``ChannelContext.deliver_response`` resolves the target list against the -configured channels, calling :class:`host.ChannelPush` ``push`` on each -non-originating destination — here, the operator's Telegram chat. The -``"originating"`` pseudo-name keeps the agent reply on this script's wire -too, so the local terminal sees the reply alongside Telegram. - -Drop ``--include-originating`` to deliver only to Telegram (the local -response becomes a small acknowledgement string referencing the push -targets). - -The ``--previous-response-id`` flag (the AgentSession id) is independent -of ``--telegram-chat-id`` (the push destination). They were conflated in -an earlier iteration; in general one Entra user may have several Telegram -chat ids, and the session id is usually their Entra/responses isolation -key, not the chat id. Pass them both to resume a specific session and -fan-out to a specific chat:: - - python call_server_multicast.py \\ - --previous-response-id telegram:8741188429 \\ - --telegram-chat-id 8741188429 \\ - "What did we discuss?" - -Start the server first (in another shell):: - - cd server && uv run python advanced_app.py -""" - -from __future__ import annotations - -import argparse - -from openai import OpenAI - -BASE_URL = "http://127.0.0.1:8000" - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument( - "--telegram-chat-id", - required=True, - help="Native Telegram chat id to push the agent reply to.", - ) - parser.add_argument( - "--previous-response-id", - default=None, - help=( - "Existing AgentSession id (e.g. 'telegram:8741188429' or " - "'responses:local-dev'). Defaults to no resume — the server " - "creates a fresh session keyed by safety_identifier." - ), - ) - parser.add_argument( - "--no-originating", - action="store_true", - help="Skip 'originating' in response_target; only Telegram receives the reply.", - ) - parser.add_argument("prompt", nargs="*", help="Prompt to send to the agent.") - args = parser.parse_args() - - prompt = " ".join(args.prompt) or "What is the weather in Seattle?" - - response_target: list[str] = [] - if not args.no_originating: - response_target.append("originating") - response_target.append(f"telegram:{args.telegram_chat_id}") - - if args.previous_response_id: - print(f"Resuming AgentSession: {args.previous_response_id}") - print(f"response_target: {response_target}") - - client = OpenAI(base_url=BASE_URL, api_key="not-needed") - response = client.responses.create( - model="agent", - input=prompt, - safety_identifier="local-dev", - previous_response_id=args.previous_response_id, - extra_body={"response_target": response_target}, - ) - print(f"User: {prompt}") - print(f"Agent: {response.output_text}") - - -if __name__ == "__main__": - main() diff --git a/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml b/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml index 52a5f966ad..39e0704816 100644 --- a/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml +++ b/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "agent-framework-hosting-sample-advanced" version = "0.0.1" -description = "Advanced multi-channel hosting sample (Responses + Telegram with @tool, FileHistoryProvider, hooks, ResponseTarget multicast)." +description = "Advanced multi-channel hosting sample (Responses + Telegram with @tool, FileHistoryProvider, hooks)." requires-python = ">=3.10" dependencies = [ "agent-framework-foundry", diff --git a/python/uv.lock b/python/uv.lock index f3044b9a68..3040c3ac75 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -50,7 +50,6 @@ members = [ "agent-framework-hosting", "agent-framework-hosting-activity-protocol", "agent-framework-hosting-discord", - "agent-framework-hosting-entra", "agent-framework-hosting-invocations", "agent-framework-hosting-responses", "agent-framework-hosting-telegram", @@ -686,27 +685,6 @@ requires-dist = [ { name = "pynacl", specifier = ">=1.2.0,<2" }, ] -[[package]] -name = "agent-framework-hosting-entra" -version = "1.0.0a260424" -source = { editable = "packages/hosting-entra" } -dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "msal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, -] - -[package.metadata] -requires-dist = [ - { name = "agent-framework-core", editable = "packages/core" }, - { name = "agent-framework-hosting", editable = "packages/hosting" }, - { name = "cryptography", specifier = ">=42" }, - { name = "httpx", specifier = ">=0.27,<1" }, - { name = "msal", specifier = ">=1.28,<2" }, -] - [[package]] name = "agent-framework-hosting-invocations" version = "1.0.0a260424"