From f9bf1961762bfd9317dc8eeacedb2cf03af4d691 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 30 Jun 2026 18:58:18 +0200 Subject: [PATCH 01/16] Revise Python hosting channels ADR Refocus the accepted-but-unreleased Python hosting channels ADR on protocol-specific Agent Framework conversion helpers and an optional execution-state host instead of a channel route-contribution framework. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions/0027-hosting-channels.md | 369 ++++++++++++++++++------ 1 file changed, 287 insertions(+), 82 deletions(-) diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index 3870fb908c2..e1aaad3e105 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -1,144 +1,349 @@ --- status: accepted contact: eavanvalkenburg -date: 2026-06-11 +date: 2026-06-30 deciders: eavanvalkenburg --- -# Python minimal hosting core and pluggable channels +# Python protocol helpers and optional execution state ## Context and Problem Statement -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. +Agent Framework needs to help applications expose agents and workflows over external protocols such as OpenAI +Responses, Telegram, Activity Protocol, and future transports. -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. +The first version of this ADR chose a host/channel model: channel packages contributed routes, middleware, commands, +lifecycle callbacks, hooks, and protocol dispatch to a common host object. That design was accepted before it was +released. Implementation experiments showed that the most valuable part is narrower: translating protocol-native +payloads into Agent Framework inputs and translating Agent Framework results back to protocol-native payloads. + +FastAPI, Starlette, Azure Functions, Django, Telegram SDKs, Bot Framework SDKs, and other app frameworks already own +route registration, dependency injection, middleware, authentication, background tasks, lifecycle, and native client +calls. Agent Framework should not duplicate those surfaces unless a specific hosting environment requires it. ## Decision Drivers -- 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=...)`. +- Keep the released surface small enough to explain without first teaching a channel framework. +- Provide reusable Agent Framework run translation that works with FastAPI and other web frameworks. +- Let app/framework code own route declaration, auth, middleware, native SDK clients, command handling, and background + work. +- Keep stateful execution support explicit: session lookup, session reset, and workflow checkpointing may still need a + small AF-owned home. - Avoid approving cross-channel identity and delivery semantics before their safety model is reviewed. ## Considered Options -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. +1. Create protocol-specific hosts. +2. Ship a full host/channel framework with route contribution and channel hooks. +3. Ship protocol conversion helpers plus optional execution state. -### Keep only protocol-specific hosts +### Create 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. +- Good: no new shared abstraction. +- Neutral: each protocol host can evolve independently. +- Bad: every package reinvents AF input/result mapping, session-key conventions, and stateful execution helpers. -### Ship the large cross-channel host in v1 +### Ship a full host/channel framework -- 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. +- Good: one object can assemble routes, channels, session handling, hooks, and lifecycle callbacks. +- Good: app code using the supported host shape can be short. +- Bad: the framework owns concerns already handled by web frameworks, protocol SDKs and/or other services. +- Bad: users must understand `Channel`, contribution, hook, and host-dispatch concepts before they can see how a request + becomes `agent.run(...)`. +- Bad: the abstraction is hard to reuse outside the chosen ASGI shape. -### Ship the minimal core now +### Ship protocol helpers plus optional execution state -- 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. +- Good: protocol packages provide the Agent Framework run value directly: `_to_run(...)` and + `_from_run(...)` style helpers. +- Good: apps keep native FastAPI, Starlette, Azure Functions, Django, Bot Framework, or Telegram SDK code. +- Good: helper functions can be tested without an ASGI app or host pipeline. +- Good: a small state object can still own target-coupled state such as an agent/workflow target, a `SessionStore`, and + future workflow checkpoint coordination. +- Good: provides maximum configurability in handling input and outputs (outside of the conversions) +- Bad: building a first iteration of a new Host is more verbose. +- Bad: samples show more explicit route/client code than a fully assembled channel host. ## Decision Outcome -Chosen option: **minimal host/channel core now, follow-up enhancements later**. +Chosen option: **protocol helpers plus optional execution state**. + +Protocol packages own: + +- parsing protocol-native input into Agent Framework run input and options; +- rendering `AgentResponse`, `AgentResponseUpdate`, workflow results, or workflow updates back into protocol-native + response/event payloads; +- protocol-specific isolation/session id helper functions when useful, such as `telegram_chat_session_id(update)`; +- protocol-specific typing/update event helpers where the protocol has a native concept. + +Application or web-framework code owns: + +- HTTP route declaration and route grouping; +- dependency injection; +- authentication and authorization; +- middleware; +- background tasks and webhook acknowledgement policy; +- native protocol SDK clients and outbound calls; +- command registration and command dispatch; +- request/response status codes and framework-specific error handling; +- choosing the isolation/session id source for the current deployment and route. + +`AgentFrameworkState`, if provided, is limited to shared execution state: + +- one first-class hostable target: either a `SupportsAgentRun` agent-compatible object or a `Workflow`; +- a `SessionStore` instance or factory; +- optional workflow checkpoint execution state. + +It is **not** an app object, channel registry, or route owner. It does not own FastAPI/Starlette setup, route +contribution, protocol dispatch, command projection, or native SDK calls. + +### Helper naming + +Helpers should be protocol-specific, not generic. Prefer: + +- `responses_to_run(...)` +- `responses_from_run(...)` +- `responses_stream_event_from_run(...)` if streaming needs a separate event helper +- `telegram_to_run(...)` +- `telegram_from_run(...)` -`AgentFrameworkHost` owns: +Avoid a generic `protocol_to_run(...)` name in public samples because it hides the protocol-specific contract behind a +second abstraction. -- one application object, -- one hostable target (`SupportsAgentRun` agent-compatible object or a `Workflow`), and -- one or more channels. +### Session continuity -Channels own: +Session continuity remains explicit. Run parsing and isolation/session id selection are separate operations because +isolation can come from more than one source: -- 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. +- protocol input, such as OpenAI Responses `previous_response_id`, a Telegram chat id, or an Activity conversation id; +- running environment, such as Foundry Hosted Agents user/chat isolation context; +- app-specific trusted middleware or route state. -The host owns: +The app chooses which helper to call for that route and deployment. For example: -- 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`. +- `responses_session_id(body)` from `agent-framework-hosting-responses`, which can return either a `resp_*` previous + response id or a `conv_*` conversation id when present; +- `telegram_chat_session_id(update)` from `agent-framework-hosting-telegram`; +- `foundry_user_isolation_key()` or `foundry_chat_isolation_key()` from `agent-framework-foundry-hosting`. -`ChannelIdentity`, when present, is request metadata only. In v1 it is not a linking, authorization, or delivery key. +Keep these helpers outside `responses_to_run(...)`, `telegram_to_run(...)`, and other run-input parsers. That makes the +trust boundary visible: using a request-derived key is a different decision than using a platform-provided isolation key. -### Trust boundary for `isolation_key` +A `SessionStore` resolves that key into an `AgentSession`: -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. +For agent targets: -### Hook ownership +```python +session = await state.session_store.get(session_id) +result = await state.target.run(messages, session=session, options=options) +``` -Channels provide hook configuration and protocol-native context. The host invokes those hooks as part of the common invocation pipeline: +For workflow targets, app code adapts the protocol helper output into the workflow's expected input and invokes the +workflow through the state object's target: -- `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. +```python +result = await state.target.run(message=workflow_input) +``` -`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. +`SessionStore.reset(session_id)` rotates or clears the current session for non-persisted servers. Persisted stores can +implement the same async interface later. -This keeps hook call conventions centralized while leaving protocol payload parsing and response formatting in channel packages. +The session id is a partition key, not proof of identity. App or platform code must authenticate and authorize any +externally supplied key before using it. -### State owned by v1 +### Workflow checkpoints -`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. +Workflow checkpointing is execution state, not protocol state. A small state object may help coordinate checkpoint +storage for workflow targets, but protocol helper packages should not own checkpoint layout, route lifecycle, or durable +execution. ## Non-goals for v1 -The following are deliberately **not** part of the v1 contract: - -- 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 +The following remain outside the v1 contract: + +- 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 protocol (`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`; - a host-level multi-agent router. -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. +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 protocol-helper surface. ADR-0028 was written against the earlier +host/channel framing and must be revised to align with this protocol-helper and execution-state boundary before those +enhancements are implemented. ## Consequences Positive: -- 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 released surface is smaller and easier to inspect: helpers plus state, not a channel framework. +- Protocol helpers can be used from FastAPI, Starlette, Azure Functions, Django, CLI tools, tests, or native SDK webhook + handlers. +- App authors can use the authentication, dependency injection, lifecycle, and background-task tools they already know. +- Session continuity stays explicit and debuggable. +- Workflow checkpointing can still be centralized if needed without making protocol packages own routing. Negative: -- 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. +- Multi-protocol samples include explicit route/client code. +- Apps that want a batteries-included ASGI app must write or depend on an app-specific wrapper. +- Existing unreleased code and docs that mention channels, contribution, or hooks must be revised before release. ## Validation Gates -Before this ADR is accepted: +Before this ADR is considered implemented: -- 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. +- A Responses sample uses normal FastAPI route code plus `responses_to_run(...)`, `responses_from_run(...)`, and + `SessionStore`; it does not use `ResponsesChannel` or `ChannelRunHook`. +- Protocol helper tests cover Responses input parsing, option policy, response rendering, and streaming event rendering. +- Protocol helper tests cover Telegram message parsing, typing events, streaming update events, final response rendering, + and session-key derivation. +- `SessionStore` tests prove session reuse and reset behavior. +- The same helper functions can be used without FastAPI in at least one direct unit test or sample. +- The v1 public package docs do not advertise `Channel`, contribution, command, or hook APIs as the intended released + surface. +- The Python spec is updated to match this revised contract. ## More Information -- Follow-up linking and multicast ADR: [ADR-0028](0028-hosting-linking-multicast-enhancements.md) +- Follow-up linking and multicast ADR: [ADR-0028](0028-hosting-linking-multicast-enhancements.md). That ADR still uses + some earlier host/channel terminology and must be aligned before implementation work starts. + +## Appendix: Developer experience sketch + +### Optional execution state + +`AgentFrameworkState` stays small: it is only the target/session/checkpoint state holder. The target can be an agent or +a workflow. It is shown here for shape, but the Responses route below imports it from `agent_framework_hosting`. + +```python +from agent_framework import SupportsAgentRun, Workflow + + +class AgentFrameworkState: + def __init__(self, target: SupportsAgentRun | Workflow, *, session_store: SessionStore | type[SessionStore]) -> None: + self.target = target + self.session_store = session_store(target) if isinstance(session_store, type) else session_store +``` + +### Responses-only route + +This sketch shows the intended Responses-only shape. The protocol package owns the Agent Framework run conversion helpers and +response-id minting details; the application owns FastAPI routing, auth, policy adjustment, and response construction. + +```python +import os + +from agent_framework import Agent +from agent_framework.openai import OpenAIChatClient +from agent_framework_hosting import AgentFrameworkState, SessionStore +from agent_framework_hosting_responses import ( + create_response_id, + responses_from_run, + responses_session_id, + responses_to_run, +) +from fastapi import Body, FastAPI, Header, HTTPException +from fastapi.responses import JSONResponse + + +app = FastAPI() +agent = Agent( + client=OpenAIChatClient(), + name="Assistant", + instructions="Be concise and helpful.", +) +state = AgentFrameworkState(agent, session_store=SessionStore) + + +@app.post("/responses") +async def responses(body: dict = Body(...), x_api_key: str | None = Header(default=None)) -> JSONResponse: + if x_api_key != os.environ["RESPONSES_API_KEY"]: + raise HTTPException(status_code=401, detail="bad api key") + + # parse the request body into a set of AF objects + run = responses_to_run(body) + # get the session id from the body + # can be a resp_* for previous_response_id or a conv_* for a conversation + session_id = responses_session_id(body) + # create a new response_id for this run + response_id = create_response_id() + + # in this space, the developer can make any adjustments to the request, i.e.: + options = dict(run["options"]) + options["store"] = False + options.pop("model", None) + + # load the session (or a new one) + session = await state.session_store.get(session_id or response_id) + # call the agent + result = await state.target.run( + run["messages"], + session=session, + options=options, + ) + # any post-processing steps the developer wants to do can be done here + return JSONResponse(responses_from_run(result, response_id=response_id, session_id=session_id)) + +``` + +### Responses-only Django class-based view + +The same helper surface can be used without FastAPI. A Django app owns URL routing, CSRF/auth policy, request parsing, +and `JsonResponse` construction. In a real Django project this would live in the app's normal view module (for example +`assistant/views.py`) and be routed from that app's `urls.py`; Django discovers it through its standard project/app +layout, not through Agent Framework. + +```python +import json +import os + +from agent_framework import Agent +from agent_framework.openai import OpenAIChatClient +from agent_framework_hosting import AgentFrameworkState, SessionStore +from agent_framework_hosting_responses import ( + create_response_id, + responses_from_run, + responses_session_id, + responses_to_run, +) +from asgiref.sync import async_to_sync +from django.http import HttpRequest, HttpResponseBadRequest, HttpResponseForbidden, JsonResponse +from django.views import View + + +agent = Agent( + client=OpenAIChatClient(), + name="Assistant", + instructions="Be concise and helpful.", +) +state = AgentFrameworkState(agent, session_store=SessionStore) + + +class ResponsesView(View): + def post(self, request: HttpRequest) -> JsonResponse: + if request.headers.get("x-api-key") != os.environ["RESPONSES_API_KEY"]: + return HttpResponseForbidden("bad api key") + + try: + body = json.loads(request.body) + except json.JSONDecodeError: + return HttpResponseBadRequest("invalid json") + + run = responses_to_run(body) + session_id = responses_session_id(body) + response_id = create_response_id() + options = run["options"] + result = async_to_sync(state.target.run)( + run["messages"], + session=async_to_sync(state.session_store.get)(session_id or response_id), + options=options, + ) + return JsonResponse(responses_from_run(result, response_id=response_id, session_id=session_id)) +``` From 89fd10866b7dc077901d2cd5ac6ca4c884d7b84b Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 3 Jul 2026 11:25:08 +0200 Subject: [PATCH 02/16] Align hosting ADR with split state helpers Update the protocol-helper ADR to reflect AgentState and WorkflowState, plain SessionStore and CheckpointStore behavior, explicit post-run session storage, workflow checkpoint storage, and direct WorkflowBuilder/orchestration-builder support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions/0027-hosting-channels.md | 164 ++++++++++++++++-------- 1 file changed, 112 insertions(+), 52 deletions(-) diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index e1aaad3e105..6f65152f115 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -27,8 +27,8 @@ calls. Agent Framework should not duplicate those surfaces unless a specific hos - Provide reusable Agent Framework run translation that works with FastAPI and other web frameworks. - Let app/framework code own route declaration, auth, middleware, native SDK clients, command handling, and background work. -- Keep stateful execution support explicit: session lookup, session reset, and workflow checkpointing may still need a - small AF-owned home. +- Keep stateful execution support explicit: session lookup/storage and workflow checkpoint lookup/storage may still need + a small AF-owned home. - Avoid approving cross-channel identity and delivery semantics before their safety model is reviewed. ## Considered Options @@ -58,8 +58,8 @@ calls. Agent Framework should not duplicate those surfaces unless a specific hos `_from_run(...)` style helpers. - Good: apps keep native FastAPI, Starlette, Azure Functions, Django, Bot Framework, or Telegram SDK code. - Good: helper functions can be tested without an ASGI app or host pipeline. -- Good: a small state object can still own target-coupled state such as an agent/workflow target, a `SessionStore`, and - future workflow checkpoint coordination. +- Good: small state objects can still own target-coupled state: `AgentState` pairs an agent target with a `SessionStore`, + and `WorkflowState` pairs a workflow target with a `CheckpointStore`. - Good: provides maximum configurability in handling input and outputs (outside of the conversions) - Bad: building a first iteration of a new Host is more verbose. - Bad: samples show more explicit route/client code than a fully assembled channel host. @@ -88,14 +88,19 @@ Application or web-framework code owns: - request/response status codes and framework-specific error handling; - choosing the isolation/session id source for the current deployment and route. -`AgentFrameworkState`, if provided, is limited to shared execution state: +The optional execution-state helpers, if provided, are limited to shared execution state: -- one first-class hostable target: either a `SupportsAgentRun` agent-compatible object or a `Workflow`; -- a `SessionStore` instance or factory; -- optional workflow checkpoint execution state. +- `AgentState`: one `SupportsAgentRun`-compatible target plus a `SessionStore`; +- `WorkflowState`: one `Workflow`, `WorkflowBuilder`-shaped builder, orchestration builder, or workflow factory plus a + `CheckpointStore`; +- `SessionStore` and `CheckpointStore`: plain async storage (`get` / `set` / `delete`) by an app-selected id. -It is **not** an app object, channel registry, or route owner. It does not own FastAPI/Starlette setup, route -contribution, protocol dispatch, command projection, or native SDK calls. +The stores do not create sessions or checkpoint storage. State objects provide the target-aware helpers +(`AgentState.get_or_create_session(...)`, `WorkflowState.get_or_create_checkpoint_storage(...)`) because only the state +object has both the store and the resolved target. + +These objects are **not** app objects, channel registries, or route owners. They do not own FastAPI/Starlette setup, +route contribution, protocol dispatch, command projection, or native SDK calls. ### Helper naming @@ -129,33 +134,64 @@ The app chooses which helper to call for that route and deployment. For example: Keep these helpers outside `responses_to_run(...)`, `telegram_to_run(...)`, and other run-input parsers. That makes the trust boundary visible: using a request-derived key is a different decision than using a platform-provided isolation key. -A `SessionStore` resolves that key into an `AgentSession`: +A `SessionStore` stores `session_id -> AgentSession`, but it does not create sessions. `AgentState` resolves the agent +target and creates the session on first use: For agent targets: ```python -session = await state.session_store.get(session_id) -result = await state.target.run(messages, session=session, options=options) +session = await state.get_or_create_session(session_id) +target = await state.get_target() +result = await target.run(messages, session=session, options=options) ``` -For workflow targets, app code adapts the protocol helper output into the workflow's expected input and invokes the -workflow through the state object's target: +If the protocol mints a new continuation id as part of the response being created (for example, OpenAI Responses +`resp_*` ids), store the **post-run** session explicitly under that new id: ```python -result = await state.target.run(message=workflow_input) +session = await state.get_or_create_session(previous_response_id) +target = await state.get_target() +result = await target.run(messages, session=session, options=options) +await state.session_store.set(response_id, session) ``` -`SessionStore.reset(session_id)` rotates or clears the current session for non-persisted servers. Persisted stores can -implement the same async interface later. +`agent.run(...)` may update the session object (for example, with service continuation state), so the explicit store call +belongs after the run, not before it. The session id is a partition key, not proof of identity. App or platform code must authenticate and authorize any externally supplied key before using it. ### Workflow checkpoints -Workflow checkpointing is execution state, not protocol state. A small state object may help coordinate checkpoint -storage for workflow targets, but protocol helper packages should not own checkpoint layout, route lifecycle, or durable -execution. +Workflow checkpointing is execution state, not protocol state. `WorkflowState` pairs a workflow target with a +`CheckpointStore` (`session_id -> CheckpointStorage`) and provides `get_or_create_checkpoint_storage(...)` for first use. +The store itself remains plain storage and does not decide which checkpoint to resume. + +For workflow targets, app code adapts the protocol helper output into the workflow's expected input and invokes the +workflow through the state object's target: + +```python +storage = await state.get_or_create_checkpoint_storage(session_id) +target = await state.get_target() +result = await target.run(message=workflow_input, checkpoint_storage=storage) +``` + +If a route wants to resume from a prior checkpoint, it explicitly chooses the checkpoint and passes it to +`workflow.run(...)`: + +```python +storage = await state.get_or_create_checkpoint_storage(session_id) +target = await state.get_target() +latest = await storage.get_latest(workflow_name=target.name) +result = await target.run( + message=workflow_input, + checkpoint_id=latest.checkpoint_id if latest else None, + checkpoint_storage=storage, +) +``` + +`workflow.run(...)` writes checkpoints to the provided storage, so storage selection must be explicit at the route layer. +Protocol helper packages should not own checkpoint layout, route lifecycle, or durable execution. ## Non-goals for v1 @@ -200,11 +236,13 @@ Negative: Before this ADR is considered implemented: - A Responses sample uses normal FastAPI route code plus `responses_to_run(...)`, `responses_from_run(...)`, and - `SessionStore`; it does not use `ResponsesChannel` or `ChannelRunHook`. + `AgentState` / `SessionStore`; it does not use `ResponsesChannel` or `ChannelRunHook`. - Protocol helper tests cover Responses input parsing, option policy, response rendering, and streaming event rendering. - Protocol helper tests cover Telegram message parsing, typing events, streaming update events, final response rendering, and session-key derivation. -- `SessionStore` tests prove session reuse and reset behavior. +- `SessionStore` tests prove plain get/set/delete behavior, and `AgentState` tests prove get-or-create behavior. +- `WorkflowState` tests prove workflow factory, `WorkflowBuilder`, orchestration-style builder, and checkpoint-store + behavior. - The same helper functions can be used without FastAPI in at least one direct unit test or sample. - The v1 public package docs do not advertise `Channel`, contribution, command, or hook APIs as the intended released surface. @@ -219,19 +257,46 @@ Before this ADR is considered implemented: ### Optional execution state -`AgentFrameworkState` stays small: it is only the target/session/checkpoint state holder. The target can be an agent or -a workflow. It is shown here for shape, but the Responses route below imports it from `agent_framework_hosting`. +`AgentState` and `WorkflowState` stay small: they are target-specific state holders, not app hosts. ```python -from agent_framework import SupportsAgentRun, Workflow +from typing import Protocol + +from agent_framework import AgentSession, CheckpointStorage, SupportsAgentRun, Workflow + + +class SupportsBuild(Protocol): + def build(self) -> Workflow: ... + + +class SessionStore: + async def get(self, session_id: str) -> AgentSession | None: ... + async def set(self, session_id: str, session: AgentSession) -> None: ... + async def delete(self, session_id: str) -> None: ... + +class CheckpointStore: + async def get(self, session_id: str) -> CheckpointStorage | None: ... + async def set(self, session_id: str, storage: CheckpointStorage) -> None: ... + async def delete(self, session_id: str) -> None: ... -class AgentFrameworkState: - def __init__(self, target: SupportsAgentRun | Workflow, *, session_store: SessionStore | type[SessionStore]) -> None: - self.target = target - self.session_store = session_store(target) if isinstance(session_store, type) else session_store + +class AgentState: + def __init__(self, target: SupportsAgentRun, *, session_store: SessionStore | None = None) -> None: ... + async def get_target(self) -> SupportsAgentRun: ... + async def get_or_create_session(self, session_id: str) -> AgentSession: ... + + +class WorkflowState: + def __init__(self, target: Workflow | SupportsBuild, *, checkpoint_store: CheckpointStore | None = None) -> None: ... + async def get_target(self) -> Workflow: ... + async def get_or_create_checkpoint_storage(self, session_id: str) -> CheckpointStorage: ... ``` +`WorkflowState` accepts direct `Workflow` instances, workflow factories, and builder-shaped objects with +`build() -> Workflow`. That structurally covers `WorkflowBuilder` and the builders in `agent_framework_orchestrations` +without making `agent-framework-hosting` depend on the orchestration package. + ### Responses-only route This sketch shows the intended Responses-only shape. The protocol package owns the Agent Framework run conversion helpers and @@ -242,13 +307,8 @@ import os from agent_framework import Agent from agent_framework.openai import OpenAIChatClient -from agent_framework_hosting import AgentFrameworkState, SessionStore -from agent_framework_hosting_responses import ( - create_response_id, - responses_from_run, - responses_session_id, - responses_to_run, -) +from agent_framework_hosting import AgentState # pyright: ignore[reportAttributeAccessIssue] +from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_session_id, responses_to_run # pyright: ignore[reportAttributeAccessIssue] from fastapi import Body, FastAPI, Header, HTTPException from fastapi.responses import JSONResponse @@ -259,7 +319,7 @@ agent = Agent( name="Assistant", instructions="Be concise and helpful.", ) -state = AgentFrameworkState(agent, session_store=SessionStore) +state = AgentState(agent) @app.post("/responses") @@ -280,15 +340,17 @@ async def responses(body: dict = Body(...), x_api_key: str | None = Header(defau options["store"] = False options.pop("model", None) - # load the session (or a new one) - session = await state.session_store.get(session_id or response_id) + # load the session (or create a new one) + session = await state.get_or_create_session(session_id or response_id) # call the agent - result = await state.target.run( + target = await state.get_target() + result = await target.run( run["messages"], session=session, options=options, ) - # any post-processing steps the developer wants to do can be done here + # agent.run may update the session, so store the post-run session explicitly under the response id + await state.session_store.set(response_id, session) return JSONResponse(responses_from_run(result, response_id=response_id, session_id=session_id)) ``` @@ -306,13 +368,8 @@ import os from agent_framework import Agent from agent_framework.openai import OpenAIChatClient -from agent_framework_hosting import AgentFrameworkState, SessionStore -from agent_framework_hosting_responses import ( - create_response_id, - responses_from_run, - responses_session_id, - responses_to_run, -) +from agent_framework_hosting import AgentState # pyright: ignore[reportAttributeAccessIssue] +from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_session_id, responses_to_run # pyright: ignore[reportAttributeAccessIssue] from asgiref.sync import async_to_sync from django.http import HttpRequest, HttpResponseBadRequest, HttpResponseForbidden, JsonResponse from django.views import View @@ -323,7 +380,7 @@ agent = Agent( name="Assistant", instructions="Be concise and helpful.", ) -state = AgentFrameworkState(agent, session_store=SessionStore) +state = AgentState(agent) class ResponsesView(View): @@ -340,10 +397,13 @@ class ResponsesView(View): session_id = responses_session_id(body) response_id = create_response_id() options = run["options"] - result = async_to_sync(state.target.run)( + session = async_to_sync(state.get_or_create_session)(session_id or response_id) + target = async_to_sync(state.get_target)() + result = async_to_sync(target.run)( run["messages"], - session=async_to_sync(state.session_store.get)(session_id or response_id), + session=session, options=options, ) + async_to_sync(state.session_store.set)(response_id, session) return JsonResponse(responses_from_run(result, response_id=response_id, session_id=session_id)) ``` From 107aab65fa577cf4639b251bf616c41ed99a143f Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 3 Jul 2026 14:42:38 +0200 Subject: [PATCH 03/16] Generalize protocol helper taxonomy Add protocol-neutral helper families for run conversion, result rendering, streaming, session-id extraction, and command/action parsing. Classify protocol-specific helpers based on quick scans across Activity/Bot Framework, Discord, A2A, and MCP. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions/0027-hosting-channels.md | 55 ++++++++++++++++++------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index 6f65152f115..5236f328f1f 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -73,7 +73,7 @@ Protocol packages own: - parsing protocol-native input into Agent Framework run input and options; - rendering `AgentResponse`, `AgentResponseUpdate`, workflow results, or workflow updates back into protocol-native response/event payloads; -- protocol-specific isolation/session id helper functions when useful, such as `telegram_chat_session_id(update)`; +- protocol-specific isolation/session id helper functions when useful, such as `telegram_session_id(update)`; - protocol-specific typing/update event helpers where the protocol has a native concept. Application or web-framework code owns: @@ -102,18 +102,41 @@ object has both the store and the resolved target. These objects are **not** app objects, channel registries, or route owners. They do not own FastAPI/Starlette setup, route contribution, protocol dispatch, command projection, or native SDK calls. -### Helper naming +### Helper naming and families -Helpers should be protocol-specific, not generic. Prefer: +Helpers should be protocol-specific, not generic. Avoid a generic `protocol_to_run(...)` name in public samples because it +hides the protocol-specific contract behind a second abstraction. -- `responses_to_run(...)` -- `responses_from_run(...)` -- `responses_stream_event_from_run(...)` if streaming needs a separate event helper -- `telegram_to_run(...)` -- `telegram_from_run(...)` +Protocol packages should consider these helper families. Not every protocol needs every helper, but when a protocol has +the concept the naming should stay consistent: -Avoid a generic `protocol_to_run(...)` name in public samples because it hides the protocol-specific contract behind a -second abstraction. +| Helper family | Shape | Purpose | +| --- | --- | --- | +| Run conversion | `_to_run(...)` | Convert one protocol-native call/update/request into `Agent.run` or `Workflow.run` values. | +| Final rendering | `_from_run(...)` | Convert a final `AgentResponse` / workflow result into protocol-native response payloads or operations. | +| Stream rendering | `_stream_events_from_run(...)` or `_stream_ops_from_run(...)` | Convert `ResponseStream` / workflow updates into protocol-native events or operations. | +| Session id extraction | `_session_id(...)` | Extract the protocol's natural continuation/partition key from the call, if present. | +| Command/action parsing | `_command(...)` | Parse a protocol-native command/action/operation name without deciding app policy. | + +Examples: + +- `responses_to_run(...)`, `responses_from_run(...)`, `responses_stream_events_from_run(...)`, + `responses_session_id(...)`; +- `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_stream_ops_from_run(...)`, + `telegram_session_id(...)`, `telegram_command(...)`; +- `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, `activity_command(...)`; +- `discord_to_run(...)`, `discord_from_run(...)`, `discord_session_id(...)`, `discord_command(...)`. + +The app still owns what a parsed command means. For example, a Telegram `/new`, Discord slash command, Bot Framework +command activity, or A2A cancellation/request action may parse through a command/action helper, but the route or SDK +handler decides whether that command clears a session, cancels a task, calls an agent, or is ignored. + +Additional helper functions can be protocol-specific when the concept is not broadly shared. Examples include +`telegram_chat_id(...)`, `telegram_callback_query_id(...)`, `telegram_media_file_id(...)`, +`discord_interaction_id(...)`, `activity_conversation_id(...)`, `a2a_task_id(...)`, `a2a_context_id(...)`, and MCP +tool/prompt/resource helpers. These helpers should still stay side-effect-free: they extract, normalize, or describe +protocol data, while app/native SDK code performs acknowledgements, sends/edits messages, resolves protected file URLs, +applies rate limits, and registers handlers. ### Session continuity @@ -128,7 +151,10 @@ The app chooses which helper to call for that route and deployment. For example: - `responses_session_id(body)` from `agent-framework-hosting-responses`, which can return either a `resp_*` previous response id or a `conv_*` conversation id when present; -- `telegram_chat_session_id(update)` from `agent-framework-hosting-telegram`; +- `telegram_session_id(update)` from `agent-framework-hosting-telegram`, which can choose the chat, user, thread, or + other Telegram-native partitioning logic for that helper; +- `activity_session_id(activity)`, `discord_session_id(interaction_or_message)`, or + `a2a_session_id(request_context)` from their respective protocol packages; - `foundry_user_isolation_key()` or `foundry_chat_isolation_key()` from `agent-framework-foundry-hosting`. Keep these helpers outside `responses_to_run(...)`, `telegram_to_run(...)`, and other run-input parsers. That makes the @@ -237,9 +263,10 @@ Before this ADR is considered implemented: - A Responses sample uses normal FastAPI route code plus `responses_to_run(...)`, `responses_from_run(...)`, and `AgentState` / `SessionStore`; it does not use `ResponsesChannel` or `ChannelRunHook`. -- Protocol helper tests cover Responses input parsing, option policy, response rendering, and streaming event rendering. -- Protocol helper tests cover Telegram message parsing, typing events, streaming update events, final response rendering, - and session-key derivation. +- Protocol helper tests cover Responses input parsing, option policy, response rendering, session-id extraction, and + streaming event rendering. +- Protocol helper tests cover Telegram message parsing, command parsing, session-id extraction, typing/operation helpers, + streaming update operations, and final response rendering. - `SessionStore` tests prove plain get/set/delete behavior, and `AgentState` tests prove get-or-create behavior. - `WorkflowState` tests prove workflow factory, `WorkflowBuilder`, orchestration-style builder, and checkpoint-store behavior. From bd79f7c1635193372aec364f786fbc869ae992a3 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 3 Jul 2026 14:46:08 +0200 Subject: [PATCH 04/16] Simplify stream helper naming Use the single _stream_from_run(...) helper naming convention in the hosting protocol-helper ADR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions/0027-hosting-channels.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index 5236f328f1f..9a790b026ad 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -114,15 +114,15 @@ the concept the naming should stay consistent: | --- | --- | --- | | Run conversion | `_to_run(...)` | Convert one protocol-native call/update/request into `Agent.run` or `Workflow.run` values. | | Final rendering | `_from_run(...)` | Convert a final `AgentResponse` / workflow result into protocol-native response payloads or operations. | -| Stream rendering | `_stream_events_from_run(...)` or `_stream_ops_from_run(...)` | Convert `ResponseStream` / workflow updates into protocol-native events or operations. | +| Stream rendering | `_stream_from_run(...)` | Convert `ResponseStream` / workflow updates into protocol-native events or operations. | | Session id extraction | `_session_id(...)` | Extract the protocol's natural continuation/partition key from the call, if present. | | Command/action parsing | `_command(...)` | Parse a protocol-native command/action/operation name without deciding app policy. | Examples: -- `responses_to_run(...)`, `responses_from_run(...)`, `responses_stream_events_from_run(...)`, +- `responses_to_run(...)`, `responses_from_run(...)`, `responses_stream_from_run(...)`, `responses_session_id(...)`; -- `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_stream_ops_from_run(...)`, +- `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_stream_from_run(...)`, `telegram_session_id(...)`, `telegram_command(...)`; - `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, `activity_command(...)`; - `discord_to_run(...)`, `discord_from_run(...)`, `discord_session_id(...)`, `discord_command(...)`. From a0952c526b7d1ec7481ae12911fcaeec312f93f6 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 3 Jul 2026 15:03:24 +0200 Subject: [PATCH 05/16] Use state-level storage helpers in hosting ADR Update ADR examples so app code calls AgentState.set_session and WorkflowState.set_checkpoint_storage instead of reaching into underlying stores directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions/0027-hosting-channels.md | 38 ++++++++----------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index 9a790b026ad..e6e3dddcf8c 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -178,7 +178,7 @@ If the protocol mints a new continuation id as part of the response being create session = await state.get_or_create_session(previous_response_id) target = await state.get_target() result = await target.run(messages, session=session, options=options) -await state.session_store.set(response_id, session) +await state.set_session(response_id, session) ``` `agent.run(...)` may update the session object (for example, with service continuation state), so the explicit store call @@ -200,6 +200,7 @@ workflow through the state object's target: storage = await state.get_or_create_checkpoint_storage(session_id) target = await state.get_target() result = await target.run(message=workflow_input, checkpoint_storage=storage) +await state.set_checkpoint_storage(session_id, storage) ``` If a route wants to resume from a prior checkpoint, it explicitly chooses the checkpoint and passes it to @@ -214,6 +215,7 @@ result = await target.run( checkpoint_id=latest.checkpoint_id if latest else None, checkpoint_storage=storage, ) +await state.set_checkpoint_storage(session_id, storage) ``` `workflow.run(...)` writes checkpoints to the provided storage, so storage selection must be explicit at the route layer. @@ -257,24 +259,6 @@ Negative: - Apps that want a batteries-included ASGI app must write or depend on an app-specific wrapper. - Existing unreleased code and docs that mention channels, contribution, or hooks must be revised before release. -## Validation Gates - -Before this ADR is considered implemented: - -- A Responses sample uses normal FastAPI route code plus `responses_to_run(...)`, `responses_from_run(...)`, and - `AgentState` / `SessionStore`; it does not use `ResponsesChannel` or `ChannelRunHook`. -- Protocol helper tests cover Responses input parsing, option policy, response rendering, session-id extraction, and - streaming event rendering. -- Protocol helper tests cover Telegram message parsing, command parsing, session-id extraction, typing/operation helpers, - streaming update operations, and final response rendering. -- `SessionStore` tests prove plain get/set/delete behavior, and `AgentState` tests prove get-or-create behavior. -- `WorkflowState` tests prove workflow factory, `WorkflowBuilder`, orchestration-style builder, and checkpoint-store - behavior. -- The same helper functions can be used without FastAPI in at least one direct unit test or sample. -- The v1 public package docs do not advertise `Channel`, contribution, command, or hook APIs as the intended released - surface. -- The Python spec is updated to match this revised contract. - ## More Information - Follow-up linking and multicast ADR: [ADR-0028](0028-hosting-linking-multicast-enhancements.md). That ADR still uses @@ -312,12 +296,14 @@ class AgentState: def __init__(self, target: SupportsAgentRun, *, session_store: SessionStore | None = None) -> None: ... async def get_target(self) -> SupportsAgentRun: ... async def get_or_create_session(self, session_id: str) -> AgentSession: ... + async def set_session(self, session_id: str, session: AgentSession) -> None: ... class WorkflowState: def __init__(self, target: Workflow | SupportsBuild, *, checkpoint_store: CheckpointStore | None = None) -> None: ... async def get_target(self) -> Workflow: ... async def get_or_create_checkpoint_storage(self, session_id: str) -> CheckpointStorage: ... + async def set_checkpoint_storage(self, session_id: str, storage: CheckpointStorage) -> None: ... ``` `WorkflowState` accepts direct `Workflow` instances, workflow factories, and builder-shaped objects with @@ -363,21 +349,21 @@ async def responses(body: dict = Body(...), x_api_key: str | None = Header(defau response_id = create_response_id() # in this space, the developer can make any adjustments to the request, i.e.: - options = dict(run["options"]) - options["store"] = False - options.pop("model", None) + run["options"]["store"] = False + run["options"].pop("model", None) - # load the session (or create a new one) + # load the session (or create a new one) - this is optional session = await state.get_or_create_session(session_id or response_id) # call the agent target = await state.get_target() result = await target.run( run["messages"], session=session, - options=options, + options=run["options"], ) # agent.run may update the session, so store the post-run session explicitly under the response id - await state.session_store.set(response_id, session) + # this might also be skipped, if the app chooses to respect `store=False` policy + await state.set_session(response_id, session) return JSONResponse(responses_from_run(result, response_id=response_id, session_id=session_id)) ``` @@ -431,6 +417,6 @@ class ResponsesView(View): session=session, options=options, ) - async_to_sync(state.session_store.set)(response_id, session) + async_to_sync(state.set_session)(response_id, session) return JsonResponse(responses_from_run(result, response_id=response_id, session_id=session_id)) ``` From ca599835f5691b0d17131bdd4df7109218679957 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 6 Jul 2026 10:43:52 +0200 Subject: [PATCH 06/16] Address hosting ADR review comments Clarify fail-closed Foundry isolation helpers, fix workflow checkpoint resume examples, describe durable checkpoint cursor storage, add caller-owned session authorization comments, and switch the Django sketch to an async view. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions/0027-hosting-channels.md | 73 +++++++++++++++++-------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index e6e3dddcf8c..ca95d15e305 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -133,10 +133,10 @@ handler decides whether that command clears a session, cancels a task, calls an Additional helper functions can be protocol-specific when the concept is not broadly shared. Examples include `telegram_chat_id(...)`, `telegram_callback_query_id(...)`, `telegram_media_file_id(...)`, -`discord_interaction_id(...)`, `activity_conversation_id(...)`, `a2a_task_id(...)`, `a2a_context_id(...)`, and MCP -tool/prompt/resource helpers. These helpers should still stay side-effect-free: they extract, normalize, or describe -protocol data, while app/native SDK code performs acknowledgements, sends/edits messages, resolves protected file URLs, -applies rate limits, and registers handlers. +`discord_interaction_id(...)`, `a2a_task_id(...)`, `a2a_context_id(...)`, and MCP tool/prompt/resource helpers. These +helpers should still stay side-effect-free: they extract, normalize, or describe protocol data, while app/native SDK code +performs acknowledgements, sends/edits messages, resolves protected file URLs, applies rate limits, and registers +handlers. ### Session continuity @@ -159,6 +159,10 @@ The app chooses which helper to call for that route and deployment. For example: Keep these helpers outside `responses_to_run(...)`, `telegram_to_run(...)`, and other run-input parsers. That makes the trust boundary visible: using a request-derived key is a different decision than using a platform-provided isolation key. +Platform-provided isolation helpers must fail closed outside their trusted hosting environment. For example, +Foundry-specific helpers may read values established by Foundry hosting middleware, but must not treat raw request +headers as trusted Foundry isolation when the app is running outside Foundry. Implementations must test that non-Foundry +requests do not accept spoofable isolation headers as platform-provided keys. A `SessionStore` stores `session_id -> AgentSession`, but it does not create sessions. `AgentState` resolves the agent target and creates the session on first use: @@ -189,9 +193,17 @@ externally supplied key before using it. ### Workflow checkpoints -Workflow checkpointing is execution state, not protocol state. `WorkflowState` pairs a workflow target with a -`CheckpointStore` (`session_id -> CheckpointStorage`) and provides `get_or_create_checkpoint_storage(...)` for first use. -The store itself remains plain storage and does not decide which checkpoint to resume. +Workflow checkpointing is execution state, not protocol state. `WorkflowState` pairs a workflow target with checkpoint +state, but durable stores should not persist live `CheckpointStorage` client instances by value. Two shapes are useful: + +- local/in-memory state may map `session_id -> CheckpointStorage` when the storage object is process-local; +- durable or multi-replica state should map `session_id -> checkpoint_id` (or an equivalent cursor/config) and use a + workflow-owned or app-owned `CheckpointStorage` to load that checkpoint. + +Workflow runs do not currently emit a checkpoint id on `WorkflowRunResult` or normal workflow events by default. The +runner receives checkpoint ids internally from `CheckpointStorage.save(...)`. App/state code that owns the storage can +observe the latest id by querying the storage after a run, for example +`await storage.get_latest(workflow_name=target.name)`. For workflow targets, app code adapts the protocol helper output into the workflow's expected input and invokes the workflow through the state object's target: @@ -200,7 +212,9 @@ workflow through the state object's target: storage = await state.get_or_create_checkpoint_storage(session_id) target = await state.get_target() result = await target.run(message=workflow_input, checkpoint_storage=storage) -await state.set_checkpoint_storage(session_id, storage) +latest = await storage.get_latest(workflow_name=target.name) +if latest is not None: + await state.set_checkpoint_id(session_id, latest.checkpoint_id) ``` If a route wants to resume from a prior checkpoint, it explicitly chooses the checkpoint and passes it to @@ -209,13 +223,14 @@ If a route wants to resume from a prior checkpoint, it explicitly chooses the ch ```python storage = await state.get_or_create_checkpoint_storage(session_id) target = await state.get_target() +checkpoint_id = await state.get_checkpoint_id(session_id) +if checkpoint_id is None: + result = await target.run(message=workflow_input, checkpoint_storage=storage) +else: + result = await target.run(checkpoint_id=checkpoint_id, checkpoint_storage=storage) latest = await storage.get_latest(workflow_name=target.name) -result = await target.run( - message=workflow_input, - checkpoint_id=latest.checkpoint_id if latest else None, - checkpoint_storage=storage, -) -await state.set_checkpoint_storage(session_id, storage) +if latest is not None: + await state.set_checkpoint_id(session_id, latest.checkpoint_id) ``` `workflow.run(...)` writes checkpoints to the provided storage, so storage selection must be explicit at the route layer. @@ -287,8 +302,8 @@ class SessionStore: class CheckpointStore: - async def get(self, session_id: str) -> CheckpointStorage | None: ... - async def set(self, session_id: str, storage: CheckpointStorage) -> None: ... + async def get(self, session_id: str) -> str | None: ... + async def set(self, session_id: str, checkpoint_id: str) -> None: ... async def delete(self, session_id: str) -> None: ... @@ -300,10 +315,17 @@ class AgentState: class WorkflowState: - def __init__(self, target: Workflow | SupportsBuild, *, checkpoint_store: CheckpointStore | None = None) -> None: ... + def __init__( + self, + target: Workflow | SupportsBuild, + *, + checkpoint_storage: CheckpointStorage | None = None, + checkpoint_store: CheckpointStore | None = None, + ) -> None: ... async def get_target(self) -> Workflow: ... async def get_or_create_checkpoint_storage(self, session_id: str) -> CheckpointStorage: ... - async def set_checkpoint_storage(self, session_id: str, storage: CheckpointStorage) -> None: ... + async def get_checkpoint_id(self, session_id: str) -> str | None: ... + async def set_checkpoint_id(self, session_id: str, checkpoint_id: str) -> None: ... ``` `WorkflowState` accepts direct `Workflow` instances, workflow factories, and builder-shaped objects with @@ -353,6 +375,8 @@ async def responses(body: dict = Body(...), x_api_key: str | None = Header(defau run["options"].pop("model", None) # load the session (or create a new one) - this is optional + # verify this caller owns session_id before loading it; API-key auth alone + # does not prove ownership of a caller-supplied resp_* or conv_* id session = await state.get_or_create_session(session_id or response_id) # call the agent target = await state.get_target() @@ -383,7 +407,6 @@ from agent_framework import Agent from agent_framework.openai import OpenAIChatClient from agent_framework_hosting import AgentState # pyright: ignore[reportAttributeAccessIssue] from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_session_id, responses_to_run # pyright: ignore[reportAttributeAccessIssue] -from asgiref.sync import async_to_sync from django.http import HttpRequest, HttpResponseBadRequest, HttpResponseForbidden, JsonResponse from django.views import View @@ -397,7 +420,7 @@ state = AgentState(agent) class ResponsesView(View): - def post(self, request: HttpRequest) -> JsonResponse: + async def post(self, request: HttpRequest) -> JsonResponse: if request.headers.get("x-api-key") != os.environ["RESPONSES_API_KEY"]: return HttpResponseForbidden("bad api key") @@ -410,13 +433,15 @@ class ResponsesView(View): session_id = responses_session_id(body) response_id = create_response_id() options = run["options"] - session = async_to_sync(state.get_or_create_session)(session_id or response_id) - target = async_to_sync(state.get_target)() - result = async_to_sync(target.run)( + # verify this caller owns session_id before loading it; API-key auth alone + # does not prove ownership of a caller-supplied resp_* or conv_* id + session = await state.get_or_create_session(session_id or response_id) + target = await state.get_target() + result = await target.run( run["messages"], session=session, options=options, ) - async_to_sync(state.set_session)(response_id, session) + await state.set_session(response_id, session) return JsonResponse(responses_from_run(result, response_id=response_id, session_id=session_id)) ``` From 2bbcb8e8e53414fbc0421c2de112bff149fd0e85 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 6 Jul 2026 11:56:58 +0200 Subject: [PATCH 07/16] Simplify workflow checkpoint state in hosting ADR Keep WorkflowState focused on resolving workflow targets, use existing CheckpointStorage directly, describe app-owned checkpoint cursor storage, and mark appendix code as minimum-shape sketches rather than runtime-ready samples. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions/0027-hosting-channels.md | 125 ++++++++++++++---------- 1 file changed, 73 insertions(+), 52 deletions(-) diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index ca95d15e305..2f15bb0a112 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -59,7 +59,7 @@ calls. Agent Framework should not duplicate those surfaces unless a specific hos - Good: apps keep native FastAPI, Starlette, Azure Functions, Django, Bot Framework, or Telegram SDK code. - Good: helper functions can be tested without an ASGI app or host pipeline. - Good: small state objects can still own target-coupled state: `AgentState` pairs an agent target with a `SessionStore`, - and `WorkflowState` pairs a workflow target with a `CheckpointStore`. + and `WorkflowState` resolves a workflow target while reusing the existing `CheckpointStorage` abstraction. - Good: provides maximum configurability in handling input and outputs (outside of the conversions) - Bad: building a first iteration of a new Host is more verbose. - Bad: samples show more explicit route/client code than a fully assembled channel host. @@ -91,13 +91,13 @@ Application or web-framework code owns: The optional execution-state helpers, if provided, are limited to shared execution state: - `AgentState`: one `SupportsAgentRun`-compatible target plus a `SessionStore`; -- `WorkflowState`: one `Workflow`, `WorkflowBuilder`-shaped builder, orchestration builder, or workflow factory plus a - `CheckpointStore`; -- `SessionStore` and `CheckpointStore`: plain async storage (`get` / `set` / `delete`) by an app-selected id. +- `WorkflowState`: one `Workflow`, `WorkflowBuilder`-shaped builder, orchestration builder, or workflow factory; +- `SessionStore`: plain async storage (`get` / `set` / `delete`) by an app-selected id. -The stores do not create sessions or checkpoint storage. State objects provide the target-aware helpers -(`AgentState.get_or_create_session(...)`, `WorkflowState.get_or_create_checkpoint_storage(...)`) because only the state -object has both the store and the resolved target. +The store does not create sessions. `AgentState` provides the target-aware `get_or_create_session(...)` helper because +only the state object has both the store and the resolved agent target. Workflow checkpointing should use the existing +`CheckpointStorage` abstraction directly; app/state code may keep a small cursor (`session_id -> checkpoint_id`) when it +needs to resume a workflow for a session. These objects are **not** app objects, channel registries, or route owners. They do not own FastAPI/Starlette setup, route contribution, protocol dispatch, command projection, or native SDK calls. @@ -107,8 +107,8 @@ route contribution, protocol dispatch, command projection, or native SDK calls. Helpers should be protocol-specific, not generic. Avoid a generic `protocol_to_run(...)` name in public samples because it hides the protocol-specific contract behind a second abstraction. -Protocol packages should consider these helper families. Not every protocol needs every helper, but when a protocol has -the concept the naming should stay consistent: +Protocol packages should consider these helper families. This table is a set of examples, not a required protocol or +checklist. Not every protocol needs every helper, but when a protocol has the concept the naming should stay consistent: | Helper family | Shape | Purpose | | --- | --- | --- | @@ -138,6 +138,35 @@ helpers should still stay side-effect-free: they extract, normalize, or describe performs acknowledgements, sends/edits messages, resolves protected file URLs, applies rate limits, and registers handlers. +### Security responsibilities for application builders + +The application builder owns the trust boundary. Protocol helper packages can parse native payloads and expose candidate +ids or operations, but they do not authenticate callers, authorize access to state, or decide which side effects are +allowed. + +Application code that uses these helpers must: + +- authenticate the caller through the app's normal mechanism before using protocol-provided ids; +- authorize any caller-supplied session, checkpoint, task, context, conversation, thread, or response id before loading + state for it; +- bind externally supplied ids to the authenticated user, tenant, workspace, installation, or chat context before using + them as `SessionStore` keys or checkpoint cursor keys; +- treat `_session_id(...)` results as untrusted candidate keys until that ownership check has passed; +- keep platform-provided isolation helpers fail-closed outside their trusted hosting environment; +- authorize command/action effects such as reset, cancel, approve, submit, or tool invocation after parsing them; +- opt in explicitly before resolving protected media/resource/file URLs and passing them to a remote model provider; +- persist post-run session or checkpoint state only after `agent.run(...)`, `workflow.run(...)`, or stream finalization has + updated that state. + +For Foundry specifically, helpers may read values established by Foundry hosting middleware, but must not treat raw +request headers as trusted Foundry isolation when the app is running outside Foundry. Implementations must test that +non-Foundry requests do not accept spoofable isolation headers as platform-provided keys. + +For durable workflow checkpointing, the checkpoint boundary must be at least as specific as the authorized session/tenant +boundary. A shared storage lookup such as "latest checkpoint for workflow name" is safe only when the storage is already +scoped to the authorized session. In a shared durable store, map the authorized `session_id` to a checkpoint id or other +cursor and load that specific checkpoint. + ### Session continuity Session continuity remains explicit. Run parsing and isolation/session id selection are separate operations because @@ -159,10 +188,6 @@ The app chooses which helper to call for that route and deployment. For example: Keep these helpers outside `responses_to_run(...)`, `telegram_to_run(...)`, and other run-input parsers. That makes the trust boundary visible: using a request-derived key is a different decision than using a platform-provided isolation key. -Platform-provided isolation helpers must fail closed outside their trusted hosting environment. For example, -Foundry-specific helpers may read values established by Foundry hosting middleware, but must not treat raw request -headers as trusted Foundry isolation when the app is running outside Foundry. Implementations must test that non-Foundry -requests do not accept spoofable isolation headers as platform-provided keys. A `SessionStore` stores `session_id -> AgentSession`, but it does not create sessions. `AgentState` resolves the agent target and creates the session on first use: @@ -194,11 +219,9 @@ externally supplied key before using it. ### Workflow checkpoints Workflow checkpointing is execution state, not protocol state. `WorkflowState` pairs a workflow target with checkpoint -state, but durable stores should not persist live `CheckpointStorage` client instances by value. Two shapes are useful: - -- local/in-memory state may map `session_id -> CheckpointStorage` when the storage object is process-local; -- durable or multi-replica state should map `session_id -> checkpoint_id` (or an equivalent cursor/config) and use a - workflow-owned or app-owned `CheckpointStorage` to load that checkpoint. +state, but it should not wrap or replace the existing `CheckpointStorage` abstraction. Apps should pass the actual +`CheckpointStorage` they want the workflow to use. If an app needs per-session resume, it can keep a small cursor from +authorized `session_id` to `checkpoint_id` (or an equivalent store-specific resume token). Workflow runs do not currently emit a checkpoint id on `WorkflowRunResult` or normal workflow events by default. The runner receives checkpoint ids internally from `CheckpointStorage.save(...)`. App/state code that owns the storage can @@ -209,28 +232,28 @@ For workflow targets, app code adapts the protocol helper output into the workfl workflow through the state object's target: ```python -storage = await state.get_or_create_checkpoint_storage(session_id) +# session_id must already be authenticated and authorized for this caller target = await state.get_target() -result = await target.run(message=workflow_input, checkpoint_storage=storage) -latest = await storage.get_latest(workflow_name=target.name) +result = await target.run(message=workflow_input, checkpoint_storage=checkpoint_storage) +latest = await checkpoint_storage.get_latest(workflow_name=target.name) if latest is not None: - await state.set_checkpoint_id(session_id, latest.checkpoint_id) + await checkpoint_cursor_store.set(session_id, latest.checkpoint_id) ``` If a route wants to resume from a prior checkpoint, it explicitly chooses the checkpoint and passes it to `workflow.run(...)`: ```python -storage = await state.get_or_create_checkpoint_storage(session_id) +# session_id must already be authenticated and authorized for this caller target = await state.get_target() -checkpoint_id = await state.get_checkpoint_id(session_id) +checkpoint_id = await checkpoint_cursor_store.get(session_id) if checkpoint_id is None: - result = await target.run(message=workflow_input, checkpoint_storage=storage) + result = await target.run(message=workflow_input, checkpoint_storage=checkpoint_storage) else: - result = await target.run(checkpoint_id=checkpoint_id, checkpoint_storage=storage) -latest = await storage.get_latest(workflow_name=target.name) + result = await target.run(checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage) +latest = await checkpoint_storage.get_latest(workflow_name=target.name) if latest is not None: - await state.set_checkpoint_id(session_id, latest.checkpoint_id) + await checkpoint_cursor_store.set(session_id, latest.checkpoint_id) ``` `workflow.run(...)` writes checkpoints to the provided storage, so storage selection must be explicit at the route layer. @@ -281,6 +304,10 @@ Negative: ## Appendix: Developer experience sketch +The examples below are sketches, not runtime-ready sample code. They show the minimum shape a developer would need to +build: where protocol helpers are called, where app-owned auth/authorization belongs, where state is loaded/stored, and +where native framework code remains in charge. + ### Optional execution state `AgentState` and `WorkflowState` stay small: they are target-specific state holders, not app hosts. @@ -288,7 +315,7 @@ Negative: ```python from typing import Protocol -from agent_framework import AgentSession, CheckpointStorage, SupportsAgentRun, Workflow +from agent_framework import AgentSession, SupportsAgentRun, Workflow class SupportsBuild(Protocol): @@ -301,7 +328,7 @@ class SessionStore: async def delete(self, session_id: str) -> None: ... -class CheckpointStore: +class CheckpointCursorStore: async def get(self, session_id: str) -> str | None: ... async def set(self, session_id: str, checkpoint_id: str) -> None: ... async def delete(self, session_id: str) -> None: ... @@ -315,17 +342,8 @@ class AgentState: class WorkflowState: - def __init__( - self, - target: Workflow | SupportsBuild, - *, - checkpoint_storage: CheckpointStorage | None = None, - checkpoint_store: CheckpointStore | None = None, - ) -> None: ... + def __init__(self, target: Workflow | SupportsBuild) -> None: ... async def get_target(self) -> Workflow: ... - async def get_or_create_checkpoint_storage(self, session_id: str) -> CheckpointStorage: ... - async def get_checkpoint_id(self, session_id: str) -> str | None: ... - async def set_checkpoint_id(self, session_id: str, checkpoint_id: str) -> None: ... ``` `WorkflowState` accepts direct `Workflow` instances, workflow factories, and builder-shaped objects with @@ -364,20 +382,22 @@ async def responses(body: dict = Body(...), x_api_key: str | None = Header(defau # parse the request body into a set of AF objects run = responses_to_run(body) - # get the session id from the body + # get the candidate session id from the body # can be a resp_* for previous_response_id or a conv_* for a conversation - session_id = responses_session_id(body) + candidate_session_id = responses_session_id(body) # create a new response_id for this run response_id = create_response_id() - # in this space, the developer can make any adjustments to the request, i.e.: + # the developer can make any adjustments to the request, i.e.: run["options"]["store"] = False run["options"].pop("model", None) + # the options here are of the shape defined by the ChatClient/Agent # load the session (or create a new one) - this is optional - # verify this caller owns session_id before loading it; API-key auth alone - # does not prove ownership of a caller-supplied resp_* or conv_* id - session = await state.get_or_create_session(session_id or response_id) + # verify this caller owns candidate_session_id before loading it; API-key auth + # alone does not prove ownership of a caller-supplied resp_* or conv_* id + session_id = candidate_session_id or response_id + session = await state.get_or_create_session(session_id) # call the agent target = await state.get_target() result = await target.run( @@ -388,7 +408,7 @@ async def responses(body: dict = Body(...), x_api_key: str | None = Header(defau # agent.run may update the session, so store the post-run session explicitly under the response id # this might also be skipped, if the app chooses to respect `store=False` policy await state.set_session(response_id, session) - return JSONResponse(responses_from_run(result, response_id=response_id, session_id=session_id)) + return JSONResponse(responses_from_run(result, response_id=response_id, session_id=candidate_session_id)) ``` @@ -430,12 +450,13 @@ class ResponsesView(View): return HttpResponseBadRequest("invalid json") run = responses_to_run(body) - session_id = responses_session_id(body) + candidate_session_id = responses_session_id(body) response_id = create_response_id() options = run["options"] - # verify this caller owns session_id before loading it; API-key auth alone - # does not prove ownership of a caller-supplied resp_* or conv_* id - session = await state.get_or_create_session(session_id or response_id) + # verify this caller owns candidate_session_id before loading it; API-key auth + # alone does not prove ownership of a caller-supplied resp_* or conv_* id + session_id = candidate_session_id or response_id + session = await state.get_or_create_session(session_id) target = await state.get_target() result = await target.run( run["messages"], @@ -443,5 +464,5 @@ class ResponsesView(View): options=options, ) await state.set_session(response_id, session) - return JsonResponse(responses_from_run(result, response_id=response_id, session_id=session_id)) + return JsonResponse(responses_from_run(result, response_id=response_id, session_id=candidate_session_id)) ``` From 1933f51933e2d9d7b065c5bbe76b59efca87f291 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 7 Jul 2026 09:34:54 +0200 Subject: [PATCH 08/16] Rename stream helper convention Use _from_streaming_run(...) as the protocol-helper naming convention for rendering streaming run output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions/0027-hosting-channels.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index 2f15bb0a112..3affccb973d 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -114,15 +114,15 @@ checklist. Not every protocol needs every helper, but when a protocol has the co | --- | --- | --- | | Run conversion | `_to_run(...)` | Convert one protocol-native call/update/request into `Agent.run` or `Workflow.run` values. | | Final rendering | `_from_run(...)` | Convert a final `AgentResponse` / workflow result into protocol-native response payloads or operations. | -| Stream rendering | `_stream_from_run(...)` | Convert `ResponseStream` / workflow updates into protocol-native events or operations. | +| Stream rendering | `_from_streaming_run(...)` | Convert `ResponseStream` / workflow updates into protocol-native events or operations. | | Session id extraction | `_session_id(...)` | Extract the protocol's natural continuation/partition key from the call, if present. | | Command/action parsing | `_command(...)` | Parse a protocol-native command/action/operation name without deciding app policy. | Examples: -- `responses_to_run(...)`, `responses_from_run(...)`, `responses_stream_from_run(...)`, +- `responses_to_run(...)`, `responses_from_run(...)`, `responses_from_streaming_run(...)`, `responses_session_id(...)`; -- `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_stream_from_run(...)`, +- `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_from_streaming_run(...)`, `telegram_session_id(...)`, `telegram_command(...)`; - `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, `activity_command(...)`; - `discord_to_run(...)`, `discord_from_run(...)`, `discord_session_id(...)`, `discord_command(...)`. From f51f64c9479a7136aff97ee850b4bcdadeb2ad3a Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 7 Jul 2026 09:49:40 +0200 Subject: [PATCH 09/16] added notes on state and continuity --- docs/decisions/0027-hosting-channels.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index 3affccb973d..b736c990f7b 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -12,11 +12,6 @@ deciders: eavanvalkenburg Agent Framework needs to help applications expose agents and workflows over external protocols such as OpenAI Responses, Telegram, Activity Protocol, and future transports. -The first version of this ADR chose a host/channel model: channel packages contributed routes, middleware, commands, -lifecycle callbacks, hooks, and protocol dispatch to a common host object. That design was accepted before it was -released. Implementation experiments showed that the most valuable part is narrower: translating protocol-native -payloads into Agent Framework inputs and translating Agent Framework results back to protocol-native payloads. - FastAPI, Starlette, Azure Functions, Django, Telegram SDKs, Bot Framework SDKs, and other app frameworks already own route registration, dependency injection, middleware, authentication, background tasks, lifecycle, and native client calls. Agent Framework should not duplicate those surfaces unless a specific hosting environment requires it. @@ -189,6 +184,19 @@ The app chooses which helper to call for that route and deployment. For example: Keep these helpers outside `responses_to_run(...)`, `telegram_to_run(...)`, and other run-input parsers. That makes the trust boundary visible: using a request-derived key is a different decision than using a platform-provided isolation key. +The application builder is also responsible for deciding whether the hosting environment is **persistent** (for example, +a long-running container or web app) or **transient** (for example, Azure Functions, Foundry Hosted Agents, or any +environment where process memory is not a reliable continuity boundary). That decision controls which state mechanisms are +safe to use: + +- persistent single-process apps may use in-memory state for local development or simple deployments, while still needing + durable state for multi-replica continuity; +- transient apps must not rely on in-memory `SessionStore` state between calls and need a durable session store or a + service-owned continuation id; +- workflow hosts must choose an explicit `CheckpointStorage` and, when they need per-session resume, a durable + `session_id -> checkpoint_id` cursor because in-process workflow state and in-memory checkpoint cursors do not survive + transient execution. + A `SessionStore` stores `session_id -> AgentSession`, but it does not create sessions. `AgentState` resolves the agent target and creates the session on first use: From d8c0fe8d5061036808c309696db6123dfedafa87 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 7 Jul 2026 12:01:29 +0200 Subject: [PATCH 10/16] updates based on review --- docs/decisions/0027-hosting-channels.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index b736c990f7b..b2ea6afd937 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -19,12 +19,11 @@ calls. Agent Framework should not duplicate those surfaces unless a specific hos ## Decision Drivers - Keep the released surface small enough to explain without first teaching a channel framework. -- Provide reusable Agent Framework run translation that works with FastAPI and other web frameworks. +- Provide reusable Agent Framework run translation that works with FastAPI, Django, and other web frameworks. - Let app/framework code own route declaration, auth, middleware, native SDK clients, command handling, and background work. - Keep stateful execution support explicit: session lookup/storage and workflow checkpoint lookup/storage may still need a small AF-owned home. -- Avoid approving cross-channel identity and delivery semantics before their safety model is reviewed. ## Considered Options @@ -32,27 +31,27 @@ calls. Agent Framework should not duplicate those surfaces unless a specific hos 2. Ship a full host/channel framework with route contribution and channel hooks. 3. Ship protocol conversion helpers plus optional execution state. -### Create protocol-specific hosts +### 1. Create protocol-specific hosts - Good: no new shared abstraction. - Neutral: each protocol host can evolve independently. - Bad: every package reinvents AF input/result mapping, session-key conventions, and stateful execution helpers. -### Ship a full host/channel framework +### 2. Ship a full host/channel framework - Good: one object can assemble routes, channels, session handling, hooks, and lifecycle callbacks. - Good: app code using the supported host shape can be short. - Bad: the framework owns concerns already handled by web frameworks, protocol SDKs and/or other services. - Bad: users must understand `Channel`, contribution, hook, and host-dispatch concepts before they can see how a request becomes `agent.run(...)`. -- Bad: the abstraction is hard to reuse outside the chosen ASGI shape. +- Bad: the abstraction is hard to reuse outside the chosen web framework. -### Ship protocol helpers plus optional execution state +### 3. Ship protocol helpers plus optional execution state - Good: protocol packages provide the Agent Framework run value directly: `_to_run(...)` and `_from_run(...)` style helpers. - Good: apps keep native FastAPI, Starlette, Azure Functions, Django, Bot Framework, or Telegram SDK code. -- Good: helper functions can be tested without an ASGI app or host pipeline. +- Good: helper functions can be tested without a web framework app or host pipeline. - Good: small state objects can still own target-coupled state: `AgentState` pairs an agent target with a `SessionStore`, and `WorkflowState` resolves a workflow target while reusing the existing `CheckpointStorage` abstraction. - Good: provides maximum configurability in handling input and outputs (outside of the conversions) @@ -61,7 +60,7 @@ calls. Agent Framework should not duplicate those surfaces unless a specific hos ## Decision Outcome -Chosen option: **protocol helpers plus optional execution state**. +Chosen option: **3. Ship protocol helpers plus optional execution state**. Protocol packages own: From f592554fdae5d3099722a495c68be52f919dc11b Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 7 Jul 2026 12:04:35 +0200 Subject: [PATCH 11/16] added consulted --- docs/decisions/0027-hosting-channels.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index b2ea6afd937..04a40df39cd 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -3,6 +3,7 @@ status: accepted contact: eavanvalkenburg date: 2026-06-30 deciders: eavanvalkenburg +consulted: rogerbarreto, moonbox3 --- # Python protocol helpers and optional execution state From 855c3fb98625fce91911fc3ee6c0996efba96659 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 7 Jul 2026 12:39:36 +0200 Subject: [PATCH 12/16] updates based on review --- docs/decisions/0027-hosting-channels.md | 50 ++++++++++++++++--------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index 04a40df39cd..6291ffa8331 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -139,7 +139,7 @@ The application builder owns the trust boundary. Protocol helper packages can pa ids or operations, but they do not authenticate callers, authorize access to state, or decide which side effects are allowed. -Application code that uses these helpers must: +Application code that uses these helpers are responsible for: - authenticate the caller through the app's normal mechanism before using protocol-provided ids; - authorize any caller-supplied session, checkpoint, task, context, conversation, thread, or response id before loading @@ -157,7 +157,7 @@ For Foundry specifically, helpers may read values established by Foundry hosting request headers as trusted Foundry isolation when the app is running outside Foundry. Implementations must test that non-Foundry requests do not accept spoofable isolation headers as platform-provided keys. -For durable workflow checkpointing, the checkpoint boundary must be at least as specific as the authorized session/tenant +For workflow checkpointing, the checkpoint boundary must be at least as specific as the authorized session/tenant boundary. A shared storage lookup such as "latest checkpoint for workflow name" is safe only when the storage is already scoped to the authorized session. In a shared durable store, map the authorized `session_id` to a checkpoint id or other cursor and load that specific checkpoint. @@ -269,21 +269,37 @@ Protocol helper packages should not own checkpoint layout, route lifecycle, or d ## Non-goals for v1 -The following remain outside the v1 contract: - -- 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 protocol (`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`; -- a host-level multi-agent router. - -These areas are follow-up enhancements covered by [ADR-0028](0028-hosting-linking-multicast-enhancements.md). They are +The following remain outside the v1 protocol-helper contract. Some are deliberately app-owned in v1; others are possible +future framework work only after a separate design. + +### App-owned in v1 + +The app builder owns these concerns with normal web-framework, SDK, platform, or application code: + +- authentication, authorization policy, and allowlists; +- deciding whether identities across protocols map to the same `session_id`; +- non-originating sends using native SDK clients; +- background work, durable execution, retry, and replay when app code owns the work; +- routing between multiple agents. + +This is easier in the protocol-helper model than it was in the host/channel model: app code already owns the native SDK +clients, route handlers, authenticated caller context, session id selection, and outbound send calls. An app can link +channels by choosing the same authorized `session_id` for multiple protocols, and can do non-originating delivery by +calling the destination protocol's native client directly. That does not make a reusable framework feature safe by +default; it just means the app-specific version no longer has to fight a host abstraction. + +### Future framework work + +The following require a reviewed identity, storage, delivery, replay, and observability model before becoming reusable +framework features: + +- reusable cross-channel identity linking; +- framework-owned proactive or non-originating delivery; +- fan-out, multicast, selected-channel, active-channel, or all-linked delivery; +- framework-owned delivery observability, dead-letter handling, and replay semantics; +- cross-channel confidentiality and link policy. + +These possible framework enhancements are tracked by [ADR-0028](0028-hosting-linking-multicast-enhancements.md). They are not prerequisites for shipping or using the v1 protocol-helper surface. ADR-0028 was written against the earlier host/channel framing and must be revised to align with this protocol-helper and execution-state boundary before those enhancements are implemented. From 9bece4215e9121829a2a4ce329ded0a6672c2cf4 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 7 Jul 2026 13:33:34 +0200 Subject: [PATCH 13/16] remove pyright for illustrative code --- docs/decisions/0027-hosting-channels.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index 6291ffa8331..d4a2bacde19 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -384,11 +384,11 @@ import os from agent_framework import Agent from agent_framework.openai import OpenAIChatClient -from agent_framework_hosting import AgentState # pyright: ignore[reportAttributeAccessIssue] -from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_session_id, responses_to_run # pyright: ignore[reportAttributeAccessIssue] +from agent_framework_hosting import AgentState +from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_session_id, responses_to_run from fastapi import Body, FastAPI, Header, HTTPException from fastapi.responses import JSONResponse - +s app = FastAPI() agent = Agent( @@ -449,8 +449,8 @@ import os from agent_framework import Agent from agent_framework.openai import OpenAIChatClient -from agent_framework_hosting import AgentState # pyright: ignore[reportAttributeAccessIssue] -from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_session_id, responses_to_run # pyright: ignore[reportAttributeAccessIssue] +from agent_framework_hosting import AgentState +from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_session_id, responses_to_run from django.http import HttpRequest, HttpResponseBadRequest, HttpResponseForbidden, JsonResponse from django.views import View From edc2cb629f6261cad7d32ad27d2b4296243d0d14 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 8 Jul 2026 11:26:53 +0200 Subject: [PATCH 14/16] Add streaming to Responses ADR sketch Extend the FastAPI appendix sketch with the streaming branch and note that the Django sketch omits streaming to avoid duplicating the same state/finalization pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions/0027-hosting-channels.md | 40 ++++++++++++++++++------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index d4a2bacde19..3cd70ff25e9 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -381,14 +381,14 @@ response-id minting details; the application owns FastAPI routing, auth, policy ```python import os +from collections.abc import AsyncIterator -from agent_framework import Agent +from agent_framework import Agent, ResponseStream from agent_framework.openai import OpenAIChatClient -from agent_framework_hosting import AgentState -from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_session_id, responses_to_run +from agent_framework_hosting import AgentState # pyright: ignore[reportAttributeAccessIssue] +from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_from_streaming_run, responses_session_id, responses_to_run # pyright: ignore[reportAttributeAccessIssue] from fastapi import Body, FastAPI, Header, HTTPException -from fastapi.responses import JSONResponse -s +from fastapi.responses import JSONResponse, StreamingResponse app = FastAPI() agent = Agent( @@ -400,7 +400,7 @@ state = AgentState(agent) @app.post("/responses") -async def responses(body: dict = Body(...), x_api_key: str | None = Header(default=None)) -> JSONResponse: +async def responses(body: dict = Body(...), x_api_key: str | None = Header(default=None)) -> JSONResponse | StreamingResponse: if x_api_key != os.environ["RESPONSES_API_KEY"]: raise HTTPException(status_code=401, detail="bad api key") @@ -422,8 +422,27 @@ async def responses(body: dict = Body(...), x_api_key: str | None = Header(defau # alone does not prove ownership of a caller-supplied resp_* or conv_* id session_id = candidate_session_id or response_id session = await state.get_or_create_session(session_id) - # call the agent target = await state.get_target() + + if run["stream"]: + stream = target.run( + run["messages"], + stream=True, + session=session, + options=run["options"], + ) + async def stream_events() -> AsyncIterator[str]: + async for event in responses_from_streaming_run( + stream, + response_id=response_id, + session_id=candidate_session_id, + ): + yield event + # agent.run may update the session during stream finalization, so store the post-run session explicitly + await state.set_session(response_id, session) + + return StreamingResponse(stream_events(), media_type="text/event-stream") + result = await target.run( run["messages"], session=session, @@ -441,7 +460,8 @@ async def responses(body: dict = Body(...), x_api_key: str | None = Header(defau The same helper surface can be used without FastAPI. A Django app owns URL routing, CSRF/auth policy, request parsing, and `JsonResponse` construction. In a real Django project this would live in the app's normal view module (for example `assistant/views.py`) and be routed from that app's `urls.py`; Django discovers it through its standard project/app -layout, not through Agent Framework. +layout, not through Agent Framework. This sketch shows the non-streaming path only; the streaming branch is the same +state/finalization pattern shown in the FastAPI sketch and is omitted here to avoid duplicating it. ```python import json @@ -449,8 +469,8 @@ import os from agent_framework import Agent from agent_framework.openai import OpenAIChatClient -from agent_framework_hosting import AgentState -from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_session_id, responses_to_run +from agent_framework_hosting import AgentState # pyright: ignore[reportAttributeAccessIssue] +from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_session_id, responses_to_run # pyright: ignore[reportAttributeAccessIssue] from django.http import HttpRequest, HttpResponseBadRequest, HttpResponseForbidden, JsonResponse from django.views import View From 0167cb1fba4856149c70f1bad6c5f9ec226ca330 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 8 Jul 2026 11:44:35 +0200 Subject: [PATCH 15/16] added note on extending the server --- docs/decisions/0027-hosting-channels.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index 3cd70ff25e9..1c4f3b5be6b 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -83,6 +83,11 @@ Application or web-framework code owns: - request/response status codes and framework-specific error handling; - choosing the isolation/session id source for the current deployment and route. +The application builder can make the server exactly as they see fit, but this is outside the responsibilities of this proposed scheme. +This might include implementing other known API surfaces from vendors like OpenAI, such as creating conversations, vector stores, deleting things, etc. +If they want they can build the full OpenAI API, but it will include code that does not rely on agent-framework-hosting, which is fine. +They are responsible for what they expose. + The optional execution-state helpers, if provided, are limited to shared execution state: - `AgentState`: one `SupportsAgentRun`-compatible target plus a `SessionStore`; From fc4d450dc3b52965aa6623e8366a77f65543f10d Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 8 Jul 2026 11:50:17 +0200 Subject: [PATCH 16/16] added note on responsible for --- docs/decisions/0027-hosting-channels.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md index 1c4f3b5be6b..55bbc52cfaf 100644 --- a/docs/decisions/0027-hosting-channels.md +++ b/docs/decisions/0027-hosting-channels.md @@ -144,7 +144,8 @@ The application builder owns the trust boundary. Protocol helper packages can pa ids or operations, but they do not authenticate callers, authorize access to state, or decide which side effects are allowed. -Application code that uses these helpers are responsible for: +Application code that uses these helpers are responsible for (this means that we advice you to think through these topics, +but ultimately, the choice of which controls are needed for the intended use case is up to the application builder): - authenticate the caller through the app's normal mechanism before using protocol-provided ids; - authorize any caller-supplied session, checkpoint, task, context, conversation, thread, or response id before loading