diff --git a/docs/decisions/0027-hosting-channels.md b/docs/decisions/0027-hosting-channels.md new file mode 100644 index 00000000000..ffbad09d81b --- /dev/null +++ b/docs/decisions/0027-hosting-channels.md @@ -0,0 +1,145 @@ +--- +status: proposed +contact: eavanvalkenburg +date: 2026-06-11 +deciders: eavanvalkenburg +--- + +# Python minimal hosting core and pluggable channels + +## 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. + +We will introduce a small Python hosting core that owns the common server shape and leaves protocol details inside channel packages. The first public contract must be intentionally narrow so Python can ship a base contract before adding identity linking, proactive delivery, or multicast behavior. Other language implementations may reuse the same conceptual boundary, but this ADR records the Python decision. + +## Decision Drivers + +- Keep the first host easy to explain: one app, one hostable target, one or more channels. +- Reuse Agent Framework's existing agent, workflow, session, history, and checkpoint primitives. +- Let channel packages own protocol parsing, protocol responses, authentication details, and native command surfaces. +- Make session continuity explicit through a channel-supplied `ChannelSession(isolation_key=...)`. +- Avoid approving cross-channel identity and delivery semantics before their safety model is reviewed. + +## Considered Options + +1. Keep only protocol-specific hosts. +2. Ship a large hosting core with identity linking, authorization, background delivery, active-channel routing, and multicast in v1. +3. Ship a minimal host/channel core now and track linking/multicast as follow-up work. + +### Keep only protocol-specific hosts + +- Good: no new abstraction or package surface. +- Neutral: each protocol can continue evolving independently. +- Bad: every multi-channel app still has to compose servers, lifecycle, and session handling by hand. + +### Ship the large cross-channel host in v1 + +- Good: the richest cross-channel scenarios are available immediately. +- Neutral: the host becomes the natural place to demonstrate identity and delivery policy. +- Bad: v1 becomes a security-sensitive identity and delivery system before the safety model is reviewed. + +### Ship the minimal core now + +- Good: the host/channel boundary can be implemented, tested, and explained without solving linking and durable delivery at the same time. +- Neutral: apps that need richer behavior must build it locally or wait for ADR-0028 follow-up work. +- Bad: proactive delivery and multicast scenarios are deliberately absent from v1. + +## Decision Outcome + +Chosen option: **minimal host/channel core now, follow-up enhancements later**. + +`AgentFrameworkHost` owns: + +- one application object, +- one hostable target (`SupportsAgentRun` agent-compatible object or a `Workflow`), and +- one or more channels. + +Channels own: + +- 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. + +The host owns: + +- 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`. + +`ChannelIdentity`, when present, is request metadata only. In v1 it is not a linking, authorization, or delivery key. + +### Trust boundary for `isolation_key` + +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. + +### Hook ownership + +Channels provide hook configuration and protocol-native context. The host invokes those hooks as part of the common invocation pipeline: + +- `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. + +`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. + +This keeps hook call conventions centralized while leaving protocol payload parsing and response formatting in channel packages. + +### State owned by v1 + +`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. + +## 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 +- 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. + +## 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. + +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. + +## Validation Gates + +Before this ADR is accepted: + +- A sample can expose one target on multiple channels with one `AgentFrameworkHost` and no handwritten Starlette route composition. +- Built-in channel tests prove that routes, commands, startup, and shutdown callbacks are contributed by channels and aggregated by the host. +- Session tests prove that identical `ChannelSession.isolation_key` values resolve to the same cached `AgentSession`, and `reset_session` rotates that mapping. +- Channel tests prove that each channel renders only its own originating response; there is no host-level push, multicast, or active-channel delivery path. +- Workflow tests or samples use an explicit `checkpoint_location`. +- Foundry isolation middleware is documented and covered by integration or contract tests, including the non-Foundry case where raw isolation headers are ignored. +- The v1 API and packages do not expose the removed symbols or packages listed in [Non-goals for v1](#non-goals-for-v1). +- The Python spec is updated to match this simplified contract and uses "public", "stable", or "released" terminology for Agent Framework APIs. + +## More Information + +- Python v1 specification: [SPEC-002](../specs/002-python-hosting-channels.md) +- Follow-up linking and multicast ADR: [ADR-0028](0028-hosting-linking-multicast-enhancements.md) diff --git a/docs/decisions/0028-hosting-linking-multicast-enhancements.md b/docs/decisions/0028-hosting-linking-multicast-enhancements.md new file mode 100644 index 00000000000..390881b7ea6 --- /dev/null +++ b/docs/decisions/0028-hosting-linking-multicast-enhancements.md @@ -0,0 +1,132 @@ +--- +status: proposed +contact: eavanvalkenburg +date: 2026-06-11 +deciders: eavanvalkenburg +--- + +# Hosting linking and multicast enhancements + +## Context and Problem Statement + +[ADR-0027](0027-hosting-channels.md) defines the minimal v1 hosting core: originating-channel responses, explicit `ChannelSession.isolation_key`, and no host-level identity linking, push, multicast, background delivery, or durable runners. + +This ADR tracks the richer cross-channel behaviors that were removed from v1. These enhancements are **follow-up work** and are **not prerequisites** for shipping, using, or stabilizing the v1 host/channel core. + +## Decision Drivers + +- Cross-channel continuity must not create accidental cross-user, cross-tenant, or cross-channel data leaks. +- Non-originating delivery must be observable, idempotent, retryable, and supportable. +- Protocol payloads must remain channel-native while still being safe to persist and replay. +- App authors need opt-in policy controls, not hidden defaults. +- The enhancement stack should layer on top of the v1 host without reshaping the minimal channel contract. + +## Enhancement Areas + +The follow-up design should cover these capabilities together because they share identity, storage, delivery, and replay concerns: + +- **Cross-channel identity linking** — a user can connect multiple `ChannelIdentity` values to one channel-neutral `isolation_key`. +- **Authorization and allowlist policy** — channels or hosts can require verified identity, allow specific native identities or claims, and deny unknown callers. +- **Non-originating response delivery** — a run can respond somewhere other than the request's originating protocol when explicitly configured. +- **Active-channel routing** — delivery can target the most recently observed linked channel for an `isolation_key`. +- **Multicast / all-linked delivery** — delivery can fan out to every linked channel or a selected set. +- **Background runs and continuation tokens** — long-running requests can return immediately and complete later, with a polling/status fallback. +- **Durable delivery runners** — delivery work can survive process restarts and support dead-letter handling. +- **Retry and replay semantics** — delivery attempts are bounded, deduplicated, and safe to replay. +- **Payload serialization** — channel-specific payloads can be persisted, redacted, versioned, and reconstructed without losing protocol fidelity. + +Candidate API names from the broader design (`IdentityLinker`, `IdentityAllowlist`, `AuthPolicy`, `ResponseTarget`, `ChannelPush`, `ChannelPushCodec`, `DurableTaskRunner`, `InProcessTaskRunner`, `RetryPolicy`, `LinkPolicy`) remain design vocabulary for this ADR. They are not approved v1 APIs. + +## Considered Options + +### Option A — Leave all behavior to applications + +Applications implement linking, authorization, push, retry, and serialization independently. + +- Good: the hosting core stays very small. +- Neutral: advanced apps can still build what they need. +- Bad: every app must solve the same security and delivery problems, likely inconsistently. + +### Option B — Add the full enhancement stack to v1 + +The first host release includes linking, authorization, active channel, multicast, background runs, durable runners, and codecs. + +- Good: the original cross-channel experience is available immediately. +- Neutral: samples can demonstrate rich end-to-end flows. +- Bad: v1 becomes security-sensitive, storage-heavy, and harder to stabilize. + +### Option C — Layer opt-in enhancement packages after v1 + +Ship the minimal host first, then add linking, authorization, and delivery packages behind explicit configuration. + +- Good: v1 remains simple while leaving room for a reviewed, supportable enhancement stack. +- Neutral: apps that need advanced delivery wait for follow-up packages. +- Bad: the first release does not satisfy proactive or all-linked scenarios. + +### Option D — Build only platform-specific integrations + +Implement linking and proactive delivery separately in Telegram, Activity Protocol, Discord, and future channels. + +- Good: each package can match its protocol exactly. +- Neutral: some shared abstractions may emerge later. +- Bad: cross-channel behavior becomes fragmented and hard to reason about. + +## Decision Outcome + +Proposed direction: **Option C — layered opt-in enhancement packages after v1**. + +The minimal host remains the foundation. Follow-up packages may add linking, authorization, delivery, and durable execution, but must be explicitly enabled and must pass the validation gates below before becoming part of the public contract. + +## Safety Requirements + +### Threat model + +The design must account for: + +- spoofed channel-native identities, +- stolen or replayed link challenges, +- cross-tenant or cross-confidentiality data leakage, +- unsolicited proactive messages, +- malicious payloads persisted for replay, +- denial-of-service through fan-out or retry storms, and +- privacy leakage through logs, metrics, or support tooling. + +Required mitigations include verified identity claims where available, signed and expiring link challenges, explicit user consent, per-channel capability checks, default-deny policy options, tenant partitioning, and uninformative denial messages on shared channels. + +### Idempotency and replay + +Exactly-once delivery is not a realistic guarantee. The design must provide: + +- stable run, continuation, and delivery-attempt identifiers, +- channel-level idempotency keys where protocols support them, +- bounded retry with jitter and explicit terminal states, +- replay windows and expiration, +- duplicate suppression for persisted attempts, and +- clear semantics for "delivered", "accepted by platform", and "observed by user". + +### Storage + +Enhancement storage must stay distinct from v1 `AgentSession` history and workflow checkpoints unless an implementation deliberately backs them with the same physical store. + +Stored data should be schema-versioned, minimized, encrypted or otherwise protected as appropriate, and partitioned by tenant/project. Link records, continuation records, active-channel state, delivery attempts, dead letters, and serialized payloads need independent TTL and deletion policies. + +### Observability and support + +The design must include structured logs, traces, and metrics for link attempts, authorization decisions, delivery scheduling, retries, replay, and dead-letter outcomes. Logs must avoid message content and sensitive identity claims by default. Operators need a way to inspect, revoke, replay, or purge stuck records safely. + +## Validation Gates + +Before these enhancements are accepted: + +- A reviewed threat model covers identity linking, authorization, non-originating delivery, multicast, and replay. +- Cross-channel linking tests prove a verified identity can link two channels and that unlink/deny paths do not leak information. +- Authorization tests cover native-id allowlists, verified-claim allowlists, default-deny behavior, and misconfiguration failures. +- Delivery tests cover originating-only, specific-channel, active-channel, selected-channel, and all-linked routing. +- Background/continuation tests cover polling fallback, cancellation or expiration, process restart, retry, and dead-letter behavior. +- Codec tests prove payloads are versioned, redacted where needed, backward compatible, and rejected safely when unknown. +- Multicast tests prove fan-out is bounded, independently retried, and idempotent per destination. +- Observability tests or manual validation prove support operators can correlate a request to delivery attempts without exposing sensitive content. + +## Relationship to ADR-0027 + +ADR-0027 remains valid without any of these enhancements. This ADR extends the hosting model only after the safety, storage, and support requirements above are satisfied. diff --git a/docs/specs/002-python-hosting-channels.md b/docs/specs/002-python-hosting-channels.md new file mode 100644 index 00000000000..fbfaa0a8145 --- /dev/null +++ b/docs/specs/002-python-hosting-channels.md @@ -0,0 +1,320 @@ +--- +status: proposed +contact: eavanvalkenburg +date: 2026-06-11 +deciders: eavanvalkenburg +--- + +# Python hosting core and pluggable channels + +## Scope + +This specification is the Python implementation plan for [ADR-0027](../decisions/0027-hosting-channels.md). It documents the simplified v1 host/channel contract only. + +The v1 contract is: + +- `AgentFrameworkHost` owns one Starlette app, one hostable target, and one or more channels. +- A hostable target is either a `SupportsAgentRun`-compatible agent or a `Workflow`. +- Channels contribute routes, middleware, commands, and lifecycle callbacks. +- Channels parse protocol-native input into `ChannelRequest`. +- Channels render their own originating response. +- Session continuity is explicit: a channel supplies `ChannelSession(isolation_key=...)`, and the host resolves/caches an `AgentSession` for that key. +- The host invokes `ChannelRunHook` and `ChannelResponseHook`; channels provide hook configuration and protocol context. + +The host does not link identities, route responses to other channels, run background continuations, or multicast in v1. Those enhancements are tracked in [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md). + +## Goals + +- Let an app expose one agent or workflow on multiple protocols without handwritten Starlette composition. +- Keep protocol parsing and response formatting inside channel packages. +- Provide one session-resolution path shared by all channels. +- Keep the channel authoring surface small enough for new channels to implement. +- Preserve full-fidelity agent and workflow results until a channel decides how to render them. + +## Non-goals for v1 + +The following are removed from the v1 implementation pass: + +- `IdentityLinker`, `IdentityAllowlist`, `AuthPolicy`, and `LinkPolicy` +- `ResponseTarget`, active-channel routing, `all_linked`, fan-out, and multicast +- `ChannelPush` and `ChannelPushCodec` +- `DurableTaskRunner`, `InProcessTaskRunner`, and `RetryPolicy` +- continuation tokens and background delivery +- confidentiality tiers +- `agent-framework-hosting-entra` +- `local_identity_link` + +These are follow-up design topics, not hidden requirements of the v1 host. + +## Packages + +| Package | Import surface | Contents | +|---|---|---| +| `agent-framework-hosting` | `agent_framework_hosting` | `AgentFrameworkHost`, channel protocols, key request/result types, hooks, `reset_session`, state-path helpers. | +| `agent-framework-hosting-responses` | `agent_framework_hosting_responses` | `ResponsesChannel`. | +| `agent-framework-hosting-invocations` | `agent_framework_hosting_invocations` | `InvocationsChannel`. | +| `agent-framework-hosting-telegram` | `agent_framework_hosting_telegram` | `TelegramChannel` and Telegram command helpers. | +| `agent-framework-hosting-activity-protocol` | `agent_framework_hosting_activity_protocol` | `ActivityProtocolChannel` for Activity Protocol over Azure Bot Service. | +| `agent-framework-hosting-discord` | `agent_framework_hosting_discord` | `DiscordChannel` and Discord command/interaction helpers. | +| `agent-framework-foundry-hosting` | `agent_framework.foundry_hosting` | Foundry isolation middleware and Foundry-backed hosting helpers usable with the v1 host. | + +Channel packages may depend on their native SDKs. The core hosting package should not depend on channel SDKs or on top-level legacy protocol hosts. + +## Key Types + +### `AgentFrameworkHost` + +The host constructor accepts: + +- `target`: one `SupportsAgentRun`-compatible object or one `Workflow` +- `channels`: one or more `Channel` instances +- optional Starlette middleware +- optional `state_dir` +- optional workflow `checkpoint_location` + +The host exposes: + +- `app`: the canonical Starlette ASGI application +- `serve(...)`: a convenience wrapper for local serving +- `reset_session(isolation_key: str)`: rotate the cached `AgentSession` for a host-tracked conversation + +`state_dir` is narrowed to v1 host-owned local files only: + +- session aliases (`isolation_key` to current `AgentSession` id), and +- workflow checkpoint paths when the app chooses the host-provided file layout. + +It is not a store for identity links, continuations, active-channel state, delivery attempts, or multicast payloads. + +Externally supplied isolation keys are trusted only after the channel or host middleware has authenticated and authorized the caller. The host uses `isolation_key` as a partition key; the string itself is not proof of identity or ownership. + +### `Channel` + +A channel implements a small protocol: + +- declare a stable channel id/name, +- contribute routes, middleware, commands, and lifecycle callbacks, +- parse inbound protocol data into `ChannelRequest`, +- call the host through `ChannelContext.run(...)` or `ChannelContext.run_stream(...)`, and +- serialize the returned result to the originating protocol response. + +Channels own protocol authentication, signature validation, native command registration, and protocol-specific error bodies. + +### `ChannelContribution` + +`ChannelContribution` is the channel's host-facing contribution: + +- Starlette routes and optional middleware, +- native command descriptors, +- startup and shutdown callbacks, and +- any channel-local metadata needed by the package. + +The host aggregates contributions but does not interpret protocol payloads. + +### `ChannelRequest` + +`ChannelRequest` is the host-neutral request envelope produced by a channel. It carries: + +- target input, +- optional `ChannelSession`, +- optional `ChannelIdentity`, +- options and attributes produced by the channel, and +- request metadata useful to hooks and context providers. + +The host may pass attributes through to context providers and middleware. Channels should treat attributes as a documented extension bag, not as a cross-channel delivery contract. + +### `ChannelSession` + +`ChannelSession(isolation_key=...)` is the only v1 session-continuity mechanism. + +When a request contains an isolation key: + +1. The host looks up or creates the cached `AgentSession` for that key. +2. The target runs with that `AgentSession` when the target is an agent. +3. `reset_session(isolation_key)` rotates the alias so the next request starts a new conversation. + +If two channels produce the same isolation key on the same host, they share the same cached session. If they produce different keys, they do not share session state. + +### `ChannelIdentity` + +`ChannelIdentity` is optional request metadata such as channel id, native user id, tenant id, claims, or display attributes. + +In v1, `ChannelIdentity` does not link channels, authorize callers, select delivery destinations, or imply that two identities should share an `AgentSession`. A channel that wants shared history must still produce the same `ChannelSession.isolation_key`. + +### Hooks + +Hooks are optional and channel-owned: + +- `ChannelRunHook`: runs after channel parsing and before host invocation; returns the `ChannelRequest` to execute. +- `ChannelResponseHook`: runs after target completion and before the originating channel renders a one-shot response. +- `ChannelStreamUpdateHook`: the host applies it to streamed updates before the originating channel serializes the stream. + +Common uses include adapting chat text into workflow inputs, enforcing deployment-specific options, flattening rich output for text-only protocols, or filtering streamed updates for a protocol. Stream update hooks are update-only; they do not automatically sanitize `get_final_response()` output. Channels choose their response transport from the parsed protocol request before invoking run hooks. + +### `HostedRunResult` + +`HostedRunResult[T]` wraps the target's full-fidelity result plus the resolved `AgentSession | None`. + +- Agent targets produce `HostedRunResult[AgentResponse]`. +- Workflow targets produce `HostedRunResult[WorkflowRunResult]`. + +The host does not flatten, filter, or translate the result. Each channel decides how much of the result its protocol can carry. + +## Host Behavior + +1. `AgentFrameworkHost` builds one Starlette app and asks each channel for its contribution. +2. A channel route receives a protocol-native request. +3. The channel validates/parses the native payload and creates `ChannelRequest`. +4. The channel passes the request, optional `ChannelRunHook`, and protocol-native context to the host. +5. The host invokes `ChannelRunHook`, if configured, and receives the prepared request. +6. The host resolves an `AgentSession` from `ChannelSession.isolation_key` when present. +7. The host invokes the agent or workflow target. +8. The host wraps the result in `HostedRunResult` or the streaming equivalent. +9. The host invokes `ChannelResponseHook`, if configured, for non-streaming/final response shaping. +10. The host applies stream update hooks while the channel consumes streams; the channel renders the originating protocol response. + +There is no host-level route from one channel's request to another channel's response in v1. + +## Workflow Checkpoints + +Workflow checkpointing is explicit. Apps either configure checkpoint storage on the workflow itself or pass a `checkpoint_location` to the host so the workflow dispatch path can use the intended file location. + +`state_dir` may provide a conventional location for workflow checkpoint files, but checkpointing is still opt-in and separate from agent session history. Checkpoints are workflow-runtime state, not channel state and not identity-link state. + +## Foundry Isolation Middleware + +V1 keeps Foundry isolation as middleware rather than as a channel-linking feature. + +The middleware is installed only when the Foundry hosting environment flag is present. In that environment it reads Foundry-provided isolation values at the trusted hosting boundary, exposes them as read-only request context for Foundry-aware history or memory providers, and rejects unsafe session resumes when the live isolation context does not match persisted session context. Outside Foundry, raw isolation headers are ignored unless an app supplies its own trusted middleware. + +This middleware does not create cross-channel identity links and does not authorize non-Foundry channels. + +## Current Channels + +### Responses + +`ResponsesChannel` exposes the OpenAI-compatible Responses API shape. It maps request body fields such as input, options, and conversation identifiers into `ChannelRequest`, and it renders Responses-compatible one-shot or streaming responses. + +Responses session continuity uses a channel-selected `isolation_key`, commonly derived from a response/conversation id, caller-provided session id, Foundry isolation context, or deployment-specific request metadata. + +### Invocations + +`InvocationsChannel` exposes an invocation endpoint for server-side callers and tools. It maps the request body into `ChannelRequest` and renders the invocation result on the same HTTP response. + +Invocations is useful for typed workflow inputs because a `ChannelRunHook` can translate the request body into the workflow's expected input type. + +### Telegram + +`TelegramChannel` supports webhook or polling transport, native command registration, and message rendering back to the originating Telegram chat. + +The channel chooses a default `isolation_key` from Telegram-native data such as chat id, user id, or a configured user/chat scope. A `/new` or equivalent command may call `reset_session` for that isolation key. + +### Activity Protocol + +`ActivityChannel` supports Activity Protocol requests, typically through Azure Bot Service for Teams, Web Chat, and other Bot Framework-fronted surfaces. + +The channel maps incoming `Activity` objects to `ChannelRequest` and renders a reply activity to the originating conversation. Proactive Activity delivery, active-channel routing, and all-linked fan-out are not v1 host semantics. + +### Discord + +`DiscordChannel` supports Discord messages, slash commands, and interactions as channel-native input. + +The channel maps Discord-native user, guild, channel, thread, and interaction data into `ChannelRequest` metadata and a configured `ChannelSession.isolation_key`. It renders the result to the originating Discord response path. + +## High-level Samples + +### One agent on Responses + +```python +host = AgentFrameworkHost( + target=agent, + channels=[ResponsesChannel()], +) + +app = host.app +``` + +### One agent on multiple channels + +```python +host = AgentFrameworkHost( + target=agent, + channels=[ + ResponsesChannel(), + InvocationsChannel(), + TelegramChannel(bot_token=os.environ["TELEGRAM_BOT_TOKEN"]), + ], +) + +host.serve(host="localhost", port=8000) +``` + +The host owns one Starlette app. Each channel contributes its own routes and renders its own response. + +### Adapting a request before execution + +```python +from dataclasses import replace + + +def enforce_options(request: ChannelRequest) -> ChannelRequest: + options = dict(request.options or {}) + options["temperature"] = 0 + return replace(request, options=options) + + +host = AgentFrameworkHost( + target=agent, + channels=[ResponsesChannel(run_hook=enforce_options)], +) +``` + +### Workflow with explicit checkpoints + +```python +host = AgentFrameworkHost( + target=workflow, + channels=[InvocationsChannel(run_hook=adapt_to_workflow_input)], + checkpoint_location=Path("./.af-hosting/workflow_checkpoints"), +) +``` + +The hook adapts channel-native input to the workflow's typed input. Checkpoints use the explicit workflow checkpoint location, not identity-link or delivery storage. + +### Message channel reset command + +```python +async def new_chat(context): + if context.request.session is not None: + await context.host.reset_session(context.request.session.isolation_key) + await context.reply("Started a new conversation.") +``` + +Telegram, Activity Protocol, and Discord can expose equivalent native commands when their protocols support them. + +## Follow-up Enhancements + +See [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md) for the deferred design covering: + +- cross-channel identity linking, +- authorization and allowlists, +- non-originating response delivery, +- active-channel routing, +- multicast and all-linked delivery, +- background runs and continuation tokens, +- durable delivery runners, +- retry/replay semantics, and +- payload serialization. + +Those enhancements must layer on top of this v1 contract without requiring v1 users to adopt them. + +## Validation Gates + +The Python implementation should be considered complete when: + +- a sample uses one `AgentFrameworkHost` with multiple channels and no manual Starlette route composition, +- each current channel has contract tests for route contribution, lifecycle, request parsing, hooks, and originating response rendering, +- session tests prove shared `isolation_key` values share an `AgentSession` and `reset_session` rotates it, +- workflow tests or samples use explicit `checkpoint_location`, +- Foundry isolation middleware is covered by integration or contract tests, +- no v1 package exposes the removed linking, multicast, durable-runner, or continuation APIs, and +- this spec and ADR-0027 remain aligned. diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index ae8334d2cc1..cb965454bf8 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -34,6 +34,7 @@ Status is grouped into these buckets: | `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` | | `agent-framework-gemini` | `python/packages/gemini` | `alpha` | | `agent-framework-github-copilot` | `python/packages/github_copilot` | `rc` | +| `agent-framework-hosting-discord` | `python/packages/hosting-discord` | `alpha` | | `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` | | `agent-framework-lab` | `python/packages/lab` | `beta` | | `agent-framework-mem0` | `python/packages/mem0` | `beta` | diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/__init__.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/__init__.py index 81e8430783c..691353a0e16 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/__init__.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/__init__.py @@ -2,6 +2,16 @@ import importlib.metadata +from ._history_provider import ( + FoundryHostedAgentHistoryProvider, + bind_request_context, + get_current_request_context, +) +from ._ids import ( + foundry_item_id, + foundry_response_id, + foundry_response_id_factory, +) from ._invocations import InvocationsHostServer from ._responses import ResponsesHostServer @@ -10,4 +20,13 @@ except importlib.metadata.PackageNotFoundError: __version__ = "0.0.0" -__all__ = ["InvocationsHostServer", "ResponsesHostServer"] +__all__ = [ + "FoundryHostedAgentHistoryProvider", + "InvocationsHostServer", + "ResponsesHostServer", + "bind_request_context", + "foundry_item_id", + "foundry_response_id", + "foundry_response_id_factory", + "get_current_request_context", +] diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_history_provider.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_history_provider.py new file mode 100644 index 00000000000..5b781edb60f --- /dev/null +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_history_provider.py @@ -0,0 +1,991 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Foundry Hosted Agent history provider. + +A standalone :class:`agent_framework.HistoryProvider` implementation that +sources conversation history from the Foundry Hosted Agent storage backend. + +Transport is delegated to the SDK's +:class:`azure.ai.agentserver.responses.FoundryStorageProvider` (when running +inside a Foundry Hosted Agent container) or +:class:`azure.ai.agentserver.responses.InMemoryResponseProvider` (for local +development). Both implement the same read/write surface +(``get_history_item_ids`` / ``get_items`` / ``create_response``), so this +provider's persistence logic stays backend-agnostic. + +Allowed dependencies (deliberately narrow): + +* :mod:`agent_framework` (core, for ``HistoryProvider`` / ``Message``) +* :mod:`azure.ai.agentserver.responses` (for the storage backends, + ``IsolationContext`` typing, and ``OutputItem`` deserialization) +* :mod:`azure.core.credentials_async` (typing of token credentials) + +It MUST NOT depend on any ``agent_framework_hosting*`` package at module +import time. (The host's isolation contextvar is consulted lazily via an +``import`` inside :func:`_host_isolation` so the dependency stays soft.) + +Environment variables read: + +* ``FOUNDRY_HOSTING_ENVIRONMENT`` — non-empty marks "running inside Foundry" + and selects the SDK-backed storage transport. Detection is delegated to + :class:`azure.ai.agentserver.core.AgentConfig` so a future SDK rename + propagates without touching this module. +* ``FOUNDRY_PROJECT_ENDPOINT`` — base URL of the Foundry project; required + when running hosted unless an explicit ``endpoint=`` is supplied. +* ``FOUNDRY_AGENT_NAME`` / ``FOUNDRY_AGENT_VERSION`` — stamped onto the + ``agent_reference`` field of every persisted response envelope. +* ``MODEL_DEPLOYMENT_NAME`` / ``AZURE_AI_MODEL_DEPLOYMENT_NAME`` — model + field stamped on the persisted envelope (must match a real deployment). + +Note on ``FOUNDRY_AGENT_SESSION_ID``: this env var identifies the +*container instance*, not the conversation, so it is **not** consulted as +a fallback ``previous_response_id``. The host-bound +``previous_response_id`` (set by :class:`ResponsesChannel` from the +request envelope) is the authoritative anchor. The value is still +persisted into the ``agent_session_id`` envelope field for operator +correlation only. + +Local fallback: when ``FOUNDRY_HOSTING_ENVIRONMENT`` is unset, the provider +transparently falls back to :class:`InMemoryResponseProvider` so the same +agent code runs in dev. Pass ``local_storage_root`` to use a persistent +file-based store instead of in-memory; histories are then laid out as +``{root}/{user_key or "~none"}/{chat_key or "~none"}/{session_id}.jsonl`` +via :class:`agent_framework.FileHistoryProvider`. +""" + +from __future__ import annotations + +import logging +import os +import time +from base64 import urlsafe_b64encode +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar + +from agent_framework import FileHistoryProvider, HistoryProvider, Message +from azure.ai.agentserver.core import AgentConfig +from azure.ai.agentserver.responses import ( + FoundryStorageProvider, + FoundryStorageSettings, + InMemoryResponseProvider, + IsolationContext, +) +from azure.ai.agentserver.responses._id_generator import IdGenerator +from azure.ai.agentserver.responses.models import OutputItem, ResponseObject +from azure.ai.agentserver.responses.store._foundry_errors import ( # pyright: ignore[reportPrivateUsage] + FoundryBadRequestError, + FoundryResourceNotFoundError, + FoundryStorageError, +) + +from ._shared import ( + _messages_to_output_items, # pyright: ignore[reportPrivateUsage] + _output_items_to_messages, # pyright: ignore[reportPrivateUsage] +) + +if TYPE_CHECKING: + from collections.abc import Iterator, Sequence + + from azure.core.credentials_async import AsyncTokenCredential + +logger = logging.getLogger(__name__) + +# Environment variable name — re-declared (not imported) so this module +# stays decoupled from the private ``azure.ai.agentserver.core._config`` +# constants while still matching exactly. Hosted-vs-local detection is +# delegated to :class:`AgentConfig` so a future SDK rename propagates. +_ENV_FOUNDRY_PROJECT_ENDPOINT = "FOUNDRY_PROJECT_ENDPOINT" + +# Per-request isolation context. The owning Channel is expected to set this +# from the inbound request (e.g. user / tenant headers) for the duration of +# an ``agent.run(...)`` call. When unset, requests are made without +# isolation headers (matches how ``ResponseContext`` behaves with no +# ``IsolationContext``). +_isolation_var: ContextVar[IsolationContext | None] = ContextVar( + "agent_framework_foundry_hosting_isolation", + default=None, +) + + +def set_current_isolation(isolation: IsolationContext | None) -> Any: + """Set the per-request isolation context for downstream history calls. + + Channels that drive an agent backed by :class:`FoundryHostedAgentHistoryProvider` + should call this before invoking ``agent.run(...)`` and reset the token + afterwards. + + Args: + isolation: The isolation context to associate with the current + ``contextvars`` context, or ``None`` to clear it. + + Returns: + A token suitable for :func:`reset_current_isolation` that restores + the previous value. + """ + return _isolation_var.set(isolation) + + +def reset_current_isolation(token: Any) -> None: + """Restore a previously-saved isolation context. + + Args: + token: A token returned by :func:`set_current_isolation`. + """ + _isolation_var.reset(token) + + +def get_current_isolation() -> IsolationContext | None: + """Return the isolation context bound to the current async context, if any. + + Returns: + The :class:`IsolationContext` for the current request, or ``None`` + when no channel has set one. + """ + return _isolation_var.get() + + +@dataclass(frozen=True) +class _RequestContext: + """Per-request anchors the host binds before invoking the agent. + + ``response_id`` is the id this provider's :meth:`save_messages` call + will write under, so the channel and the storage backend agree on + one stable handle per turn (the channel surfaces the same id on the + response envelope, the next turn arrives with this value as + ``previous_response_id`` and the chain walks). + + ``previous_response_id`` is the prior turn's anchor (``None`` on + first turn). Used to seed ``history_item_ids`` on the new write so + the storage chain stays connected, and to load history without + needing to know the channel's session minting convention. + + Per-request Foundry isolation keys (the + ``x-agent-{user,chat}-isolation-key`` headers) are *not* carried + here; the host's own ASGI middleware lifts them off every inbound + HTTP request into a contextvar + (:func:`agent_framework_hosting.get_current_isolation_keys`) which + this provider consults at storage-call time. Keeping the headers + out of the per-request bind means channels never have to import + Foundry-specific types and the host owns the (intentional) coupling + to those two well-known headers. + """ + + response_id: str + previous_response_id: str | None + + +_request_var: ContextVar[_RequestContext | None] = ContextVar( + "agent_framework_foundry_hosting_request", + default=None, +) + + +@contextmanager +def bind_request_context( + *, + response_id: str, + previous_response_id: str | None = None, + **_unused: Any, +) -> Iterator[None]: + """Bind the per-request response-chain anchors for this provider. + + Intended for the host (or any caller orchestrating an + ``agent.run(...)``) to call immediately before invocation, so the + provider's :meth:`save_messages` writes under a known, stable + ``response_id`` (the same one the channel surfaces to the client) + and walks ``previous_response_id`` for history continuity. Unknown + keyword arguments are accepted and ignored so the host can extend + the ``ChannelRequest.attributes`` contract without breaking existing + providers. Foundry isolation keys flow through a separate + host-installed contextvar; see the class docstring on + :class:`_RequestContext`. + + The binding is scoped to the current ``contextvars.Context``, so + concurrent requests in the same process do not interfere. + """ + token = _request_var.set( + _RequestContext( + response_id=response_id, + previous_response_id=previous_response_id, + ) + ) + try: + yield + finally: + _request_var.reset(token) + + +def get_current_request_context() -> _RequestContext | None: + """Return the per-request response chain anchors, if bound.""" + return _request_var.get() + + +def _host_isolation() -> IsolationContext | None: + """Lift the host-bound isolation contextvar into our local type. + + The host installs an ASGI middleware that reads + ``x-agent-{user,chat}-isolation-key`` off every inbound HTTP request + and stores them in a generic ``IsolationKeys`` slot on a contextvar + we import from :mod:`agent_framework_hosting`. We translate it into + our :class:`IsolationContext` shape on demand so the provider stays + in charge of the storage-side type while the host stays free of any + Foundry-specific dependencies. + """ + # Soft dep: ``agent_framework_hosting`` may not be installed (this + # provider is also usable standalone). The whole block is wrapped in + # ``# pyright: ignore`` so the optional import does not block type + # checking when the package isn't on sys.path; when it is, pyright + # picks up the real types automatically. + try: + from agent_framework_hosting import ( # pyright: ignore[reportMissingImports] + get_current_isolation_keys, # pyright: ignore[reportUnknownVariableType] + ) + except ImportError: # pragma: no cover - hosting is a soft dep + return None + keys = get_current_isolation_keys() # pyright: ignore[reportUnknownVariableType] + if keys is None or keys.is_empty: # pyright: ignore[reportUnknownMemberType] + return None + return IsolationContext( + user_key=keys.user_key, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] + chat_key=keys.chat_key, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] + ) + + +# Type alias for the storage backend surface this provider depends on. +# Both ``FoundryStorageProvider`` and ``InMemoryResponseProvider`` from +# ``azure.ai.agentserver.responses`` expose the same +# ``get_history_item_ids`` / ``get_items`` / ``create_response`` methods. +_StorageBackend = "FoundryStorageProvider | InMemoryResponseProvider" + + +# Sentinel directory name used in place of a missing ``user_key`` / +# ``chat_key`` when laying out file-based local history. The tilde +# prefix is reserved (``_is_safe_isolation_segment`` rejects keys that +# start with one) so a real isolation key can never collide with the +# sentinel after sanitisation. +_ISOLATION_NONE_MARKER = "~none" +_ISOLATION_ENCODED_PREFIX = "~iso-" + +# Windows reserved file/directory stems. Mirrors +# ``FileHistoryProvider._WINDOWS_RESERVED_FILE_STEMS`` so the directory +# layer enforces the same portability constraints the file layer does. +_WINDOWS_RESERVED_STEMS = frozenset({ + "CON", + "PRN", + "AUX", + "NUL", + *(f"COM{i}" for i in range(1, 10)), + *(f"LPT{i}" for i in range(1, 10)), +}) + + +def _is_safe_isolation_segment(value: str) -> bool: + """Return whether ``value`` is safe to use directly as a directory name. + + Rules mirror :meth:`FileHistoryProvider._is_literal_session_file_stem_safe`, + with the additional rule that a leading tilde is reserved for our + sentinel/encoded prefixes so real keys can never collide with them. + """ + if ( + not value + or value.startswith((".", "~")) + or value.endswith((" ", ".")) + or value.upper() in _WINDOWS_RESERVED_STEMS + ): + return False + if any(ord(character) < 32 for character in value): + return False + return all(character.isalnum() or character in "._-" for character in value) + + +def _encode_isolation_segment(value: str | None) -> str: + """Encode an isolation key into a filesystem-safe directory name. + + * ``None`` / empty → ``"~none"`` sentinel. + * Already-safe values pass through unchanged. + * Anything else is base64-url-encoded and prefixed with ``"~iso-"`` + so it is unambiguous and never collides with a real (safe) key. + """ + if value is None or value == "": + return _ISOLATION_NONE_MARKER + if _is_safe_isolation_segment(value): + return value + encoded = urlsafe_b64encode(value.encode("utf-8")).decode("ascii").rstrip("=") + return f"{_ISOLATION_ENCODED_PREFIX}{encoded}" + + +class FoundryHostedAgentHistoryProvider(HistoryProvider): + """``HistoryProvider`` backed by Foundry Hosted Agent storage. + + Wraps :class:`azure.ai.agentserver.responses.FoundryStorageProvider` + when running inside a Foundry Hosted Agent container, or + :class:`InMemoryResponseProvider` for local development. The + selection is driven by the ``FOUNDRY_HOSTING_ENVIRONMENT`` + environment variable. + + For local runs that need to *persist* history across process + restarts, pass ``local_storage_root``: the provider then writes + each conversation to + ``{root}/{user_key or "~none"}/{chat_key or "~none"}/{session_id}.jsonl`` + via :class:`agent_framework.FileHistoryProvider`. The Foundry + response-chain semantics (``previous_response_id`` walking, + ``caresp_*`` id stamping, ``ResponseObject`` envelopes) are + bypassed in file mode — the on-disk format is plain JSONL of + :class:`Message` payloads, identical to ``FileHistoryProvider`` + standalone usage. ``local_storage_root`` is ignored when running + hosted (Foundry storage always wins). + + ``session_id`` semantics: in hosted / in-memory mode the value + passed to :meth:`get_messages` and :meth:`save_messages` is treated + as the Responses ``previous_response_id`` (or ``conversation_id``) + whose chain to load. When omitted (and no host-bound chain anchor + is set), :meth:`get_messages` returns an empty list (a fresh + conversation). In file mode ``session_id`` is used as the literal + filename stem (``FileHistoryProvider`` sanitises unsafe values). + """ + + DEFAULT_SOURCE_ID: ClassVar[str] = "foundry_hosted_agent" + + def __init__( + self, + *, + credential: AsyncTokenCredential | None = None, + endpoint: str | None = None, + history_limit: int = 100, + source_id: str = DEFAULT_SOURCE_ID, + load_messages: bool = True, + store_inputs: bool = True, + store_context_messages: bool = False, + store_context_from: set[str] | None = None, + store_outputs: bool = True, + local_storage_root: str | Path | None = None, + ) -> None: + """Initialize the provider. + + Args: + credential: Async token credential used to authenticate against + the Foundry storage API. Required when running hosted + (``FOUNDRY_HOSTING_ENVIRONMENT`` is set). Ignored in + local-mode (the in-memory / file backends need no auth). + endpoint: Foundry project endpoint URL. Defaults to the value + of the ``FOUNDRY_PROJECT_ENDPOINT`` environment variable. + Required when running hosted. + history_limit: Maximum number of history items to fetch per + ``get_messages`` call. Mirrors the agent-server runtime's + ``ResponseContext._history_limit``. Default ``100``. + Ignored in file mode (``FileHistoryProvider`` returns the + full session file each call). + source_id: Unique identifier for this provider instance, as + required by ``HistoryProvider``. + load_messages: Whether to load messages before invocation. + Default ``True``. + store_inputs: Whether to mirror input messages into Foundry + storage. Default ``True`` — the Foundry Hosted Agents + runtime does not persist Responses turns automatically, so + without this the chain would never be visible to subsequent + requests. Set ``False`` only if you know an external writer + is populating storage on your behalf. + store_context_messages: Whether to mirror context-provider + messages. Default ``False``. + store_context_from: If set, only mirror context messages from + these source IDs. + store_outputs: Whether to mirror response messages into Foundry + storage. Default ``True`` for the same reason as + ``store_inputs``. + local_storage_root: When set, *and* the provider is running + outside a Foundry Hosted Agent container, persist history + to JSONL files under + ``{root}/{user_key or "~none"}/{chat_key or "~none"}/{session_id}.jsonl`` + instead of using the in-memory backend. Ignored when + hosted (with a one-time INFO log). Defaults to ``None`` + (in-memory local fallback). + """ + super().__init__( + source_id=source_id, + load_messages=load_messages, + store_inputs=store_inputs, + store_context_messages=store_context_messages, + store_context_from=store_context_from, + store_outputs=store_outputs, + ) + + self._history_limit = history_limit + self._credential = credential + self._endpoint = endpoint or os.environ.get(_ENV_FOUNDRY_PROJECT_ENDPOINT) or None + self._backend: FoundryStorageProvider | InMemoryResponseProvider | None = None + + self._local_storage_root: Path | None = ( + Path(local_storage_root).resolve() if local_storage_root is not None else None + ) + # Cache one ``FileHistoryProvider`` per (user_key, chat_key) + # tuple. Bounded by the number of distinct isolation scopes the + # process sees; cleared on ``aclose``. + self._file_providers: dict[tuple[str, str], FileHistoryProvider] = {} + self._hosted_local_root_warned = False + if self._local_storage_root is not None and self.is_hosted_environment(): + self._warn_hosted_local_root_ignored() + + # Observability: number of ``save_messages`` calls dropped by + # :class:`FoundryStorageError` from ``backend.create_response``. + # Operators / health probes can read this attribute directly to + # detect silent persistence loss; never decremented. + self.failed_writes: int = 0 + + @staticmethod + def is_hosted_environment() -> bool: + """Return ``True`` when running inside a Foundry Hosted Agent container. + + Delegates to :meth:`azure.ai.agentserver.core.AgentConfig.from_env` + so the detection rule stays in lockstep with the Foundry SDK; if + the platform ever renames the underlying signal (today + ``FOUNDRY_HOSTING_ENVIRONMENT``) the SDK update is picked up + automatically without a code change here. + """ + return AgentConfig.from_env().is_hosted + + def _resolve_backend(self) -> FoundryStorageProvider | InMemoryResponseProvider: + """Return the storage backend, constructing it lazily on first use. + + * If ``FOUNDRY_HOSTING_ENVIRONMENT`` is set, build a + :class:`FoundryStorageProvider` (requires ``credential`` and a + resolved ``endpoint``). + * Otherwise, fall back to a process-local + :class:`InMemoryResponseProvider` so dev/local runs work without + additional configuration. + """ + if self._backend is not None: + return self._backend + + if self.is_hosted_environment(): + if self._credential is None: + raise RuntimeError( + "FoundryHostedAgentHistoryProvider requires an async credential when running " + "inside a Foundry Hosted Agent container. Pass credential=... ." + ) + if not self._endpoint: + raise RuntimeError( + "FoundryHostedAgentHistoryProvider needs a Foundry project endpoint. Pass " + "endpoint=... or set the FOUNDRY_PROJECT_ENDPOINT environment variable." + ) + self._backend = FoundryStorageProvider( + credential=self._credential, + settings=FoundryStorageSettings.from_endpoint(self._endpoint), + ) + logger.debug( + "FoundryHostedAgentHistoryProvider using FoundryStorageProvider against %s", + self._endpoint, + ) + return self._backend + + logger.info( + "FOUNDRY_HOSTING_ENVIRONMENT is unset — FoundryHostedAgentHistoryProvider falling " + "back to InMemoryResponseProvider for local development.", + ) + self._backend = InMemoryResponseProvider() + return self._backend + + async def aclose(self) -> None: + """Release storage resources held by this provider. + + Safe to call multiple times. Closes the lazily-constructed + backend if one was created and drops any cached file-history + providers. ``InMemoryResponseProvider`` and + ``FileHistoryProvider`` have no ``aclose`` and are closed + implicitly on garbage collection. + """ + self._file_providers.clear() + if self._backend is None: + return + aclose = getattr(self._backend, "aclose", None) + if aclose is not None: + await aclose() + self._backend = None + + def _warn_hosted_local_root_ignored(self) -> None: + """Log (once) that ``local_storage_root`` is being ignored under hosted mode.""" + if self._hosted_local_root_warned: + return + self._hosted_local_root_warned = True + logger.info( + "FoundryHostedAgentHistoryProvider ignored local_storage_root=%s because " + "FOUNDRY_HOSTING_ENVIRONMENT is set; Foundry storage takes precedence " + "when hosted.", + self._local_storage_root, + ) + + def _resolve_local_file_provider( + self, + isolation: IsolationContext | None, + ) -> FileHistoryProvider | None: + """Return a ``FileHistoryProvider`` for the current isolation, or ``None``. + + Returns ``None`` when ``local_storage_root`` is unset *or* the + provider is running in hosted mode (in which case Foundry + storage handles persistence). Otherwise builds — and caches — + one provider per (user_key, chat_key) tuple, rooted at the + sanitised ``{root}/{user_segment}/{chat_segment}`` directory. + + Raises: + ValueError: If the resolved isolation directory escapes + ``local_storage_root`` (defence in depth — the + sanitisation should already prevent this). + """ + if self._local_storage_root is None: + return None + if self.is_hosted_environment(): + self._warn_hosted_local_root_ignored() + return None + + user_key = isolation.user_key if isolation is not None else None + chat_key = isolation.chat_key if isolation is not None else None + cache_key = (user_key or "", chat_key or "") + cached = self._file_providers.get(cache_key) + if cached is not None: + return cached + + user_segment = _encode_isolation_segment(user_key) + chat_segment = _encode_isolation_segment(chat_key) + target_dir = (self._local_storage_root / user_segment / chat_segment).resolve() + if not target_dir.is_relative_to(self._local_storage_root): + raise ValueError( + "Isolation segments resolved outside of local_storage_root: " + f"user_key={user_key!r} chat_key={chat_key!r}" + ) + + provider = FileHistoryProvider( + target_dir, + source_id=f"{self.source_id}__file__{user_segment}__{chat_segment}", + load_messages=self.load_messages, + store_inputs=self.store_inputs, + store_context_messages=self.store_context_messages, + store_context_from=self.store_context_from, + store_outputs=self.store_outputs, + ) + self._file_providers[cache_key] = provider + logger.debug( + "FoundryHostedAgentHistoryProvider created file backend for isolation (user=%s, chat=%s) at %s", + user_key, + chat_key, + target_dir, + ) + return provider + + async def get_messages( + self, + session_id: str | None, + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[Message]: + """Load conversation history for the given Foundry response chain. + + Args: + session_id: The Responses ``previous_response_id`` / + ``conversation_id`` to anchor history on. When ``None`` / + empty, an empty history is returned (fresh conversation). + state: Unused — kept for ``HistoryProvider`` compatibility. + **kwargs: Extensibility hook; ``isolation`` may be supplied + explicitly to override the contextvar. + + Returns: + The conversation history materialised as a list of + :class:`agent_framework.Message`, oldest-first. + + Notes: + History anchoring follows the Foundry response-id chain. The + preferred anchor is the per-request ``previous_response_id`` + bound by the host via :func:`bind_request_context` — that's + the prior turn's resp id, written by *this* provider's + previous :meth:`save_messages` call, so the chain is + guaranteed walkable. When unbound (e.g. local dev calling + the provider directly), we fall back to the ``session_id`` + argument as long as it's ``resp_*``-shaped; opaque tokens + (such as chat-isolation-key values) are skipped because the + storage backend rejects them with HTTP 400 "Malformed + identifier". + + When ``local_storage_root`` is configured (and the provider + is running outside a Foundry Hosted Agent container), this + method instead delegates to a per-isolation + :class:`FileHistoryProvider` and ``session_id`` is used as + the literal file stem. + """ + isolation = kwargs.get("isolation") or _host_isolation() or get_current_isolation() + file_provider = self._resolve_local_file_provider(isolation) + if file_provider is not None: + return await file_provider.get_messages(session_id, state=state, **kwargs) + + bound = get_current_request_context() + # Prefer the host-bound previous_response_id over the session_id + # the framework feeds in: the bound value is the id we ourselves + # wrote on the previous turn, so we know it's storage-valid. + anchor = bound.previous_response_id if bound is not None else None + if anchor is None and session_id and session_id.startswith(("caresp_", "resp_")): + anchor = session_id + if anchor is None: + # No walkable anchor → fresh conversation, nothing to load. + # Note: we intentionally do NOT fall back to + # ``FOUNDRY_AGENT_SESSION_ID`` — per the Foundry SDK that env + # var identifies the *container instance*, not the + # conversation, so it doesn't yield a walkable response-id + # chain. The host-bound ``previous_response_id`` (set by + # ``ResponsesChannel`` from the request envelope) is the + # authoritative anchor. + return [] + + backend = self._resolve_backend() + + try: + item_ids = await backend.get_history_item_ids( + anchor, + None, + self._history_limit, + isolation=isolation, + ) + except (FoundryBadRequestError, FoundryResourceNotFoundError) as err: + # 400 / 404 here means the anchor isn't storage-valid — treat + # it as an empty history rather than failing the whole request. + logger.debug( + "get_messages: anchor %r rejected by storage (%s); returning empty history", + anchor, + type(err).__name__, + ) + return [] + if not item_ids: + return [] + + items = await backend.get_items(item_ids, isolation=isolation) + # ``get_items`` may return ``None`` placeholders for missing IDs. + resolved = [item for item in items if item is not None] + return await _output_items_to_messages(resolved) + + async def save_messages( + self, + session_id: str | None, + messages: Sequence[Message], + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Persist messages for ``session_id`` into Foundry storage. + + Unlike the standalone ``azure.ai.agentserver`` runtime — which + owns response orchestration end-to-end and writes turns + authoritatively — the Agent Framework hosting stack treats + ``HistoryProvider`` as the *only* persistence path. Without this + method actively writing, a deployed hosted agent would silently + drop every turn. + + Strategy: + + * Use the host-bound ``response_id`` as the envelope id (mints + a fresh ``caresp_*`` id when unbound, e.g. local dev). + * Anchor the new write to the previous turn via + ``previous_response_id``, walking the prior turn's history + item ids forward so the full transcript stays visible. + * Split items by role: ``"message"`` (user/system inputs) into + ``input_items``, everything else (assistant outputs, tool + calls, reasoning, ...) into ``response.output``. + + Args: + session_id: The Responses ``previous_response_id`` / + ``conversation_id`` the messages belong to. + messages: The messages selected for persistence by the base + ``HistoryProvider`` after-run hook. + state: Unused — kept for ``HistoryProvider`` compatibility. + **kwargs: Extensibility hook; ``isolation`` may be supplied + explicitly to override the contextvar. + + Notes: + When ``local_storage_root`` is configured (and the provider + is running outside a Foundry Hosted Agent container), this + method instead delegates to a per-isolation + :class:`FileHistoryProvider` and ``session_id`` is used as + the literal file stem. The Foundry response-chain stamping + described above is bypassed entirely in that mode. + """ + if not messages: + return + + isolation = kwargs.get("isolation") or _host_isolation() or get_current_isolation() + file_provider = self._resolve_local_file_provider(isolation) + if file_provider is not None: + await file_provider.save_messages(session_id, messages, state=state, **kwargs) + return + + bound = get_current_request_context() + # Prefer the host-bound response_id so the channel envelope and + # the storage write agree on a single id per turn — which is + # what makes the next turn's ``previous_response_id`` walkable. + # Without a binding (e.g. local dev calling ``save_messages`` + # directly), fall back to a fresh Foundry-format response id. + # Free-form ``resp_`` ids carry no embedded partition key + # and the storage backend rejects writes with a server error; + # ``IdGenerator.new_response_id()`` mints a ``caresp_*`` id with + # the partition-key segment the backend expects. The chain + # walks only when ``session_id`` is itself a ``caresp_*``-shaped + # value (i.e. a previous response id), matching the prefix the + # ``ResponsesChannel`` factory uses. + if bound is not None: + response_id = bound.response_id + previous_response_id = bound.previous_response_id + else: + if not session_id: + return + response_id = IdGenerator.new_response_id() + previous_response_id = session_id if session_id.startswith(("caresp_", "resp_")) else None + + # Note: we intentionally do NOT consult ``FOUNDRY_AGENT_SESSION_ID`` + # as a fallback ``previous_response_id`` here. Per the Foundry SDK + # that env var identifies the *container instance*, not the + # conversation, so chaining off it produces an unwalkable history. + # The host-bound ``previous_response_id`` (set by + # ``ResponsesChannel`` from the request envelope) is the only + # authoritative anchor; if it's missing the new turn is the start + # of a fresh chain. + + logger.debug( + "save_messages: response_id=%r previous_response_id=%r isolation=%s", + response_id, + previous_response_id, + "" if isolation else "", + ) + backend = self._resolve_backend() + + # The agentserver runtime puts INBOUND items (user/system messages + # the request sent in) in the envelope's ``input_items`` axis and + # OUTBOUND items (assistant outputs, tool calls, reasoning) in + # ``response.output``. See + # ``_resolve_input_items_for_persistence`` (orchestrator.py:61) + + # ``_extract_response_snapshot_from_events`` in + # ``azure.ai.agentserver.responses``: ``input_items`` comes from + # ``ctx.input_items`` (request inputs only); ``response.output`` + # is populated from the lifecycle event stream. + # + # Putting everything in ``input_items`` with ``response.output: []`` + # is a schema violation that the storage backend rejects with an + # opaque HTTP 500. Split by role to mirror the runtime. + all_items = _messages_to_output_items(list(messages), id_prefix=response_id) + + # Re-stamp every item id via ``IdGenerator`` so each carries a + # Foundry-format ``{type-prefix}_`` + # identifier, with the response_id as the partition-key hint + # (co-locates each item with the response record). Free-form + # ``{response_id}_itm_N`` ids are rejected by the storage + # backend with an opaque HTTP 500 because the partition-key + # extractor cannot parse them. ``IdGenerator.new_item_id`` + # dispatches by *Item* (input) type and returns ``None`` for + # our *OutputItem* (storage) instances, so we dispatch by the + # ``type`` discriminator string instead. + ITEM_ID_FACTORY: dict[str, Any] = { + "message": IdGenerator.new_message_item_id, + "output_message": IdGenerator.new_output_message_item_id, + "function_call": IdGenerator.new_function_call_item_id, + "function_call_output": IdGenerator.new_function_call_output_item_id, + "reasoning": IdGenerator.new_reasoning_item_id, + "file_search_call": IdGenerator.new_file_search_call_item_id, + "web_search_call": IdGenerator.new_web_search_call_item_id, + "image_generation_call": IdGenerator.new_image_gen_call_item_id, + "code_interpreter_call": IdGenerator.new_code_interpreter_call_item_id, + "computer_call": IdGenerator.new_computer_call_item_id, + "computer_call_output": IdGenerator.new_computer_call_output_item_id, + "local_shell_call": IdGenerator.new_local_shell_call_item_id, + "local_shell_call_output": IdGenerator.new_local_shell_call_output_item_id, + "mcp_call": IdGenerator.new_mcp_call_item_id, + "mcp_list_tools": IdGenerator.new_mcp_list_tools_item_id, + "mcp_approval_request": IdGenerator.new_mcp_approval_request_item_id, + "mcp_approval_response": IdGenerator.new_mcp_approval_response_item_id, + "custom_tool_call": IdGenerator.new_custom_tool_call_item_id, + "custom_tool_call_output": IdGenerator.new_custom_tool_call_output_item_id, + } + for item in all_items: + factory = ITEM_ID_FACTORY.get(getattr(item, "type", "") or "") + if factory is None: + continue + new_id = factory(response_id) + # Plain attribute assignment — the SDK ``OutputItem`` models + # are ``MutableMapping``s with ``__setattr__`` wired to dict + # set, so this is expected to succeed for every type listed + # above. The previous ``contextlib.suppress`` masked SDK + # contract changes (next save would silently retain the + # synthetic prefix-based id and the storage backend would + # reject the entire ``create_response`` with HTTP 500). + # Letting it raise surfaces those breakages to the test + # suite instead. + item.id = new_id # type: ignore[attr-defined] + + input_items: list[Any] = [] + output_items: list[Any] = [] + for item in all_items: + item_type = getattr(item, "type", None) + if item_type == "message": + input_items.append(item) + else: + # ``output_message``, tool calls, reasoning, etc. all + # belong to the response output stream. + output_items.append(item) + + # Walk the previous response's history chain so the new write + # carries the full transcript forward. Without this, each turn + # would only see the messages saved on that very turn. + history_item_ids: list[str] | None = None + if previous_response_id is not None: + try: + history_item_ids = await backend.get_history_item_ids( + previous_response_id, + None, + self._history_limit, + isolation=isolation, + ) + except (FoundryBadRequestError, FoundryResourceNotFoundError) as err: + # Don't let history fetch failures torpedo the write — + # we still want to persist the new turn even if the + # chain seed is unreachable for some reason. + logger.warning( + "save_messages: failed to walk previous_response_id=%r (%s); writing new turn without history seed", + previous_response_id, + type(err).__name__, + ) + + # Mirror what the agentserver runtime serialises onto the wire + # (see ``_extract_response_snapshot_from_events`` + + # ``strip_nulls`` in + # ``azure.ai.agentserver.responses.streaming._helpers``): + # + # * ``agent_reference`` (Required on the response envelope) — + # built from ``FOUNDRY_AGENT_NAME`` / ``FOUNDRY_AGENT_VERSION``, + # which the hosted platform sets per-deploy (sentinel fallback + # for local dev so the envelope stays well-formed). + # * ``agent_session_id`` (S-038) — forcibly stamped by the + # runtime; sourced from ``FOUNDRY_AGENT_SESSION_ID``. + # * ``conversation`` is intentionally omitted: the (user, chat) + # isolation headers are the Foundry storage partition key, + # and the chat-isolation-key value is opaque (the API + # returns "Malformed identifier"/HTTP 400 if used as a + # body-level ``conversation_id``). + # * Per-item ``response_id`` / ``agent_reference`` are NOT + # stamped here — those B20/B21 defaults only apply to items + # inside ``response.output_item.added/done`` *events* (see + # ``_coerce_handler_event``); items inside ``input_items`` + # and ``response.output`` go through ``to_output_item`` which + # never sets these fields, and the storage validator returns + # HTTP 400 ``invalid_payload`` when extras leak in. + agent_name = os.environ.get("FOUNDRY_AGENT_NAME") or "agent-framework-host" + agent_version = os.environ.get("FOUNDRY_AGENT_VERSION") or None + agent_reference: dict[str, Any] = {"type": "agent_reference", "name": agent_name} + if agent_version: + agent_reference["version"] = agent_version + + agent_session_id = os.environ.get("FOUNDRY_AGENT_SESSION_ID") or None + # ``model`` must be a real deployed model name — the storage + # validator rejects arbitrary strings. Pull it from the + # platform-provided ``MODEL_DEPLOYMENT_NAME`` (set in agent.yaml) + # and fall back to ``AZURE_AI_MODEL_DEPLOYMENT_NAME`` for local + # dev. When neither is set we omit the field entirely (it is + # ``Optional[str]`` per the ResponseObject schema). + model_deployment = ( + os.environ.get("MODEL_DEPLOYMENT_NAME") or os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME") or None + ) + + # Build the wire payload to match exactly what the agentserver + # runtime emits via ``_extract_response_snapshot_from_events`` + # for a synthetic ``status=completed`` snapshot: + # + # {id, object, output, created_at, [model], agent_reference, + # status, completed_at, [agent_session_id]} + # + # ``previous_response_id`` is appended when chaining; the runtime + # threads it through the same code path. + now = int(time.time()) + response_body: dict[str, Any] = { + "id": response_id, + # SDK mirror: ``streaming/_helpers.py:244`` always stamps + # ``response_id`` alongside ``id`` on the snapshot before it + # reaches ``serialize_create_request``. + "response_id": response_id, + "object": "response", + # S-040 auto-stamp: the orchestrator (``_orchestrator.py:1706``) + # echoes ``background`` from the request to every response + # envelope; storage rejects payloads that omit it. + "background": False, + # ``ResponseObject`` schema (``_models.py:13995``) declares + # ``parallel_tool_calls: bool`` as REQUIRED. The SDK's synthetic + # fallback path (``_build_events``) never sets it because it's + # only invoked for failure recovery; real handler events carry + # it through. Storage rejects payloads that omit it. + "parallel_tool_calls": False, + # Same story for ``instructions`` (``_models.py:13989``) — + # required ``str | list[Item]`` field. + "instructions": "", + "output": [item.as_dict() for item in output_items], + "created_at": now, + "agent_reference": agent_reference, + "status": "completed", + "completed_at": now, + } + if model_deployment is not None: + response_body["model"] = model_deployment + if agent_session_id is not None: + response_body["agent_session_id"] = agent_session_id + if previous_response_id is not None: + response_body["previous_response_id"] = previous_response_id + response = ResponseObject(response_body) + + try: + await backend.create_response( + response, + input_items=input_items, + history_item_ids=history_item_ids, + isolation=isolation, + ) + except FoundryStorageError as exc: + # Storage-validation failures (4xx ``invalid_payload`` / + # ``not_found``, opaque 5xx) are best-effort losses: the + # caller's run already produced output and we don't want to + # crash the whole turn over a chain-write the user can't + # recover from. They are still observable: every drop bumps + # ``failed_writes`` (operators can poll it / surface in + # health probes) and the full traceback + ``response_body`` + # is logged. + # + # Network / TLS / DNS errors, expired-credential 401/403s, + # and bugs in the wire-payload builder above (e.g. a + # required-field regression) deliberately propagate so they + # surface to the caller and trigger retry / alerting paths + # instead of being silently dropped here. + self.failed_writes += 1 + err_body = getattr(exc, "response_body", None) + logger.exception( + "FoundryHostedAgentHistoryProvider.save_messages: storage rejected " + "%d message(s) (response_id=%s, previous_response_id=%s, error_body=%s, " + "failed_writes=%d).", + len(messages), + response_id, + previous_response_id, + err_body, + self.failed_writes, + ) + return + logger.debug( + "FoundryHostedAgentHistoryProvider.save_messages: persisted %d message(s) " + "(response_id=%s, previous_response_id=%s).", + len(messages), + response_id, + previous_response_id, + ) + + +# Re-export ``OutputItem`` for callers that want to construct test items +# without reaching into the SDK's ``models`` namespace directly. +__all__ = [ + "FoundryHostedAgentHistoryProvider", + "OutputItem", + "bind_request_context", + "get_current_isolation", + "get_current_request_context", + "reset_current_isolation", + "set_current_isolation", +] diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_ids.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_ids.py new file mode 100644 index 00000000000..f4e6b3d4fe0 --- /dev/null +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_ids.py @@ -0,0 +1,72 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Foundry-storage-compatible identifier helpers. + +The Foundry hosted-agent storage backend partitions records by extracting +an embedded partition-key segment from every record/item id. The id +format is ``{prefix}_{18charPartitionKey}{32charEntropy}`` (or a 48-char +legacy body). Free-form ids such as ``resp_`` carry no valid +partition key and the storage API rejects writes with an opaque +``HTTP 500 server_error``. + +These helpers wrap :class:`azure.ai.agentserver.responses._id_generator.IdGenerator` +so callers (e.g. the ``ResponsesChannel.response_id_factory`` argument +or :class:`FoundryHostedAgentHistoryProvider.save_messages`) can mint +ids that the storage backend accepts without leaking the SDK import +path into user code. +""" + +from __future__ import annotations + +from typing import Any + +from azure.ai.agentserver.responses._id_generator import IdGenerator + +__all__ = [ + "foundry_item_id", + "foundry_response_id", + "foundry_response_id_factory", +] + + +def foundry_response_id(previous_response_id: str | None = None) -> str: + """Mint a Foundry-storage-compatible response id (``caresp_*``). + + Args: + previous_response_id: When supplied (and shaped like a Foundry + id with an embedded partition key), the new id co-locates + with the chain by reusing that partition key. The storage + backend rejects chained writes whose new record sits in a + different partition than the prior one. + + Returns: + A new id of the form ``caresp_<18charPartitionKey><32charEntropy>``. + """ + return IdGenerator.new_response_id(previous_response_id or "") + + +def foundry_response_id_factory() -> Any: + """Return a callable suitable for ``ResponsesChannel(response_id_factory=...)``. + + The returned callable accepts an optional ``previous_response_id`` + hint which the channel passes for chained turns so the new id + inherits the prior turn's partition key (Foundry storage requirement). + """ + return foundry_response_id + + +def foundry_item_id(item: Any, response_id: str | None = None) -> str | None: + """Mint a Foundry-storage-compatible item id for *item*. + + Dispatches via :meth:`IdGenerator.new_item_id` so the id picks up + the right type prefix (``msg`` / ``om`` / ``fc`` / ``rs`` / ...). + When ``response_id`` is supplied it acts as a partition-key hint so + every item written under one response co-locates with the response + record (Foundry storage requirement). + + Returns: + A new id of the form ``{type-prefix}_``, + or ``None`` when *item* is an unrecognised / reference-only type + (mirrors the SDK helper's contract). + """ + return IdGenerator.new_item_id(item, response_id) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index 3f9ae41b97b..0e404e86984 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -901,9 +901,6 @@ def _close(self) -> Generator[ResponseStreamEvent]: self._accumulated.clear() -# endregion - - # region Option Conversion @@ -939,7 +936,6 @@ def _to_chat_options(request: CreateResponse) -> tuple[ChatOptions, bool]: # endregion - # region Input Message Conversion diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_shared.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_shared.py new file mode 100644 index 00000000000..53007a979c0 --- /dev/null +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_shared.py @@ -0,0 +1,1340 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Shared transformation helpers between the agent-server data model and Agent Framework. + +This module is the single home for *pure-data* conversions between the +:mod:`azure.ai.agentserver.responses.models` SDK shapes (``Item``, +``OutputItem``, ``MessageContent``, …) and the Agent Framework public types +(:class:`agent_framework.Message`, :class:`agent_framework.Content`, …). + +Why this lives in one module +---------------------------- +* The :mod:`._responses` channel adapter and the + :class:`._history_provider.FoundryHostedAgentHistoryProvider` both need the + exact same OutputItem→Message conversion. Keeping it in one place means we + only have **one** ``isinstance(item.type, ...)`` dispatch table to keep up + to date when the agent-server SDK grows new item kinds. If you spot a + ``type`` value that this module raises ``ValueError`` for, that is the place + to add support — and **both** consumers benefit immediately. +* The whole module references the agent-server SDK through a single + ``from azure.ai.agentserver.responses import models`` import. Looking at the + ``models.X`` references makes it obvious which generated types we already + consume and which ones (e.g. ``models.A2AToolCall``, + ``models.AzureFunctionToolCall``, …) are not yet wired into + :func:`_output_item_to_message`. + +``additional_properties`` round-trip +------------------------------------ +Both the SDK models and :class:`agent_framework.Message` carry an extensible +extras bag — the agent-server models are +:class:`collections.abc.MutableMapping` instances that round-trip *any* key +through their JSON serialisation, and ``Message`` (and ``Content``) expose a +public ``additional_properties: dict[str, Any]`` slot. + +To preserve channel-specific extras across a load/save cycle: + +* On **load** (SDK model → Message) :func:`_collect_unknown_keys` extracts + every key on the source model that is **not** part of its declared schema + (per ``_attr_to_rest_field``) and stashes it on + ``Message.additional_properties["foundry"]`` (and per-content the same + bag is attached onto ``Content.additional_properties["foundry"]``). The + bag is only attached when at least one extra key is present, so messages + that didn't have extras stay byte-equal to the previous behaviour. +* On **save** (Message → SDK model) :func:`_inject_extras` writes any + previously stashed bag back as direct keys on the SDK model — Foundry + storage will round-trip them as opaque JSON. + +This means an app can stash channel-specific bookkeeping (delivery +fingerprints, `hosting` envelope from the host, AG-UI ``client_state`` +snapshots, …) under a known top-level key and rely on it surviving a +write/read cycle through the Foundry response store. +""" + +from __future__ import annotations + +import base64 +import json +import logging +from collections.abc import Mapping, Sequence +from typing import Any, Protocol, cast + +from agent_framework import Content, Message +from azure.ai.agentserver.responses import models + +logger = logging.getLogger(__name__) + +# Top-level key under which round-tripped SDK extras live on +# ``Message.additional_properties`` and ``Content.additional_properties``. +# Stable on purpose: write-paths look it up by name to re-inject extras into +# outbound SDK models. +EXTRAS_KEY = "foundry" + +# Sub-key (under ``additional_properties[EXTRAS_KEY]``) that stores a +# verbatim snapshot of the original SDK ``OutputItem`` mapping captured at +# read time. The write path re-emits the SDK item from this snapshot when +# present, giving lossless audit/replay semantics: every declared field +# (item id, type discriminator, content array, status, …) AND every undeclared +# extra Foundry handed us survive the AF round-trip. Without this, a +# message synthesised back from ``Message.text`` alone would discard the +# original item shape. +RAW_KEY = "__raw__" + +# Top-level key on the SDK ``OutputItem`` mapping under which we round-trip +# *every* :class:`agent_framework.Message` ``additional_properties`` namespace +# **other than** :data:`EXTRAS_KEY` (the foundry-internal namespace, handled +# separately by :func:`_inject_extras`). +# +# Why a single container key instead of writing each namespace as a top-level +# extra on the SDK item: Foundry's storage backend round-trips arbitrary +# unknown keys, but on **load** :func:`_collect_unknown_keys` cannot tell +# which unknowns were AF-written namespaces (``hosting``, ``agui_state``, +# ...) vs Foundry-runtime additions. Funnelling AF namespaces under a single +# sentinel key removes that ambiguity: anything inside ``agent_framework`` +# is restored under its original namespace; anything else stays under +# :data:`EXTRAS_KEY` (preserving today's behaviour for Foundry-side extras). +# +# Concretely, this is the mechanism that gives the Hosting spec's +# ``Message.additional_properties["hosting"]`` envelope (channel / +# identity / response_target / initial-write ``deliveries[]``) durable +# round-trip semantics through the Foundry response store — see +# ``docs/specs/002-python-hosting-channels.md`` §"Channel metadata +# persisted onto stored messages". +AF_EXTRAS_KEY = "agent_framework" + +# Re-exports — these helpers are consumed by sibling modules +# (``_responses.py`` and ``_history_provider.py``); declaring them in +# ``__all__`` quiets pyright's ``reportUnusedFunction`` for module-private +# names that are intentionally part of the package-internal API. +__all__ = ( + "AF_EXTRAS_KEY", + "EXTRAS_KEY", + "RAW_KEY", + "ApprovalStorage", + "_arguments_to_str", + "_attach_content_extras", + "_attach_extras", + "_capture_raw", + "_collect_af_extras", + "_collect_unknown_keys", + "_convert_message_content", + "_convert_output_message_content", + "_inject_af_extras", + "_inject_extras", + "_item_to_message", + "_items_to_messages", + "_message_text", + "_message_to_output_item", + "_messages_to_output_items", + "_output_item_to_message", + "_output_items_to_messages", +) + + +class ApprovalStorage(Protocol): + """Storage for saving function approval requests.""" + + async def save_approval_request(self, approval_request_id: str, request: Content) -> None: + """Save a function approval request under the given ID.""" + ... + + async def load_approval_request(self, approval_request_id: str) -> Content: + """Load a function approval request by its ID.""" + ... + + +# region Extras helpers + + +def _collect_unknown_keys(model: Mapping[str, Any]) -> dict[str, Any]: + """Return any keys present on the SDK model that are not part of its declared schema. + + The agent-server SDK models are + :class:`collections.abc.MutableMapping` instances generated from the + Foundry REST contract; declared fields are exposed via the class-level + ``_attr_to_rest_field`` map. Any extra key on the instance therefore + represents data the Foundry runtime stored that the SDK doesn't model + explicitly — typically channel-specific extras a previous write-path + deliberately stashed there via :func:`_inject_extras`. + + Args: + model: A model instance (or any mapping) to inspect. + + Returns: + A new ``dict`` containing only the keys on ``model`` that are not + declared in the model's REST schema. Empty when the model only + carries declared fields. + """ + if not isinstance(model, Mapping): + return {} + known = set(getattr(type(model), "_attr_to_rest_field", {}).keys()) + return {key: value for key, value in model.items() if key not in known} + + +def _attach_extras(message: Message, model: Mapping[str, Any]) -> Message: + """Attach SDK extras (if any) to ``message.additional_properties``. + + Two-tier restoration so the Hosting spec's namespaced envelopes + (``hosting``, ``agui_state``, …) come back under their **original** + keys while Foundry-side extras (anything the runtime layered on the + SDK item) stay under the foundry-internal :data:`EXTRAS_KEY` + namespace: + + 1. Pop :data:`AF_EXTRAS_KEY` from the unknown-keys bag and merge each + sub-key directly onto ``message.additional_properties`` — this is + how the inbound ``hosting`` envelope (channel/identity/ + response_target) and the initial-write ``deliveries[]`` snapshot + round-trip through Foundry storage. + 2. Anything remaining (Foundry-runtime extras the SDK doesn't model + explicitly) is stashed under + ``additional_properties[EXTRAS_KEY]`` for backward compatibility + and audit/replay. + + No-op when the model carries no extras — ``additional_properties`` is left + alone so callers and tests that compare ``Message`` instances for equality + by ``role``/``contents`` only continue to pass. + + Args: + message: The message to enrich. + model: The SDK model whose extras should be preserved. + + Returns: + The same ``message`` instance (returned for fluent chaining). + """ + extras = _collect_unknown_keys(model) + if not extras: + return message + af_extras = extras.pop(AF_EXTRAS_KEY, None) + if isinstance(af_extras, Mapping): + af_extras_typed = cast("Mapping[str, Any]", af_extras) + for ns_key, ns_val in af_extras_typed.items(): + # Per-namespace overwrite: a fresh load is the source of + # truth for the message we're rebuilding. + message.additional_properties[ns_key] = ns_val + if extras: + message.additional_properties.setdefault(EXTRAS_KEY, {}).update(extras) + return message + + +def _capture_raw(message: Message, item: Mapping[str, Any]) -> Message: + """Snapshot the SDK item's full mapping onto the message for replay. + + Stored under ``message.additional_properties[EXTRAS_KEY][RAW_KEY]`` so + :func:`_message_to_output_item` can re-emit the byte-for-byte original + SDK shape on the write side. This is what lets the AF → + Foundry-storage round-trip preserve item ids, content variants + (citations, reasoning, tool results, …) and any extras Foundry + layered on top of the declared schema. + + Narrow ``TypeError`` is the only swallowed failure (matches the + ``Mapping`` contract precondition); ``MemoryError`` and other + ``Exception`` subclasses propagate so genuine bugs aren't masked. + A WARNING with ``exc_info`` is logged so the lossy fallback is + observable downstream — without it a regression in the SDK schema + silently drops citations / reasoning / tool-result extras on every + round-tripped message and there is no breadcrumb pointing here. + """ + try: + raw = dict(item) + except TypeError: + logger.warning( + "_capture_raw: SDK item %r is not mapping-like; round-tripping without raw snapshot", + type(item).__name__, + exc_info=True, + ) + return message + message.additional_properties.setdefault(EXTRAS_KEY, {})[RAW_KEY] = raw + return message + + +def _inject_extras(model: Any, source: Mapping[str, Any] | None) -> Any: + """Inject previously-stashed extras back onto an outbound SDK model. + + The SDK models are :class:`collections.abc.MutableMapping`; setting + arbitrary keys on them is supported and round-trips through serialisation. + Use this when **emitting** SDK shapes (e.g. when ``save_messages`` decides + to write back through the Foundry storage API). + + Args: + model: The SDK model instance to enrich. Must be mapping-like. + source: The extras bag previously read from + ``Message.additional_properties[EXTRAS_KEY]`` (or any equivalent). + ``None`` is treated as an empty bag. + + Returns: + The same ``model`` instance (returned for fluent chaining). + """ + if not source: + return model + for key, value in source.items(): + # Internal sentinel — never write the raw-snapshot back as a + # storage field; it lives only inside ``additional_properties``. + if key == RAW_KEY: + continue + # Avoid clobbering declared fields — extras are never allowed to + # overwrite the schema-defined contract on the model. + model_type: Any = type(model) # pyright: ignore[reportUnknownVariableType] + known: set[str] = set(getattr(model_type, "_attr_to_rest_field", {})) + if key in known: + continue + model[key] = value + return model + + +def _collect_af_extras(message: Message) -> dict[str, Any]: + """Gather every AF-side ``additional_properties`` namespace except :data:`EXTRAS_KEY`. + + Returns the namespaces (``hosting``, ``agui_state``, …) that should + round-trip through Foundry storage as a single opaque container under + :data:`AF_EXTRAS_KEY` on the SDK item. The foundry-internal namespace + is excluded because :func:`_inject_extras` handles it separately and + its contents are AF-specific bookkeeping (raw snapshots, Foundry + runtime extras) that don't belong inside the AF container. + """ + props = message.additional_properties or {} + return {key: value for key, value in props.items() if key != EXTRAS_KEY} + + +def _inject_af_extras(model: Any, source: Mapping[str, Any] | None) -> Any: + """Write AF-side namespaces onto the SDK model under :data:`AF_EXTRAS_KEY`. + + This is the save-side counterpart to :func:`_attach_extras`'s + AF-namespace restoration. The container key collides with declared + schema fields only if Foundry decides to add an + ``agent_framework`` field to its REST contract — at which point we + rename the constant. + + A non-empty ``source`` overwrites any value already at + :data:`AF_EXTRAS_KEY` on the model (e.g. a stale value baked into a + raw-snapshot replay) so the in-process :class:`Message` remains the + source of truth at write time. + """ + if not source: + return model + model[AF_EXTRAS_KEY] = dict(source) + return model + + +# endregion + + +# region Small utilities + + +def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str: + """Convert a tool-call ``arguments`` payload to its on-the-wire JSON string form. + + Args: + arguments: The arguments to serialise. ``None`` becomes an empty + string, an existing string is returned verbatim, and any mapping + is JSON-encoded. + + Returns: + The arguments as a JSON string. + """ + if arguments is None: + return "" + if isinstance(arguments, str): + return arguments + return json.dumps(arguments) + + +# endregion + + +# region Content conversion + + +def _convert_file_data(data_uri: str, filename: str | None = None) -> Content: + """Convert a ``file_data`` data URI to a :class:`Content`. + + For ``text/*`` MIME types the base64 payload is decoded and returned as + plain text (with a ``[File: ]`` prefix when a filename is known); + other media types fall through to a URI-based content with the + filename preserved as an additional property. + """ + if data_uri.startswith("data:") and ";base64," in data_uri: + header, encoded = data_uri.split(";base64,", 1) + media_type = header[len("data:") :] + if media_type.startswith("text/"): + try: + decoded_text = base64.b64decode(encoded).decode("utf-8") + except (ValueError, UnicodeDecodeError): + logger.warning( + "Failed to decode text/* file_data as UTF-8, falling through to URI passthrough.", + exc_info=True, + ) + else: + prefix = f"[File: {filename}]\n" if filename else "" + return Content.from_text(f"{prefix}{decoded_text}") + additional_properties = {"filename": filename} if filename else None + return Content.from_uri(data_uri, additional_properties=additional_properties) + + +def _convert_message_content(content: models.MessageContent) -> Content: + """Convert an SDK ``MessageContent`` (input-side) into a framework ``Content``. + + Handles all input/output content variants currently understood by the + Responses channel — text, output text, summary, refusal, reasoning text, + input images, input files, computer screenshot. + + Args: + content: The SDK content node to convert. + + Returns: + The corresponding :class:`agent_framework.Content`. + + Raises: + ValueError: If the SDK content ``type`` is not yet supported by this + adapter. + """ + if content.type == "input_text": + return _attach_content_extras( + Content.from_text(cast(models.MessageContentInputTextContent, content).text), content + ) + if content.type == "output_text": + return _attach_content_extras( + Content.from_text(cast(models.MessageContentOutputTextContent, content).text), content + ) + if content.type == "text": + return _attach_content_extras(Content.from_text(cast(models.TextContent, content).text), content) + if content.type == "summary_text": + return _attach_content_extras(Content.from_text(cast(models.SummaryTextContent, content).text), content) + if content.type == "refusal": + return _attach_content_extras( + Content.from_text(cast(models.MessageContentRefusalContent, content).refusal), content + ) + if content.type == "reasoning_text": + return _attach_content_extras( + Content.from_text_reasoning(text=cast(models.MessageContentReasoningTextContent, content).text), + content, + ) + if content.type == "input_image": + image = cast(models.MessageContentInputImageContent, content) + if image.image_url: + return _attach_content_extras(Content.from_uri(image.image_url), content) + if image.file_id: + return _attach_content_extras(Content.from_hosted_file(image.file_id), content) + if content.type == "input_file": + file = cast(models.MessageContentInputFileContent, content) + if file.file_url: + return _attach_content_extras(Content.from_uri(file.file_url), content) + if file.file_id: + return _attach_content_extras(Content.from_hosted_file(file.file_id, name=file.filename), content) + if file.file_data: + return _attach_content_extras(_convert_file_data(file.file_data, file.filename), content) + if content.type == "computer_screenshot": + return _attach_content_extras( + Content.from_uri(cast(models.ComputerScreenshotContent, content).image_url), content + ) + + raise ValueError(f"Unsupported MessageContent type: {content.type}") + + +def _convert_output_message_content(content: models.OutputMessageContent) -> Content: + """Convert an SDK ``OutputMessageContent`` (assistant output side) into a framework ``Content``. + + Handles assistant-output variants: ``output_text`` and ``refusal``. + + Args: + content: The SDK content node to convert. + + Returns: + The corresponding :class:`agent_framework.Content`. + + Raises: + ValueError: If the SDK content ``type`` is not yet supported. + """ + if content.type == "output_text": + return _attach_content_extras( + Content.from_text(cast(models.OutputMessageContentOutputTextContent, content).text), content + ) + if content.type == "refusal": + return _attach_content_extras( + Content.from_text(cast(models.OutputMessageContentRefusalContent, content).refusal), content + ) + + raise ValueError(f"Unsupported OutputMessageContent type: {content.type}") + + +def _attach_content_extras(content: Content, model: Mapping[str, Any]) -> Content: + """Round-trip SDK content extras onto :attr:`Content.additional_properties`. + + Mirror of :func:`_attach_extras` but for individual content nodes. Only + attaches the bag when at least one extra key is present, so the produced + ``Content`` stays byte-equivalent to a non-extras conversion when there is + nothing to preserve. + + Args: + content: The framework content to enrich. + model: The SDK content node whose extras should be preserved. + + Returns: + The same ``content`` instance. + """ + extras = _collect_unknown_keys(model) + if extras: + content.additional_properties.setdefault(EXTRAS_KEY, {}).update(extras) + return content + + +# endregion + + +# region Item → Message (input side) + + +async def _items_to_messages( + input_items: Sequence[models.Item], + *, + approval_storage: ApprovalStorage | None = None, +) -> list[Message]: + """Convert a sequence of input ``Item`` SDK objects to framework ``Message`` objects. + + One :class:`agent_framework.Message` per input item — fan-out is the + caller's responsibility. + + Args: + input_items: The input items to convert. + + Keyword Args: + approval_storage: Optional approval storage. Required when the input + stream contains ``mcp_approval_request`` / ``mcp_approval_response`` + items so the original function-call payload can be looked up. + + Returns: + A list of messages in the same order as the input. + """ + return [await _item_to_message(item, approval_storage=approval_storage) for item in input_items] + + +async def _item_to_message( + item: models.Item, + *, + approval_storage: ApprovalStorage | None = None, +) -> Message: + """Convert a single input ``Item`` SDK object to a framework ``Message``. + + Wraps :func:`_item_to_message_inner` and stamps a :data:`RAW_KEY` + snapshot of the SDK item so the write path can rebuild the original + shape losslessly. See :func:`_capture_raw`. + """ + return _capture_raw(await _item_to_message_inner(item, approval_storage=approval_storage), item) + + +async def _item_to_message_inner( + item: models.Item, + *, + approval_storage: ApprovalStorage | None = None, +) -> Message: + """Convert a single input ``Item`` SDK object to a framework ``Message``. + + The conversion table is intentionally explicit (no auto-discovery) so it + is easy to scan for missing variants. To add support for a new item kind: + + 1. Add an ``elif item.type == "...":`` branch here. + 2. Reference the corresponding ``models.ItemX`` (or + ``models.XItemParam``) type via ``cast(...)``. + 3. Map its fields onto :class:`agent_framework.Content` factory methods. + 4. Add an ``isinstance(...)`` branch in :func:`_output_item_to_message` + if the same kind also appears on the output side. + + Args: + item: The SDK item to convert. + + Keyword Args: + approval_storage: Optional approval storage. Required when the item is + an ``mcp_approval_request`` / ``mcp_approval_response``; ignored + otherwise. + + Returns: + The converted message, with any unknown extras round-tripped under + ``message.additional_properties[EXTRAS_KEY]``. + + Raises: + ValueError: If the SDK item ``type`` is not yet supported by this + adapter. + """ + if item.type == "message": + msg = cast(models.ItemMessage, item) + if isinstance(msg.content, str): + message = Message(role=msg.role, contents=[Content.from_text(msg.content)]) + else: + message = Message(role=msg.role, contents=[_convert_message_content(part) for part in msg.content]) + return _attach_extras(message, item) + + if item.type == "output_message": + output_msg = cast(models.ItemOutputMessage, item) + return _attach_extras( + Message( + role=output_msg.role, + contents=[_convert_output_message_content(part) for part in output_msg.content], + ), + item, + ) + + if item.type == "function_call": + fc = cast(models.ItemFunctionToolCall, item) + return _attach_extras( + Message( + role="assistant", + contents=[Content.from_function_call(fc.call_id, fc.name, arguments=fc.arguments)], + ), + item, + ) + + if item.type == "function_call_output": + fco = cast(models.FunctionCallOutputItemParam, item) + output = fco.output if isinstance(fco.output, str) else str(fco.output) + return _attach_extras( + Message(role="tool", contents=[Content.from_function_result(fco.call_id, result=output)]), + item, + ) + + if item.type == "reasoning": + reasoning = cast(models.ItemReasoningItem, item) + reason_contents: list[Content] = [] + if reasoning.summary: + for summary in reasoning.summary: + reason_contents.append(Content.from_text(summary.text)) + return _attach_extras(Message(role="assistant", contents=reason_contents), item) + + if item.type == "mcp_call": + mcp = cast(models.ItemMcpToolCall, item) + return _attach_extras( + Message( + role="assistant", + contents=[ + Content.from_mcp_server_tool_call( + mcp.id, + mcp.name, + server_name=mcp.server_label, + arguments=mcp.arguments, + ) + ], + ), + item, + ) + + if item.type == "mcp_approval_request": + mcp_req = cast(models.ItemMcpApprovalRequest, item) + if approval_storage is None: + raise ValueError("ApprovalStorage is required to load approval request.") + mcp_call_content = await approval_storage.load_approval_request(mcp_req.id) + return _attach_extras( + Message(role="assistant", contents=[mcp_call_content]), + item, + ) + + if item.type == "mcp_approval_response": + mcp_resp = cast(models.MCPApprovalResponse, item) + if approval_storage is None: + raise ValueError("ApprovalStorage is required to load approval request.") + function_approval_request_content = await approval_storage.load_approval_request(mcp_resp.approval_request_id) + return _attach_extras( + Message( + role="user", + contents=[function_approval_request_content.to_function_approval_response(mcp_resp.approve)], + ), + item, + ) + + if item.type == "code_interpreter_call": + ci = cast(models.ItemCodeInterpreterToolCall, item) + return _attach_extras( + Message(role="assistant", contents=[Content.from_code_interpreter_tool_call(call_id=ci.id)]), + item, + ) + + if item.type == "image_generation_call": + ig = cast(models.ItemImageGenToolCall, item) + return _attach_extras( + Message(role="assistant", contents=[Content.from_image_generation_tool_call(image_id=ig.id)]), + item, + ) + + if item.type == "shell_call": + sc = cast(models.FunctionShellCallItemParam, item) + return _attach_extras( + Message( + role="assistant", + contents=[ + Content.from_shell_tool_call( + call_id=sc.call_id, + commands=sc.action.commands, + status=str(sc.status), + ) + ], + ), + item, + ) + + if item.type == "shell_call_output": + sco = cast(models.FunctionShellCallOutputItemParam, item) + outputs = [ + Content.from_shell_command_output( + stdout=out.stdout or "", + stderr=out.stderr or "", + exit_code=getattr(out.outcome, "exit_code", None) if hasattr(out, "outcome") else None, + ) + for out in (sco.output or []) + ] + return _attach_extras( + Message( + role="tool", + contents=[ + Content.from_shell_tool_result( + call_id=sco.call_id, + outputs=outputs, + max_output_length=sco.max_output_length, + ) + ], + ), + item, + ) + + if item.type == "local_shell_call": + lsc = cast(models.ItemLocalShellToolCall, item) + commands = lsc.action.command if hasattr(lsc.action, "command") and lsc.action.command else [] + return _attach_extras( + Message( + role="assistant", + contents=[ + Content.from_shell_tool_call( + call_id=lsc.call_id, + commands=commands, + status=str(lsc.status), + ) + ], + ), + item, + ) + + if item.type == "local_shell_call_output": + lsco = cast(models.ItemLocalShellToolCallOutput, item) + return _attach_extras( + Message( + role="tool", + contents=[ + Content.from_shell_tool_result( + call_id=lsco.id, + outputs=[Content.from_shell_command_output(stdout=lsco.output)], + ) + ], + ), + item, + ) + + if item.type == "file_search_call": + fs = cast(models.ItemFileSearchToolCall, item) + return _attach_extras( + Message( + role="assistant", + contents=[ + Content.from_function_call( + fs.id, + "file_search", + arguments=json.dumps({"queries": fs.queries}), + ) + ], + ), + item, + ) + + if item.type == "web_search_call": + ws = cast(models.ItemWebSearchToolCall, item) + return _attach_extras( + Message(role="assistant", contents=[Content.from_function_call(ws.id, "web_search")]), + item, + ) + + if item.type == "computer_call": + cc = cast(models.ItemComputerToolCall, item) + return _attach_extras( + Message( + role="assistant", + contents=[ + Content.from_function_call( + cc.call_id, + "computer_use", + arguments=str(cc.action), + ) + ], + ), + item, + ) + + if item.type == "computer_call_output": + cco = cast(models.ComputerCallOutputItemParam, item) + return _attach_extras( + Message(role="tool", contents=[Content.from_function_result(cco.call_id, result=str(cco.output))]), + item, + ) + + if item.type == "custom_tool_call": + ct = cast(models.ItemCustomToolCall, item) + return _attach_extras( + Message( + role="assistant", + contents=[Content.from_function_call(ct.call_id, ct.name, arguments=ct.input)], + ), + item, + ) + + if item.type == "custom_tool_call_output": + cto = cast(models.ItemCustomToolCallOutput, item) + output = cto.output if isinstance(cto.output, str) else str(cto.output) + # Hosted-MCP results land here because the host writes them via + # ``aoutput_item_custom_tool_call_output`` (see ``_to_outputs`` for + # ``mcp_server_tool_result``). The persisted ``call_id`` keeps its + # ``mcp_*`` prefix; on read, route those back to a hosted-MCP + # result Content so the chat-client serialize layer can coalesce + # them onto a single ``mcp_call`` input item with ``output`` + # populated. Issue #5546. + if cto.call_id and cto.call_id.startswith("mcp_"): + return _attach_extras( + Message( + role="tool", + contents=[Content.from_mcp_server_tool_result(call_id=cto.call_id, output=output)], + ), + item, + ) + return _attach_extras( + Message(role="tool", contents=[Content.from_function_result(cto.call_id, result=output)]), + item, + ) + + if item.type == "apply_patch_call": + ap = cast(models.ApplyPatchToolCallItemParam, item) + return _attach_extras( + Message( + role="assistant", + contents=[ + Content.from_function_call( + ap.call_id, + "apply_patch", + arguments=str(ap.operation), + ) + ], + ), + item, + ) + + if item.type == "apply_patch_call_output": + apo = cast(models.ApplyPatchToolCallOutputItemParam, item) + return _attach_extras( + Message(role="tool", contents=[Content.from_function_result(apo.call_id, result=apo.output or "")]), + item, + ) + + raise ValueError(f"Unsupported Item type: {item.type}") + + +# endregion + + +# region OutputItem → Message (output / history side) + + +async def _output_items_to_messages( + history: Sequence[models.OutputItem], + *, + approval_storage: ApprovalStorage | None = None, +) -> list[Message]: + """Convert a sequence of ``OutputItem`` SDK objects to framework ``Message`` objects. + + This is the function the :class:`._history_provider.FoundryHostedAgentHistoryProvider` + calls to materialise stored Foundry response items into the message + history the agent will see on its next turn. + + Args: + history: The output items to convert, oldest-first. + + Keyword Args: + approval_storage: Optional approval storage. Required when the + history contains ``mcp_approval_request`` / + ``mcp_approval_response`` items so the original function-call + payload can be looked up. + + Returns: + A list of messages, one per supported item, in the same order. + """ + return [await _output_item_to_message(item, approval_storage=approval_storage) for item in history] + + +async def _output_item_to_message( + item: models.OutputItem, + *, + approval_storage: ApprovalStorage | None = None, +) -> Message: + """Convert a single ``OutputItem`` SDK object to a framework ``Message``. + + Wraps :func:`_output_item_to_message_inner` and stamps a + :data:`RAW_KEY` snapshot of the SDK item onto + ``Message.additional_properties[EXTRAS_KEY]`` so the write path can + re-emit byte-for-byte. See :func:`_capture_raw` for the rationale. + """ + return _capture_raw(await _output_item_to_message_inner(item, approval_storage=approval_storage), item) + + +async def _output_item_to_message_inner( + item: models.OutputItem, + *, + approval_storage: ApprovalStorage | None = None, +) -> Message: + """Convert a single ``OutputItem`` SDK object to a framework ``Message``. + + Variant table — keep in sync with :func:`_item_to_message` when both + sides exist for the same item kind. To add a new variant: + + 1. Add a ``elif item.type == "...":`` branch here. + 2. Reference the corresponding ``models.OutputItemX`` type. + 3. Map its fields to :class:`agent_framework.Content` factory methods. + + Variants currently **missing** from this dispatch (visible by scanning + ``models.OutputItem*`` and comparing against the branches below): + + * ``models.OutputItemCompactionBody`` — context compaction summaries + * ``models.OutputItemMcpListTools`` — MCP server ``list_tools`` results + * ``models.WorkflowActionOutputItem`` — workflow-channel actions + * Any tool-call variant produced by Azure-specific tools + (Azure Search, Bing Grounding, SharePoint, Fabric, OpenAPI, A2A, + browser automation, memory search, …) — the ``models.*ToolCall`` + / ``models.*ToolCallOutput`` family. + + Args: + item: The SDK item to convert. + + Keyword Args: + approval_storage: Optional approval storage. Required when the item is + an ``mcp_approval_request`` / ``mcp_approval_response``; ignored + otherwise. + + Returns: + The converted message, with any unknown extras round-tripped under + ``message.additional_properties[EXTRAS_KEY]``. + + Raises: + ValueError: If the SDK item ``type`` is not yet supported. + """ + if item.type == "output_message": + output_msg = cast(models.OutputItemOutputMessage, item) + return _attach_extras( + Message( + role=output_msg.role, + contents=[_convert_output_message_content(part) for part in output_msg.content], + ), + item, + ) + + if item.type == "message": + msg = cast(models.OutputItemMessage, item) + return _attach_extras( + Message(role=msg.role, contents=[_convert_message_content(part) for part in msg.content]), + item, + ) + + if item.type == "function_call": + fc = cast(models.OutputItemFunctionToolCall, item) + return _attach_extras( + Message( + role="assistant", + contents=[Content.from_function_call(fc.call_id, fc.name, arguments=fc.arguments)], + ), + item, + ) + + if item.type == "function_call_output": + fco = cast(models.FunctionCallOutputItemParam, item) + output = fco.output if isinstance(fco.output, str) else str(fco.output) + return _attach_extras( + Message(role="tool", contents=[Content.from_function_result(fco.call_id, result=output)]), + item, + ) + + if item.type == "reasoning": + reasoning = cast(models.OutputItemReasoningItem, item) + contents: list[Content] = [] + if reasoning.summary: + for summary in reasoning.summary: + contents.append(Content.from_text(summary.text)) + return _attach_extras(Message(role="assistant", contents=contents), item) + + if item.type == "mcp_call": + mcp = cast(models.OutputItemMcpToolCall, item) + return _attach_extras( + Message( + role="assistant", + contents=[ + Content.from_mcp_server_tool_call( + mcp.id, + mcp.name, + server_name=mcp.server_label, + arguments=mcp.arguments, + ) + ], + ), + item, + ) + + if item.type == "mcp_approval_request": + mcp_req = cast(models.OutputItemMcpApprovalRequest, item) + if approval_storage is None: + raise ValueError("ApprovalStorage is required to load approval request.") + function_approval_request_content = await approval_storage.load_approval_request(mcp_req.id) + return _attach_extras( + Message(role="assistant", contents=[function_approval_request_content]), + item, + ) + + if item.type == "mcp_approval_response": + mcp_resp = cast(models.OutputItemMcpApprovalResponseResource, item) + if approval_storage is None: + raise ValueError("ApprovalStorage is required to load approval request.") + function_approval_request_content = await approval_storage.load_approval_request(mcp_resp.approval_request_id) + return _attach_extras( + Message( + role="user", + contents=[function_approval_request_content.to_function_approval_response(mcp_resp.approve)], + ), + item, + ) + + if item.type == "code_interpreter_call": + ci = cast(models.OutputItemCodeInterpreterToolCall, item) + return _attach_extras( + Message(role="assistant", contents=[Content.from_code_interpreter_tool_call(call_id=ci.id)]), + item, + ) + + if item.type == "image_generation_call": + ig = cast(models.OutputItemImageGenToolCall, item) + return _attach_extras( + Message(role="assistant", contents=[Content.from_image_generation_tool_call(image_id=ig.id)]), + item, + ) + + if item.type == "shell_call": + sc = cast(models.OutputItemFunctionShellCall, item) + return _attach_extras( + Message( + role="assistant", + contents=[ + Content.from_shell_tool_call( + call_id=sc.call_id, + commands=sc.action.commands, + status=str(sc.status), + ) + ], + ), + item, + ) + + if item.type == "shell_call_output": + sco = cast(models.OutputItemFunctionShellCallOutput, item) + outputs = [ + Content.from_shell_command_output( + stdout=out.stdout or "", + stderr=out.stderr or "", + exit_code=getattr(out.outcome, "exit_code", None) if hasattr(out, "outcome") else None, + ) + for out in (sco.output or []) + ] + return _attach_extras( + Message( + role="tool", + contents=[ + Content.from_shell_tool_result( + call_id=sco.call_id, + outputs=outputs, + max_output_length=sco.max_output_length, + ) + ], + ), + item, + ) + + if item.type == "local_shell_call": + lsc = cast(models.OutputItemLocalShellToolCall, item) + commands = lsc.action.command if hasattr(lsc.action, "command") and lsc.action.command else [] + return _attach_extras( + Message( + role="assistant", + contents=[ + Content.from_shell_tool_call( + call_id=lsc.call_id, + commands=commands, + status=str(lsc.status), + ) + ], + ), + item, + ) + + if item.type == "local_shell_call_output": + lsco = cast(models.OutputItemLocalShellToolCallOutput, item) + return _attach_extras( + Message( + role="tool", + contents=[ + Content.from_shell_tool_result( + call_id=lsco.id, + outputs=[Content.from_shell_command_output(stdout=lsco.output)], + ) + ], + ), + item, + ) + + if item.type == "file_search_call": + fs = cast(models.OutputItemFileSearchToolCall, item) + return _attach_extras( + Message( + role="assistant", + contents=[ + Content.from_function_call( + fs.id, + "file_search", + arguments=json.dumps({"queries": fs.queries}), + ) + ], + ), + item, + ) + + if item.type == "web_search_call": + ws = cast(models.OutputItemWebSearchToolCall, item) + return _attach_extras( + Message(role="assistant", contents=[Content.from_function_call(ws.id, "web_search")]), + item, + ) + + if item.type == "computer_call": + cc = cast(models.OutputItemComputerToolCall, item) + return _attach_extras( + Message( + role="assistant", + contents=[ + Content.from_function_call( + cc.call_id, + "computer_use", + arguments=str(cc.action), + ) + ], + ), + item, + ) + + if item.type == "computer_call_output": + cco = cast(models.OutputItemComputerToolCallOutputResource, item) + return _attach_extras( + Message(role="tool", contents=[Content.from_function_result(cco.call_id, result=str(cco.output))]), + item, + ) + + if item.type == "custom_tool_call": + ct = cast(models.OutputItemCustomToolCall, item) + return _attach_extras( + Message( + role="assistant", + contents=[Content.from_function_call(ct.call_id, ct.name, arguments=ct.input)], + ), + item, + ) + + if item.type == "custom_tool_call_output": + cto = cast(models.OutputItemCustomToolCallOutput, item) + output = cto.output if isinstance(cto.output, str) else str(cto.output) + # Hosted-MCP results land here because the host writes them via + # ``aoutput_item_custom_tool_call_output``. Route ``mcp_*`` + # call_ids back to a hosted-MCP result Content so the chat-client + # serialize layer can coalesce onto the matching ``mcp_call`` + # input item. Issue #5546. + if cto.call_id and cto.call_id.startswith("mcp_"): + return _attach_extras( + Message( + role="tool", + contents=[Content.from_mcp_server_tool_result(call_id=cto.call_id, output=output)], + ), + item, + ) + return _attach_extras( + Message(role="tool", contents=[Content.from_function_result(cto.call_id, result=output)]), + item, + ) + + if item.type == "apply_patch_call": + ap = cast(models.OutputItemApplyPatchToolCall, item) + return _attach_extras( + Message( + role="assistant", + contents=[ + Content.from_function_call( + ap.call_id, + "apply_patch", + arguments=str(ap.operation), + ) + ], + ), + item, + ) + + if item.type == "apply_patch_call_output": + apo = cast(models.OutputItemApplyPatchToolCallOutput, item) + return _attach_extras( + Message(role="tool", contents=[Content.from_function_result(apo.call_id, result=apo.output or "")]), + item, + ) + + if item.type == "oauth_consent_request": + oauth = cast(models.OAuthConsentRequestOutputItem, item) + return _attach_extras( + Message(role="assistant", contents=[Content.from_oauth_consent_request(oauth.consent_link)]), + item, + ) + + if item.type == "structured_outputs": + so = cast(models.StructuredOutputsOutputItem, item) + text = json.dumps(so.output) if not isinstance(so.output, str) else so.output + return _attach_extras(Message(role="assistant", contents=[Content.from_text(text)]), item) + + raise ValueError(f"Unsupported OutputItem type: {item.type}") + + +# endregion + + +# region AF Message → SDK OutputItem (write path) + + +def _message_text(message: Message) -> str: + """Collapse a :class:`Message` into a single text blob. + + The Foundry storage write path only persists the user-visible text — the + same compression the Responses runtime applies on its own write side. We + walk ``contents`` rather than relying on ``Message.text`` so we get a + consistent ordering and can drop non-text parts cleanly. + """ + chunks: list[str] = [] + for content in message.contents: + text = getattr(content, "text", None) + if isinstance(text, str) and text: + chunks.append(text) + if chunks: + return "".join(chunks) + # Fallback: surface ``Message.text`` if the framework knows how to + # render the contents (covers structured contents that synthesise text). + return message.text or "" + + +def _message_to_output_item(message: Message, item_id: str) -> models.OutputItem: + """Convert a single :class:`Message` to a Foundry SDK :class:`OutputItem`. + + Two-tier strategy: + + 1. **Lossless replay** — if the message carries a previously-captured + raw SDK snapshot under ``additional_properties[EXTRAS_KEY][RAW_KEY]`` + (set by :func:`_capture_raw` on the read path), rebuild the SDK + item from that snapshot via the model registry's discriminator + dispatch (:meth:`models.OutputItem._deserialize`). The snapshot's + ``id`` is rewritten to ``item_id`` so each write turn gets a + unique storage row, but every other declared field — content + variants (citations, reasoning, tool calls, function results, + …) AND any undeclared extras Foundry layered on top — survives + intact. This is the auditable round-trip the Foundry storage + backend relies on. + + 2. **Synthesise from text** — for messages constructed in user code + (no raw snapshot), fall back to the text-only path. ``assistant`` + maps to :class:`OutputItemOutputMessage` (output_text content, + ``status="completed"``); anything else maps to + :class:`OutputItemMessage` with the role normalised onto the + enum's three accepted values (``user`` / ``system`` / + ``developer`` — ``tool`` collapses to ``user`` because the + discriminator forbids it). + + In both branches: + + * ``additional_properties[EXTRAS_KEY]`` extras other than the raw + snapshot are layered onto the emitted model via + :func:`_inject_extras` so message-level Foundry annotations + round-trip. + * **Every other ``additional_properties`` namespace** (notably the + Hosting spec's ``hosting`` envelope — channel, identity, + response_target, initial-write ``deliveries[]`` — plus any future + AF namespaces) is funneled into a single + :data:`AF_EXTRAS_KEY` container key on the SDK item via + :func:`_inject_af_extras`. Foundry storage round-trips that key + as opaque JSON, and :func:`_attach_extras` peels each sub-key + back onto its original namespace on load. This is what makes the + audit/replay envelope from the Hosting spec durable across + Foundry-storage save/load cycles. + """ + extras_raw: Any = (message.additional_properties or {}).get(EXTRAS_KEY) or {} + extras: dict[str, Any] = dict(cast("Mapping[str, Any]", extras_raw)) if isinstance(extras_raw, Mapping) else {} + raw_snapshot: Any = extras.get(RAW_KEY) + af_extras = _collect_af_extras(message) + + if isinstance(raw_snapshot, Mapping): + # ``_deserialize`` does discriminator dispatch and tolerates + # extras-bearing mappings; bypassing it (constructing the + # concrete class directly) would lose the discriminator wiring + # and break round-trip for tool-call / reasoning / ... variants. + snapshot: dict[str, Any] = dict(cast("Mapping[str, Any]", raw_snapshot)) + snapshot["id"] = item_id + deserialize = cast(Any, models.OutputItem)._deserialize + item = cast("models.OutputItem", deserialize(snapshot, [])) + return cast( + "models.OutputItem", + _inject_af_extras(_inject_extras(item, extras), af_extras), + ) + + text = _message_text(message) + # ``Message.role`` is an unconstrained ``str | enum`` slot — the + # framework keeps whatever the constructor was handed (str literals + # round-trip as ``str``; converters that pass the SDK's + # ``MessageRole`` enum store the enum). Normalise to the enum's + # ``value`` (or the bare string) so we don't end up writing + # ``"MessageRole.USER"`` to storage. + role_str = getattr(message.role, "value", message.role) + + # Construct via the mapping overload — the SDK's keyword overload tags + # ``content`` with the abstract base type and rejects our concrete list. + if role_str == "assistant": + item = models.OutputItemOutputMessage({ + "id": item_id, + "type": "output_message", + "role": "assistant", + "status": "completed", + "content": [ + {"type": "output_text", "text": text, "annotations": [], "logprobs": []}, + ], + }) + else: + # OutputItemMessage's role enum admits "user" / "system" / + # "developer". Anything outside that set (e.g. "tool") collapses to + # "user" so we don't crash on the SDK's discriminator validation. + role_value = role_str if role_str in ("user", "system", "developer") else "user" + item = models.OutputItemMessage({ + "id": item_id, + "type": "message", + "role": role_value, + "status": "completed", + "content": [ + {"type": "input_text", "text": text}, + ], + }) + return cast("models.OutputItem", _inject_af_extras(_inject_extras(item, extras), af_extras)) + + +def _messages_to_output_items(messages: Sequence[Message], *, id_prefix: str) -> list[models.OutputItem]: + """Convert a batch of messages to Foundry SDK items with stable IDs. + + Each message gets a deterministic id of the form ``{id_prefix}_itm_{i}``. + Callers (typically :meth:`FoundryHostedAgentHistoryProvider.save_messages`) + derive ``id_prefix`` from the response id they're persisting under so + the per-item ids are unique across a conversation. + """ + return [_message_to_output_item(msg, f"{id_prefix}_itm_{i}") for i, msg in enumerate(messages)] + + +# endregion diff --git a/python/packages/foundry_hosting/tests/test_history_provider.py b/python/packages/foundry_hosting/tests/test_history_provider.py new file mode 100644 index 00000000000..6ee8a63cb3f --- /dev/null +++ b/python/packages/foundry_hosting/tests/test_history_provider.py @@ -0,0 +1,1435 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for FoundryHostedAgentHistoryProvider.""" + +from __future__ import annotations + +import os +import time +from collections.abc import Iterable +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from agent_framework import Content, HistoryProvider, Message +from azure.ai.agentserver.responses import ( + FoundryStorageProvider, + InMemoryResponseProvider, + IsolationContext, +) +from azure.ai.agentserver.responses.models import ( + OutputItem, + OutputItemOutputMessage, + OutputMessageContentOutputTextContent, +) +from azure.ai.agentserver.responses.store._foundry_errors import ( # pyright: ignore[reportPrivateUsage] + FoundryBadRequestError, +) + +from agent_framework_foundry_hosting import FoundryHostedAgentHistoryProvider +from agent_framework_foundry_hosting._history_provider import ( # pyright: ignore[reportPrivateUsage] + get_current_isolation, + reset_current_isolation, + set_current_isolation, +) + + +def _with_backend(prov: FoundryHostedAgentHistoryProvider, backend: Any) -> FoundryHostedAgentHistoryProvider: + """Inject a fake backend into ``prov`` so ``_resolve_backend`` returns it. + + Replaces the old ``backend=`` constructor parameter that was removed + when the dual-backend model was collapsed onto ``FoundryStorageProvider``. + """ + prov._backend = backend # pyright: ignore[reportPrivateUsage] + return prov + + +# region Helpers + + +def _make_text_item(item_id: str, text: str) -> OutputItemOutputMessage: + return OutputItemOutputMessage( + id=item_id, + type="output_message", + role="assistant", + status="completed", + content=[OutputMessageContentOutputTextContent(type="output_text", text=text, annotations=[])], + ) + + +def _make_fake_backend( + *, + history_ids: list[str] | None = None, + items: list[OutputItem | None] | None = None, +) -> MagicMock: + """Build a MagicMock matching the _StorageBackend protocol.""" + backend = MagicMock() + + async def _ids(*args: Any, **kwargs: Any) -> list[str]: + return list(history_ids or []) + + async def _items(item_ids: Iterable[str], *, isolation: IsolationContext | None = None) -> list[OutputItem | None]: + return list(items or []) + + backend.get_history_item_ids = AsyncMock(side_effect=_ids) + backend.get_items = AsyncMock(side_effect=_items) + backend.create_response = AsyncMock() + return backend + + +class _FakeAccessToken: + def __init__(self, token: str, *, expires_in: float = 3600.0) -> None: + self.token = token + self.expires_on = int(time.time() + expires_in) + + +class _FakeCredential: + """Minimal AsyncTokenCredential stand-in.""" + + def __init__(self, *, token: str = "fake-token", expires_in: float = 3600.0) -> None: + self._token = token + self._expires_in = expires_in + self.calls: list[tuple[str, ...]] = [] + + async def get_token(self, *scopes: str) -> _FakeAccessToken: + self.calls.append(scopes) + return _FakeAccessToken(self._token, expires_in=self._expires_in) + + +# region Construction + + +class TestConstruction: + """Constructor + class-level invariants.""" + + def test_defaults(self) -> None: + prov = _with_backend(FoundryHostedAgentHistoryProvider(), _make_fake_backend()) + assert isinstance(prov, HistoryProvider) + assert prov.source_id == FoundryHostedAgentHistoryProvider.DEFAULT_SOURCE_ID + assert prov.store_inputs is True + assert prov.store_outputs is True + assert prov.load_messages is True + + def test_is_hosted_environment_reads_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + assert FoundryHostedAgentHistoryProvider.is_hosted_environment() is False + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + assert FoundryHostedAgentHistoryProvider.is_hosted_environment() is True + + def test_endpoint_falls_back_to_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://example.foundry.azure.com") + prov = _with_backend(FoundryHostedAgentHistoryProvider(), _make_fake_backend()) + assert prov._endpoint == "https://example.foundry.azure.com" # pyright: ignore[reportPrivateUsage] + + +# region Backend resolution + + +class TestBackendResolution: + """Lazy backend construction + local fallback.""" + + def test_uses_explicit_backend(self) -> None: + backend = _make_fake_backend() + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + assert prov._resolve_backend() is backend # pyright: ignore[reportPrivateUsage] + + def test_local_fallback_when_not_hosted(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + prov = FoundryHostedAgentHistoryProvider() + resolved = prov._resolve_backend() # pyright: ignore[reportPrivateUsage] + assert isinstance(resolved, InMemoryResponseProvider) + # Cached on subsequent calls. + assert prov._resolve_backend() is resolved # pyright: ignore[reportPrivateUsage] + + def test_hosted_without_credential_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://x.foundry.azure.com") + prov = FoundryHostedAgentHistoryProvider() + with pytest.raises(RuntimeError, match="requires an async credential"): + prov._resolve_backend() # pyright: ignore[reportPrivateUsage] + + def test_hosted_without_endpoint_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + monkeypatch.delenv("FOUNDRY_PROJECT_ENDPOINT", raising=False) + prov = FoundryHostedAgentHistoryProvider(credential=_FakeCredential()) # type: ignore[arg-type] + with pytest.raises(RuntimeError, match="needs a Foundry project endpoint"): + prov._resolve_backend() # pyright: ignore[reportPrivateUsage] + + def test_hosted_builds_http_backend(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://x.foundry.azure.com") + prov = FoundryHostedAgentHistoryProvider(credential=_FakeCredential()) # type: ignore[arg-type] + resolved = prov._resolve_backend() # pyright: ignore[reportPrivateUsage] + assert isinstance(resolved, FoundryStorageProvider) + + +# region get_messages + + +class TestGetMessages: + async def test_no_session_id_returns_empty(self) -> None: + backend = _make_fake_backend(history_ids=["x"], items=[_make_text_item("x", "hi")]) + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + assert await prov.get_messages(None) == [] + assert await prov.get_messages("") == [] + backend.get_history_item_ids.assert_not_called() + + async def test_no_history_returns_empty(self) -> None: + backend = _make_fake_backend(history_ids=[]) + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + assert await prov.get_messages("resp_123") == [] + backend.get_items.assert_not_called() + + async def test_loads_and_converts(self) -> None: + items: list[OutputItem | None] = [_make_text_item("itm_1", "hello"), _make_text_item("itm_2", "world")] + backend = _make_fake_backend(history_ids=["itm_1", "itm_2"], items=items) + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + + messages = await prov.get_messages("resp_123") + assert len(messages) == 2 + assert all(isinstance(m, Message) for m in messages) + assert messages[0].text == "hello" + assert messages[1].text == "world" + + backend.get_history_item_ids.assert_awaited_once() + call = backend.get_history_item_ids.await_args + assert call.args[0] == "resp_123" + assert call.args[1] is None # conversation_id + assert call.args[2] == 100 # default history_limit + + async def test_drops_missing_items(self) -> None: + backend = _make_fake_backend( + history_ids=["a", "b", "c"], + items=[_make_text_item("a", "first"), None, _make_text_item("c", "third")], + ) + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + messages = await prov.get_messages("resp_x") + assert [m.text for m in messages] == ["first", "third"] + + async def test_history_limit_propagates(self) -> None: + backend = _make_fake_backend(history_ids=[]) + prov = _with_backend(FoundryHostedAgentHistoryProvider(history_limit=7), backend) + # ``resp_*``-shaped session anchors directly; we expect a single + # backend call carrying the configured limit. + await prov.get_messages("resp_s") + assert backend.get_history_item_ids.await_count == 1 + assert backend.get_history_item_ids.await_args.args[2] == 7 + + async def test_non_resp_session_skips_storage_probe(self) -> None: + """Non-``resp_*`` session ids (e.g. opaque chat-isolation keys) + are not valid storage anchors — the provider must skip the + backend probe entirely so we don't hit "Malformed identifier" + HTTP 400s, returning an empty history instead. + """ + backend = _make_fake_backend(history_ids=[]) + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + messages = await prov.get_messages("5leZSsJ3m1UtB-JW3m3iowFd5_zqP30SE0MmGUEkcGQ") + assert messages == [] + backend.get_history_item_ids.assert_not_awaited() + + async def test_resp_probe_tolerates_400(self) -> None: + """A 400 on the storage probe must not abort ``get_messages`` — + the provider falls through to an empty history.""" + backend = _make_fake_backend() + backend.get_history_item_ids.side_effect = FoundryBadRequestError("malformed", response_body=None) + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + messages = await prov.get_messages("resp_x") + assert messages == [] + + +# region IsolationContext + + +class TestIsolationContext: + async def test_explicit_isolation_kwarg_wins(self) -> None: + backend = _make_fake_backend(history_ids=[]) + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + explicit = IsolationContext(user_key="u-explicit", chat_key="c-explicit") + await prov.get_messages("resp_s", isolation=explicit) + assert backend.get_history_item_ids.await_args.kwargs["isolation"] is explicit + + async def test_contextvar_picked_up(self) -> None: + backend = _make_fake_backend(history_ids=["a"], items=[_make_text_item("a", "x")]) + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + ctx = IsolationContext(user_key="u-1", chat_key="c-1") + token = set_current_isolation(ctx) + try: + assert get_current_isolation() is ctx + await prov.get_messages("resp_s") + finally: + reset_current_isolation(token) + assert backend.get_history_item_ids.await_args.kwargs["isolation"] is ctx + assert backend.get_items.await_args.kwargs["isolation"] is ctx + + async def test_no_isolation_when_unset(self) -> None: + backend = _make_fake_backend(history_ids=[]) + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + await prov.get_messages("resp_s") + assert backend.get_history_item_ids.await_args.kwargs["isolation"] is None + + async def test_host_isolation_keys_picked_up(self) -> None: + """The host's ASGI middleware lifts the + ``x-agent-{user,chat}-isolation-key`` headers into a contextvar + exposed by ``agent_framework_hosting``. The provider lifts that + into its own ``IsolationContext`` so the storage call carries + the platform partition keys without channels having to forward + anything (or even know the headers exist).""" + pytest.importorskip("agent_framework_hosting") + from agent_framework_hosting import ( + IsolationKeys, + reset_current_isolation_keys, + set_current_isolation_keys, + ) + + backend = _make_fake_backend(history_ids=["a"], items=[_make_text_item("a", "x")]) + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + token = set_current_isolation_keys(IsolationKeys(user_key="u-3", chat_key="c-3")) + try: + await prov.get_messages("resp_s") + finally: + reset_current_isolation_keys(token) + applied = backend.get_history_item_ids.await_args.kwargs["isolation"] + assert applied is not None + assert applied.user_key == "u-3" + assert applied.chat_key == "c-3" + + +# region save_messages + + +class TestSaveMessages: + async def test_save_messages_writes_to_backend_when_bound(self) -> None: + """``save_messages`` writes a ``create_response`` envelope using + the host-bound response_id when present. + + The host's ``_bind_request_context`` plumbs the channel-minted + ``response_id`` (and prior turn's ``previous_response_id``) into + the provider via :func:`bind_request_context`, so the channel + envelope and the storage write share a single id per turn — + which is what makes the next turn's ``previous_response_id`` + walkable. + """ + from agent_framework_foundry_hosting import bind_request_context + + backend = _make_fake_backend() + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + msg = Message(role="assistant", contents=[Content.from_text("hello")]) + with bind_request_context(response_id="resp_bound_1", previous_response_id=None): + await prov.save_messages("session-x", [msg]) + + backend.create_response.assert_awaited_once() + call = backend.create_response.await_args + response = call.args[0] + assert response.id == "resp_bound_1" + # Conversation is intentionally omitted — Foundry isolation + # headers handle partitioning; cross-turn chaining is via the + # response-id chain only. + assert response.conversation is None + # Assistant outputs go on ``response.output``, not ``input_items`` + # — mirrors the agentserver runtime split (see + # ``_resolve_input_items_for_persistence``). + assert call.kwargs["input_items"] == [] + output = response.output or [] + assert len(output) == 1 + assert output[0]["type"] == "output_message" + + async def test_save_messages_falls_back_to_session_id_when_unbound(self) -> None: + """Without a host binding (e.g. local dev), ``save_messages`` + mints a fresh ``resp_*`` envelope and only chains when the + ``session_id`` is itself ``resp_*``-shaped.""" + backend = _make_fake_backend() + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + msg = Message(role="user", contents=[Content.from_text("hi")]) + await prov.save_messages("resp_prev", [msg]) + + backend.create_response.assert_awaited_once() + call = backend.create_response.await_args + response = call.args[0] + assert response.id.startswith("caresp_") + # Provider walked the prior chain to seed history_item_ids; the + # fake backend returns ``[]`` so this stays empty but the call + # was made. + assert backend.get_history_item_ids.await_count == 1 + assert backend.get_history_item_ids.await_args.args[0] == "resp_prev" + + async def test_save_messages_empty_short_circuits(self) -> None: + backend = _make_fake_backend() + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + await prov.save_messages("s", []) + backend.create_response.assert_not_called() + + async def test_save_messages_no_session_short_circuits(self) -> None: + """No session id and no host binding → nothing to anchor against, + skip the write.""" + backend = _make_fake_backend() + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + await prov.save_messages(None, [Message(role="user", contents=[Content.from_text("hi")])]) + backend.create_response.assert_not_called() + + async def test_save_messages_swallows_storage_errors(self) -> None: + """Persistence is best-effort for *Foundry storage* failures. + + Storage-validation rejections, opaque 5xx, etc. should be + swallowed (the agent run already produced output and the + caller can't recover from a chain-write failure mid-stream). + Counter is bumped for observability. + """ + backend = _make_fake_backend() + backend.create_response.side_effect = FoundryBadRequestError( + "simulated invalid_payload", + response_body={"error": {"code": "invalid_payload"}}, + ) + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + # Must not raise. + await prov.save_messages("resp_session_x", [Message(role="user", contents=[Content.from_text("hi")])]) + backend.create_response.assert_awaited_once() + assert prov.failed_writes == 1 + + async def test_save_messages_propagates_non_storage_errors(self) -> None: + """Network / auth / payload-builder bugs MUST surface to the caller. + + Anything that's not a ``FoundryStorageError`` — connection + resets, expired credential 401/403s, ``AttributeError`` from a + regression in the wire-payload builder — propagates so the + caller can retry / alert. Counter is NOT bumped for these. + """ + backend = _make_fake_backend() + backend.create_response.side_effect = ConnectionError("simulated network failure") + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + with pytest.raises(ConnectionError, match="simulated network failure"): + await prov.save_messages( + "resp_session_x", + [Message(role="user", contents=[Content.from_text("hi")])], + ) + assert prov.failed_writes == 0 + + async def test_save_then_get_round_trip_via_in_memory_backend(self) -> None: + """End-to-end save→get round-trip through ``InMemoryResponseProvider``. + + Mirrors the host-bound multi-turn flow: turn 1 binds a fresh + response id; turn 2 binds a new response id with the prior id + as ``previous_response_id``. ``get_messages`` on turn 2 is + called with the prior anchor and must return both turns. + """ + from agent_framework_foundry_hosting import bind_request_context + + backend = InMemoryResponseProvider() + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + + with bind_request_context(response_id="resp_turn1", previous_response_id=None): + await prov.save_messages( + "resp_turn1", + [Message(role="user", contents=[Content.from_text("ping")])], + ) + + with bind_request_context(response_id="resp_turn2", previous_response_id="resp_turn1"): + history = await prov.get_messages("resp_turn1") + assert [m.text for m in history] == ["ping"] + await prov.save_messages( + "resp_turn2", + [Message(role="assistant", contents=[Content.from_text("pong")])], + ) + + # Final read for turn 3: walking turn 2 must reveal both turns. + with bind_request_context(response_id="resp_turn3", previous_response_id="resp_turn2"): + messages = await prov.get_messages("resp_turn2") + assert [m.text for m in messages] == ["ping", "pong"] + roles = [getattr(m.role, "value", m.role) for m in messages] + assert roles == ["user", "assistant"] + + +# region aclose + + +class TestAclose: + async def test_closes_backend_with_aclose(self) -> None: + # Provider always closes whatever backend is currently bound; + # the dual-mode (external vs owned) distinction was dropped + # along with the ``backend=`` constructor param. + backend = _make_fake_backend() + backend.aclose = AsyncMock() + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + prov._resolve_backend() # pyright: ignore[reportPrivateUsage] + await prov.aclose() + backend.aclose.assert_awaited_once() + + async def test_aclose_idempotent(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + prov = FoundryHostedAgentHistoryProvider() + prov._resolve_backend() # pyright: ignore[reportPrivateUsage] + await prov.aclose() + await prov.aclose() # idempotent — second call is a no-op + + +# region Local file storage option + + +class TestLocalFileStorage: + """`local_storage_root` swaps the in-memory local fallback for a + per-isolation :class:`FileHistoryProvider` so dev runs persist + across process restarts.""" + + async def test_unset_keeps_in_memory_fallback(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + prov = FoundryHostedAgentHistoryProvider() + assert prov._resolve_local_file_provider(None) is None # pyright: ignore[reportPrivateUsage] + assert isinstance( + prov._resolve_backend(), # pyright: ignore[reportPrivateUsage] + InMemoryResponseProvider, + ) + + async def test_creates_per_isolation_provider(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + prov = FoundryHostedAgentHistoryProvider(local_storage_root=tmp_path) + iso = IsolationContext(user_key="alice", chat_key="chat-1") + + fp = prov._resolve_local_file_provider(iso) # pyright: ignore[reportPrivateUsage] + assert fp is not None + # Cached on subsequent calls for the same (user, chat). + assert prov._resolve_local_file_provider(iso) is fp # pyright: ignore[reportPrivateUsage] + # Different isolation → different provider rooted at a different dir. + other = prov._resolve_local_file_provider( # pyright: ignore[reportPrivateUsage] + IsolationContext(user_key="bob", chat_key="chat-1"), + ) + assert other is not None and other is not fp + assert fp.storage_path != other.storage_path + assert fp.storage_path == (tmp_path / "alice" / "chat-1").resolve() + + async def test_missing_isolation_uses_sentinel_dir(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + prov = FoundryHostedAgentHistoryProvider(local_storage_root=tmp_path) + fp = prov._resolve_local_file_provider(None) # pyright: ignore[reportPrivateUsage] + assert fp is not None + assert fp.storage_path == (tmp_path / "~none" / "~none").resolve() + + async def test_unsafe_isolation_segments_are_encoded(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + prov = FoundryHostedAgentHistoryProvider(local_storage_root=tmp_path) + iso = IsolationContext(user_key="../escape", chat_key="ok-chat") + fp = prov._resolve_local_file_provider(iso) # pyright: ignore[reportPrivateUsage] + assert fp is not None + # Encoded segment never contains a ``/`` and never escapes the root. + assert fp.storage_path.is_relative_to(tmp_path.resolve()) + assert "../" not in str(fp.storage_path) + # Encoded segments use the reserved ``~iso-`` prefix. + parts = fp.storage_path.relative_to(tmp_path.resolve()).parts + assert parts[0].startswith("~iso-") + assert parts[1] == "ok-chat" + + async def test_hosted_mode_ignores_local_storage_root( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Any, caplog: pytest.LogCaptureFixture + ) -> None: + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + with caplog.at_level("INFO", logger="agent_framework_foundry_hosting._history_provider"): + prov = FoundryHostedAgentHistoryProvider(local_storage_root=tmp_path) + # File provider is never resolved when hosted. + assert prov._resolve_local_file_provider(None) is None # pyright: ignore[reportPrivateUsage] + assert any("ignored local_storage_root" in record.message for record in caplog.records) + + async def test_get_and_save_round_trip_via_file(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + prov = FoundryHostedAgentHistoryProvider(local_storage_root=tmp_path) + iso = IsolationContext(user_key="alice", chat_key="chat-1") + + msgs = [ + Message(role="user", contents=["hello"]), + Message(role="assistant", contents=["hi back"]), + ] + await prov.save_messages("conv-1", msgs, isolation=iso) + + # File exists at the expected nested path with session_id as stem. + expected_path = tmp_path / "alice" / "chat-1" / "conv-1.jsonl" + assert expected_path.exists() + # Two JSONL records (one per message). + assert len([line for line in expected_path.read_text().splitlines() if line.strip()]) == 2 + + loaded = await prov.get_messages("conv-1", isolation=iso) + assert [m.text for m in loaded] == ["hello", "hi back"] + + # Different isolation → different file → independent history. + bob_loaded = await prov.get_messages( + "conv-1", + isolation=IsolationContext(user_key="bob", chat_key="chat-1"), + ) + assert bob_loaded == [] + + async def test_session_id_with_special_chars_is_sanitised_by_file_provider( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Any + ) -> None: + # The wrapper passes ``session_id`` through unchanged; the + # delegate ``FileHistoryProvider`` is responsible for sanitising + # it. This test just confirms the delegation works for a + # non-trivial id without raising. + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + prov = FoundryHostedAgentHistoryProvider(local_storage_root=tmp_path) + msgs = [Message(role="user", contents=["hi"])] + await prov.save_messages("conv:with:colons", msgs) + loaded = await prov.get_messages("conv:with:colons") + assert [m.text for m in loaded] == ["hi"] + + async def test_aclose_clears_file_provider_cache(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + prov = FoundryHostedAgentHistoryProvider(local_storage_root=tmp_path) + prov._resolve_local_file_provider(IsolationContext(user_key="alice")) # pyright: ignore[reportPrivateUsage] + assert prov._file_providers # pyright: ignore[reportPrivateUsage] + await prov.aclose() + assert not prov._file_providers # pyright: ignore[reportPrivateUsage] + + +# region Foundry id helpers (`_ids.py`) + + +class TestFoundryIdHelpers: + """Cover the public ``_ids`` re-exports so SDK ``IdGenerator`` + contract changes surface in unit tests rather than as opaque + HTTP 500 ``server_error`` from Foundry storage at runtime.""" + + def test_foundry_response_id_carries_partition_key(self) -> None: + """A minted ``caresp_*`` id must embed an 18-char partition key. + + Free-form ``resp_`` ids carry no parseable partition key + and Foundry storage rejects writes with HTTP 500. + """ + from agent_framework_foundry_hosting import foundry_response_id + + new_id = foundry_response_id() + assert new_id.startswith("caresp_") + # ``caresp_`` (7) + 18-char partition key + 32-char entropy = 57. + # The legacy 48-char body variant is also accepted by storage, + # so just check the lower bound. + assert len(new_id) >= 7 + 18 + 32 - 8 + + def test_foundry_response_id_reuses_previous_partition_key(self) -> None: + """Chained writes co-locate by reusing the prior partition key. + + Foundry storage rejects chained writes whose new record sits in + a different partition than the prior one. Passing a ``caresp_*`` + ``previous_response_id`` should produce a new id whose partition + segment matches. + """ + from agent_framework_foundry_hosting import foundry_response_id + + prior = foundry_response_id() + # Partition key = 18 chars after the ``caresp_`` prefix. + prior_partition = prior[len("caresp_") : len("caresp_") + 18] + chained = foundry_response_id(prior) + assert chained.startswith("caresp_") + assert chained != prior + assert chained[len("caresp_") : len("caresp_") + 18] == prior_partition + + def test_foundry_response_id_factory_returns_callable(self) -> None: + """The factory wrapper used by ``ResponsesChannel`` must + delegate to :func:`foundry_response_id` so chained turns can + seed the partition key from ``previous_response_id``.""" + from agent_framework_foundry_hosting import ( + foundry_response_id, + foundry_response_id_factory, + ) + + factory = foundry_response_id_factory() + assert factory is foundry_response_id + + def test_foundry_item_id_for_known_input_type(self) -> None: + """Recognised ``Item`` types get a typed prefix and a + partition-key hint matching the response id when supplied.""" + from azure.ai.agentserver.responses.models import ( + ItemMessage, + MessageContentInputTextContent, + ) + + from agent_framework_foundry_hosting import foundry_item_id, foundry_response_id + + response_id = foundry_response_id() + partition = response_id[len("caresp_") : len("caresp_") + 18] + item = ItemMessage( + type="message", + role="user", + content=[MessageContentInputTextContent(type="input_text", text="hi")], + ) + new_id = foundry_item_id(item, response_id) + assert new_id is not None + # ``msg_*`` is what ``IdGenerator.new_message_item_id`` mints. + assert new_id.startswith("msg_") + assert partition in new_id + + def test_foundry_item_id_returns_none_for_unknown_type(self) -> None: + """Reference-only / unrecognised types must return ``None`` + per the SDK helper's contract — callers (e.g. + ``save_messages``'s id-stamping loop) skip these so storage + only receives ids it can parse.""" + from agent_framework_foundry_hosting import foundry_item_id + + class _UnknownItem: + pass + + assert foundry_item_id(_UnknownItem()) is None + + +# region Wire payload stamping (`save_messages`) + + +class TestSaveMessagesWirePayload: + """Storage rejects ``create_response`` payloads that omit fields + flagged as REQUIRED in ``ResponseObject`` (``parallel_tool_calls``, + ``instructions``, ``background``) or that leak extras the validator + refuses (``conversation``, ``model=None``, …). Any regression that + drops one of these silently breaks every hosted deploy with an + opaque 4xx; cover them here so the test suite catches it first.""" + + async def test_envelope_includes_required_storage_fields( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """``background``, ``parallel_tool_calls``, ``instructions``, + and ``agent_reference`` MUST be present on every stamped + envelope; storage returns HTTP 400 ``invalid_payload`` if any + of them is missing.""" + from agent_framework_foundry_hosting import bind_request_context + + # Strip env so the defaults are exercised cleanly. + for var in ( + "FOUNDRY_AGENT_NAME", + "FOUNDRY_AGENT_VERSION", + "FOUNDRY_AGENT_SESSION_ID", + "MODEL_DEPLOYMENT_NAME", + "AZURE_AI_MODEL_DEPLOYMENT_NAME", + ): + monkeypatch.delenv(var, raising=False) + + backend = _make_fake_backend() + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + with bind_request_context(response_id="resp_envelope_1", previous_response_id=None): + await prov.save_messages( + "session-x", + [Message(role="assistant", contents=[Content.from_text("hi")])], + ) + + backend.create_response.assert_awaited_once() + response = backend.create_response.await_args.args[0] + body = response.as_dict() + + # Required-by-storage fields. + assert body["background"] is False + assert body["parallel_tool_calls"] is False + assert body["instructions"] == "" + assert body["agent_reference"] == { + "type": "agent_reference", + "name": "agent-framework-host", + } + + async def test_envelope_omits_optional_fields_when_env_unset( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """``model``, ``agent_session_id``, and the ``version`` slot of + ``agent_reference`` are omitted (NOT stamped as ``None``) when + their env vars are unset — storage rejects ``model: null``.""" + from agent_framework_foundry_hosting import bind_request_context + + for var in ( + "FOUNDRY_AGENT_NAME", + "FOUNDRY_AGENT_VERSION", + "FOUNDRY_AGENT_SESSION_ID", + "MODEL_DEPLOYMENT_NAME", + "AZURE_AI_MODEL_DEPLOYMENT_NAME", + ): + monkeypatch.delenv(var, raising=False) + + backend = _make_fake_backend() + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + with bind_request_context(response_id="resp_omit_1", previous_response_id=None): + await prov.save_messages( + "session-x", + [Message(role="assistant", contents=[Content.from_text("hi")])], + ) + + body = backend.create_response.await_args.args[0].as_dict() + # Either entirely absent or explicitly None — assert the field + # was NOT stamped to a non-None value. + assert body.get("model") is None + assert body.get("agent_session_id") is None + # ``version`` slot inside agent_reference is omitted entirely + # (the key is absent, not set to None) when the env var is unset. + assert "version" not in body["agent_reference"] + + async def test_envelope_picks_up_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: + """When the platform-set env vars are present they MUST land on + the envelope: ``FOUNDRY_AGENT_NAME`` / ``FOUNDRY_AGENT_VERSION`` + feed ``agent_reference``, ``FOUNDRY_AGENT_SESSION_ID`` feeds + ``agent_session_id``, and ``MODEL_DEPLOYMENT_NAME`` feeds + ``model``.""" + from agent_framework_foundry_hosting import bind_request_context + + monkeypatch.setenv("FOUNDRY_AGENT_NAME", "concierge") + monkeypatch.setenv("FOUNDRY_AGENT_VERSION", "v3") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "caresp_envsessionABCDEF") + monkeypatch.setenv("MODEL_DEPLOYMENT_NAME", "gpt-4o-mini-prod") + monkeypatch.delenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", raising=False) + + backend = _make_fake_backend() + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + with bind_request_context(response_id="resp_env_1", previous_response_id=None): + await prov.save_messages( + "session-x", + [Message(role="assistant", contents=[Content.from_text("hi")])], + ) + + body = backend.create_response.await_args.args[0].as_dict() + assert body["agent_reference"] == { + "type": "agent_reference", + "name": "concierge", + "version": "v3", + } + assert body["agent_session_id"] == "caresp_envsessionABCDEF" + assert body["model"] == "gpt-4o-mini-prod" + + async def test_envelope_falls_back_to_local_dev_model_var( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Local dev sets ``AZURE_AI_MODEL_DEPLOYMENT_NAME`` rather than + the platform-only ``MODEL_DEPLOYMENT_NAME``; the latter wins + when both are present, the former fills in when only it is.""" + from agent_framework_foundry_hosting import bind_request_context + + monkeypatch.delenv("MODEL_DEPLOYMENT_NAME", raising=False) + monkeypatch.setenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o-mini-dev") + for var in ("FOUNDRY_AGENT_NAME", "FOUNDRY_AGENT_VERSION", "FOUNDRY_AGENT_SESSION_ID"): + monkeypatch.delenv(var, raising=False) + + backend = _make_fake_backend() + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + with bind_request_context(response_id="resp_devmodel_1", previous_response_id=None): + await prov.save_messages( + "session-x", + [Message(role="assistant", contents=[Content.from_text("hi")])], + ) + + body = backend.create_response.await_args.args[0].as_dict() + assert body["model"] == "gpt-4o-mini-dev" + + +# region FOUNDRY_AGENT_SESSION_ID chain anchor + + +class TestFoundryAgentSessionIdAnchor: + """``FOUNDRY_AGENT_SESSION_ID`` identifies the *container instance*, + not the conversation (per the Foundry SDK), so it MUST NOT be used + as a fallback ``previous_response_id`` for chain walking. The host- + bound ``previous_response_id`` (set by ``ResponsesChannel`` from the + request envelope) is the only authoritative anchor; any code that + re-introduces an env-based fallback would silently merge unrelated + conversations across container restarts.""" + + async def test_get_messages_ignores_env_session_anchor_when_unbound( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """No host binding, opaque ``session_id`` and a populated + ``FOUNDRY_AGENT_SESSION_ID``: ``get_messages`` must return ``[]`` + and never call the backend (no walkable conversation anchor).""" + for var in ("MODEL_DEPLOYMENT_NAME", "AZURE_AI_MODEL_DEPLOYMENT_NAME"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "caresp_envanchor1") + + backend = _make_fake_backend( + history_ids=["msg_envanchor_1"], + items=[_make_text_item("msg_envanchor_1", "from-env-anchor")], + ) + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + + messages = await prov.get_messages("opaque-session") + + assert messages == [] + backend.get_history_item_ids.assert_not_called() + + async def test_save_messages_ignores_env_session_anchor_when_unbound( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """When no host binding supplies a ``previous_response_id`` and + ``session_id`` is opaque, the env var must NOT be consulted as a + fallback; the new turn writes without a prior chain seed.""" + for var in ( + "FOUNDRY_AGENT_NAME", + "FOUNDRY_AGENT_VERSION", + "MODEL_DEPLOYMENT_NAME", + "AZURE_AI_MODEL_DEPLOYMENT_NAME", + ): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "caresp_envchain1") + + backend = _make_fake_backend() + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + # Opaque session_id, no host binding → save proceeds without + # walking any chain (no get_history_item_ids call). + await prov.save_messages( + "opaque-session", + [Message(role="assistant", contents=[Content.from_text("hi")])], + ) + + backend.get_history_item_ids.assert_not_called() + # The persisted envelope still stamps the env value into + # ``agent_session_id`` for operator correlation (see the + # docstring on the module): only the chain anchor is gated. + backend.create_response.assert_awaited_once() + wire_payload = backend.create_response.await_args.args[0].as_dict() + assert wire_payload["agent_session_id"] == "caresp_envchain1" + + async def test_save_messages_env_anchor_skipped_when_host_bound( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A host-bound ``previous_response_id`` wins over any env value; + the binding is the authoritative chain seed for the request.""" + from agent_framework_foundry_hosting import bind_request_context + + for var in ( + "FOUNDRY_AGENT_NAME", + "FOUNDRY_AGENT_VERSION", + "MODEL_DEPLOYMENT_NAME", + "AZURE_AI_MODEL_DEPLOYMENT_NAME", + ): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "caresp_envignored") + + backend = _make_fake_backend() + prov = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + with bind_request_context(response_id="resp_bound_2", previous_response_id="caresp_boundprev"): + await prov.save_messages( + "session-x", + [Message(role="assistant", contents=[Content.from_text("hi")])], + ) + + # Host binding wins; the env anchor is ignored for chaining. + assert backend.get_history_item_ids.await_args.args[0] == "caresp_boundprev" + + +# region Shared module re-exports + + +class TestSharedReExports: + """`_responses.py` must re-export the conversion helpers so tests and + downstream code that historically imported them keep working.""" + + def test_responses_re_exports_helpers(self) -> None: + # These helpers historically lived in ``_responses``. They must + # remain importable there for compatibility even when ``_shared`` + # also provides canonical implementations for the history provider. + from agent_framework_foundry_hosting import ( + _responses, # pyright: ignore[reportPrivateUsage] + _shared, # pyright: ignore[reportPrivateUsage] + ) + + for name in ( + "_arguments_to_str", + "_convert_message_content", + "_convert_output_message_content", + "_item_to_message", + "_items_to_messages", + "_output_item_to_message", + "_output_items_to_messages", + ): + assert callable(getattr(_responses, name)) + assert callable(getattr(_shared, name)) + + +# region Full AF ↔ Foundry round-trip via InMemoryResponseProvider + + +class TestAfFoundryRoundTrip: + """Round-trip two AF :class:`Message` instances through the Foundry SDK + types and back via the real :class:`InMemoryResponseProvider` backend. + + This is the same backend the provider uses in its local-fallback path + (i.e. the one that runs whenever ``FOUNDRY_HOSTING_ENVIRONMENT`` is + unset), so this test gives us coverage of the + "AF → Foundry SDK shape → storage → Foundry SDK shape → AF" pipeline + using exactly the production conversion code in :mod:`._shared`. + """ + + @staticmethod + def _af_message(text: str, item_id: str) -> tuple[Message, OutputItem]: + """Build an AF ``Message`` and the matching Foundry ``OutputItem``. + + Both messages are assistant ``output_message`` items because that's + the only OutputItem variant we round-trip through here — this test + exercises the conversion path, not every input/output shape. + """ + from agent_framework import Content + + af_message = Message(role="assistant", contents=[Content.from_text(text)]) + foundry_item = OutputItemOutputMessage( + id=item_id, + type="output_message", + role="assistant", + status="completed", + content=[OutputMessageContentOutputTextContent(type="output_text", text=text, annotations=[])], + ) + return af_message, foundry_item + + async def test_two_messages_round_trip_through_in_memory_backend(self) -> None: + from azure.ai.agentserver.responses.models import ResponseObject + + # 1. Start from two AF Messages (the "outside world" shape). + original_first, foundry_first = self._af_message("First message: 2 + 2 equals 4.", "itm_1") + original_second, foundry_second = self._af_message("Second message: 3 + 5 equals 8.", "itm_2") + + # 2. Hand the Foundry items to the real in-memory storage backend + # via the same ``create_response`` API the agent-server runtime + # uses on every successful turn. Passing them as ``input_items`` + # is enough — the in-memory backend records each item under its + # own id and exposes it via ``get_history_item_ids``. + backend = InMemoryResponseProvider() + response = ResponseObject( + id="resp_round_trip", + object="response", + status="completed", + model="test-model", + created_at=0, + ) + await backend.create_response( + response, + input_items=[foundry_first, foundry_second], + history_item_ids=None, + ) + + # 3. Wire the provider to the seeded backend (no HTTP, no + # credential needed — this exercises the local-mode contract). + provider = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + + # 4. Retrieve via the public API. Internally this fans out: + # backend.get_history_item_ids → backend.get_items + # → ``_output_items_to_messages`` from ``_shared`` → AF Messages. + retrieved = await provider.get_messages("resp_round_trip") + + # 5. Round-trip preserves role + text content for both messages. + assert len(retrieved) == 2 + assert all(isinstance(m, Message) for m in retrieved) + + assert retrieved[0].role == original_first.role + assert retrieved[0].text == original_first.text == "First message: 2 + 2 equals 4." + + assert retrieved[1].role == original_second.role + assert retrieved[1].text == original_second.text == "Second message: 3 + 5 equals 8." + + async def test_additional_properties_round_trip_through_in_memory_backend(self) -> None: + """End-to-end audit/replay verification via the public provider API. + + Seeds the in-memory backend with an :class:`OutputItemOutputMessage` + carrying: + + * a non-default item id; + * declared content fields (``output_text`` with annotations); + * a non-default ``status``; + * an arbitrary, undeclared top-level key + (``"audit_trace_id": "..."``) — i.e. the kind of opaque field + Foundry might layer on for audit/replay; + * an undeclared key on a content child + (``"vendor_metadata": {...}``). + + Reads the items back through ``get_messages`` (which captures the + :data:`RAW_KEY` snapshot), then writes them via ``save_messages`` + (which re-emits via the snapshot), then reads again and asserts + every field above survives the storage → AF → storage hop. Without + the raw-snapshot path, the second read would see synthesised + text-only items with newly-minted ids and lose every audit field. + """ + from azure.ai.agentserver.responses.models import ResponseObject + + from agent_framework_foundry_hosting._shared import EXTRAS_KEY, RAW_KEY # pyright: ignore[reportPrivateUsage] + + backend = InMemoryResponseProvider() + original_id = "itm_audit_001" + seed_item = OutputItemOutputMessage( + id=original_id, + type="output_message", + role="assistant", + status="completed", + content=[ + OutputMessageContentOutputTextContent( + type="output_text", + text="The final answer is 42.", + annotations=[], + ) + ], + ) + # Layer audit fields onto the SDK model directly — these are the + # "extras" that pyright would warn about but the runtime + # round-trips faithfully via as_dict(). + seed_item["audit_trace_id"] = "trace-abc-123" + seed_item.content[0]["vendor_metadata"] = {"score": 0.97, "model": "gpt-x"} + + seed_response = ResponseObject( + id="resp_audit", + object="response", + status="completed", + model="test-model", + created_at=0, + ) + await backend.create_response(seed_response, input_items=[seed_item], history_item_ids=None) + + provider = _with_backend(FoundryHostedAgentHistoryProvider(), backend) + + # 1. Read back — provider stamps the RAW_KEY snapshot onto the + # AF Message's additional_properties. + first_read = await provider.get_messages("resp_audit") + assert len(first_read) == 1 + msg = first_read[0] + raw = msg.additional_properties[EXTRAS_KEY][RAW_KEY] + assert raw["id"] == original_id + assert raw["type"] == "output_message" + assert raw["audit_trace_id"] == "trace-abc-123" + assert raw["content"][0]["text"] == "The final answer is 42." + assert raw["content"][0]["vendor_metadata"] == {"score": 0.97, "model": "gpt-x"} + + # 2. Write back — this is where the snapshot-driven write path + # matters: save_messages mints a new response_id but must + # re-emit the SDK item from the captured raw shape. + from agent_framework_foundry_hosting import bind_request_context + + with bind_request_context(response_id="resp_audit_replay", previous_response_id="resp_audit"): + await provider.save_messages("resp_audit_replay", [msg]) + + # 3. Inspect what was stored. We walk the new response id and + # expect to see the prior history seeded plus the replayed + # message — proof the snapshot survived storage→AF→storage. + item_ids = await backend.get_history_item_ids( + previous_response_id="resp_audit_replay", conversation_id=None, limit=20 + ) + assert len(item_ids) >= 1 + stored_items = await backend.get_items(item_ids) + # Find the replayed item (its content text matches). + replay = next( + dict(it) + for it in stored_items + if it is not None + and dict(it).get("type") == "output_message" + and dict(it).get("audit_trace_id") == "trace-abc-123" + and dict(it).get("id") != original_id + ) + stored_dict = replay + assert stored_dict["type"] == "output_message" + assert stored_dict["status"] == "completed" + assert stored_dict["audit_trace_id"] == "trace-abc-123" + assert stored_dict["content"][0]["text"] == "The final answer is 42." + assert stored_dict["content"][0]["vendor_metadata"] == {"score": 0.97, "model": "gpt-x"} + # The replay item id is regenerated per write turn (caller + # supplies it), so it must NOT equal the original — that's how + # we know the snapshot path didn't naively echo back the seed. + assert stored_dict["id"] != original_id + + # 4. Final read confirms the entire chain is observable through + # the public AF surface. Walking the new response id returns + # both the seeded prior item and the replayed one. + second_read = await provider.get_messages("resp_audit_replay") + assert len(second_read) >= 1 + # Find the replayed message (matches the seed text + audit field). + replayed_msg = next( + m + for m in second_read + if EXTRAS_KEY in m.additional_properties + and m.additional_properties[EXTRAS_KEY].get(RAW_KEY, {}).get("audit_trace_id") == "trace-abc-123" + ) + replayed_raw = replayed_msg.additional_properties[EXTRAS_KEY][RAW_KEY] + assert replayed_raw["content"][0]["vendor_metadata"] == {"score": 0.97, "model": "gpt-x"} + + +# region Integration tests against a real Foundry project +# +# Required environment variables: +# +# * ``FOUNDRY_PROJECT_ENDPOINT`` — base URL of a real Foundry project, +# e.g. ``https://my-proj.services.ai.azure.com``. +# * Azure auth (any one of): +# - ``az login`` (recommended for local dev) +# - ``AZURE_CLIENT_ID`` + ``AZURE_CLIENT_SECRET`` + ``AZURE_TENANT_ID`` +# - Managed identity when on Azure +# The identity needs at least the ``Azure AI User`` role on the project. +# +# Optional (enables the seeded-history test): +# +# * ``FOUNDRY_HOSTING_PREVIOUS_RESPONSE_ID`` — a real response id with attached items. +# * ``FOUNDRY_HOSTING_CONVERSATION_ID`` — alternative. +# * ``FOUNDRY_HOSTING_USER_ISOLATION_KEY`` / +# ``FOUNDRY_HOSTING_CHAT_ISOLATION_KEY`` — set if your project enforces isolation. +# +# Run with: ``uv run pytest -m integration packages/foundry_hosting/tests/test_history_provider.py`` + + +_FOUNDRY_PROJECT_ENDPOINT = os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") + +_skip_if_no_foundry_endpoint = pytest.mark.skipif( + not _FOUNDRY_PROJECT_ENDPOINT or _FOUNDRY_PROJECT_ENDPOINT == "https://test-project.services.ai.azure.com/", + reason=( + "FOUNDRY_PROJECT_ENDPOINT not set to a real Foundry project; " + "skipping FoundryHostedAgentHistoryProvider integration tests." + ), +) + + +def _isolation_from_env() -> IsolationContext | None: + user_key = os.getenv("FOUNDRY_HOSTING_USER_ISOLATION_KEY") + chat_key = os.getenv("FOUNDRY_HOSTING_CHAT_ISOLATION_KEY") + if not user_key and not chat_key: + return None + return IsolationContext(user_key=user_key, chat_key=chat_key) + + +@pytest.fixture +async def _live_credential() -> object: + """Yield a :class:`AzureCliCredential` and close it afterwards.""" + # Imported lazily so collection still works in environments without + # ``azure-identity`` available (e.g. minimal CI matrices). + from azure.identity.aio import AzureCliCredential + + cred = AzureCliCredential() + try: + yield cred + finally: + await cred.close() + + +class TestLiveFoundryStorage: + """End-to-end tests against a real Foundry project's storage HTTP API. + + These tests are gated behind ``@pytest.mark.integration`` so the + default ``pytest -m 'not integration'`` run skips them; they are + additionally skipped unless ``FOUNDRY_PROJECT_ENDPOINT`` points at a + real project. + """ + + @pytest.mark.flaky + @pytest.mark.integration + @_skip_if_no_foundry_endpoint + async def test_get_messages_unknown_response_id_returns_empty(self, _live_credential: object) -> None: + """A brand-new previous_response_id should yield an empty history. + + The native HTTP backend treats a 404 from the storage ``item_ids`` + endpoint as "no prior history" rather than raising, so a freshly + bootstrapped client never crashes on its first request. This test + proves that contract end-to-end against the live service. + """ + isolation = _isolation_from_env() + provider = FoundryHostedAgentHistoryProvider( + endpoint=_FOUNDRY_PROJECT_ENDPOINT, + credential=_live_credential, # type: ignore[arg-type] + ) + try: + messages = await provider.get_messages( + "resp_does_not_exist_integration_smoke", + isolation=isolation, + ) + finally: + await provider.aclose() + + assert messages == [] + + @pytest.mark.flaky + @pytest.mark.integration + @_skip_if_no_foundry_endpoint + @pytest.mark.skipif( + not os.getenv("FOUNDRY_HOSTING_PREVIOUS_RESPONSE_ID") and not os.getenv("FOUNDRY_HOSTING_CONVERSATION_ID"), + reason=( + "Set FOUNDRY_HOSTING_PREVIOUS_RESPONSE_ID or " + "FOUNDRY_HOSTING_CONVERSATION_ID to a real seeded conversation to " + "enable this test." + ), + ) + async def test_get_messages_returns_real_history(self, _live_credential: object) -> None: + """When pointed at a real seeded conversation we should get Messages back.""" + previous_response_id = os.getenv("FOUNDRY_HOSTING_PREVIOUS_RESPONSE_ID") or "" + conversation_id = os.getenv("FOUNDRY_HOSTING_CONVERSATION_ID") + isolation = _isolation_from_env() + + provider = FoundryHostedAgentHistoryProvider( + endpoint=_FOUNDRY_PROJECT_ENDPOINT, + credential=_live_credential, # type: ignore[arg-type] + history_limit=20, + ) + try: + # ``get_messages`` is keyed on ``session_id`` (== previous_response_id) + # so we pass that as the primary lookup; conversation_id is the + # fallback when only a conversation id is configured. + messages = await provider.get_messages( + previous_response_id or (conversation_id or ""), + isolation=isolation, + ) + finally: + await provider.aclose() + + assert isinstance(messages, list) + assert messages, "Expected at least one message in the seeded history" + assert all(isinstance(m, Message) for m in messages) + + @pytest.mark.flaky + @pytest.mark.integration + @_skip_if_no_foundry_endpoint + async def test_invoke_then_read_and_write_with_isolation(self, _live_credential: object) -> None: + """Invoke a deployed Foundry hosted agent, then round-trip via storage. + + This test exercises the realistic, fully-permissioned path: + + 1. Use :class:`FoundryAgent` to invoke the deployed + ``agent-framework-hosting-sample`` (version 10) hosted agent + with an explicit ``isolation_key``. The Foundry runtime + creates the response + history items inside the storage + backend on the user's behalf. + 2. Read the resulting history back through our own native HTTP + :class:`FoundryHostedAgentHistoryProvider` using the matching + :class:`IsolationContext`. This is the production read path + that DevUI / external clients use to render conversation + transcripts. + 3. Best-effort: try to APPEND two more items to the same + response via :class:`FoundryStorageProvider` write API. The + storage write path is normally callable only from inside the + agent-server container's runtime identity (Foundry strips + the user's bearer token at the runtime boundary), so a 403 + here is expected for ordinary user principals; we skip the + write-side assertions in that case rather than failing. + """ + from agent_framework_foundry import FoundryAgent + from azure.ai.agentserver.responses import ( + FoundryStorageProvider, + FoundryStorageSettings, + ) + from azure.ai.agentserver.responses.store._foundry_errors import ( # pyright: ignore[reportPrivateImportUsage] + FoundryApiError, + ) + + # Per-run-unique isolation key keeps each test run in its own + # tenant partition so concurrent runs (CI matrix, retries) don't + # collide. + isolation_key = f"af-hosting-roundtrip-{int(time.time())}" + isolation = IsolationContext(user_key=isolation_key, chat_key=isolation_key) + + # 1. Invoke the deployed hosted agent. + agent = FoundryAgent( + project_endpoint=_FOUNDRY_PROJECT_ENDPOINT, + agent_name="agent-framework-hosting-sample", + agent_version="10", + credential=_live_credential, # type: ignore[arg-type] + allow_preview=True, + default_options={"isolation_key": isolation_key}, + ) + # ``create_session()`` makes a fresh local session with no + # ``service_session_id`` set; the FoundryAgent's + # ``_prepare_run_context`` will lazily call + # ``project_client.beta.agents.create_session`` under our + # isolation key on first run. + session = agent.create_session() + prompt = "Please reply with exactly: 'Round-trip ack.'" + result = await agent.run(prompt, session=session) + + assert result.text, "FoundryAgent.run returned an empty response" + response_id = result.response_id + assert isinstance(response_id, str) and response_id, "Expected a non-empty response_id from FoundryAgent.run" + + # 2. Read history back via the native HTTP provider with the + # same isolation context. Try both the response_id and the + # service_session_id Foundry created on our behalf — depending + # on the runtime's storage layout, history may be anchored to + # either. + service_session_id = session.service_session_id + candidates = [c for c in (response_id, service_session_id) if c] + + reader = FoundryHostedAgentHistoryProvider( + endpoint=_FOUNDRY_PROJECT_ENDPOINT, + credential=_live_credential, # type: ignore[arg-type] + history_limit=20, + ) + try: + messages_after_invoke: list[Message] = [] + for cand in candidates: + msgs = await reader.get_messages(cand, isolation=isolation) + if msgs: + messages_after_invoke = msgs + break + finally: + await reader.aclose() + + # The read path returning a well-typed list (possibly empty if + # Foundry compacts items out of the response chain we queried) + # is enough to confirm the isolation header path works end-to-end. + assert all(isinstance(m, Message) for m in messages_after_invoke) + + # If we got messages back, every one should carry the lossless + # raw-snapshot under additional_properties[EXTRAS_KEY][RAW_KEY] — + # this is what guarantees audit/replay round-trip through the + # storage backend. Without it, a write-back would synthesise a + # text-only item and lose every audit field. + if messages_after_invoke: + from agent_framework_foundry_hosting._shared import ( # pyright: ignore[reportPrivateUsage] + EXTRAS_KEY, + RAW_KEY, + ) + + for m in messages_after_invoke: + extras = m.additional_properties.get(EXTRAS_KEY) or {} + assert RAW_KEY in extras, f"Live read message missing raw snapshot: {m!r}" + raw = extras[RAW_KEY] + # Snapshot must carry the discriminator + id — the two + # fields save_messages relies on to rebuild the SDK item. + assert isinstance(raw, dict) + assert "type" in raw and "id" in raw + + # 3. Best-effort write: create a fresh response under the same + # isolation key carrying two known items, then read it back + # via the native HTTP provider. Skip the write-side + # assertions if Foundry rejects the call with 403 (expected + # when the runtime is the only authorised writer). + from azure.ai.agentserver.responses.models import ResponseObject + + write_response_id = f"resp_af_write_{int(time.time())}" + _, foundry_first = TestAfFoundryRoundTrip._af_message( + "Appended message 1: 2 + 2 equals 4.", f"{write_response_id}_itm_1" + ) + _, foundry_second = TestAfFoundryRoundTrip._af_message( + "Appended message 2: 3 + 5 equals 8.", f"{write_response_id}_itm_2" + ) + + write_succeeded = False + writer = FoundryStorageProvider( + credential=_live_credential, # type: ignore[arg-type] + settings=FoundryStorageSettings.from_endpoint(_FOUNDRY_PROJECT_ENDPOINT), + ) + try: + await writer.create_response( + ResponseObject( + id=write_response_id, + object="response", + status="completed", + model="agent", + created_at=int(time.time()), + ), + input_items=[foundry_first, foundry_second], + history_item_ids=None, + isolation=isolation, + ) + write_succeeded = True + except FoundryApiError as exc: + if "403" not in str(exc): + raise + # Foundry strips the user bearer token at the runtime + # boundary, so external principals can't write directly to + # storage. The container's MSI is the authorised writer. + pytest.skip("Foundry rejected external storage write with 403 (expected outside container).") + finally: + await writer.aclose() + + # Re-read and verify our two appended items now show up. + if not write_succeeded: # pragma: no cover — defensive; pytest.skip already raised + return + reader2 = FoundryHostedAgentHistoryProvider( + endpoint=_FOUNDRY_PROJECT_ENDPOINT, + credential=_live_credential, # type: ignore[arg-type] + history_limit=20, + ) + try: + messages_after_write = await reader2.get_messages(write_response_id, isolation=isolation) + finally: + await reader2.aclose() + + appended_texts = {m.text for m in messages_after_write} + assert "Appended message 1: 2 + 2 equals 4." in appended_texts + assert "Appended message 2: 3 + 5 equals 8." in appended_texts diff --git a/python/packages/hosting-a2a/LICENSE b/python/packages/hosting-a2a/LICENSE new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/python/packages/hosting-a2a/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/hosting-a2a/README.md b/python/packages/hosting-a2a/README.md new file mode 100644 index 00000000000..4beab43952e --- /dev/null +++ b/python/packages/hosting-a2a/README.md @@ -0,0 +1,36 @@ +# agent-framework-hosting-a2a + +Agent-to-Agent (A2A) protocol channel for `agent-framework-hosting`. + +Exposes the hosted target (an `Agent` or a `Workflow`) as an A2A peer agent: it +publishes an agent card and JSON-RPC routes and drives every request through the +host pipeline, so host sessions, request metadata, and run/response hooks all +apply. + +```python +from agent_framework.openai import OpenAIChatClient +from agent_framework_hosting import AgentFrameworkHost +from agent_framework_hosting_a2a import A2AChannel + +agent = OpenAIChatClient().as_agent(name="Assistant") + +host = AgentFrameworkHost( + target=agent, + channels=[A2AChannel(url="https://my-host.example.com/")], +) +host.serve(port=8000) +``` + +By default the channel mounts at the app root so the well-known agent card is +reachable at `/.well-known/agent-card.json`, with the JSON-RPC endpoint at `/`. +The A2A `context_id` maps onto the host session (caller-supplied session family). +A default agent card is derived from the target's name and description; pass a +fully-specified `agent_card` to override it. To advertise additional protocol +bindings in the generated card, pass `supported_interfaces`. + +> **Note:** Task state is held in an in-memory A2A task store for this version; it +> is independent of the host's session storage and is not persisted across +> restarts. + +The base host plumbing lives in +[`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/). diff --git a/python/packages/hosting-a2a/agent_framework_hosting_a2a/__init__.py b/python/packages/hosting-a2a/agent_framework_hosting_a2a/__init__.py new file mode 100644 index 00000000000..c2cfab8cad5 --- /dev/null +++ b/python/packages/hosting-a2a/agent_framework_hosting_a2a/__init__.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""A2A (Agent-to-Agent) channel for :mod:`agent_framework_hosting`. + +Exposes the hosted target (an ``Agent`` or a ``Workflow``) as an A2A peer agent +— publishing an agent card and JSON-RPC routes — while routing every request +through the host pipeline so sessions, request metadata, and hooks apply. +""" + +import importlib.metadata + +from ._channel import A2AChannel +from ._executor import HostAgentExecutor + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = [ + "A2AChannel", + "HostAgentExecutor", + "__version__", +] diff --git a/python/packages/hosting-a2a/agent_framework_hosting_a2a/_channel.py b/python/packages/hosting-a2a/agent_framework_hosting_a2a/_channel.py new file mode 100644 index 00000000000..585725ac636 --- /dev/null +++ b/python/packages/hosting-a2a/agent_framework_hosting_a2a/_channel.py @@ -0,0 +1,141 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""A2A (Agent-to-Agent) channel for :mod:`agent_framework_hosting`. + +Exposes the hosted target as an A2A peer agent: it publishes an agent card and +JSON-RPC routes, and drives every request through the host pipeline via +:class:`HostAgentExecutor`. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes +from a2a.server.tasks import InMemoryTaskStore +from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill +from agent_framework_hosting import ( + ChannelContext, + ChannelContribution, + ChannelResponseHook, + ChannelRunHook, +) + +from ._executor import HostAgentExecutor + + +class A2AChannel: + """Channel that exposes the hosted target over the A2A protocol. + + The A2A ``context_id`` maps onto the host session (caller-supplied session + family) and each request is routed through :class:`ChannelContext`, so host + session resolution and hooks apply. + + Note: + Task state is held in an in-memory A2A task store for this version; it + is independent of the host's session storage and is not persisted. + """ + + name: str = "a2a" + + def __init__( + self, + *, + name: str | None = None, + path: str = "", + url: str = "/", + agent_name: str | None = None, + agent_description: str | None = None, + agent_version: str = "1.0.0", + agent_card: AgentCard | None = None, + skills: Sequence[AgentSkill] | None = None, + supported_interfaces: Sequence[AgentInterface] | None = None, + streaming: bool = True, + rpc_url: str = "/", + card_url: str = "/.well-known/agent-card.json", + run_hook: ChannelRunHook | None = None, + response_hook: ChannelResponseHook | None = None, + ) -> None: + """Configure the A2A channel. + + Keyword Args: + name: Override the channel name (defaults to ``"a2a"``). + path: Sub-path to mount the channel under; empty string (default) + mounts the agent-card and JSON-RPC routes at the app root so + the well-known card path is reachable. + url: Public URL advertised in the agent card's interface (the base + URL clients use to reach the JSON-RPC endpoint). + agent_name: Name advertised in the default agent card. Defaults to + the hosted target's name. + agent_description: Description advertised in the default agent card. + Defaults to the hosted target's description. + agent_version: Version advertised in the default agent card. + agent_card: A fully-specified agent card; when provided it takes + precedence over the ``agent_*``/``url``/``skills`` fields. + skills: Skills advertised in the default agent card. + supported_interfaces: Interfaces advertised in the default agent card. + Defaults to one JSON-RPC interface using ``url``. + streaming: Consume the target via streaming and publish incremental + A2A task artifacts (default ``True``). + rpc_url: Path for the JSON-RPC endpoint (relative to ``path``). + card_url: Path for the agent-card endpoint (relative to ``path``). + run_hook: Optional run hook applied to each request. + response_hook: Optional response hook applied to originating replies. + """ + if name is not None: + self.name = name + self.path = path + self._url = url + self._agent_name = agent_name + self._agent_description = agent_description + self._agent_version = agent_version + self._agent_card = agent_card + self._skills = list(skills) if skills is not None else [] + self._supported_interfaces = list(supported_interfaces) if supported_interfaces is not None else None + self._streaming = streaming + self._rpc_url = rpc_url + self._card_url = card_url + self._run_hook = run_hook + self._response_hook = response_hook + + def _build_agent_card(self, context: ChannelContext) -> AgentCard: + """Derive a default agent card from the hosted target, if not supplied.""" + if self._agent_card is not None: + return self._agent_card + target: Any = context.target + name = self._agent_name or getattr(target, "name", None) or self.name + description = self._agent_description or getattr(target, "description", None) or f"{name} (A2A)" + return AgentCard( + name=name, + description=description, + version=self._agent_version, + default_input_modes=["text"], + default_output_modes=["text"], + capabilities=AgentCapabilities(streaming=self._streaming), + supported_interfaces=self._supported_interfaces + or [AgentInterface(url=self._url, protocol_binding="JSONRPC")], + skills=self._skills, + ) + + def contribute(self, context: ChannelContext) -> ChannelContribution: + """Build the A2A request handler and contribute its routes.""" + agent_card = self._build_agent_card(context) + executor = HostAgentExecutor( + context, + channel_name=self.name, + streaming=self._streaming, + run_hook=self._run_hook, + response_hook=self._response_hook, + ) + handler = DefaultRequestHandler( + agent_executor=executor, + task_store=InMemoryTaskStore(), + agent_card=agent_card, + ) + routes = [ + *create_agent_card_routes(agent_card, card_url=self._card_url), + *create_jsonrpc_routes(handler, self._rpc_url), + ] + return ChannelContribution(routes=routes) diff --git a/python/packages/hosting-a2a/agent_framework_hosting_a2a/_executor.py b/python/packages/hosting-a2a/agent_framework_hosting_a2a/_executor.py new file mode 100644 index 00000000000..495209442e1 --- /dev/null +++ b/python/packages/hosting-a2a/agent_framework_hosting_a2a/_executor.py @@ -0,0 +1,195 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Host-routed A2A :class:`AgentExecutor`. + +Unlike ``agent_framework_a2a.A2AExecutor`` (which calls ``agent.run`` directly +and manages its own session), :class:`HostAgentExecutor` routes every incoming +A2A request through the host pipeline via :class:`ChannelContext` — so host +session resolution, request metadata, and run/response hooks all apply. The A2A +``context_id`` maps onto :class:`ChannelSession` (caller-supplied session +family). +""" + +from __future__ import annotations + +import base64 +import re +from asyncio import CancelledError +from typing import Any, cast + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.server.tasks import TaskUpdater +from a2a.types import Part, Task, TaskState +from agent_framework import Content +from agent_framework_hosting import ( + ChannelContext, + ChannelIdentity, + ChannelRequest, + ChannelResponseHook, + ChannelRunHook, + ChannelSession, + logger, +) + +try: + from a2a.helpers import new_task_from_user_message +except ImportError: # pragma: no cover - older a2a-sdk layout + from a2a.utils import new_task_from_user_message # type: ignore[no-redef, attr-defined, import-not-found] + +_DATA_URI_PATTERN = re.compile(r"^data:(?P[^;]+);base64,(?P[A-Za-z0-9+/=]+)$") + + +def _contents_to_parts(contents: list[Content]) -> list[Part]: + """Convert Agent Framework contents into A2A parts (text, uri, inline data).""" + parts: list[Part] = [] + for content in contents: + if content.type == "text" and content.text: + parts.append(Part(text=content.text)) + elif content.type == "uri" and content.uri: + parts.append(Part(url=content.uri, media_type=content.media_type or "")) + elif content.type == "data" and content.uri: + match = _DATA_URI_PATTERN.match(content.uri) + if match is None: + logger.warning("A2AChannel could not parse data URI; omitted.") + continue + parts.append(Part(raw=base64.b64decode(match.group("data")), media_type=content.media_type or "")) + else: + logger.warning("A2AChannel does not support content type: %s. Omitted.", content.type) + return parts + + +class HostAgentExecutor(AgentExecutor): + """A2A executor that drives the hosted target through :class:`ChannelContext`.""" + + def __init__( + self, + context: ChannelContext, + *, + channel_name: str, + streaming: bool = True, + run_hook: ChannelRunHook | None = None, + response_hook: ChannelResponseHook | None = None, + ) -> None: + """Bind the executor to the host context. + + Args: + context: The host-supplied :class:`ChannelContext`. + + Keyword Args: + channel_name: The owning channel's name (stamped on requests). + streaming: When ``True`` (default) the target is consumed via + :meth:`ChannelContext.run_stream` and incremental updates are + published as A2A task artifacts; otherwise the full reply is + published as a single working-state message. + run_hook: Optional :data:`ChannelRunHook` applied to the request. + response_hook: Optional :data:`ChannelResponseHook` applied to the + originating final response. + """ + super().__init__() + self._ctx = context + self._channel_name = channel_name + self._streaming = streaming + self._run_hook = run_hook + self._response_hook = response_hook + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + """Publish a cancellation event for the in-flight task.""" + if context.context_id is None: + raise ValueError("Context ID must be provided in the RequestContext") + updater = TaskUpdater(event_queue, context.task_id or "", context.context_id) + await updater.cancel() + + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + """Route an A2A request through the host and publish task events.""" + if context.context_id is None: + raise ValueError("Context ID must be provided in the RequestContext") + if context.message is None: + raise ValueError("Message must be provided in the RequestContext") + + query = context.get_user_input() + task: Task | None = context.current_task + if not task: + task = cast(Task, new_task_from_user_message(context.message)) # type: ignore[redundant-cast] + await event_queue.enqueue_event(task) + + task_id: str = task.id + updater = TaskUpdater(event_queue, task_id, context.context_id) + await updater.submit() + + try: + await updater.start_work() + request = self._build_request(query, context, task_id) + if request.stream: + await self._run_stream(request, updater, protocol_request=context.message) + else: + await self._run(request, updater, protocol_request=context.message) + await updater.complete() + except CancelledError: + await updater.update_status(state=TaskState.TASK_STATE_CANCELED) + except Exception as exc: + logger.exception("A2AChannel encountered an error during execution.") + await updater.update_status( + state=TaskState.TASK_STATE_FAILED, + message=updater.new_agent_message([Part(text=str(exc))]), + ) + + def _build_request(self, query: Any, context: RequestContext, task_id: str) -> ChannelRequest: + """Build the channel-neutral request from the A2A request context.""" + context_id = cast(str, context.context_id) + return ChannelRequest( + channel=self._channel_name, + operation="message.create", + input=query if isinstance(query, str) else str(query), + session=ChannelSession(isolation_key=context_id), + stream=self._streaming, + identity=ChannelIdentity(channel=self._channel_name, native_id=context_id), + attributes={"task_id": task_id}, + ) + + async def _run(self, request: ChannelRequest, updater: TaskUpdater, *, protocol_request: Any) -> None: + """Non-streaming: run the target and publish the reply as task messages.""" + result = await self._ctx.run( + request, + run_hook=self._run_hook, + protocol_request=protocol_request, + response_hook=self._response_hook, + channel_name=self._channel_name, + ) + response: Any = result.result + messages: list[Any] = list(getattr(response, "messages", None) or []) + for message in messages: + if getattr(message, "role", None) == "user": + continue + contents: list[Content] = list(getattr(message, "contents", None) or []) + parts = _contents_to_parts(contents) + if parts: + await updater.update_status( + state=TaskState.TASK_STATE_WORKING, + message=updater.new_agent_message(parts=parts), + ) + + async def _run_stream(self, request: ChannelRequest, updater: TaskUpdater, *, protocol_request: Any) -> None: + """Streaming: publish incremental updates as task artifacts.""" + streamed_ids: set[str] = set() + stream = await self._ctx.run_stream( + request, + run_hook=self._run_hook, + protocol_request=protocol_request, + response_hook=self._response_hook, + channel_name=self._channel_name, + ) + async for update in stream: + contents: list[Content] = list(getattr(update, "contents", None) or []) + parts = _contents_to_parts(contents) + if not parts: + continue + message_id: str | None = getattr(update, "message_id", None) + await updater.add_artifact( + parts=parts, + artifact_id=message_id, + append=True if message_id is not None and message_id in streamed_ids else None, + ) + if message_id is not None: + streamed_ids.add(message_id) + await stream.get_final_response() diff --git a/python/packages/hosting-a2a/pyproject.toml b/python/packages/hosting-a2a/pyproject.toml new file mode 100644 index 00000000000..ee72982537a --- /dev/null +++ b/python/packages/hosting-a2a/pyproject.toml @@ -0,0 +1,102 @@ +[project] +name = "agent-framework-hosting-a2a" +description = "Agent-to-Agent (A2A) protocol channel for agent-framework-hosting." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0a260424" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.2.0,<2", + "agent-framework-hosting>=1.0.0a260424,<2", + "a2a-sdk>=1.0.0,<2", + "starlette>=0.37", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_hosting_a2a"] +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_hosting_a2a"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_a2a" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_a2a --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" + +[dependency-groups] +dev = [] diff --git a/python/packages/hosting-a2a/tests/hosting_a2a/test_channel.py b/python/packages/hosting-a2a/tests/hosting_a2a/test_channel.py new file mode 100644 index 00000000000..467c67d431d --- /dev/null +++ b/python/packages/hosting-a2a/tests/hosting_a2a/test_channel.py @@ -0,0 +1,309 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for :class:`A2AChannel` and :class:`HostAgentExecutor`.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Awaitable +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from typing import Any + +import pytest +import uvicorn +from a2a.server.events import EventQueue +from a2a.types import AgentCard, AgentInterface, Message, Part, Role, Task, TaskState +from agent_framework import AgentResponse, Content +from agent_framework import Message as AFMessage +from agent_framework_a2a import A2AAgent +from agent_framework_hosting import AgentFrameworkHost, ChannelContribution, ChannelRequest, HostedRunResult +from starlette.types import ASGIApp + +from agent_framework_hosting_a2a import A2AChannel, HostAgentExecutor + +# --------------------------------------------------------------------------- # +# Fakes # +# --------------------------------------------------------------------------- # + + +@dataclass +class _FakeResp: + text: str + messages: list[Message] = field(default_factory=list) + + +@dataclass +class _FakeUpdate: + text: str + contents: list[Content] = field(default_factory=list) + message_id: str | None = None + + +class _FakeStream: + def __init__(self, chunks: list[str]) -> None: + self._chunks = chunks + self._final = _FakeResp(text="".join(chunks)) + + def __aiter__(self) -> AsyncIterator[_FakeUpdate]: + async def _gen() -> AsyncIterator[_FakeUpdate]: + for i, c in enumerate(self._chunks): + yield _FakeUpdate(text=c, contents=[Content.from_text(text=c)], message_id=f"m{i}") + + return _gen() + + async def get_final_response(self) -> _FakeResp: + return self._final + + +@dataclass +class _FakeTarget: + name: str = "Assistant" + description: str = "A helpful assistant." + + +class _FakeContext: + def __init__( + self, + *, + reply: str = "hello", + chunks: list[str] | None = None, + ) -> None: + self.target = _FakeTarget() + self._reply = reply + self._chunks = chunks or [reply] + self.requests: list[ChannelRequest] = [] + + async def run( + self, + request: ChannelRequest, + *, + run_hook: Any | None = None, + protocol_request: Any | None = None, + response_hook: Any | None = None, + channel_name: str | None = None, + ) -> HostedRunResult[Any]: + if run_hook is not None: + maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request) + if isinstance(maybe_request, Awaitable): + request = await maybe_request + else: + request = maybe_request + self.requests.append(request) + msg = Message(role=Role.ROLE_AGENT, parts=[Part(text=self._reply)]) + result = HostedRunResult(_FakeResp(text=self._reply, messages=[msg])) + if response_hook is not None: + maybe_result = response_hook(result, request=request, channel_name=channel_name or request.channel) + if isinstance(maybe_result, Awaitable): + return await maybe_result + return maybe_result + return result + + async def run_stream( + self, + request: ChannelRequest, + *, + run_hook: Any | None = None, + protocol_request: Any | None = None, + stream_update_hook: Any | None = None, + response_hook: Any | None = None, + channel_name: str | None = None, + ) -> _FakeStream: + if run_hook is not None: + maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request) + if isinstance(maybe_request, Awaitable): + request = await maybe_request + else: + request = maybe_request + self.requests.append(request) + return _FakeStream(self._chunks) + + +class _RecordingEventQueue(EventQueue): + def __init__(self) -> None: + super().__init__() + self.events: list[Any] = [] + + async def enqueue_event(self, event: Any) -> None: + self.events.append(event) + await super().enqueue_event(event) + + +class _FakeRequestContext: + def __init__(self, *, context_id: str, text: str, current_task: Task | None = None) -> None: + self.context_id = context_id + self.task_id: str | None = None + self.message = Message( + message_id="msg-1", + context_id=context_id, + role=Role.ROLE_USER, + parts=[Part(text=text)], + ) + self.current_task = current_task + self._text = text + + def get_user_input(self) -> str: + return self._text + + +class _HostedAgent: + name = "HostedAssistant" + description = "A hosted test assistant." + + async def run(self, messages: Any = None, *, stream: bool = False, **_kwargs: Any) -> AgentResponse[Any]: + text = messages.text if isinstance(messages, AFMessage) else str(messages) + return AgentResponse(messages=[AFMessage(role="assistant", contents=[Content.from_text(text=f"host: {text}")])]) + + +@asynccontextmanager +async def _serve_app(app: ASGIApp, *, port: int) -> AsyncIterator[str]: + config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning", lifespan="on") + server = uvicorn.Server(config) + task = asyncio.create_task(server.serve()) + try: + for _ in range(100): + if server.started: + break + await asyncio.sleep(0.01) + else: + raise RuntimeError("Test A2A server did not start") + yield f"http://127.0.0.1:{port}" + finally: + server.should_exit = True + await task + + +def _status_states(events: list[Any]) -> list[int]: + states: list[int] = [] + for event in events: + status = getattr(event, "status", None) + if status is not None and getattr(status, "state", None): + states.append(status.state) + return states + + +# --------------------------------------------------------------------------- # +# A2AChannel tests # +# --------------------------------------------------------------------------- # + + +def test_default_name_and_root_path() -> None: + channel = A2AChannel() + assert channel.name == "a2a" + assert channel.path == "" + + +def test_build_agent_card_defaults_from_target() -> None: + channel = A2AChannel(url="https://example.com/") + card = channel._build_agent_card(_FakeContext()) # type: ignore[arg-type] + assert card.name == "Assistant" + assert card.description == "A helpful assistant." + assert card.capabilities.streaming is True + assert card.supported_interfaces[0].url == "https://example.com/" + + +def test_build_agent_card_accepts_supported_interfaces() -> None: + interfaces = [ + AgentInterface(url="https://example.com/jsonrpc", protocol_binding="JSONRPC"), + AgentInterface(url="https://example.com/grpc", protocol_binding="GRPC"), + ] + channel = A2AChannel(supported_interfaces=interfaces) + card = channel._build_agent_card(_FakeContext()) # type: ignore[arg-type] + assert card.supported_interfaces == interfaces + + +def test_build_agent_card_override_wins() -> None: + custom = AgentCard(name="Custom", description="custom card", version="9.9.9") + channel = A2AChannel(agent_card=custom) + card = channel._build_agent_card(_FakeContext()) # type: ignore[arg-type] + assert card.name == "Custom" + assert card.version == "9.9.9" + + +def test_contribute_returns_card_and_jsonrpc_routes() -> None: + channel = A2AChannel(url="https://example.com/") + contribution = channel.contribute(_FakeContext()) # type: ignore[arg-type] + assert isinstance(contribution, ChannelContribution) + paths = {getattr(r, "path", None) for r in contribution.routes} + assert "/.well-known/agent-card.json" in paths + assert any(p == "/" for p in paths) + + +# --------------------------------------------------------------------------- # +# HostAgentExecutor tests # +# --------------------------------------------------------------------------- # + + +async def test_execute_routes_through_host_and_completes() -> None: + ctx = _FakeContext(reply="hi back") + executor = HostAgentExecutor(ctx, channel_name="a2a", streaming=False) # type: ignore[arg-type] + queue = _RecordingEventQueue() + request_context = _FakeRequestContext(context_id="conv-1", text="hello") + + await executor.execute(request_context, queue) # type: ignore[arg-type] + + # Routed through the host with the context id mapped onto the session. + assert len(ctx.requests) == 1 + request = ctx.requests[0] + assert request.channel == "a2a" + assert request.input == "hello" + assert request.session is not None + assert request.session.isolation_key == "conv-1" + assert request.identity is not None + assert request.identity.native_id == "conv-1" + # Task progressed to a completed state. + assert TaskState.TASK_STATE_COMPLETED in _status_states(queue.events) + + +async def test_execute_streaming_emits_artifacts() -> None: + ctx = _FakeContext(chunks=["foo", "bar"]) + executor = HostAgentExecutor(ctx, channel_name="a2a", streaming=True) # type: ignore[arg-type] + queue = _RecordingEventQueue() + request_context = _FakeRequestContext(context_id="conv-2", text="hello") + + await executor.execute(request_context, queue) # type: ignore[arg-type] + + artifact_events = [e for e in queue.events if getattr(e, "artifact", None)] + assert artifact_events, "expected at least one artifact update event" + assert ctx.requests[0].stream is True + assert TaskState.TASK_STATE_COMPLETED in _status_states(queue.events) + + +async def test_execute_requires_context_id() -> None: + ctx = _FakeContext() + executor = HostAgentExecutor(ctx, channel_name="a2a") # type: ignore[arg-type] + queue = _RecordingEventQueue() + request_context = _FakeRequestContext(context_id="x", text="hello") + request_context.context_id = None # type: ignore[assignment] + + with pytest.raises(ValueError, match="Context ID"): + await executor.execute(request_context, queue) # type: ignore[arg-type] + + +async def test_a2a_agent_can_call_hosted_channel(unused_tcp_port: int) -> None: + host = AgentFrameworkHost(target=_HostedAgent(), channels=[A2AChannel(streaming=False)]) + + async with ( + _serve_app(host.app, port=unused_tcp_port) as base_url, + A2AAgent( + url=base_url, + timeout=5.0, + ) as agent, + ): + response = await agent.run("hello") + + assert response.messages[0].text == "host: hello" + + +def test_contents_to_parts_conversion() -> None: + from agent_framework_hosting_a2a._executor import _contents_to_parts + + contents = [ + Content.from_text(text="hello"), + Content.from_uri(uri="https://x/y.png", media_type="image/png"), + Content.from_data(data=b"AAAA", media_type="image/png"), + ] + parts = _contents_to_parts(contents) + assert parts[0].text == "hello" + assert parts[1].url == "https://x/y.png" + assert parts[2].raw == b"AAAA" diff --git a/python/packages/hosting-activity-protocol/LICENSE b/python/packages/hosting-activity-protocol/LICENSE new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/python/packages/hosting-activity-protocol/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/hosting-activity-protocol/README.md b/python/packages/hosting-activity-protocol/README.md new file mode 100644 index 00000000000..661194c7a55 --- /dev/null +++ b/python/packages/hosting-activity-protocol/README.md @@ -0,0 +1,42 @@ +# agent-framework-hosting-activity-protocol + +Bot Framework **Activity Protocol** channel for +[agent-framework-hosting](../hosting). Connects to **Azure Bot Service** so +the same agent can be reached from Microsoft Teams, Slack, Webex, +Telegram-via-bot-channel, and any other channel Azure Bot Service +supports — without having to learn each channel's native protocol. + +> Looking for a deeper Microsoft Teams integration with adaptive cards, +> message extensions, dialogs, SSO, etc? That is intentionally separate from +> this Activity Protocol channel, which focuses on Azure Bot Service +> compatibility rather than Teams-specific affordances. + +Handles inbound `message` activities, outbound replies, mid-stream +`updateActivity` edits, typing indicators, and both client-secret and +certificate credential modes for the outbound Bot Framework token. + +## Usage + +```python +from agent_framework_hosting import AgentFrameworkHost +from agent_framework_hosting_activity_protocol import ActivityProtocolChannel + +host = AgentFrameworkHost( + target=my_agent, + channels=[ + ActivityProtocolChannel( + app_id="", + client_secret="", + tenant_id="botframework.com", # or your tenant id + ) + ], +) +host.serve() +``` + +For tenants that disallow client secrets, supply `certificate_path=` (and +optionally `certificate_password=`) instead. See the docstring at the top of +`_channel.py` for the openssl one-liner that generates a usable PEM. + +In dev mode (no credentials), the channel skips outbound auth so the Bot +Framework Emulator can hit the endpoint without setup. diff --git a/python/packages/hosting-activity-protocol/agent_framework_hosting_activity_protocol/__init__.py b/python/packages/hosting-activity-protocol/agent_framework_hosting_activity_protocol/__init__.py new file mode 100644 index 00000000000..4c205b4f045 --- /dev/null +++ b/python/packages/hosting-activity-protocol/agent_framework_hosting_activity_protocol/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Bot Framework Activity Protocol channel for :mod:`agent_framework_hosting`.""" + +from ._channel import ActivityProtocolChannel, activity_protocol_isolation_key + +__all__ = ["ActivityProtocolChannel", "activity_protocol_isolation_key"] diff --git a/python/packages/hosting-activity-protocol/agent_framework_hosting_activity_protocol/_channel.py b/python/packages/hosting-activity-protocol/agent_framework_hosting_activity_protocol/_channel.py new file mode 100644 index 00000000000..212311235f1 --- /dev/null +++ b/python/packages/hosting-activity-protocol/agent_framework_hosting_activity_protocol/_channel.py @@ -0,0 +1,1012 @@ +# Copyright (c) Microsoft. All rights reserved. + +r"""Built-in channel: Bot Framework Activity Protocol (Azure Bot Service). + +Activity Protocol is the Bot Framework messaging shape used by Azure Bot +Service to fan one bot endpoint out across many surfaces (Microsoft +Teams, Slack, Webex, Telegram, …). An incoming ``Activity`` is POSTed to +your bot's ``/messages`` endpoint, and you reply by POSTing one or more +``Activity`` objects back to the conversation URL the inbound activity +carried in ``serviceUrl``. Auth is an OAuth2 client-credentials token +from Entra (the legacy multi-tenant ``botframework.com`` authority for +public Bot Framework channels, or your own tenant for single-tenant +bots). + +This is the channel-neutral Activity-Protocol channel — it surfaces what +every Bot-Service-connected channel has in common (text in, text out). +For deeper Microsoft Teams affordances (adaptive cards, message +extensions, dialogs, SSO, …) on the same Bot Service transport, see the +companion ``agent-framework-hosting-teams`` package. + +This channel handles: + +- inbound ``message`` activities — text and attachments resolved to URIs, +- outbound replies via ``POST /v3/conversations/{id}/activities``, +- streaming via ``PUT /v3/conversations/{id}/activities/{id}`` mid-stream + edits on channels that support ``updateActivity`` (Teams personal chats + and groups); every other channel — Web Chat, Direct Line, the Emulator — + rejects the PUT with ``405``, so those buffer the stream and POST a + single final message instead, +- typing indicators while the agent works, +- per-conversation isolation key ``activity:`` so a Responses + caller can resume a Teams conversation by passing the conversation id, +- two credential modes for the outbound token — **client secret** or + **certificate** (for tenants that disallow secrets) — both via + ``azure.identity.aio``, +- dev-mode auth bypass when no credentials are passed so the Bot Framework + Emulator can hit the endpoint with no credentials. + +Out of scope for the prototype: full JWT validation of inbound requests, +adaptive cards, file uploads, OAuth sign-in flows, and the Teams streaming +preview API (``StreamItem``). + +Generating a certificate +------------------------ +For tenants that disallow client secrets, register a certificate on your +Bot Framework / Entra app instead. Self-signed PEM (private key + cert in +one file) is what ``azure.identity.CertificateCredential`` expects:: + + # 1. Generate a 2048-bit RSA key + self-signed cert (10y), single PEM. + openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \\ + -subj "/CN=my-teams-bot" \\ + -keyout teams-bot.key -out teams-bot.crt + cat teams-bot.key teams-bot.crt > teams-bot.pem + + # 2. Upload teams-bot.crt to your Entra app under + # "Certificates & secrets" → "Certificates" → "Upload certificate". + + # 3. Point the channel at the combined PEM: + ActivityProtocolChannel( + app_id="", + tenant_id="", # or "botframework.com" for legacy bots + certificate_path="teams-bot.pem", + ) + +To encrypt the private key, drop ``-nodes`` from the openssl command and +pass ``certificate_password=`` to the channel. +""" + +from __future__ import annotations + +import asyncio +import time +from collections.abc import Awaitable, Callable, Mapping, Sequence +from typing import Any +from urllib.parse import urlparse + +import httpx +from agent_framework import ( + AgentResponse, + AgentResponseUpdate, + Content, + Message, + ResponseStream, +) +from agent_framework.exceptions import ContentError +from agent_framework_hosting import ( + ChannelCommand, + ChannelCommandContext, + ChannelContext, + ChannelContribution, + ChannelIdentity, + ChannelRequest, + ChannelResponseHook, + ChannelRunHook, + ChannelSession, + ChannelStreamUpdateHook, + logger, +) +from azure.core.credentials_async import AsyncTokenCredential +from azure.identity.aio import CertificateCredential, ClientSecretCredential +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route + +# Bot Framework v4 multi-tenant authority used by the public Bot Framework +# channels (including Microsoft Teams). Single-tenant bots should override +# ``tenant_id`` with their own tenant. +_BOTFRAMEWORK_TENANT = "botframework.com" +_BOTFRAMEWORK_SCOPE = "https://api.botframework.com/.default" + +# Default allow-list of host suffixes the channel will POST a bearer token +# to. Bot Service surfaces ``serviceUrl`` per-conversation as one of these +# canonical hosts; a malicious inbound activity claiming a serviceUrl +# outside this set could otherwise exfiltrate a real Bot Framework access +# token. Operators with a private deployment (sovereign cloud, Direct Line +# only, etc.) override this via ``service_url_allowed_hosts``. +_DEFAULT_SERVICE_URL_HOSTS = ( + "botframework.com", + "smba.trafficmanager.net", +) + +# Bot Framework channels that support editing an Activity in place via +# ``PUT /v3/conversations/{id}/activities/{id}`` (the ``updateActivity`` +# REST operation). Progressive-edit streaming (POST a placeholder, then +# repeatedly PUT it) only works on these. Every other channel — Web Chat, +# Direct Line, the Emulator, etc. — returns ``405 Method Not Allowed`` on +# the PUT, so those channels buffer the stream and POST a single final +# message instead. Teams is the canonical (and effectively only) public +# channel that supports the edit operation. +_EDIT_CAPABLE_CHANNELS = frozenset({"msteams"}) + + +InboundAuthValidator = Callable[[Request], Awaitable[bool]] + + +def activity_protocol_isolation_key(conversation_id: Any) -> str: + """Build the namespaced isolation key the Teams channel writes under. + + Exposed at module scope so other channels' run hooks can opt into the + same per-conversation session (e.g. a Responses caller resuming a Teams + conversation by passing the conversation id). + """ + return f"activity:{conversation_id}" + + +class _OutboundError(RuntimeError): + """Marker for transient outbound failures that should produce 502/retry.""" + + +def _parse_activity(activity: Mapping[str, Any]) -> Message: + """Translate one Bot Framework ``message`` Activity into an Agent Framework Message. + + Pulls the activity's ``text`` plus any image/file attachments that expose a + resolvable ``contentUrl`` into ``Content`` parts. Bot Framework's inline + ``content`` field (e.g. the ``text/html`` rendering Teams attaches alongside + ``text``, or an Adaptive Card payload) is *not* a URI, so it is ignored here + to avoid mis-parsing it as a URL. If the activity has no usable parts an + empty text part is emitted so the caller never sees a content-less message. + """ + parts: list[Content] = [] + if (text := activity.get("text")) and isinstance(text, str): + parts.append(Content.from_text(text=text)) + + for attachment in activity.get("attachments") or []: + if not isinstance(attachment, Mapping): + continue + url = attachment.get("contentUrl") + content_type = attachment.get("contentType") + if not (isinstance(url, str) and isinstance(content_type, str) and "/" in content_type): + continue + # contentUrl is occasionally a relative reference or otherwise lacks a + # scheme; skip those so one odd attachment can't fail the whole turn. + if not urlparse(url).scheme: + logger.debug("Skipping attachment with non-absolute contentUrl: %r", url) + continue + try: + parts.append(Content.from_uri(uri=url, media_type=content_type)) + except ContentError: + logger.debug("Skipping attachment with unparseable contentUrl: %r", url) + continue + + if not parts: + parts.append(Content.from_text(text="")) + return Message("user", parts) + + +def _command_text(activity: Mapping[str, Any]) -> str: + """Return the activity text with the bot's own @mention stripped. + + Channels that require an @mention to address the bot (Teams team and + group-chat scopes) prefix the message ``text`` with a mention whose literal + rendering is carried in the matching ``entities[].text`` (e.g. + ``"Personal Assistant /todos"``). Personal 1:1 chats carry no + mention. We remove only the bot's own mention substring(s) — never other + users' mentions — so a leading ``/command`` can be detected in every scope. + """ + text = activity.get("text") + if not isinstance(text, str): + return "" + bot_id = (activity.get("recipient") or {}).get("id") + for entity in activity.get("entities") or []: + if not isinstance(entity, Mapping) or entity.get("type") != "mention": + continue + mentioned = entity.get("mentioned") + mentioned_id = mentioned.get("id") if isinstance(mentioned, Mapping) else None + # Only strip the bot's own mention; leave mentions of other users intact. + # When the recipient id is unknown we cannot disambiguate, so fall back + # to stripping every mention to keep command detection working. + if bot_id is not None and mentioned_id != bot_id: + continue + mention_text = entity.get("text") + if isinstance(mention_text, str) and mention_text: + text = text.replace(mention_text, "") + return text.strip() + + +class ActivityProtocolChannel: + """Microsoft Teams channel via Bot Framework v4 webhook. + + Streaming + --------- + When ``stream=True`` (default), the channel sends an initial placeholder + activity, then edits it in place as the agent emits ``AgentResponseUpdate`` + chunks (``PUT /v3/conversations/{id}/activities/{id}``). When ``stream=False`` + it just sends the final reply. A ``stream_update_hook`` can rewrite or + drop individual updates before they hit the wire. + """ + + name = "activity" + + def __init__( + self, + *, + path: str = "/activity/messages", + app_id: str | None = None, + app_password: str | None = None, + certificate_path: str | None = None, + certificate_password: bytes | None = None, + tenant_id: str = _BOTFRAMEWORK_TENANT, + token_scope: str = _BOTFRAMEWORK_SCOPE, + credential: AsyncTokenCredential | None = None, + commands: Sequence[ChannelCommand] = (), + run_hook: ChannelRunHook | None = None, + response_hook: ChannelResponseHook | None = None, + send_typing_action: bool = True, + stream: bool = True, + stream_update_hook: ChannelStreamUpdateHook | None = None, + stream_edit_min_interval: float = 0.7, + inbound_auth_validator: InboundAuthValidator | None = None, + service_url_allowed_hosts: tuple[str, ...] = _DEFAULT_SERVICE_URL_HOSTS, + ) -> None: + """Configure the Activity Protocol channel. + + Streaming multimodal updates are automatically converted to Activity text via + the stream response text rendering chain (images, files, etc. → URIs, then + included in plain-text stream updates); the channel stream-update hook can + further customize. + + Keyword Args: + path: Messages endpoint path on the host. Use ``""`` to expose the + webhook at the app root. + app_id: Bot Framework / Entra application (client) id. Required + whenever any credential is supplied. + app_password: Application secret for OAuth2 client credentials. + Mutually exclusive with ``certificate_path``. + certificate_path: Path to a PEM file containing **both** the + private key and the X.509 certificate. Use this for tenants + that disallow client secrets. See the module docstring for an + ``openssl`` recipe. + certificate_password: Password for the PEM private key, if any. + tenant_id: Entra tenant. Defaults to ``"botframework.com"`` for + public Bot Framework channels; pass your tenant id for + single-tenant bots. + token_scope: OAuth2 scope to request. Defaults to the Bot + Framework resource. + credential: Bring your own ``AsyncTokenCredential`` (e.g. a + ``DefaultAzureCredential`` configured elsewhere). Overrides + ``app_password`` / ``certificate_path``. + commands: Discoverable ``/command`` handlers. An inbound message + whose text (after stripping the bot's own @mention) begins with + ``/`` and matches a command ``name`` (case-insensitive) is + dispatched to that handler instead of the agent, mirroring the + Telegram channel. The matching ``run_hook`` is applied to the + command request first, so command handlers observe the same + resolved ``session.isolation_key`` as ordinary messages. + Unknown ``/foo`` text falls through to the agent. Handlers reply + via ``ChannelCommandContext.reply``; surface them to users with + a Teams manifest ``commandLists`` entry. + run_hook: Optional rewrite of ``ChannelRequest`` before invocation; + the host owns invocation of this hook. Defaults to stripping + reserved request options so the host can manage agent invocation + context safely. + response_hook: Optional rewrite of the + :class:`HostedRunResult` before the originating Activity + reply is serialized; the host owns invocation of this hook. + send_typing_action: Whether to send ``typing`` activities while + the agent runs. + stream: Whether to stream by default. + stream_update_hook: Optional rewrite of each + ``AgentResponseUpdate`` before it hits the wire. + stream_edit_min_interval: Seconds between successive in-place + edits. Teams is more rate-sensitive than Telegram, so default + is higher. + inbound_auth_validator: Optional async callable invoked for each + inbound webhook request **before** the activity is parsed. + Return ``True`` to allow, ``False`` to reject with HTTP 401. + The webhook endpoint accepts unauthenticated requests by + default — Bot Framework normally validates inbound calls via + the JWT in the ``Authorization`` header (see Microsoft's + bot framework auth docs). The prototype intentionally does + NOT ship a built-in JWT validator (key rotation, OpenID + config caching, etc. are out of scope); plug your own + validator here, or terminate auth in front of the channel + (e.g. APIM, Application Gateway). When no credentials AND + no validator are configured the channel logs a loud + warning at startup so the dev-mode bypass cannot + accidentally ship. + service_url_allowed_hosts: Host (or host suffix) allow-list the + channel will POST a bearer token to. Defaults to the public + Bot Framework host suffixes (``botframework.com`` and + ``smba.trafficmanager.net``). An inbound activity claiming a + ``serviceUrl`` outside this set is rejected — without this + gate a malicious caller could redirect outbound replies (and + the attached bearer token) to an attacker-controlled host. + Pass an extended tuple for sovereign clouds or private + deployments; pass ``()`` to disable the check entirely + (only safe with strong inbound auth). + + Keyword Args: + path: Messages endpoint path on the host. Use ``""`` to expose the + webhook at the app root. + app_id: Bot Framework / Entra application (client) id. Required + whenever any credential is supplied. + app_password: Application secret for OAuth2 client credentials. + Mutually exclusive with ``certificate_path``. + certificate_path: Path to a PEM file containing **both** the + private key and the X.509 certificate. Use this for tenants + that disallow client secrets. See the module docstring for an + ``openssl`` recipe. + certificate_password: Password for the PEM private key, if any. + tenant_id: Entra tenant. Defaults to ``"botframework.com"`` for + public Bot Framework channels; pass your tenant id for + single-tenant bots. + token_scope: OAuth2 scope to request. Defaults to the Bot + Framework resource. + credential: Bring your own ``AsyncTokenCredential`` (e.g. a + ``DefaultAzureCredential`` configured elsewhere). Overrides + ``app_password`` / ``certificate_path``. + commands: Discoverable ``/command`` handlers. An inbound message + whose text (after stripping the bot's own @mention) begins with + ``/`` and matches a command ``name`` (case-insensitive) is + dispatched to that handler instead of the agent, mirroring the + Telegram channel. The matching ``run_hook`` is applied to the + command request first, so command handlers observe the same + resolved ``session.isolation_key`` as ordinary messages. + Unknown ``/foo`` text falls through to the agent. Handlers reply + via ``ChannelCommandContext.reply``; surface them to users with + a Teams manifest ``commandLists`` entry. + run_hook: Optional rewrite of ``ChannelRequest`` before invocation; + the host owns invocation of this hook. + response_hook: Optional rewrite of the + :class:`HostedRunResult` before the originating Activity + reply is serialized; the host owns invocation of this hook. + send_typing_action: Whether to send ``typing`` activities while + the agent runs. + stream: Whether to stream by default. + stream_update_hook: Optional rewrite of each + ``AgentResponseUpdate`` before it hits the wire. + stream_edit_min_interval: Seconds between successive in-place + edits. Teams is more rate-sensitive than Telegram, so default + is higher. + inbound_auth_validator: Optional async callable invoked for each + inbound webhook request **before** the activity is parsed. + Return ``True`` to allow, ``False`` to reject with HTTP 401. + The webhook endpoint accepts unauthenticated requests by + default — Bot Framework normally validates inbound calls via + the JWT in the ``Authorization`` header (see Microsoft's + bot framework auth docs). The prototype intentionally does + NOT ship a built-in JWT validator (key rotation, OpenID + config caching, etc. are out of scope); plug your own + validator here, or terminate auth in front of the channel + (e.g. APIM, Application Gateway). When no credentials AND + no validator are configured the channel logs a loud + warning at startup so the dev-mode bypass cannot + accidentally ship. + service_url_allowed_hosts: Host (or host suffix) allow-list the + channel will POST a bearer token to. Defaults to the public + Bot Framework host suffixes (``botframework.com`` and + ``smba.trafficmanager.net``). An inbound activity claiming a + ``serviceUrl`` outside this set is rejected — without this + gate a malicious caller could redirect outbound replies (and + the attached bearer token) to an attacker-controlled host. + Pass an extended tuple for sovereign clouds or private + deployments; pass ``()`` to disable the check entirely + (only safe with strong inbound auth). + """ + if app_password and certificate_path: + raise ValueError("ActivityProtocolChannel: pass either app_password or certificate_path, not both.") + self.path = path + self._app_id = app_id + self._token_scope = token_scope + self._tenant_id = tenant_id + self._commands = list(commands) + self._hook = run_hook + self.response_hook = response_hook + self._send_typing_action = send_typing_action + self._stream_default = stream + self._stream_update_hook = stream_update_hook + self._stream_edit_min_interval = stream_edit_min_interval + self._inbound_auth_validator = inbound_auth_validator + self._service_url_allowed_hosts = tuple(h.lower().lstrip(".") for h in service_url_allowed_hosts) + self._ctx: ChannelContext | None = None + self._http: httpx.AsyncClient | None = None + + # Build the credential up front so misconfiguration fails at construction. + self._credential: AsyncTokenCredential | None + if credential is not None: + self._credential = credential + elif app_id and certificate_path: + self._credential = CertificateCredential( + tenant_id=tenant_id, + client_id=app_id, + certificate_path=certificate_path, + password=certificate_password, + ) + elif app_id and app_password: + self._credential = ClientSecretCredential( + tenant_id=tenant_id, + client_id=app_id, + client_secret=app_password, + ) + else: + self._credential = None # dev mode + + def contribute(self, context: ChannelContext) -> ChannelContribution: + """Capture the host context and register the messages webhook.""" + self._ctx = context + return ChannelContribution( + routes=[Route("/", self._handle, methods=["POST"])], + commands=self._commands, + on_startup=[self._on_startup], + on_shutdown=[self._on_shutdown], + ) + + # -- lifecycle --------------------------------------------------------- # + + async def _on_startup(self) -> None: + """Open the outbound HTTP client and emit a startup banner. + + When no Bot Framework credential is configured we log a loud warning — + outbound replies will not authenticate, which is only acceptable + against the local Bot Framework Emulator. + + When no inbound auth validator is configured we also log a loud + warning so the dev-mode bypass cannot accidentally ship to + production: Bot Framework normally validates inbound requests via + a JWT in ``Authorization``; without that gate any caller that can + reach the webhook can drive the bot. + """ + if self._http is None: + self._http = httpx.AsyncClient(timeout=30.0) + if self._credential is None: + logger.warning( + "ActivityProtocolChannel running without credentials — outbound replies " + "will not authenticate. Use only with the Bot Framework " + "Emulator for local development." + ) + else: + cred_kind = type(self._credential).__name__ + logger.info( + "ActivityProtocolChannel listening on %s (auth=%s, tenant=%s)", + self.path, + cred_kind, + self._tenant_id, + ) + if self._inbound_auth_validator is None: + logger.warning( + "ActivityProtocolChannel %s has no inbound_auth_validator — " + "the webhook will accept ANY caller. Plug an inbound_auth_validator " + "or terminate auth in front of the channel before exposing this " + "endpoint to a public network.", + self.path, + ) + + async def _on_shutdown(self) -> None: + """Close the HTTP client and best-effort close the credential. + + Credential ``close`` failures are logged but never raised — shutdown + must never be allowed to mask the original cause of an app exit. + """ + if self._http is not None: + await self._http.aclose() + if self._credential is not None: + close = getattr(self._credential, "close", None) + if close is not None: + try: + await close() + except Exception: # pragma: no cover - best-effort + logger.exception("ActivityProtocolChannel credential close failed") + + # -- token management -------------------------------------------------- # + + async def _get_token(self) -> str | None: + """Acquire (and cache) an outbound bearer token. + + ``azure.identity`` credentials cache and refresh internally, so we + just delegate. + """ + if self._credential is None: + return None + access_token = await self._credential.get_token(self._token_scope) + return access_token.token + + def _auth_headers(self, token: str | None) -> dict[str, str]: + """Return Bot Framework auth headers, or an empty dict in dev mode.""" + return {"Authorization": f"Bearer {token}"} if token else {} + + # -- request handling -------------------------------------------------- # + + def _is_service_url_allowed(self, service_url: str | None) -> bool: + """Return ``True`` if ``service_url`` host matches the allow-list.""" + if not self._service_url_allowed_hosts: + return True + if not service_url: + return False + try: + host = (urlparse(service_url).hostname or "").lower() + except Exception: + return False + if not host: + return False + return any(host == allowed or host.endswith(f".{allowed}") for allowed in self._service_url_allowed_hosts) + + async def _handle(self, request: Request) -> Response: + """Bot Framework webhook entry point. + + Only ``message`` activities are processed; ``conversationUpdate``, + ``invoke``, ``typing`` and other activity types are silently + acknowledged. Auth-rejected requests return 401, malformed JSON + returns 400, and serviceUrl outside the allow-list returns 400. + + For *transient* outbound failures (network error / non-2xx from + Bot Service / token acquisition failure) we surface 502 so Bot + Service retries the inbound activity. Non-transient failures + (parsing errors, validation errors, deterministic agent crashes) + return 200 so Bot Service does not retry the same broken + activity in a loop. + """ + if self._inbound_auth_validator is not None: + try: + allowed = await self._inbound_auth_validator(request) + except Exception: + logger.exception("ActivityProtocolChannel inbound_auth_validator raised; rejecting request") + return JSONResponse({"error": "unauthorized"}, status_code=401) + if not allowed: + return JSONResponse({"error": "unauthorized"}, status_code=401) + + try: + activity = await request.json() + except Exception: + return JSONResponse({"error": "invalid json"}, status_code=400) + + # We accept only message activities for now. ``conversationUpdate``, + # ``invoke``, ``typing`` and friends are silently ack'd. + if activity.get("type") != "message": + return JSONResponse({}, status_code=202) + + service_url = activity.get("serviceUrl") + if not self._is_service_url_allowed(service_url if isinstance(service_url, str) else None): + logger.warning( + "ActivityProtocolChannel rejecting activity with serviceUrl=%r (not in allow-list)", + service_url, + ) + return JSONResponse({"error": "serviceUrl not allowed"}, status_code=400) + + try: + await self._process_activity(activity) + except (httpx.HTTPError, _OutboundError): + # Transient outbound failure (network error, non-2xx from Bot + # Service, token acquisition error). Surface 502 so Bot + # Service retries the inbound activity rather than dropping it. + logger.exception("ActivityProtocolChannel outbound transient failure — signalling Bot Service to retry") + return JSONResponse({"error": "upstream failure"}, status_code=502) + except Exception: + # Deterministic / agent-side failure: 200 so Bot Service does + # not retry the same broken activity in a loop. Operator picks + # the failure up via logs / telemetry. + logger.exception("ActivityProtocolChannel activity processing failed") + # Bot Framework expects 200 OK to dequeue the activity. + return JSONResponse({}, status_code=200) + + async def _process_activity(self, activity: Mapping[str, Any]) -> None: + """Build a :class:`ChannelRequest` from a message Activity and dispatch. + + The Teams isolation key is per-conversation so all members of a + group chat share session state. Activity metadata (``reply_to_id``, + ``recipient``) is preserved so reply-as-reaction style flows can + reconstruct the original message context. + """ + if self._ctx is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("activity channel not started") + conversation = activity.get("conversation") or {} + conversation_id = conversation.get("id") + service_url = activity.get("serviceUrl") + if not isinstance(conversation_id, str) or not isinstance(service_url, str): + logger.warning("Teams activity missing conversation.id or serviceUrl — dropping") + return + + # Native command dispatch — a leading ``/command`` (after stripping the + # bot's own @mention) bypasses the agent, mirroring the Telegram channel. + # Unknown commands fall through to the agent as a normal message. + if self._commands: + command_text = _command_text(activity) + if command_text.startswith("/"): + tokens = command_text[1:].split() + if tokens: + command_name = tokens[0].split("@", 1)[0].lower() + handler = next((c for c in self._commands if c.name.lower() == command_name), None) + if handler is not None: + await self._invoke_command(activity, conversation_id, service_url, handler, command_text) + return + + parsed = _parse_activity(activity) + # Store a Bot Framework conversation reference on the identity so + # channel hooks and command handlers can inspect it. Cross-channel + # proactive delivery is a follow-up enhancement outside the v1 host + # contract. + identity = ChannelIdentity( + channel=self.name, + native_id=conversation_id, + attributes={ + "service_url": service_url, + "conversation": dict(conversation), + # Inbound recipient is the bot → outbound ``from``; inbound + # ``from`` is the user → outbound ``recipient``. + "bot": dict(activity.get("recipient") or {}), + "user": dict(activity.get("from") or {}), + "channel_id": activity.get("channelId"), + "locale": activity.get("locale"), + }, + ) + channel_request = ChannelRequest( + channel=self.name, + operation="message.create", + input=[parsed], + session=ChannelSession(isolation_key=activity_protocol_isolation_key(conversation_id)), + identity=identity, + attributes={ + "conversation_id": conversation_id, + "service_url": service_url, + "from_id": (activity.get("from") or {}).get("id"), + "channel_id": activity.get("channelId"), + }, + metadata={"reply_to_id": activity.get("id"), "recipient": activity.get("recipient")}, + stream=self._stream_default, + ) + await self._dispatch(activity, channel_request) + + async def _invoke_command( + self, + activity: Mapping[str, Any], + conversation_id: str, + service_url: str, + handler: ChannelCommand, + command_text: str, + ) -> None: + """Run a matched ``/command`` handler and reply into the conversation. + + The command request mirrors the message-path request (same isolation + key, identity and attributes) and is run through the channel ``run_hook`` + first, so handlers observe the same resolved ``session.isolation_key`` as + ordinary messages. Handler/reply failures are logged but never raised: + commands are best-effort, and surfacing a 502 would make Bot Service + retry the inbound activity and re-run a non-idempotent command. + """ + if self._ctx is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("activity channel not started") + identity = ChannelIdentity( + channel=self.name, + native_id=conversation_id, + attributes={ + "service_url": service_url, + "conversation": dict(activity.get("conversation") or {}), + "bot": dict(activity.get("recipient") or {}), + "user": dict(activity.get("from") or {}), + "channel_id": activity.get("channelId"), + "locale": activity.get("locale"), + }, + ) + request = ChannelRequest( + channel=self.name, + operation="command.invoke", + input=command_text, + session=ChannelSession(isolation_key=activity_protocol_isolation_key(conversation_id)), + identity=identity, + attributes={ + "conversation_id": conversation_id, + "service_url": service_url, + "from_id": (activity.get("from") or {}).get("id"), + "channel_id": activity.get("channelId"), + "aad_object_id": (activity.get("from") or {}).get("aadObjectId"), + }, + metadata={"reply_to_id": activity.get("id"), "recipient": activity.get("recipient")}, + ) + + async def _reply(body: str) -> None: + await self._send_message(activity, body) + + ctx = ChannelCommandContext(request=request, reply=_reply) + try: + await handler.handle(ctx) + except Exception: + logger.exception("ActivityProtocolChannel command %r failed", command_text) + + # -- outbound helpers -------------------------------------------------- # + + async def _dispatch(self, inbound: Mapping[str, Any], request: ChannelRequest) -> None: + """Run the target and ship the result back into the originating Teams conversation. + + Optionally fires a typing indicator before non-streaming runs; + streaming runs route through ``_stream_to_conversation`` which + progressively edits a single placeholder activity. + """ + if self._ctx is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("activity channel not started") + if self._send_typing_action: + await self._send_typing(inbound) + + if not request.stream: + result = await self._ctx.run( + request, + run_hook=self._hook, + protocol_request=inbound, + response_hook=self.response_hook, + channel_name=self.name, + ) + text = getattr(result.result, "text", None) or "(no response)" + await self._send_message(inbound, text) + return + + stream = await self._ctx.run_stream( + request, + run_hook=self._hook, + protocol_request=inbound, + stream_update_hook=self._stream_update_hook, + response_hook=self.response_hook, + channel_name=self.name, + ) + await self._stream_to_conversation(inbound, request, stream) + + async def _stream_to_conversation( + self, + inbound: Mapping[str, Any], + request: ChannelRequest, + stream: ResponseStream[AgentResponseUpdate, AgentResponse], + ) -> None: + """Stream the reply back into the originating conversation. + + Channels that support the ``updateActivity`` REST operation (see + ``_EDIT_CAPABLE_CHANNELS`` — effectively only Teams) get the + progressive-edit experience: a ``…`` placeholder is POSTed, then + repeatedly PUT-edited as text accumulates. Every other channel — + Web Chat, Direct Line, the Emulator, etc. — returns ``405 Method + Not Allowed`` on the PUT, so those buffer the whole stream and POST + a single final message (``_buffer_and_send``); attempting the + edit path there would leave the user staring at a stray ``…``. + """ + if str(inbound.get("channelId") or "").lower() not in _EDIT_CAPABLE_CHANNELS: + await self._buffer_and_send(inbound, request, stream) + return + + accumulated = "" + last_sent = "" + last_edit_at = 0.0 + activity_id: str | None = None + placeholder_ok = False + edit_unsupported = False + worker_done = asyncio.Event() + wake = asyncio.Event() + + async def send_initial_placeholder() -> None: + nonlocal activity_id, last_edit_at, placeholder_ok + try: + activity_id = await self._send_message(inbound, "…") + last_edit_at = time.monotonic() + placeholder_ok = activity_id is not None + except Exception: + logger.exception( + "Activity placeholder send failed — falling back to single final POST", + ) + placeholder_ok = False + + async def edit_worker() -> None: + nonlocal last_sent, last_edit_at, edit_unsupported + # When the placeholder failed we have no activity_id to PUT + # into; the loop's only useful work is exiting cleanly. Skip + # straight to that — the final flush below will POST the + # accumulated text in one shot. + if not placeholder_ok: + return + while not (worker_done.is_set() and accumulated == last_sent): + await wake.wait() + wake.clear() + if accumulated == last_sent: + continue + elapsed = time.monotonic() - last_edit_at + if elapsed < self._stream_edit_min_interval: + try: + await asyncio.wait_for(wake.wait(), timeout=self._stream_edit_min_interval - elapsed) + wake.clear() + except asyncio.TimeoutError: + pass + snapshot = accumulated + if snapshot == last_sent: + continue + try: + await self._update_activity(inbound, activity_id or "", snapshot) + except httpx.HTTPStatusError as exc: + # Some channels advertised as edit-capable may still + # reject the PUT (405). Stop editing and let the final + # flush POST the accumulated text as a new message; + # don't advance ``last_sent`` so that flush still fires. + if exc.response.status_code == 405: + edit_unsupported = True + logger.warning( + "Activity edit not supported by channel %r — sending a single final message instead", + inbound.get("channelId"), + ) + return + logger.exception("Activity interim edit failed") + continue + except Exception: # pragma: no cover + logger.exception("Activity interim edit failed") + continue + last_sent = snapshot + last_edit_at = time.monotonic() + + await send_initial_placeholder() + edit_task = asyncio.create_task(edit_worker(), name="activity-edit-worker") + + try: + async for update in stream: + # Use multimodal stream contents: iterate and extract text from all text-type items. + # Non-text content (images, files, etc.) is ignored here and forwarded via the + # final response; this ensures text accumulation isn't corrupted by multimodal chunks. + for content in update.contents: + if content.type == "text" and content.text: + accumulated += content.text + wake.set() + except Exception: + logger.exception("Activity streaming consumption failed") + finally: + worker_done.set() + wake.set() + try: + await edit_task + except Exception: # pragma: no cover + logger.exception("Activity edit worker crashed") + + try: + final = await stream.get_final_response() + except Exception: # pragma: no cover + logger.exception("Stream finalize failed") + final = None + final_text = getattr(final, "text", None) or accumulated + + # Final flush — make sure the user sees everything that arrived after + # the worker's last edit. If the placeholder failed, or the channel + # turned out not to support edits (405), POST a fresh activity here + # with whatever accumulated rather than PUT-editing the placeholder. + if not placeholder_ok or edit_unsupported: + text = final_text or "(no response)" + try: + await self._send_message(inbound, text) + except Exception: # pragma: no cover + logger.exception("Activity fallback final send failed") + elif activity_id is not None and final_text and final_text != last_sent: + try: + await self._update_activity(inbound, activity_id, final_text) + except Exception: # pragma: no cover + logger.exception("Activity final edit failed") + elif not final_text and activity_id is not None: + # No text streamed — replace the placeholder with a stub so the + # user isn't left staring at "…". + try: + await self._update_activity(inbound, activity_id, "(no response)") + except Exception: # pragma: no cover + logger.exception("Activity placeholder replace failed") + + async def _buffer_and_send( + self, + inbound: Mapping[str, Any], + request: ChannelRequest, + stream: ResponseStream[AgentResponseUpdate, AgentResponse], + ) -> None: + """Consume the whole stream and POST a single final message. + + Used for Bot Framework channels that do not support editing an + activity in place (everything except Teams — see + ``_EDIT_CAPABLE_CHANNELS``). Those channels return ``405`` to + ``PUT /v3/conversations/{id}/activities/{id}``, so the progressive + in-place edit cannot be used; we buffer the stream and ``POST`` a + single message at the end. Mirrors the non-streaming path's + response-hook semantics so behaviour is consistent regardless of + whether the target streamed. + """ + accumulated = "" + try: + async for update in stream: + # Use multimodal stream contents: iterate and extract text from all text-type items. + # Non-text content is ignored here (forwarded via final response); text accumulation + # is protected from corruption by multimodal chunks. + for content in update.contents: + if content.type == "text" and content.text: + accumulated += content.text + except Exception: + logger.exception("Activity streaming consumption failed") + + try: + final = await stream.get_final_response() + except Exception: # pragma: no cover + logger.exception("Stream finalize failed") + final = None + text = getattr(final, "text", None) or accumulated or "(no response)" + try: + await self._send_message(inbound, text) + except Exception: # pragma: no cover + logger.exception("Activity buffered final send failed") + + # -- Bot Framework REST helpers --------------------------------------- # + + def _activity_payload(self, inbound: Mapping[str, Any], text: str) -> dict[str, Any]: + """Build the outbound Activity envelope (text-only message).""" + recipient = inbound.get("from") or {} + from_user = inbound.get("recipient") or {} + return { + "type": "message", + "from": from_user, + "recipient": recipient, + "conversation": inbound.get("conversation") or {}, + "replyToId": inbound.get("id"), + "channelId": inbound.get("channelId"), + "serviceUrl": inbound.get("serviceUrl"), + "text": text, + "textFormat": "markdown", + } + + async def _send_message(self, inbound: Mapping[str, Any], text: str) -> str | None: + """POST a new Activity. Returns the assigned activity id.""" + if self._http is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("activity channel not started") + service_url = str(inbound.get("serviceUrl") or "").rstrip("/") + conversation_id = (inbound.get("conversation") or {}).get("id") + if not service_url or not isinstance(conversation_id, str): + return None + url = f"{service_url}/v3/conversations/{conversation_id}/activities" + token = await self._get_token() + response = await self._http.post( + url, json=self._activity_payload(inbound, text), headers=self._auth_headers(token) + ) + response.raise_for_status() + payload = response.json() if response.content else {} + return payload.get("id") if isinstance(payload, dict) else None + + async def _update_activity(self, inbound: Mapping[str, Any], activity_id: str, text: str) -> None: + """PUT-edit an existing Activity (Teams updateActivity).""" + if self._http is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("activity channel not started") + service_url = str(inbound.get("serviceUrl") or "").rstrip("/") + conversation_id = (inbound.get("conversation") or {}).get("id") + if not service_url or not isinstance(conversation_id, str): + return + url = f"{service_url}/v3/conversations/{conversation_id}/activities/{activity_id}" + token = await self._get_token() + response = await self._http.put( + url, json=self._activity_payload(inbound, text), headers=self._auth_headers(token) + ) + response.raise_for_status() + + async def _send_typing(self, inbound: Mapping[str, Any]) -> None: + """Send a Teams typing indicator; failures are logged and swallowed. + + The typing activity is purely a UX nicety — if it fails (token + expired, transient network issue, channel that doesn't support + typing) we never surface that to the user or block the actual + agent run. + """ + if self._http is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("activity channel not started") + service_url = str(inbound.get("serviceUrl") or "").rstrip("/") + conversation_id = (inbound.get("conversation") or {}).get("id") + if not service_url or not isinstance(conversation_id, str): + return + url = f"{service_url}/v3/conversations/{conversation_id}/activities" + token = await self._get_token() + try: + await self._http.post( + url, + json={ + "type": "typing", + "from": inbound.get("recipient") or {}, + "recipient": inbound.get("from") or {}, + "conversation": inbound.get("conversation") or {}, + "serviceUrl": inbound.get("serviceUrl"), + }, + headers=self._auth_headers(token), + ) + except Exception: # pragma: no cover - non-critical UX + logger.exception("Teams typing send failed") + + +__all__ = ["ActivityProtocolChannel", "activity_protocol_isolation_key"] diff --git a/python/packages/hosting-activity-protocol/pyproject.toml b/python/packages/hosting-activity-protocol/pyproject.toml new file mode 100644 index 00000000000..cd18431a073 --- /dev/null +++ b/python/packages/hosting-activity-protocol/pyproject.toml @@ -0,0 +1,107 @@ +[project] +name = "agent-framework-hosting-activity-protocol" +description = "Bot Framework Activity Protocol channel for agent-framework-hosting (Teams, Slack, etc. via Azure Bot Service)." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0a260424" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.2.0,<2", + "agent-framework-hosting==1.0.0a260424", + "httpx>=0.27,<1", + "azure-identity>=1.20,<2", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_hosting_activity_protocol"] +exclude = ['tests'] +# Bot Framework activities arrive as loosely-typed JSON-ish maps. Strict +# ``Unknown`` reporting on every ``.get(...)`` adds noise without catching +# real bugs — narrowing happens via runtime isinstance checks instead. +reportUnknownArgumentType = "none" +reportUnknownMemberType = "none" +reportUnknownVariableType = "none" +reportUnknownLambdaType = "none" +reportOptionalMemberAccess = "none" + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_hosting_activity_protocol"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_activity_protocol" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_activity_protocol --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/hosting-activity-protocol/tests/__init__.py b/python/packages/hosting-activity-protocol/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/hosting-activity-protocol/tests/test_channel.py b/python/packages/hosting-activity-protocol/tests/test_channel.py new file mode 100644 index 00000000000..6108f34de04 --- /dev/null +++ b/python/packages/hosting-activity-protocol/tests/test_channel.py @@ -0,0 +1,792 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for :mod:`agent_framework_hosting_activity_protocol`. + +The Bot Framework outbound calls and azure-identity credentials are mocked +out so the suite never touches the network. Live token acquisition, +streaming edits and certificate paths are out of scope here. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from agent_framework import Content +from agent_framework_hosting import ( + AgentFrameworkHost, + ChannelCommand, + ChannelCommandContext, + ChannelRequest, + HostedRunResult, +) +from starlette.testclient import TestClient + +from agent_framework_hosting_activity_protocol import ActivityProtocolChannel, activity_protocol_isolation_key +from agent_framework_hosting_activity_protocol._channel import _command_text, _parse_activity + + +def test_activity_protocol_isolation_key_format() -> None: + assert activity_protocol_isolation_key("19:meeting_xyz@thread.v2") == "activity:19:meeting_xyz@thread.v2" + assert activity_protocol_isolation_key(123) == "activity:123" + + +class TestParseActivity: + def test_text_only(self) -> None: + msg = _parse_activity({"type": "message", "text": "hello"}) + assert msg.role == "user" + assert msg.text == "hello" + + def test_with_attachment(self) -> None: + msg = _parse_activity({ + "type": "message", + "text": "see this", + "attachments": [ + {"contentType": "image/png", "contentUrl": "https://example.com/x.png"}, + ], + }) + assert msg.text == "see this" + assert any((getattr(c, "uri", None) or "").endswith("/x.png") for c in msg.contents) + + def test_skips_invalid_attachments(self) -> None: + msg = _parse_activity({ + "type": "message", + "text": "hi", + "attachments": [ + "not-a-mapping", + {"contentType": "image/png"}, # no url + {"contentUrl": "https://example.com/y", "contentType": "no-slash"}, + ], + }) + assert msg.text == "hi" + # No URI content survived. + assert not any(getattr(c, "uri", None) for c in msg.contents) + + def test_skips_teams_text_html_inline_content(self) -> None: + # Teams attaches a text/html rendering whose inline ``content`` is raw + # HTML (not a URL). It must not be parsed as a URI. + msg = _parse_activity({ + "type": "message", + "text": "hello there", + "attachments": [ + {"contentType": "text/html", "content": "

hello there

"}, + ], + }) + assert msg.text == "hello there" + assert not any(getattr(c, "uri", None) for c in msg.contents) + + def test_skips_attachment_contenturl_without_scheme(self) -> None: + msg = _parse_activity({ + "type": "message", + "text": "hi", + "attachments": [ + {"contentType": "image/png", "contentUrl": "/relative/path.png"}, + ], + }) + assert msg.text == "hi" + assert not any(getattr(c, "uri", None) for c in msg.contents) + + +class TestCommandText: + def test_plain_text_unchanged(self) -> None: + assert _command_text({"text": "/help"}) == "/help" + + def test_non_string_text_returns_empty(self) -> None: + assert _command_text({"text": None}) == "" + assert _command_text({}) == "" + + def test_strips_bot_mention(self) -> None: + activity = { + "text": "Personal Assistant /todos", + "recipient": {"id": "bot-1"}, + "entities": [ + {"type": "mention", "text": "Personal Assistant", "mentioned": {"id": "bot-1"}}, + ], + } + assert _command_text(activity) == "/todos" + + def test_strips_bot_mention_without_space(self) -> None: + activity = { + "text": "Bot/help", + "recipient": {"id": "bot-1"}, + "entities": [{"type": "mention", "text": "Bot", "mentioned": {"id": "bot-1"}}], + } + assert _command_text(activity) == "/help" + + def test_keeps_other_user_mention(self) -> None: + activity = { + "text": "/whoami Someone", + "recipient": {"id": "bot-1"}, + "entities": [{"type": "mention", "text": "Someone", "mentioned": {"id": "user-9"}}], + } + # Another user's mention must not be stripped. + assert _command_text(activity) == "/whoami Someone" + + def test_malformed_entities_are_ignored(self) -> None: + activity = { + "text": "/help", + "recipient": {"id": "bot-1"}, + "entities": ["not-a-mapping", {"type": "clientInfo"}, {"type": "mention"}], + } + assert _command_text(activity) == "/help" + + +@dataclass +class _FakeAgentResponse: + text: str + + +@dataclass +class _FakeUpdate: + text: str + + +class _FakeStream: + def __init__(self, chunks: list[str]) -> None: + self._chunks = chunks + + def __aiter__(self) -> Any: + async def gen() -> Any: + for chunk in self._chunks: + yield _FakeUpdate(chunk) + + return gen() + + async def get_final_response(self) -> _FakeAgentResponse: + return _FakeAgentResponse(text="".join(self._chunks)) + + +class _FakeAgent: + def __init__(self, reply: str = "ok") -> None: + self._reply = reply + self.runs: list[Any] = [] + + def create_session(self, *, session_id: str | None = None) -> Any: + return {"session_id": session_id} + + def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any: + self.runs.append({"messages": messages, "stream": stream, "kwargs": kwargs}) + if stream: + return _FakeStream([self._reply]) + + async def _coro() -> _FakeAgentResponse: + return _FakeAgentResponse(text=self._reply) + + return _coro() + + +def _make_teams( + stream: bool = False, *, path: str = "/activity/messages" +) -> tuple[ActivityProtocolChannel, _FakeAgent]: + agent = _FakeAgent("hi there") + ch = ActivityProtocolChannel(path=path, stream=stream, send_typing_action=False) + fake_http = MagicMock() + response_mock = MagicMock() + response_mock.raise_for_status = MagicMock() + response_mock.json = MagicMock(return_value={"id": "act-1"}) + fake_http.post = AsyncMock(return_value=response_mock) + fake_http.put = AsyncMock(return_value=response_mock) + fake_http.aclose = AsyncMock() + ch._http = fake_http + return ch, agent + + +_VALID_ACTIVITY: dict[str, Any] = { + "type": "message", + "id": "in-1", + "text": "hello bot", + "conversation": {"id": "19:meeting_xyz@thread.v2"}, + "from": {"id": "user-1"}, + "recipient": {"id": "bot-1"}, + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/", +} + +# Minimal request envelope for direct ``_stream_to_conversation`` calls. +_VALID_REQUEST = ChannelRequest(channel="activity", operation="message.create", input=[]) + + +class TestTeamsWebhook: + def test_message_activity_dispatches_to_agent(self) -> None: + ch, agent = _make_teams() + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app) as client: + r = client.post("/activity/messages", json=_VALID_ACTIVITY) + assert r.status_code == 200 + assert agent.runs, "expected the agent to be invoked" + # And the channel posted a reply back to the conversation URL. + assert ch._http is not None + ch._http.post.assert_called() # type: ignore[attr-defined] + url, _ = ch._http.post.call_args[0], ch._http.post.call_args[1] # type: ignore[attr-defined] # noqa: F841 + assert "/v3/conversations/" in ch._http.post.call_args[0][0] # type: ignore[attr-defined] + body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined] + assert body["text"] == "hi there" + + def test_empty_path_mounts_at_app_root(self) -> None: + ch, agent = _make_teams(path="") + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app) as client: + r = client.post("/", json=_VALID_ACTIVITY) + assert r.status_code == 200 + assert agent.runs, "expected the agent to be invoked" + + def test_response_hook_can_rewrite_originating_reply(self) -> None: + seen_kwargs: list[dict[str, Any]] = [] + + def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult: + seen_kwargs.append(dict(kwargs)) + return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session) + + ch, agent = _make_teams() + ch.response_hook = hook + host = AgentFrameworkHost(target=agent, channels=[ch]) + + with TestClient(host.app) as client: + r = client.post("/activity/messages", json=_VALID_ACTIVITY) + + assert r.status_code == 200 + assert ch._http is not None + body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined] + assert body["text"] == "HI THERE" + assert seen_kwargs + assert seen_kwargs[0]["channel_name"] == "activity" + + def test_non_message_activities_are_acked(self) -> None: + ch, agent = _make_teams() + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app) as client: + r = client.post( + "/activity/messages", + json={"type": "conversationUpdate", "conversation": {"id": "x"}}, + ) + assert r.status_code == 202 + assert not agent.runs + + def test_invalid_json_returns_400(self) -> None: + ch, agent = _make_teams() + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app) as client: + r = client.post( + "/activity/messages", + content=b"not-json", + headers={"content-type": "application/json"}, + ) + assert r.status_code == 400 + assert not agent.runs + + def test_message_missing_serviceurl_is_dropped(self) -> None: + ch, agent = _make_teams() + host = AgentFrameworkHost(target=agent, channels=[ch]) + bad = dict(_VALID_ACTIVITY) + bad.pop("serviceUrl") + with TestClient(host.app) as client: + r = client.post("/activity/messages", json=bad) + # No serviceUrl → fails the allow-list check (None doesn't match + # any allowed host suffix), surfaced as 400 so a misconfigured + # caller knows the activity was structurally invalid. + assert r.status_code == 400 + assert not agent.runs + + +class TestCommands: + def _make_with_commands(self, commands: list[ChannelCommand]) -> tuple[ActivityProtocolChannel, _FakeAgent]: + agent = _FakeAgent("hi there") + ch = ActivityProtocolChannel(send_typing_action=False, commands=commands) + fake_http = MagicMock() + response_mock = MagicMock() + response_mock.raise_for_status = MagicMock() + response_mock.json = MagicMock(return_value={"id": "act-1"}) + fake_http.post = AsyncMock(return_value=response_mock) + fake_http.put = AsyncMock(return_value=response_mock) + fake_http.aclose = AsyncMock() + ch._http = fake_http + return ch, agent + + def test_slash_command_bypasses_agent_and_replies(self) -> None: + seen: list[ChannelCommandContext] = [] + + async def handle(ctx: ChannelCommandContext) -> None: + seen.append(ctx) + await ctx.reply("listed") + + ch, agent = self._make_with_commands([ChannelCommand("todos", "List", handle)]) + host = AgentFrameworkHost(target=agent, channels=[ch]) + activity = dict(_VALID_ACTIVITY, text="/todos") + with TestClient(host.app) as client: + r = client.post("/activity/messages", json=activity) + assert r.status_code == 200 + assert not agent.runs, "command must bypass the agent" + assert seen and seen[0].request.operation == "command.invoke" + assert seen[0].request.input == "/todos" + assert seen[0].request.session is not None + assert seen[0].request.session.isolation_key == activity_protocol_isolation_key("19:meeting_xyz@thread.v2") + assert ch._http is not None + assert ch._http.post.call_args[1]["json"]["text"] == "listed" # type: ignore[attr-defined] + + def test_command_match_is_case_insensitive(self) -> None: + ran = False + + async def handle(ctx: ChannelCommandContext) -> None: + nonlocal ran + ran = True + + ch, agent = self._make_with_commands([ChannelCommand("New", "reset", handle)]) + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app) as client: + r = client.post("/activity/messages", json=dict(_VALID_ACTIVITY, text="/new")) + assert r.status_code == 200 + assert ran + assert not agent.runs + + def test_unknown_command_falls_through_to_agent(self) -> None: + async def handle(ctx: ChannelCommandContext) -> None: # pragma: no cover - never called + raise AssertionError("should not run") + + ch, agent = self._make_with_commands([ChannelCommand("todos", "List", handle)]) + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app) as client: + r = client.post("/activity/messages", json=dict(_VALID_ACTIVITY, text="/unknown")) + assert r.status_code == 200 + assert agent.runs, "unknown /command must reach the agent" + + def test_command_failure_does_not_retry(self) -> None: + async def handle(ctx: ChannelCommandContext) -> None: + raise RuntimeError("boom") + + ch, agent = self._make_with_commands([ChannelCommand("todos", "List", handle)]) + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app) as client: + r = client.post("/activity/messages", json=dict(_VALID_ACTIVITY, text="/todos")) + # Best-effort: a failing command is swallowed and acked with 200 so Bot + # Service does not retry (and re-run a non-idempotent command). + assert r.status_code == 200 + assert not agent.runs + + def test_command_request_uses_activity_session(self) -> None: + captured: list[str] = [] + + async def handle(ctx: ChannelCommandContext) -> None: + assert ctx.request.session is not None + captured.append(ctx.request.session.isolation_key) + + agent = _FakeAgent("hi") + ch = ActivityProtocolChannel(send_typing_action=False, commands=[ChannelCommand("todos", "x", handle)]) + fake_http = MagicMock() + response_mock = MagicMock() + response_mock.raise_for_status = MagicMock() + response_mock.json = MagicMock(return_value={"id": "act-1"}) + fake_http.post = AsyncMock(return_value=response_mock) + fake_http.aclose = AsyncMock() + ch._http = fake_http + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app) as client: + r = client.post("/activity/messages", json=dict(_VALID_ACTIVITY, text="/todos")) + assert r.status_code == 200 + assert captured == [activity_protocol_isolation_key("19:meeting_xyz@thread.v2")] + + +class TestOutbound: + async def test_send_message_posts_to_conversation_url(self) -> None: + ch, _agent = _make_teams() + await ch._send_message(_VALID_ACTIVITY, "hi") + assert ch._http is not None + ch._http.post.assert_called() # type: ignore[attr-defined] + url = ch._http.post.call_args[0][0] # type: ignore[attr-defined] + assert "/v3/conversations/" in url + body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined] + assert body["text"] == "hi" + + +class TestIdentityRecording: + """``_process_activity`` must stamp the inbound conversation reference + onto ``ChannelRequest.identity`` so hooks and commands can inspect it.""" + + async def test_inbound_sets_request_identity(self) -> None: + ch, agent = _make_teams() + captured: dict[str, Any] = {} + + async def hook(req: ChannelRequest, **_: Any) -> ChannelRequest: + captured["request"] = req + return req + + ch._hook = hook # type: ignore[assignment] + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app) as client: + r = client.post("/activity/messages", json=_VALID_ACTIVITY) + assert r.status_code == 200 + request = captured["request"] + assert request.identity is not None + assert request.identity.channel == "activity" + assert request.identity.native_id == "19:meeting_xyz@thread.v2" + attrs = request.identity.attributes + assert attrs["service_url"] == "https://smba.trafficmanager.net/amer/" + assert attrs["bot"] == {"id": "bot-1"} + assert attrs["user"] == {"id": "user-1"} + + +class TestConfig: + def test_rejects_both_secret_and_certificate(self) -> None: + with pytest.raises(ValueError, match="not both"): + ActivityProtocolChannel( + app_id="x", + app_password="s", + certificate_path="/tmp/does-not-exist.pem", + ) + + def test_dev_mode_no_credential(self) -> None: + ch = ActivityProtocolChannel() + assert ch._credential is None + + +class TestServiceUrlAllowList: + """``serviceUrl`` is supplied by the inbound activity and the channel + POSTs a real bearer token to it — anything outside the Bot Framework + host suffixes must be rejected so a malicious caller can't redirect + outbound replies to an attacker-controlled host.""" + + def test_default_allows_smba_trafficmanager(self) -> None: + ch = ActivityProtocolChannel() + assert ch._is_service_url_allowed("https://smba.trafficmanager.net/amer/") + assert ch._is_service_url_allowed("https://emea.smba.trafficmanager.net/") + assert ch._is_service_url_allowed("https://api.botframework.com/") + + def test_default_rejects_arbitrary_host(self) -> None: + ch = ActivityProtocolChannel() + assert not ch._is_service_url_allowed("https://attacker.example.com/") + assert not ch._is_service_url_allowed("https://botframework.com.attacker.com/") + assert not ch._is_service_url_allowed("") + assert not ch._is_service_url_allowed(None) + + def test_custom_allowlist(self) -> None: + ch = ActivityProtocolChannel(service_url_allowed_hosts=("internal.contoso.com",)) + assert ch._is_service_url_allowed("https://internal.contoso.com/v3/") + assert ch._is_service_url_allowed("https://eu.internal.contoso.com/") + assert not ch._is_service_url_allowed("https://smba.trafficmanager.net/") + + def test_empty_allowlist_disables_check(self) -> None: + ch = ActivityProtocolChannel(service_url_allowed_hosts=()) + assert ch._is_service_url_allowed("https://anywhere.example.org/") + + def test_webhook_rejects_disallowed_serviceurl(self) -> None: + ch, agent = _make_teams() + host = AgentFrameworkHost(target=agent, channels=[ch]) + bad = dict(_VALID_ACTIVITY) + bad["serviceUrl"] = "https://attacker.example.com/v3/" + with TestClient(host.app) as client: + r = client.post("/activity/messages", json=bad) + assert r.status_code == 400 + assert not agent.runs + # No outbound POST attempted with a bearer token. + assert ch._http is not None + ch._http.post.assert_not_called() # type: ignore[attr-defined] + + +class TestInboundAuthValidator: + def test_allow_passes_through(self) -> None: + async def allow(_req: Any) -> bool: + return True + + ch, agent = _make_teams() + ch._inbound_auth_validator = allow + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app) as client: + r = client.post("/activity/messages", json=_VALID_ACTIVITY) + assert r.status_code == 200 + assert agent.runs + + def test_reject_returns_401(self) -> None: + async def deny(_req: Any) -> bool: + return False + + ch, agent = _make_teams() + ch._inbound_auth_validator = deny + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app) as client: + r = client.post("/activity/messages", json=_VALID_ACTIVITY) + assert r.status_code == 401 + assert not agent.runs + + def test_validator_raises_returns_401(self) -> None: + async def boom(_req: Any) -> bool: + raise RuntimeError("validator broke") + + ch, agent = _make_teams() + ch._inbound_auth_validator = boom + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app) as client: + r = client.post("/activity/messages", json=_VALID_ACTIVITY) + assert r.status_code == 401 + assert not agent.runs + + +class TestOutboundAuthHeader: + async def test_no_credential_sends_no_authorization_header(self) -> None: + ch, _agent = _make_teams() + # Default _make_teams has no credential — dev mode. + await ch._send_message(_VALID_ACTIVITY, "hi") + assert ch._http is not None + headers = ch._http.post.call_args[1]["headers"] # type: ignore[attr-defined] + assert "Authorization" not in headers + + async def test_with_credential_sends_bearer_token(self) -> None: + ch, _agent = _make_teams() + # Inject a fake credential with a fixed token. + token_obj = MagicMock() + token_obj.token = "tok-abc123" + cred = MagicMock() + cred.get_token = AsyncMock(return_value=token_obj) + ch._credential = cred # type: ignore[assignment] + await ch._send_message(_VALID_ACTIVITY, "hi") + assert ch._http is not None + headers = ch._http.post.call_args[1]["headers"] # type: ignore[attr-defined] + assert headers.get("Authorization") == "Bearer tok-abc123" + + +class TestRetrySignal: + """Distinguish transient outbound failures (network / 5xx) — which + must surface 502 so Bot Service retries — from deterministic agent + failures (which must return 200 to avoid retry loops).""" + + def test_outbound_http_error_returns_502(self) -> None: + import httpx as _httpx + + ch, agent = _make_teams() + # Make _send_message raise a transient httpx error. + assert ch._http is not None + ch._http.post = AsyncMock(side_effect=_httpx.ConnectError("nope")) # type: ignore[attr-defined] + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app) as client: + r = client.post("/activity/messages", json=_VALID_ACTIVITY) + assert r.status_code == 502 + + def test_deterministic_agent_failure_returns_200(self) -> None: + ch, agent = _make_teams() + + def boom(messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any: + async def _coro() -> Any: + raise ValueError("agent crashed") + + return _coro() + + agent.run = boom # type: ignore[assignment] + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app) as client: + r = client.post("/activity/messages", json=_VALID_ACTIVITY) + # Deterministic failure → 200 (Bot Service does not retry the same + # broken activity in a loop). + assert r.status_code == 200 + + +class TestStreaming: + async def test_stream_sends_placeholder_and_edits(self) -> None: + ch, _agent = _make_teams(stream=True) + + # Build a fake stream that emits two text chunks then finalizes. + @dataclass + class _Up: + text: str + + @property + def contents(self) -> list[Any]: + return [Content.from_text(self.text)] + + class _Stream: + def __init__(self) -> None: + self._chunks = ["hel", "lo"] + + def __aiter__(self) -> Any: + async def gen() -> Any: + for c in self._chunks: + yield _Up(c) + + return gen() + + async def get_final_response(self) -> Any: + return _FakeAgentResponse(text="hello") + + # Use a tight throttle so the test doesn't sit on `wait_for`. + ch._stream_edit_min_interval = 0.0 + await ch._stream_to_conversation(_VALID_ACTIVITY, _VALID_REQUEST, _Stream()) # type: ignore[arg-type] + assert ch._http is not None + # Placeholder POST + at least one final PUT. + ch._http.post.assert_called() # type: ignore[attr-defined] + ch._http.put.assert_called() # type: ignore[attr-defined] + # Final edit body carries the full accumulated text. + last_put_body = ch._http.put.call_args[1]["json"] # type: ignore[attr-defined] + assert last_put_body["text"] == "hello" + + async def test_stream_placeholder_failure_falls_back_to_single_post(self) -> None: + # The bug: when send_initial_placeholder fails, activity_id stays + # None, the edit_worker can never reach its exit condition + # (`accumulated == last_sent` while no PUT possible) and the + # whole conversation deadlocks. After the fix we fall back to + # buffering the stream and POSTing a single final activity. + ch, _agent = _make_teams(stream=True) + # Make the FIRST POST (placeholder) raise; subsequent POST (final + # fallback) succeeds. + import httpx as _httpx + + ok_response = MagicMock() + ok_response.raise_for_status = MagicMock() + ok_response.json = MagicMock(return_value={"id": "act-final"}) + ok_response.content = b"{}" + post_mock = AsyncMock(side_effect=[_httpx.HTTPError("boom"), ok_response]) + assert ch._http is not None + ch._http.post = post_mock # type: ignore[attr-defined] + + @dataclass + class _Up: + text: str + + @property + def contents(self) -> list[Any]: + return [Content.from_text(self.text)] + + class _Stream: + def __aiter__(self) -> Any: + async def gen() -> Any: + yield _Up("partial-1") + yield _Up("-partial-2") + + return gen() + + async def get_final_response(self) -> Any: + return _FakeAgentResponse(text="partial-1-partial-2") + + ch._stream_edit_min_interval = 0.0 + # Should NOT hang. Use asyncio.wait_for with a small timeout to + # guard the test against future regressions of the deadlock. + import asyncio as _asyncio + + await _asyncio.wait_for( + ch._stream_to_conversation(_VALID_ACTIVITY, _VALID_REQUEST, _Stream()), # type: ignore[arg-type] + timeout=2.0, + ) + # Two POSTs total: placeholder (failed) + fallback final. + assert post_mock.await_count == 2 + # Fallback POST contains the full accumulated text. + fallback_body = post_mock.call_args[1]["json"] + assert fallback_body["text"] == "partial-1-partial-2" + + async def test_stream_with_no_text_replaces_placeholder(self) -> None: + ch, _agent = _make_teams(stream=True) + + class _EmptyStream: + def __aiter__(self) -> Any: + async def gen() -> Any: + if False: + yield None # type: ignore[unreachable] + + return gen() + + async def get_final_response(self) -> Any: + return _FakeAgentResponse(text="") + + ch._stream_edit_min_interval = 0.0 + await ch._stream_to_conversation(_VALID_ACTIVITY, _VALID_REQUEST, _EmptyStream()) # type: ignore[arg-type] + # The placeholder PUT-replaces with "(no response)" so the user + # isn't left staring at "…". + assert ch._http is not None + last_put_body = ch._http.put.call_args[1]["json"] # type: ignore[attr-defined] + assert last_put_body["text"] == "(no response)" + + async def test_non_edit_channel_buffers_and_posts_single_message(self) -> None: + # Web Chat (and every non-Teams channel) does not support + # PUT /activities/{id}; the channel must buffer the stream and POST + # a single final message rather than the placeholder+edit dance. + ch, _agent = _make_teams(stream=True) + webchat_activity = {**_VALID_ACTIVITY, "channelId": "webchat"} + + @dataclass + class _Up: + text: str + + @property + def contents(self) -> list[Any]: + return [Content.from_text(self.text)] + + class _Stream: + def __aiter__(self) -> Any: + async def gen() -> Any: + yield _Up("hel") + yield _Up("lo") + + return gen() + + async def get_final_response(self) -> Any: + return _FakeAgentResponse(text="hello") + + ch._stream_edit_min_interval = 0.0 + await ch._stream_to_conversation(webchat_activity, _VALID_REQUEST, _Stream()) # type: ignore[arg-type] + assert ch._http is not None + # No PUT (no editing); exactly one POST with the full text. + ch._http.put.assert_not_called() # type: ignore[attr-defined] + assert ch._http.post.await_count == 1 # type: ignore[attr-defined] + body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined] + assert body["text"] == "hello" + + async def test_non_edit_channel_empty_stream_posts_no_response(self) -> None: + ch, _agent = _make_teams(stream=True) + webchat_activity = {**_VALID_ACTIVITY, "channelId": "directline"} + + class _EmptyStream: + def __aiter__(self) -> Any: + async def gen() -> Any: + if False: + yield None # type: ignore[unreachable] + + return gen() + + async def get_final_response(self) -> Any: + return _FakeAgentResponse(text="") + + ch._stream_edit_min_interval = 0.0 + await ch._stream_to_conversation(webchat_activity, _VALID_REQUEST, _EmptyStream()) # type: ignore[arg-type] + assert ch._http is not None + ch._http.put.assert_not_called() # type: ignore[attr-defined] + body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined] + assert body["text"] == "(no response)" + + async def test_edit_405_falls_back_to_single_post(self) -> None: + # Defensive: a channel advertised as edit-capable that nonetheless + # rejects the PUT with 405 must stop editing and POST the final + # text as a fresh message instead of silently leaving "…". + import httpx as _httpx + + ch, _agent = _make_teams(stream=True) + assert ch._http is not None + + request_405 = _httpx.Request("PUT", "https://smba.trafficmanager.net/amer/v3/x") + response_405 = _httpx.Response(405, request=request_405) + ch._http.put = AsyncMock( # type: ignore[attr-defined] + side_effect=_httpx.HTTPStatusError("405", request=request_405, response=response_405) + ) + + @dataclass + class _Up: + text: str + + @property + def contents(self) -> list[Any]: + return [Content.from_text(self.text)] + + class _Stream: + def __aiter__(self) -> Any: + async def gen() -> Any: + yield _Up("hel") + yield _Up("lo") + + return gen() + + async def get_final_response(self) -> Any: + return _FakeAgentResponse(text="hello") + + ch._stream_edit_min_interval = 0.0 + await ch._stream_to_conversation(_VALID_ACTIVITY, _VALID_REQUEST, _Stream()) # type: ignore[arg-type] + # Placeholder POST + fallback final POST = 2 POSTs; the final one + # carries the full text. + assert ch._http.post.await_count == 2 # type: ignore[attr-defined] + final_body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined] + assert final_body["text"] == "hello" diff --git a/python/packages/hosting-discord/LICENSE b/python/packages/hosting-discord/LICENSE new file mode 100644 index 00000000000..331750f6252 --- /dev/null +++ b/python/packages/hosting-discord/LICENSE @@ -0,0 +1,22 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE + diff --git a/python/packages/hosting-discord/README.md b/python/packages/hosting-discord/README.md new file mode 100644 index 00000000000..d0e1e82223a --- /dev/null +++ b/python/packages/hosting-discord/README.md @@ -0,0 +1,78 @@ +# agent-framework-hosting-discord + +Discord HTTP Interactions channel for [agent-framework-hosting](../hosting). +The channel exposes a signed Starlette route for Discord slash commands, maps a +configurable slash command to the hosted agent, maps `ChannelCommand` instances +to native Discord commands, and supports push to Discord channel ids. + +## Usage + +```python +from agent_framework_hosting import AgentFrameworkHost +from agent_framework_hosting_discord import DiscordChannel + +host = AgentFrameworkHost( + target=my_agent, + channels=[ + DiscordChannel( + application_id="", + public_key="", + bot_token="", + guild_id="", + ) + ], +) +host.serve() +``` + +Configure the Discord Developer Portal interaction endpoint as: + +```text +https:///discord/interactions +``` + +The channel verifies Discord's `X-Signature-Ed25519` header against the raw +request body before parsing JSON. `skip_signature_verification=True` exists only +for local tests and should not be used on a public endpoint. + +## Slash commands + +By default, `/ask prompt:` invokes the hosted agent. Additional +`ChannelCommand` instances are registered as Discord slash commands with an +optional `input` string option: + +```python +from agent_framework_hosting import ChannelCommand + +async def reset(ctx): + await ctx.reply("Reset acknowledged") + +DiscordChannel( + application_id="...", + public_key="...", + bot_token="...", + commands=[ChannelCommand("reset", "Reset the conversation", reset)], +) +``` + +When `guild_id` is set, commands are registered only for that guild and usually +appear quickly. Global command registration can take much longer to propagate. +If `register_commands=True` but `bot_token` is omitted, the channel logs a +warning and assumes commands were registered outside the host. + +## Identity, sessions, and push + +The default isolation key is `discord:::`, +which keeps each user private inside a Discord channel or thread. Pass +`isolation_key_factory=` to use a different scope. + +`ChannelIdentity.native_id` is the Discord user id. Push requires +`identity.attributes["channel_id"]`; the first slice intentionally does not +create DM channels as a fallback. + +## Streaming + +Set `streaming=True` to consume the host stream and edit the original Discord +interaction response as text accumulates. Edits are debounced with +`edit_interval` to avoid excessive Discord REST calls. + diff --git a/python/packages/hosting-discord/agent_framework_hosting_discord/__init__.py b/python/packages/hosting-discord/agent_framework_hosting_discord/__init__.py new file mode 100644 index 00000000000..ed048b20f3d --- /dev/null +++ b/python/packages/hosting-discord/agent_framework_hosting_discord/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Discord channel for ``agent-framework-hosting``.""" + +import importlib.metadata + +from ._channel import DiscordChannel, DiscordIsolationKeyFactory, discord_isolation_key + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = [ + "DiscordChannel", + "DiscordIsolationKeyFactory", + "__version__", + "discord_isolation_key", +] diff --git a/python/packages/hosting-discord/agent_framework_hosting_discord/_channel.py b/python/packages/hosting-discord/agent_framework_hosting_discord/_channel.py new file mode 100644 index 00000000000..f0297898b01 --- /dev/null +++ b/python/packages/hosting-discord/agent_framework_hosting_discord/_channel.py @@ -0,0 +1,610 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Discord HTTP Interactions channel.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +import time +from collections.abc import Callable, Coroutine, Mapping, Sequence +from typing import Any, cast + +import httpx +from agent_framework import AgentResponse, AgentResponseUpdate, ResponseStream +from agent_framework_hosting import ( + ChannelCommand, + ChannelCommandContext, + ChannelContext, + ChannelContribution, + ChannelIdentity, + ChannelRequest, + ChannelResponseHook, + ChannelRunHook, + ChannelSession, + ChannelStreamUpdateHook, + HostedRunResult, +) +from nacl.exceptions import BadSignatureError +from nacl.signing import VerifyKey +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route + +logger = logging.getLogger("agent_framework.hosting.discord") + +DiscordInteraction = Mapping[str, Any] +DiscordIsolationKeyFactory = Callable[[DiscordInteraction], str] + +_DISCORD_API_BASE = "https://discord.com/api/v10" +_DISCORD_MAX_BODY_BYTES = 1024 * 1024 +_DISCORD_MAX_CONTENT_LEN = 2000 +_INTERACTION_PING = 1 +_INTERACTION_APPLICATION_COMMAND = 2 +_RESPONSE_PONG = 1 +_RESPONSE_DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5 +_OPTION_STRING = 3 +_APPLICATION_COMMAND_CHAT_INPUT = 1 +_COMMAND_NAME_RE = re.compile(r"^[a-z0-9_-]{1,32}$") + + +def discord_isolation_key(guild_id: str | None, channel_id: str, user_id: str) -> str: + """Build the default Discord isolation key. + + Args: + guild_id: Discord guild id, or ``None`` for a DM interaction. + channel_id: Discord channel or thread id. + user_id: Discord user id. + + Returns: + A stable host isolation key scoped to guild/channel/user. + """ + scope = guild_id or "dm" + return f"discord:{scope}:{channel_id}:{user_id}" + + +def _default_isolation_key(interaction: DiscordInteraction) -> str: + user = _user_from_interaction(interaction) + user_id = _require_string(user.get("id"), "interaction user id") + channel_id = _require_string(interaction.get("channel_id"), "interaction channel_id") + guild_id = _string_or_none(interaction.get("guild_id")) + return discord_isolation_key(guild_id, channel_id, user_id) + + +class DiscordChannel: + """Discord channel backed by signed HTTP Interactions.""" + + name = "discord" + + def __init__( + self, + *, + application_id: str, + public_key: str, + bot_token: str | None = None, + guild_id: str | None = None, + path: str = "/discord/interactions", + agent_command: str = "ask", + agent_command_description: str = "Ask the agent", + agent_command_option: str = "prompt", + register_commands: bool = True, + commands: Sequence[ChannelCommand] | None = None, + run_hook: ChannelRunHook | None = None, + response_hook: ChannelResponseHook | None = None, + stream_update_hook: ChannelStreamUpdateHook | None = None, + streaming: bool = False, + isolation_key_factory: DiscordIsolationKeyFactory | None = None, + skip_signature_verification: bool = False, + edit_interval: float = 1.0, + max_body_bytes: int = _DISCORD_MAX_BODY_BYTES, + api_base_url: str = _DISCORD_API_BASE, + ) -> None: + """Configure the Discord channel. + + Keyword Args: + application_id: Discord application id. + public_key: Discord application public key as lowercase or + uppercase hex. Used to verify interaction signatures. + bot_token: Bot token used to register slash commands and push + messages to Discord channel ids. Interaction webhook replies + do not require this token. + guild_id: Optional guild id for guild-scoped slash command + registration. Recommended for development because global + command registration can take a long time to propagate. + path: Interaction endpoint path on the host. Use ``""`` to expose + the interaction route at the app root. + agent_command: Slash command name that invokes the hosted agent. + agent_command_description: Description for the agent slash command. + agent_command_option: String option name that carries the prompt. + register_commands: Whether startup should register slash commands + through Discord REST when ``bot_token`` is configured. + commands: Additional host ``ChannelCommand`` instances to expose + as Discord slash commands. + run_hook: Optional hook that can rewrite the channel request before + it reaches the host. + response_hook: Optional hook that can rewrite the hosted result + before the originating Discord response is serialized. + stream_update_hook: Optional per-update hook applied + while streaming. + streaming: Whether the agent command should call ``run_stream`` + and edit the original interaction response as deltas arrive. + isolation_key_factory: Optional callable that receives the raw + Discord interaction and returns a host isolation key. + skip_signature_verification: Disable Ed25519 verification. Use + only for local tests; never expose publicly with this enabled. + edit_interval: Minimum seconds between streaming edits to the + original Discord interaction response. + max_body_bytes: Maximum raw interaction request body size. + api_base_url: Discord API base URL. Primarily useful for tests. + + Raises: + ValueError: If public key hex or command names are invalid, or if + command names collide. + """ + self.application_id = application_id + self.public_key = public_key + self.bot_token = bot_token + self.guild_id = guild_id + self.path = path + self.agent_command = agent_command + self.agent_command_description = agent_command_description + self.agent_command_option = agent_command_option + self.register_commands = register_commands + self._commands = tuple(commands or ()) + self._command_by_name = {command.name: command for command in self._commands} + self._run_hook = run_hook + self.response_hook = response_hook + self._stream_update_hook = stream_update_hook + self._streaming = streaming + self._isolation_key_factory = isolation_key_factory or _default_isolation_key + self._skip_signature_verification = skip_signature_verification + self._edit_interval = edit_interval + self._max_body_bytes = max_body_bytes + self._api_base_url = api_base_url.rstrip("/") + self._ctx: ChannelContext | None = None + self._http: httpx.AsyncClient | None = None + self._tasks: set[asyncio.Task[None]] = set() + + self._validate_configuration() + try: + self._verify_key = VerifyKey(bytes.fromhex(public_key)) + except ValueError as exc: + raise ValueError("DiscordChannel public_key must be a valid Ed25519 public key hex string") from exc + + def contribute(self, context: ChannelContext) -> ChannelContribution: + """Register the Discord interaction route and lifecycle hooks.""" + self._ctx = context + return ChannelContribution( + routes=[Route("/", self._handle, methods=["POST"])], + commands=self._commands, + on_startup=[self._on_startup], + on_shutdown=[self._on_shutdown], + ) + + async def _on_startup(self) -> None: + """Open the Discord REST client and optionally register slash commands.""" + self._ensure_http() + if self._skip_signature_verification: + logger.warning( + "DiscordChannel running with skip_signature_verification=True. " + "Use only for local tests; public Discord endpoints must verify signatures." + ) + if not self.register_commands: + return + if self.bot_token is None: + logger.warning( + "DiscordChannel register_commands=True but bot_token is not configured; " + "slash commands must be registered outside the host." + ) + return + if self.guild_id is None: + logger.warning( + "DiscordChannel registering global slash commands; Discord can take a long time " + "to propagate global command changes. Set guild_id for faster development updates." + ) + try: + await self._register_commands() + except (RuntimeError, httpx.HTTPError): + logger.exception("DiscordChannel slash command registration failed; continuing startup") + + async def _on_shutdown(self) -> None: + """Drain in-flight interaction tasks and close the Discord REST client.""" + if self._tasks: + await asyncio.gather(*self._tasks, return_exceptions=True) + if self._http is not None: + await self._http.aclose() + self._http = None + + async def _handle(self, request: Request) -> Response: + """Handle one Discord interaction webhook request.""" + raw_body = await request.body() + if len(raw_body) > self._max_body_bytes: + return JSONResponse({"error": "request body too large"}, status_code=413) + if not self._skip_signature_verification and not self._verify_signature(request, raw_body): + return JSONResponse({"error": "invalid signature"}, status_code=401) + try: + body = json.loads(raw_body.decode("utf-8")) + except json.JSONDecodeError: + return JSONResponse({"error": "invalid JSON"}, status_code=400) + if not isinstance(body, Mapping): + return JSONResponse({"error": "interaction body must be a JSON object"}, status_code=400) + interaction = cast("DiscordInteraction", body) + + interaction_type = interaction.get("type") + if interaction_type == _INTERACTION_PING: + return JSONResponse({"type": _RESPONSE_PONG}) + if interaction_type != _INTERACTION_APPLICATION_COMMAND: + return JSONResponse({"error": f"unsupported interaction type: {interaction_type!r}"}, status_code=400) + + self._schedule(self._dispatch_application_command(interaction)) + return JSONResponse({"type": _RESPONSE_DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE}) + + async def _dispatch_application_command(self, interaction: DiscordInteraction) -> None: + token = _require_string(interaction.get("token"), "interaction token") + try: + name = _application_command_name(interaction) + if name == self.agent_command: + await self._run_agent_command(interaction, token) + return + command = self._command_by_name.get(name) + if command is None: + await self._edit_original(token, f"Unknown Discord command: {name}") + return + await self._run_channel_command(command, interaction, token) + except Exception: + logger.exception("DiscordChannel interaction handling failed") + await self._try_edit_original(token, "Sorry, something went wrong while handling that Discord command.") + raise + + async def _run_agent_command(self, interaction: DiscordInteraction, token: str) -> None: + if self._ctx is None: + raise RuntimeError("DiscordChannel was not contributed to a host.") + prompt = _string_option(interaction, self.agent_command_option) + if prompt is None: + await self._edit_original(token, f"Missing required `{self.agent_command_option}` option.") + return + request = self._build_request( + interaction, + operation="message.create", + input_value=prompt, + stream=self._streaming, + ) + if request.stream: + await self._run_streaming(request, token, protocol_request=interaction) + return + result = await self._ctx.run( + request=request, + run_hook=self._run_hook, + protocol_request=interaction, + response_hook=self.response_hook, + channel_name=self.name, + ) + await self._edit_original_with_result(token, result) + + async def _run_channel_command( + self, + command: ChannelCommand, + interaction: DiscordInteraction, + token: str, + ) -> None: + command_input = _string_option(interaction, "input") + request = self._build_request( + interaction, + operation="command.invoke", + input_value=f"/{command.name}" if command_input is None else f"/{command.name} {command_input}", + stream=False, + ) + reply = _DiscordInteractionReply(self, token) + await command.handle(ChannelCommandContext(request=request, reply=reply)) + if not reply.sent: + await self._edit_original(token, "Done.") + + async def _run_streaming( + self, request: ChannelRequest, token: str, *, protocol_request: DiscordInteraction | None = None + ) -> None: + if self._ctx is None: + raise RuntimeError("DiscordChannel was not contributed to a host.") + stream: ResponseStream[AgentResponseUpdate, AgentResponse] = await self._ctx.run_stream( + request, + run_hook=self._run_hook, + protocol_request=protocol_request, + stream_update_hook=self._stream_update_hook, + response_hook=self.response_hook, + channel_name=self.name, + ) + accumulated: list[str] = [] + last_edit = 0.0 + async for update in stream: + chunk = _update_text(update) + if not chunk: + continue + accumulated.append(chunk) + now = time.monotonic() + if self._edit_interval <= 0 or now - last_edit >= self._edit_interval: + await self._edit_original(token, _stream_preview_content("".join(accumulated))) + last_edit = now + + final_response = await stream.get_final_response() + await self._edit_original_with_result(token, HostedRunResult(final_response)) + + def _build_request( + self, + interaction: DiscordInteraction, + *, + operation: str, + input_value: Any, + stream: bool, + ) -> ChannelRequest: + identity = self._identity_from_interaction(interaction) + command_name = _application_command_name(interaction) + metadata = { + "interaction_id": _string_or_none(interaction.get("id")), + "application_id": self.application_id, + "guild_id": _string_or_none(interaction.get("guild_id")), + "channel_id": _string_or_none(interaction.get("channel_id")), + "user_id": identity.native_id, + "command": command_name, + } + clean_metadata = {key: value for key, value in metadata.items() if value is not None} + return ChannelRequest( + channel=self.name, + operation=operation, + input=input_value, + session=ChannelSession(isolation_key=self._isolation_key_factory(interaction)), + metadata=clean_metadata, + attributes=clean_metadata, + stream=stream, + identity=identity, + ) + + def _identity_from_interaction(self, interaction: DiscordInteraction) -> ChannelIdentity: + user = _user_from_interaction(interaction) + user_id = _require_string(user.get("id"), "interaction user id") + attributes = { + "username": _string_or_none(user.get("username")), + "global_name": _string_or_none(user.get("global_name")), + "guild_id": _string_or_none(interaction.get("guild_id")), + "channel_id": _string_or_none(interaction.get("channel_id")), + "application_id": self.application_id, + } + return ChannelIdentity( + channel=self.name, + native_id=user_id, + attributes={key: value for key, value in attributes.items() if value is not None}, + ) + + def _verify_signature(self, request: Request, raw_body: bytes) -> bool: + signature = request.headers.get("x-signature-ed25519") + timestamp = request.headers.get("x-signature-timestamp") + if not signature or not timestamp: + return False + try: + self._verify_key.verify(timestamp.encode("utf-8") + raw_body, bytes.fromhex(signature)) + except (BadSignatureError, ValueError): + return False + return True + + def _schedule(self, coro: Coroutine[Any, Any, None]) -> None: + task = asyncio.create_task(coro) + self._tasks.add(task) + task.add_done_callback(self._on_task_done) + + def _on_task_done(self, task: asyncio.Task[None]) -> None: + self._tasks.discard(task) + try: + task.result() + except asyncio.CancelledError: + return + except Exception: + logger.exception("DiscordChannel background task failed") + + def _ensure_http(self) -> httpx.AsyncClient: + if self._http is None: + self._http = httpx.AsyncClient(base_url=self._api_base_url, timeout=30.0) + return self._http + + async def _register_commands(self) -> None: + http = self._ensure_http() + path = f"/applications/{self.application_id}/commands" + if self.guild_id is not None: + path = f"/applications/{self.application_id}/guilds/{self.guild_id}/commands" + response = await http.put(path, headers=self._bot_headers(), json=self._command_payloads()) + _raise_for_discord_error(response, "register slash commands") + + async def _edit_original_with_result(self, token: str, payload: HostedRunResult[Any]) -> None: + chunks = _split_content(_payload_text(payload)) + await self._edit_original(token, chunks[0]) + for chunk in chunks[1:]: + await self._send_followup(token, chunk) + + async def _edit_original(self, token: str, content: str) -> None: + http = self._ensure_http() + response = await http.patch( + f"/webhooks/{self.application_id}/{token}/messages/@original", + json={"content": _normalize_content(content)}, + ) + _raise_for_discord_error(response, "edit interaction response") + + async def _try_edit_original(self, token: str, content: str) -> None: + try: + await self._edit_original(token, content) + except (RuntimeError, httpx.HTTPError): + logger.exception("DiscordChannel failed to edit interaction error response") + + async def _send_followup(self, token: str, content: str) -> None: + http = self._ensure_http() + response = await http.post( + f"/webhooks/{self.application_id}/{token}", + json={"content": _normalize_content(content)}, + ) + _raise_for_discord_error(response, "send interaction follow-up") + + def _bot_headers(self) -> dict[str, str]: + if self.bot_token is None: + raise RuntimeError("Discord bot token is required for this operation") + return {"Authorization": f"Bot {self.bot_token}"} + + def _command_payloads(self) -> list[dict[str, Any]]: + payloads = [ + { + "type": _APPLICATION_COMMAND_CHAT_INPUT, + "name": self.agent_command, + "description": self.agent_command_description, + "options": [ + { + "type": _OPTION_STRING, + "name": self.agent_command_option, + "description": "Prompt for the agent.", + "required": True, + } + ], + } + ] + for command in self._commands: + payloads.append({ + "type": _APPLICATION_COMMAND_CHAT_INPUT, + "name": command.name, + "description": command.description, + "options": [ + { + "type": _OPTION_STRING, + "name": "input", + "description": "Optional command input.", + "required": False, + } + ], + }) + return payloads + + def _validate_configuration(self) -> None: + names = [self.agent_command, *(command.name for command in self._commands)] + for name in names: + if not _COMMAND_NAME_RE.fullmatch(name): + raise ValueError( + "Discord command names must be lowercase ASCII letters, numbers, hyphen, " + f"or underscore, and 1-32 characters long: {name!r}" + ) + if not _COMMAND_NAME_RE.fullmatch(self.agent_command_option): + raise ValueError( + "Discord agent_command_option must be lowercase ASCII letters, numbers, hyphen, " + f"or underscore, and 1-32 characters long: {self.agent_command_option!r}" + ) + if len(set(names)) != len(names): + raise ValueError("Discord command names must be unique; agent_command cannot collide with commands") + if self._edit_interval < 0: + raise ValueError("edit_interval must be >= 0") + if self._max_body_bytes <= 0: + raise ValueError("max_body_bytes must be > 0") + + +class _DiscordInteractionReply: + """Reply helper that edits the deferred response first, then sends follow-ups.""" + + def __init__(self, channel: DiscordChannel, token: str) -> None: + self._channel = channel + self._token = token + self.sent = False + + async def __call__(self, body: str) -> None: + chunks = _split_content(body) + if not self.sent: + await self._channel._edit_original(self._token, chunks[0]) # pyright: ignore[reportPrivateUsage] + self.sent = True + for chunk in chunks[1:]: + await self._channel._send_followup(self._token, chunk) # pyright: ignore[reportPrivateUsage] + return + for chunk in chunks: + await self._channel._send_followup(self._token, chunk) # pyright: ignore[reportPrivateUsage] + + +def _user_from_interaction(interaction: DiscordInteraction) -> Mapping[str, Any]: + member = interaction.get("member") + if isinstance(member, Mapping): + member_user = member.get("user") + if isinstance(member_user, Mapping): + return member_user + user = interaction.get("user") + if isinstance(user, Mapping): + return user + raise ValueError("Discord interaction is missing user information") + + +def _application_command_name(interaction: DiscordInteraction) -> str: + data = interaction.get("data") + if not isinstance(data, Mapping): + raise ValueError("Discord application command interaction is missing data") + return _require_string(data.get("name"), "application command name") + + +def _string_option(interaction: DiscordInteraction, name: str) -> str | None: + data = interaction.get("data") + if not isinstance(data, Mapping): + return None + options = data.get("options") + if not isinstance(options, Sequence) or isinstance(options, (str, bytes)): + return None + for option in options: + if not isinstance(option, Mapping): + continue + if option.get("name") != name: + continue + value = option.get("value") + if value is None: + return None + return str(value) + return None + + +def _payload_text(payload: HostedRunResult[Any]) -> str: + text = getattr(payload.result, "text", None) + if isinstance(text, str) and text: + return text + messages = getattr(payload.result, "messages", None) + if isinstance(messages, Sequence): + for message in reversed(messages): + message_text = getattr(message, "text", None) + if isinstance(message_text, str) and message_text: + return message_text + return "(no response)" + + +def _update_text(update: AgentResponseUpdate) -> str: + parts: list[str] = [] + for content in update.contents: + text = getattr(content, "text", None) + if isinstance(text, str) and text: + parts.append(text) + return "".join(parts) + + +def _split_content(content: str) -> list[str]: + normalized = _normalize_content(content) + return [normalized[i : i + _DISCORD_MAX_CONTENT_LEN] for i in range(0, len(normalized), _DISCORD_MAX_CONTENT_LEN)] + + +def _stream_preview_content(content: str) -> str: + return _split_content(content)[0] + + +def _normalize_content(content: str) -> str: + return content if content else "(no response)" + + +def _string_or_none(value: Any) -> str | None: + return value if isinstance(value, str) and value else None + + +def _require_string(value: Any, field_name: str) -> str: + if isinstance(value, str) and value: + return value + raise ValueError(f"Discord {field_name} must be a non-empty string") + + +def _raise_for_discord_error(response: httpx.Response, action: str) -> None: + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + body = response.text[:500] + raise RuntimeError(f"Discord {action} failed with HTTP {response.status_code}: {body}") from exc diff --git a/python/packages/hosting-discord/agent_framework_hosting_discord/py.typed b/python/packages/hosting-discord/agent_framework_hosting_discord/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/hosting-discord/pyproject.toml b/python/packages/hosting-discord/pyproject.toml new file mode 100644 index 00000000000..23948b660ff --- /dev/null +++ b/python/packages/hosting-discord/pyproject.toml @@ -0,0 +1,107 @@ +[project] +name = "agent-framework-hosting-discord" +description = "Discord channel for agent-framework-hosting." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0a260526" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.2.0,<2", + "agent-framework-hosting>=1.0.0a260424,<2", + "httpx>=0.27,<1", + "PyNaCl>=1.2.0,<2", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_hosting_discord"] +exclude = ['tests'] +# Discord interactions arrive as loosely-typed JSON maps. Runtime guards narrow +# payloads where needed; strict Unknown reporting on every `.get()` is noisy. +reportUnknownArgumentType = "none" +reportUnknownMemberType = "none" +reportUnknownVariableType = "none" +reportUnknownLambdaType = "none" +reportOptionalMemberAccess = "none" + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_hosting_discord"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_discord" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_discord --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" + diff --git a/python/packages/hosting-discord/tests/discord/test_channel.py b/python/packages/hosting-discord/tests/discord/test_channel.py new file mode 100644 index 00000000000..f402fde90fb --- /dev/null +++ b/python/packages/hosting-discord/tests/discord/test_channel.py @@ -0,0 +1,643 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator, Awaitable +from typing import Any + +import httpx +import pytest +from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message +from agent_framework_hosting import ( + ChannelCommand, + ChannelCommandContext, + ChannelRequest, + HostedRunResult, +) +from nacl.signing import SigningKey +from starlette.applications import Starlette +from starlette.testclient import TestClient + +from agent_framework_hosting_discord import DiscordChannel, discord_isolation_key + + +def _run_result(text: str) -> HostedRunResult[AgentResponse]: + return HostedRunResult(AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(text=text)])])) + + +def _interaction(command: str = "ask", *, prompt: str = "hello", token: str = "token") -> dict[str, Any]: + return { + "id": "interaction-1", + "type": 2, + "application_id": "app-1", + "token": token, + "guild_id": "guild-1", + "channel_id": "channel-1", + "member": { + "user": { + "id": "user-1", + "username": "ada", + "global_name": "Ada", + } + }, + "data": { + "name": command, + "options": [{"name": "prompt", "type": 3, "value": prompt}], + }, + } + + +def _headers(signing_key: SigningKey, body: bytes) -> dict[str, str]: + timestamp = "1234567890" + signature = signing_key.sign(timestamp.encode("utf-8") + body).signature.hex() + return { + "x-signature-ed25519": signature, + "x-signature-timestamp": timestamp, + "content-type": "application/json", + } + + +class _FakeContext: + def __init__(self, *, text: str = "agent reply") -> None: + self.target = object() + self.text = text + self.requests: list[ChannelRequest] = [] + self.fake_stream: _FakeStream | None = None + + async def run( + self, + request: ChannelRequest, + *, + run_hook: Any | None = None, + protocol_request: Any | None = None, + response_hook: Any | None = None, + channel_name: str | None = None, + ) -> HostedRunResult[AgentResponse]: + if run_hook is not None: + maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request) + if isinstance(maybe_request, Awaitable): + request = await maybe_request + else: + request = maybe_request + self.requests.append(request) + result = _run_result(self.text) + if response_hook is not None: + maybe_result = response_hook(result, request=request, channel_name=channel_name or request.channel) + if isinstance(maybe_result, Awaitable): + return await maybe_result + return maybe_result + return result + + async def run_stream( + self, + request: ChannelRequest, + *, + run_hook: Any | None = None, + protocol_request: Any | None = None, + stream_update_hook: Any | None = None, + response_hook: Any | None = None, + channel_name: str | None = None, + ) -> _FakeStream: + if run_hook is not None: + maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request) + if isinstance(maybe_request, Awaitable): + request = await maybe_request + else: + request = maybe_request + self.requests.append(request) + if self.fake_stream is None: + self.fake_stream = _FakeStream(["a", "b"]) + if stream_update_hook is not None: + self.fake_stream.transform = stream_update_hook + if response_hook is not None: + self.fake_stream.response_hook = response_hook + self.fake_stream.request = request + self.fake_stream.channel_name = channel_name or request.channel + return self.fake_stream + + +class _FakeStream: + def __init__(self, chunks: list[str]) -> None: + self._chunks = chunks + self.transform: Any | None = None + self.response_hook: Any | None = None + self.request: ChannelRequest | None = None + self.channel_name: str | None = None + + def __aiter__(self) -> AsyncIterator[AgentResponseUpdate]: + return self._iter() + + async def _iter(self) -> AsyncIterator[AgentResponseUpdate]: + for chunk in self._chunks: + update = AgentResponseUpdate(contents=[Content.from_text(text=chunk)], role="assistant") + if self.transform is not None: + transformed = self.transform(update) + if isinstance(transformed, Awaitable): + transformed = await transformed + if transformed is None: + continue + update = transformed + yield update + + async def get_final_response(self) -> AgentResponse: + result = _run_result("".join(self._chunks)) + if self.response_hook is None: + return result.result + shaped = self.response_hook(result, request=self.request, channel_name=self.channel_name) + if isinstance(shaped, Awaitable): + shaped = await shaped + return shaped.result + + +class _DiscordRecorder: + def __init__(self) -> None: + self.requests: list[httpx.Request] = [] + self.json_payloads: list[Any] = [] + + def transport(self) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + self.requests.append(request) + if request.content: + self.json_payloads.append(json.loads(request.content.decode("utf-8"))) + return httpx.Response(200, json={"ok": True}) + + return httpx.MockTransport(handler) + + +def test_discord_isolation_key_scopes_to_guild_channel_user() -> None: + assert discord_isolation_key("guild", "channel", "user") == "discord:guild:channel:user" + assert discord_isolation_key(None, "dm-channel", "user") == "discord:dm:dm-channel:user" + + +def test_ping_requires_valid_signature_and_returns_pong() -> None: + signing_key = SigningKey.generate() + channel = DiscordChannel( + application_id="app-1", + public_key=signing_key.verify_key.encode().hex(), + register_commands=False, + ) + app = Starlette(routes=list(channel.contribute(_FakeContext()).routes)) # type: ignore[arg-type] + body = json.dumps({"type": 1}).encode("utf-8") + + with TestClient(app) as client: + ok = client.post("/", content=body, headers=_headers(signing_key, body)) + bad = client.post( + "/", + content=body, + headers={ + **_headers(signing_key, body), + "x-signature-ed25519": "00" * 64, + }, + ) + + assert ok.status_code == 200 + assert ok.json() == {"type": 1} + assert bad.status_code == 401 + + +def test_request_validation_errors() -> None: + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + skip_signature_verification=True, + max_body_bytes=2, + ) + app = Starlette(routes=list(channel.contribute(_FakeContext()).routes)) # type: ignore[arg-type] + unsupported_channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + skip_signature_verification=True, + ) + unsupported_app = Starlette(routes=list(unsupported_channel.contribute(_FakeContext()).routes)) # type: ignore[arg-type] + + with TestClient(app) as client: + too_large = client.post("/", content=b"{}x") + invalid_json = client.post("/", content=b"{") + with TestClient(unsupported_app) as client: + non_object = client.post("/", json=[]) + unsupported = client.post("/", json={"type": 99}) + + assert too_large.status_code == 413 + assert invalid_json.status_code == 400 + assert non_object.status_code == 400 + assert unsupported.status_code == 400 + + +def test_constructor_validates_discord_configuration() -> None: + public_key = SigningKey.generate().verify_key.encode().hex() + + with pytest.raises(ValueError, match="public_key"): + DiscordChannel(application_id="app-1", public_key="not-hex") + with pytest.raises(ValueError, match="command names"): + DiscordChannel(application_id="app-1", public_key=public_key, agent_command="Ask") + with pytest.raises(ValueError, match="unique"): + DiscordChannel( + application_id="app-1", + public_key=public_key, + commands=[ChannelCommand(name="ask", description="Ask again", handle=lambda _ctx: _noop())], + ) + with pytest.raises(ValueError, match="edit_interval"): + DiscordChannel(application_id="app-1", public_key=public_key, edit_interval=-1) + with pytest.raises(ValueError, match="max_body_bytes"): + DiscordChannel(application_id="app-1", public_key=public_key, max_body_bytes=0) + + +async def test_agent_command_runs_host_and_edits_original_response() -> None: + recorder = _DiscordRecorder() + context = _FakeContext(text="agent says hi") + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + skip_signature_verification=True, + api_base_url="https://discord.test", + ) + channel.contribute(context) # type: ignore[arg-type] + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + + await channel._run_agent_command(_interaction(prompt="what now?"), "token") + + assert context.requests[0].operation == "message.create" + assert context.requests[0].input == "what now?" + assert context.requests[0].session is not None + assert context.requests[0].session.isolation_key == "discord:guild-1:channel-1:user-1" + assert context.requests[0].identity is not None + assert context.requests[0].identity.native_id == "user-1" + assert context.requests[0].identity.attributes["channel_id"] == "channel-1" + assert recorder.requests[0].method == "PATCH" + assert recorder.requests[0].url.path == "/webhooks/app-1/token/messages/@original" + assert recorder.json_payloads[0] == {"content": "agent says hi"} + + +async def test_run_hook_can_rewrite_agent_request() -> None: + recorder = _DiscordRecorder() + context = _FakeContext(text="agent says hi") + + async def hook(request: ChannelRequest, **_: Any) -> ChannelRequest: + return ChannelRequest( + channel=request.channel, + operation=request.operation, + input="rewritten", + session=request.session, + metadata=request.metadata, + attributes=request.attributes, + stream=request.stream, + identity=request.identity, + ) + + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + run_hook=hook, + api_base_url="https://discord.test", + ) + channel.contribute(context) # type: ignore[arg-type] + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + + await channel._run_agent_command(_interaction(prompt="original"), "token") + + assert context.requests[0].input == "rewritten" + + +async def test_response_hook_rewrites_originating_reply() -> None: + recorder = _DiscordRecorder() + context = _FakeContext(text="original") + + async def hook(result: HostedRunResult[Any], **kwargs: Any) -> HostedRunResult[Any]: + assert result.result.text == "original" + assert kwargs["channel_name"] == "discord" + return _run_result("rewritten") + + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + response_hook=hook, + api_base_url="https://discord.test", + ) + channel.contribute(context) # type: ignore[arg-type] + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + + await channel._run_agent_command(_interaction(), "token") + + assert recorder.json_payloads[-1] == {"content": "rewritten"} + + +async def test_missing_prompt_edits_original_without_calling_host() -> None: + recorder = _DiscordRecorder() + context = _FakeContext(text="should not run") + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + api_base_url="https://discord.test", + ) + channel.contribute(context) # type: ignore[arg-type] + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + interaction = _interaction() + interaction["data"]["options"] = [] + + await channel._run_agent_command(interaction, "token") + + assert context.requests == [] + assert recorder.json_payloads[-1] == {"content": "Missing required `prompt` option."} + + +async def test_dispatch_application_command_routes_agent_command() -> None: + recorder = _DiscordRecorder() + context = _FakeContext(text="dispatched") + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + api_base_url="https://discord.test", + ) + channel.contribute(context) # type: ignore[arg-type] + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + + await channel._dispatch_application_command(_interaction(command="ask")) + + assert context.requests[0].operation == "message.create" + assert recorder.json_payloads[-1] == {"content": "dispatched"} + + +async def test_channel_command_handler_receives_context_and_replies() -> None: + recorder = _DiscordRecorder() + captured: list[ChannelCommandContext] = [] + + async def handler(ctx: ChannelCommandContext) -> None: + captured.append(ctx) + await ctx.reply("reset done") + + command = ChannelCommand(name="reset", description="Reset", handle=handler) + context = _FakeContext() + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + commands=[command], + api_base_url="https://discord.test", + ) + channel.contribute(context) # type: ignore[arg-type] + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + interaction = _interaction(command="reset") + interaction["data"]["options"] = [{"name": "input", "type": 3, "value": "please"}] + + await channel._run_channel_command(command, interaction, "token") + + assert captured + assert captured[0].request.operation == "command.invoke" + assert captured[0].request.input == "/reset please" + assert recorder.json_payloads == [{"content": "reset done"}] + + +async def test_channel_command_reply_sends_followups_after_first_edit() -> None: + recorder = _DiscordRecorder() + + async def handler(ctx: ChannelCommandContext) -> None: + await ctx.reply("first") + await ctx.reply("second") + + command = ChannelCommand(name="reset", description="Reset", handle=handler) + context = _FakeContext() + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + commands=[command], + api_base_url="https://discord.test", + ) + channel.contribute(context) # type: ignore[arg-type] + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + + await channel._run_channel_command(command, _interaction(command="reset"), "token") + + assert [request.method for request in recorder.requests] == ["PATCH", "POST"] + assert recorder.json_payloads == [{"content": "first"}, {"content": "second"}] + + +async def test_channel_command_reply_chunks_long_content() -> None: + recorder = _DiscordRecorder() + + async def handler(ctx: ChannelCommandContext) -> None: + await ctx.reply("a" * 2001) + + command = ChannelCommand(name="reset", description="Reset", handle=handler) + context = _FakeContext() + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + commands=[command], + api_base_url="https://discord.test", + ) + channel.contribute(context) # type: ignore[arg-type] + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + + await channel._run_channel_command(command, _interaction(command="reset"), "token") + + assert [request.method for request in recorder.requests] == ["PATCH", "POST"] + assert [len(payload["content"]) for payload in recorder.json_payloads] == [2000, 1] + + +async def test_channel_command_edits_done_when_handler_does_not_reply() -> None: + recorder = _DiscordRecorder() + + async def handler(_ctx: ChannelCommandContext) -> None: + return None + + command = ChannelCommand(name="reset", description="Reset", handle=handler) + context = _FakeContext() + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + commands=[command], + api_base_url="https://discord.test", + ) + channel.contribute(context) # type: ignore[arg-type] + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + + await channel._run_channel_command(command, _interaction(command="reset"), "token") + + assert recorder.json_payloads == [{"content": "Done."}] + + +async def test_unknown_command_edits_error_response() -> None: + recorder = _DiscordRecorder() + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + api_base_url="https://discord.test", + ) + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + + await channel._dispatch_application_command(_interaction(command="missing")) + + assert recorder.json_payloads == [{"content": "Unknown Discord command: missing"}] + + +async def test_startup_bulk_registers_guild_commands() -> None: + recorder = _DiscordRecorder() + command = ChannelCommand(name="reset", description="Reset", handle=lambda _ctx: _noop()) + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + bot_token="bot-token", + guild_id="guild-1", + commands=[command], + api_base_url="https://discord.test", + ) + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + + await channel._on_startup() + + assert recorder.requests[0].method == "PUT" + assert recorder.requests[0].url.path == "/applications/app-1/guilds/guild-1/commands" + assert recorder.requests[0].headers["authorization"] == "Bot bot-token" + assert [payload["name"] for payload in recorder.json_payloads[0]] == ["ask", "reset"] + + +async def test_global_startup_registration_warns_about_propagation(caplog: pytest.LogCaptureFixture) -> None: + recorder = _DiscordRecorder() + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + bot_token="bot-token", + api_base_url="https://discord.test", + ) + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + + await channel._on_startup() + + assert recorder.requests[0].url.path == "/applications/app-1/commands" + assert "global slash commands" in caplog.text + + +async def test_startup_warns_when_registration_has_no_bot_token(caplog: pytest.LogCaptureFixture) -> None: + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + ) + + await channel._on_startup() + await channel._on_shutdown() + + assert "slash commands must be registered outside the host" in caplog.text + + +async def test_originating_reply_sends_followup_chunks() -> None: + recorder = _DiscordRecorder() + context = _FakeContext(text="a" * 2001) + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + api_base_url="https://discord.test", + ) + channel.contribute(context) # type: ignore[arg-type] + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + + await channel._run_agent_command(_interaction(), "token") + + assert [request.method for request in recorder.requests] == ["PATCH", "POST"] + assert [len(payload["content"]) for payload in recorder.json_payloads] == [2000, 1] + + +async def test_streaming_edits_original_and_delivers_final_response() -> None: + recorder = _DiscordRecorder() + context = _FakeContext() + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + streaming=True, + edit_interval=0, + api_base_url="https://discord.test", + ) + channel.contribute(context) # type: ignore[arg-type] + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + + await channel._run_agent_command(_interaction(), "token") + + assert [payload["content"] for payload in recorder.json_payloads] == ["a", "ab", "ab"] + + +async def test_streaming_preview_is_limited_and_final_reply_is_chunked() -> None: + recorder = _DiscordRecorder() + context = _FakeContext() + context.fake_stream = _FakeStream(["a" * 2001]) + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + streaming=True, + edit_interval=0, + api_base_url="https://discord.test", + ) + channel.contribute(context) # type: ignore[arg-type] + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + + await channel._run_agent_command(_interaction(), "token") + + assert [request.method for request in recorder.requests] == ["PATCH", "PATCH", "POST"] + assert [len(payload["content"]) for payload in recorder.json_payloads] == [2000, 2000, 1] + + +async def test_stream_update_hook_can_drop_updates() -> None: + recorder = _DiscordRecorder() + context = _FakeContext() + + async def hook(update: AgentResponseUpdate) -> AgentResponseUpdate | None: + if update.text == "a": + return None + return update + + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + streaming=True, + stream_update_hook=hook, + edit_interval=0, + api_base_url="https://discord.test", + ) + channel.contribute(context) # type: ignore[arg-type] + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + + await channel._run_agent_command(_interaction(), "token") + + assert [payload["content"] for payload in recorder.json_payloads] == ["b", "ab"] + + +async def test_stream_update_hook_can_synchronously_rewrite_updates() -> None: + recorder = _DiscordRecorder() + context = _FakeContext() + + def hook(_update: AgentResponseUpdate) -> AgentResponseUpdate: + return AgentResponseUpdate(contents=[Content.from_text(text="x")], role="assistant") + + channel = DiscordChannel( + application_id="app-1", + public_key=SigningKey.generate().verify_key.encode().hex(), + register_commands=False, + streaming=True, + stream_update_hook=hook, + edit_interval=0, + api_base_url="https://discord.test", + ) + channel.contribute(context) # type: ignore[arg-type] + channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport()) + + await channel._run_agent_command(_interaction(), "token") + + assert [payload["content"] for payload in recorder.json_payloads] == ["x", "xx", "ab"] + + +async def _noop() -> None: + return None diff --git a/python/packages/hosting-invocations/LICENSE b/python/packages/hosting-invocations/LICENSE new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/python/packages/hosting-invocations/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/hosting-invocations/README.md b/python/packages/hosting-invocations/README.md new file mode 100644 index 00000000000..de77dfbd40c --- /dev/null +++ b/python/packages/hosting-invocations/README.md @@ -0,0 +1,30 @@ +# agent-framework-hosting-invocations + +Minimal `POST /invocations` channel for [agent-framework-hosting](../hosting). Useful +for smoke-testing, durable-task drivers, and bespoke clients that don't speak +the OpenAI Responses protocol. + +## Wire shape + +``` +POST /invocations +{ + "message": "hello", + "session_id": "user-42", + "stream": false +} +``` + +Non-streaming response: `{"response": "...", "session_id": "..."}`. +Streaming response: `text/event-stream` of `data:` lines, terminated by +`data: [DONE]`. + +## Usage + +```python +from agent_framework_hosting import AgentFrameworkHost +from agent_framework_hosting_invocations import InvocationsChannel + +host = AgentFrameworkHost(target=my_agent, channels=[InvocationsChannel()]) +host.serve() +``` diff --git a/python/packages/hosting-invocations/agent_framework_hosting_invocations/__init__.py b/python/packages/hosting-invocations/agent_framework_hosting_invocations/__init__.py new file mode 100644 index 00000000000..572348dec1f --- /dev/null +++ b/python/packages/hosting-invocations/agent_framework_hosting_invocations/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Minimal ``POST /invocations`` channel for :mod:`agent_framework_hosting`.""" + +from ._channel import InvocationsChannel + +__all__ = ["InvocationsChannel"] diff --git a/python/packages/hosting-invocations/agent_framework_hosting_invocations/_channel.py b/python/packages/hosting-invocations/agent_framework_hosting_invocations/_channel.py new file mode 100644 index 00000000000..6bac6588019 --- /dev/null +++ b/python/packages/hosting-invocations/agent_framework_hosting_invocations/_channel.py @@ -0,0 +1,193 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Minimal ``POST /invocations`` channel. + +Inspired by ``agent-framework-foundry-hosting``'s ``InvocationsHostServer``. +A framework-agnostic surface for callers that just want to send a message and +get an answer back — no OpenAI-style envelope, no Responses item lattice. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any, cast + +from agent_framework_hosting import ( + ChannelContext, + ChannelContribution, + ChannelRequest, + ChannelResponseHook, + ChannelRunHook, + ChannelSession, + ChannelStreamUpdateHook, + logger, +) +from starlette.requests import Request +from starlette.responses import JSONResponse, Response, StreamingResponse +from starlette.routing import Route + + +class InvocationsChannel: + """Minimal ``POST /invocations`` surface. + + A run hook can rewrite the channel request (e.g. inject a session, add + options) before the host invokes the agent. A stream-transform hook can + rewrite or drop ``AgentResponseUpdate`` chunks before they hit the wire. + """ + + name = "invocations" + + def __init__( + self, + *, + path: str = "/invocations", + run_hook: ChannelRunHook | None = None, + response_hook: ChannelResponseHook | None = None, + stream_update_hook: ChannelStreamUpdateHook | None = None, + ) -> None: + """Configure the invocations endpoint. + + ``path`` is the endpoint path the host uses when registering this + channel. Use ``""`` to expose the handler at the app root. + ``run_hook`` may rewrite the :class:`ChannelRequest` before the host + invokes the target — typically to attach session metadata or + translate the wire payload into ``Message`` instances. + ``response_hook`` may rewrite the :class:`HostedRunResult` before + the channel serializes it to JSON for the originating caller. + ``stream_update_hook`` lets callers map or drop individual + ``AgentResponseUpdate`` chunks while streaming. + """ + self.path = path + self._hook = run_hook + self.response_hook = response_hook + self._stream_update_hook = stream_update_hook + self._ctx: ChannelContext | None = None + + def contribute(self, context: ChannelContext) -> ChannelContribution: + """Capture the host-supplied context and register the endpoint route.""" + self._ctx = context + return ChannelContribution(routes=[Route("/", self._handle, methods=["POST"])]) + + async def _handle(self, request: Request) -> Response: + """Handle a single Invocations call. + + Validates the JSON body shape, builds a :class:`ChannelRequest` + (optionally with a ``ChannelSession`` keyed by ``session_id``), + runs the configured ``run_hook``, and either streams SSE chunks + when ``stream`` is true or returns a single JSON ``{response, + session_id}`` envelope. + """ + if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle + return JSONResponse({"error": "channel not initialized"}, status_code=500) + try: + body: Any = await request.json() + except Exception: + return JSONResponse({"error": "invalid json"}, status_code=400) + + if not isinstance(body, dict): + return JSONResponse({"error": "request body must be an object"}, status_code=422) + body_map: dict[str, Any] = cast("dict[str, Any]", body) + + message = body_map.get("message") + if not isinstance(message, str) or not message: + return JSONResponse({"error": "missing or empty 'message'"}, status_code=422) + + session_id = body_map.get("session_id") + if session_id is not None and not isinstance(session_id, str): + return JSONResponse({"error": "'session_id' must be a string"}, status_code=422) + + session = ChannelSession(isolation_key=f"invocations:{session_id}") if session_id else None + + attributes: dict[str, Any] = {} + if session_id: + attributes["session_id"] = session_id + + channel_request = ChannelRequest( + channel=self.name, + operation="invoke", + input=message, + session=session, + stream=bool(body_map.get("stream")), + attributes=attributes, + ) + + if channel_request.stream: + return StreamingResponse( + self._stream(channel_request, body_map), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + result = await self._ctx.run( + channel_request, + run_hook=self._hook, + protocol_request=body_map, + response_hook=self.response_hook, + channel_name=self.name, + ) + return JSONResponse({"response": result.result.text, "session_id": session_id}) + + async def _stream(self, request: ChannelRequest, protocol_request: dict[str, Any]) -> AsyncIterator[str]: + r"""Yield bare ``data:`` SSE lines for each text chunk + a final ``[DONE]``. + + SSE protocol notes: + + * The HTTP status is committed when ASGI sends headers, before the + generator runs. Emitting a stream-opening 200 + ``text/event-stream`` + and signalling errors via ``event: error`` SSE frames is the + conventional contract — ``EventSource`` and OpenAI-style SSE + consumers treat ``event: error`` as a terminal error condition. + Hard run-acquisition failures (e.g. target rejected) therefore + surface as the first frame, not as an HTTP error code. + * The SSE spec treats ``\r``, ``\n``, and ``\r\n`` as line + terminators. Per-chunk text is split on all three so embedded + carriage returns don't corrupt ``data:`` framing on the wire. + """ + if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle + yield "event: error\ndata: channel not initialized\n\n" + return + try: + stream = await self._ctx.run_stream( + request, + run_hook=self._hook, + protocol_request=protocol_request, + stream_update_hook=self._stream_update_hook, + ) + async for update in stream: + chunk = getattr(update, "text", None) + if chunk: + # Each text chunk is its own SSE event so curl-friendly + # consumers can read it directly. Newlines inside the + # chunk are escaped per SSE spec by emitting one + # ``data:`` line per source line. ``splitlines()`` is + # used over ``split('\n')`` so embedded ``\r`` / + # ``\r\n`` don't bleed into the framing. + for line in str(chunk).splitlines() or [""]: + yield f"data: {line}\n" + yield "\n" + try: + # Finalize so context-provider / history hooks on the agent + # still run even though we are emitting our own SSE. + # If finalization fails, the agent's persistence side + # effects (history-provider write, context-provider hooks) + # are unreliable — surface that to the client as an + # ``event: error`` frame so it isn't a silent drop. + await stream.get_final_response() + except Exception as finalize_exc: + logger.exception("Invocations stream finalize failed") + yield "event: error\n" + for line in f"finalize failed: {finalize_exc!s}".splitlines() or [""]: + yield f"data: {line}\n" + yield "\n" + return + except Exception as exc: + logger.exception("Invocations stream consumption failed") + yield "event: error\n" + for line in str(exc).splitlines() or [""]: + yield f"data: {line}\n" + yield "\n" + return + yield "data: [DONE]\n\n" + + +__all__ = ["InvocationsChannel"] diff --git a/python/packages/hosting-invocations/pyproject.toml b/python/packages/hosting-invocations/pyproject.toml new file mode 100644 index 00000000000..8050c4e86fd --- /dev/null +++ b/python/packages/hosting-invocations/pyproject.toml @@ -0,0 +1,97 @@ +[project] +name = "agent-framework-hosting-invocations" +description = "Minimal POST /invocations channel for agent-framework-hosting." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0a260424" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.2.0,<2", + "agent-framework-hosting==1.0.0a260424", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_hosting_invocations"] +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_hosting_invocations"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_invocations" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_invocations --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/hosting-invocations/tests/__init__.py b/python/packages/hosting-invocations/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/hosting-invocations/tests/test_channel.py b/python/packages/hosting-invocations/tests/test_channel.py new file mode 100644 index 00000000000..dcee6e85fe0 --- /dev/null +++ b/python/packages/hosting-invocations/tests/test_channel.py @@ -0,0 +1,259 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""End-to-end tests for :class:`InvocationsChannel`.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from dataclasses import dataclass, replace +from typing import Any + +from agent_framework_hosting import AgentFrameworkHost, ChannelRequest, HostedRunResult +from starlette.testclient import TestClient + +from agent_framework_hosting_invocations import InvocationsChannel + + +@dataclass +class _FakeAgentResponse: + text: str + + +@dataclass +class _FakeUpdate: + text: str + + +class _FakeStream: + def __init__(self, chunks: list[str]) -> None: + self._chunks = chunks + self._final = _FakeAgentResponse(text="".join(chunks)) + + def __aiter__(self) -> AsyncIterator[_FakeUpdate]: + async def _gen() -> AsyncIterator[_FakeUpdate]: + for c in self._chunks: + yield _FakeUpdate(c) + + return _gen() + + async def get_final_response(self) -> _FakeAgentResponse: + return self._final + + +class _FakeAgent: + def __init__(self, reply: str = "hi", chunks: list[str] | None = None) -> None: + self._reply = reply + self._chunks = chunks or [reply] + self.calls: list[dict[str, Any]] = [] + + def create_session(self, *, session_id: str | None = None) -> Any: + return {"session_id": session_id} + + def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any: + self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs}) + if stream: + return _FakeStream(self._chunks) + + async def _coro() -> _FakeAgentResponse: + return _FakeAgentResponse(text=self._reply) + + return _coro() + + +def _make_client(agent: _FakeAgent | None = None, *, path: str = "/invocations") -> tuple[TestClient, _FakeAgent]: + agent = agent or _FakeAgent() + host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel(path=path)]) + return TestClient(host.app), agent + + +class TestInvocations: + def test_post_invoke_returns_response(self) -> None: + client, _agent = _make_client(_FakeAgent(reply="pong")) + with client: + r = client.post("/invocations", json={"message": "ping"}) + assert r.status_code == 200 + assert r.json() == {"response": "pong", "session_id": None} + + def test_empty_path_mounts_at_app_root(self) -> None: + client, _agent = _make_client(_FakeAgent(reply="pong"), path="") + with client: + r = client.post("/", json={"message": "ping"}) + assert r.status_code == 200 + assert r.json() == {"response": "pong", "session_id": None} + + def test_session_id_propagates_to_target(self) -> None: + client, agent = _make_client() + with client: + r = client.post("/invocations", json={"message": "x", "session_id": "s1"}) + assert r.status_code == 200 + assert r.json()["session_id"] == "s1" + sess = agent.calls[0]["kwargs"].get("session") + # Host converts ChannelSession.isolation_key -> AgentSession via + # target.create_session(session_id=...). Our fake stashes that here. + assert sess is not None + assert sess["session_id"] == "invocations:s1" + + def test_invalid_json_returns_400(self) -> None: + client, _ = _make_client() + with client: + r = client.post( + "/invocations", + content=b"{not json", + headers={"content-type": "application/json"}, + ) + assert r.status_code == 400 + + def test_empty_message_returns_422(self) -> None: + client, _ = _make_client() + with client: + r = client.post("/invocations", json={"message": ""}) + assert r.status_code == 422 + + def test_non_string_session_id_returns_422(self) -> None: + client, _ = _make_client() + with client: + r = client.post("/invocations", json={"message": "x", "session_id": 1}) + assert r.status_code == 422 + + def test_non_object_body_returns_422(self) -> None: + client, _ = _make_client() + with client: + r = client.post("/invocations", json=[]) + assert r.status_code == 422 + + def test_streaming_emits_data_lines_and_done(self) -> None: + agent = _FakeAgent(chunks=["hel", "lo"]) + host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel()]) + with TestClient(host.app) as client: + r = client.post("/invocations", json={"message": "x", "stream": True}) + assert r.status_code == 200 + body = r.text + assert "data: hel" in body + assert "data: lo" in body + assert body.rstrip().endswith("data: [DONE]") + + def test_run_hook_can_rewrite_request(self) -> None: + captured: list[ChannelRequest] = [] + + async def hook(req: ChannelRequest, **_: Any) -> ChannelRequest: + captured.append(req) + return replace(req, input="rewritten") + + agent = _FakeAgent(reply="ok") + host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel(run_hook=hook)]) + with TestClient(host.app) as client: + r = client.post("/invocations", json={"message": "x", "stream": True}) + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/event-stream") + assert captured and captured[0].channel == "invocations" + assert agent.calls[0]["messages"].text == "rewritten" + + def test_response_hook_can_rewrite_originating_reply(self) -> None: + seen_kwargs: list[dict[str, Any]] = [] + + def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult: + seen_kwargs.append(dict(kwargs)) + return HostedRunResult(_FakeAgentResponse(text=f"hooked:{result.result.text}"), session=result.session) + + agent = _FakeAgent(reply="pong") + host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel(response_hook=hook)]) + + with TestClient(host.app) as client: + r = client.post("/invocations", json={"message": "ping"}) + + assert r.status_code == 200 + assert r.json() == {"response": "hooked:pong", "session_id": None} + assert seen_kwargs + assert seen_kwargs[0]["channel_name"] == "invocations" + + def test_stream_update_hook_can_rewrite_chunks(self) -> None: + agent = _FakeAgent(chunks=["foo", "bar"]) + + def transform(update: Any) -> Any: + return _FakeUpdate(text=update.text.upper()) + + host = AgentFrameworkHost( + target=agent, + channels=[InvocationsChannel(stream_update_hook=transform)], + ) + with TestClient(host.app) as client: + r = client.post("/invocations", json={"message": "x", "stream": True}) + assert r.status_code == 200 + body = r.text + assert "data: FOO" in body + assert "data: BAR" in body + assert "data: foo" not in body + + def test_stream_update_hook_can_drop_chunks(self) -> None: + agent = _FakeAgent(chunks=["keep", "drop", "keep2"]) + + def transform(update: Any) -> Any: + return None if update.text == "drop" else update + + host = AgentFrameworkHost( + target=agent, + channels=[InvocationsChannel(stream_update_hook=transform)], + ) + with TestClient(host.app) as client: + r = client.post("/invocations", json={"message": "x", "stream": True}) + assert r.status_code == 200 + body = r.text + assert "data: keep" in body + assert "data: keep2" in body + assert "data: drop" not in body + + def test_stream_update_hook_supports_async(self) -> None: + agent = _FakeAgent(chunks=["aa"]) + + async def transform(update: Any) -> Any: + return _FakeUpdate(text=update.text + "!") + + host = AgentFrameworkHost( + target=agent, + channels=[InvocationsChannel(stream_update_hook=transform)], + ) + with TestClient(host.app) as client: + r = client.post("/invocations", json={"message": "x", "stream": True}) + assert r.status_code == 200 + assert "data: aa!" in r.text + + def test_streaming_chunk_with_crlf_splits_into_separate_data_lines(self) -> None: + # Per SSE spec, ``\r``, ``\n`` and ``\r\n`` are all line terminators; + # a chunk like ``"line1\r\nline2"`` must produce two ``data:`` lines, + # not one ``data:`` line containing an embedded ``\r``. + agent = _FakeAgent(chunks=["line1\r\nline2"]) + host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel()]) + with TestClient(host.app) as client: + r = client.post("/invocations", json={"message": "x", "stream": True}) + assert r.status_code == 200 + body = r.text + assert "data: line1\n" in body + assert "data: line2\n" in body + assert "\r" not in body.split("data: [DONE]")[0] + + def test_streaming_finalize_error_emits_error_frame_no_done(self) -> None: + # ``get_final_response()`` is what triggers history-provider + # persistence on the agent side; if it fails we must surface that + # to the client as ``event: error`` rather than emitting ``[DONE]`` + # as if the run completed cleanly. + class _FailingFinalStream(_FakeStream): + async def get_final_response(self) -> _FakeAgentResponse: + raise RuntimeError("history backend exploded") + + class _AgentWithFailingFinal(_FakeAgent): + def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any: + self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs}) + if stream: + return _FailingFinalStream(["partial"]) + return super().run(messages, stream=stream, **kwargs) + + agent = _AgentWithFailingFinal() + host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel()]) + with TestClient(host.app) as client: + r = client.post("/invocations", json={"message": "x", "stream": True}) + assert r.status_code == 200 + body = r.text + assert "data: partial" in body + assert "event: error" in body + assert "history backend exploded" in body + assert "[DONE]" not in body diff --git a/python/packages/hosting-mcp/LICENSE b/python/packages/hosting-mcp/LICENSE new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/python/packages/hosting-mcp/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/hosting-mcp/README.md b/python/packages/hosting-mcp/README.md new file mode 100644 index 00000000000..c6f01c4f45a --- /dev/null +++ b/python/packages/hosting-mcp/README.md @@ -0,0 +1,29 @@ +# agent-framework-hosting-mcp + +Model Context Protocol (MCP) tool channel for `agent-framework-hosting`. + +Exposes the hosted target (an `Agent` or a `Workflow`) as a single MCP tool over +the Streamable-HTTP transport, so MCP clients — other agents, IDE tooling — can +invoke it. Every call is routed through the host pipeline, so host sessions, +request metadata, and run/response hooks all apply. + +```python +from agent_framework.openai import OpenAIChatClient +from agent_framework_hosting import AgentFrameworkHost +from agent_framework_hosting_mcp import MCPChannel + +agent = OpenAIChatClient().as_agent(name="Assistant") + +host = AgentFrameworkHost(target=agent, channels=[MCPChannel()]) +host.serve(port=8000) +``` + +The Streamable-HTTP endpoint is mounted at `path` (default `/mcp`). The advertised +tool accepts `{"input": str, "session_id": str?}` and returns the target's reply +as MCP content blocks, including structured output when the agent returns one. +Pass `session_id` to continue a prior conversation (it maps onto the host +session). When `streaming=True` (default) incremental text is forwarded as MCP +progress notifications while the full reply is returned as the tool result. + +The base host plumbing lives in +[`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/). diff --git a/python/packages/hosting-mcp/agent_framework_hosting_mcp/__init__.py b/python/packages/hosting-mcp/agent_framework_hosting_mcp/__init__.py new file mode 100644 index 00000000000..90abbd8776c --- /dev/null +++ b/python/packages/hosting-mcp/agent_framework_hosting_mcp/__init__.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Model Context Protocol (MCP) tool channel for :mod:`agent_framework_hosting`. + +Exposes the hosted target (an ``Agent`` or a ``Workflow``) as a single MCP +tool over the Streamable-HTTP transport so MCP clients — other agents, IDE +tooling — can invoke it. Routes through the host pipeline, so sessions, +request metadata, and hooks apply. +""" + +import importlib.metadata + +from ._channel import MCPChannel + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = [ + "MCPChannel", + "__version__", +] diff --git a/python/packages/hosting-mcp/agent_framework_hosting_mcp/_channel.py b/python/packages/hosting-mcp/agent_framework_hosting_mcp/_channel.py new file mode 100644 index 00000000000..427268c4e13 --- /dev/null +++ b/python/packages/hosting-mcp/agent_framework_hosting_mcp/_channel.py @@ -0,0 +1,437 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""``MCPChannel`` — exposes the hosted target as a Model Context Protocol tool. + +Mounts a Streamable-HTTP MCP endpoint that advertises a single tool. An MCP +client (another agent, an IDE, tooling) calls the tool with +``{"input": "...", "session_id": "..."}`` and receives the target's reply as +the tool result. + +Like the other ``agent-framework-hosting`` channels this routes through the +host pipeline (``ChannelContext.run`` / ``run_stream``) so session resolution, +request metadata, and run/response hooks all apply. The MCP ``tool/call`` +conversation key maps onto :class:`ChannelSession` (caller-supplied-session +family); the same single-tool shape works for an ``Agent`` or a ``Workflow`` +target (use a ``run_hook`` to reshape the free-form input into a workflow's +typed inputs). +""" + +from __future__ import annotations + +import base64 +import json +import re +from collections.abc import Mapping, Sequence +from contextlib import AbstractAsyncContextManager +from dataclasses import asdict, is_dataclass +from typing import Any, cast + +import mcp.types as types +from agent_framework import Content, Message +from agent_framework_hosting import ( + ChannelContext, + ChannelContribution, + ChannelIdentity, + ChannelRequest, + ChannelResponseHook, + ChannelRunHook, + ChannelSession, + HostedRunResult, + logger, +) +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from pydantic import AnyUrl +from starlette.routing import Mount +from starlette.types import Receive, Scope, Send + +_DEFAULT_TOOL_NAME = "run_agent" +_DEFAULT_TOOL_DESCRIPTION = ( + "Invoke the hosted agent (or workflow) with a free-form text request and " + "return its reply. Pass an optional ``session_id`` to continue a prior " + "conversation." +) +_DATA_URI_PATTERN = re.compile(r"^data:(?P[^;]+);base64,(?P[A-Za-z0-9+/=]+)$") + + +def _mcp_uri(uri: str) -> AnyUrl: + """Build an MCP URI model from a string URI.""" + return AnyUrl(uri) + + +def _json_safe(value: Any) -> Any: + """Return a JSON-serializable representation for MCP structured content.""" + try: + return json.loads(json.dumps(value, default=str)) + except (TypeError, ValueError): + return str(value) + + +def _structured_content(value: Any) -> dict[str, Any] | None: + """Normalize an Agent Framework structured output value for MCP.""" + if value is None: + return None + + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + value = model_dump(mode="json") + elif is_dataclass(value) and not isinstance(value, type): + value = asdict(value) + + if isinstance(value, Mapping): + mapping_value = cast("Mapping[Any, Any]", value) # type: ignore[redundant-cast] + safe_value = _json_safe(dict(mapping_value)) + if isinstance(safe_value, dict): + safe_mapping = cast("Mapping[Any, Any]", safe_value) + return {str(key): item for key, item in safe_mapping.items()} + return {"value": safe_value} + safe_value = _json_safe(value) + return {"value": safe_value} + + +def _data_content_to_mcp(content: Content) -> list[types.ContentBlock]: + """Convert Agent Framework data content into the closest MCP content block.""" + if not content.uri: + return [] + match = _DATA_URI_PATTERN.match(content.uri) + if match is None: + logger.warning("MCPChannel could not parse data URI; omitted.") + return [] + + media_type = content.media_type or match.group("media_type") + data = match.group("data") + if media_type.startswith("image/"): + return [types.ImageContent(type="image", data=data, mimeType=media_type)] + if media_type.startswith("audio/"): + return [types.AudioContent(type="audio", data=data, mimeType=media_type)] + return [ + types.EmbeddedResource( + type="resource", + resource=types.BlobResourceContents(uri=_mcp_uri(content.uri), mimeType=media_type, blob=data), + ) + ] + + +def _content_to_mcp(content: Content) -> list[types.ContentBlock]: + """Convert one Agent Framework content item into MCP content blocks.""" + match content.type: + case "text": + return [types.TextContent(type="text", text=content.text or "")] + case "text_reasoning": + return [types.TextContent(type="text", text=content.text)] if content.text else [] + case "data": + return _data_content_to_mcp(content) + case "uri": + if not content.uri: + return [] + block: types.ContentBlock = types.ResourceLink( + type="resource_link", + name=content.uri, + uri=_mcp_uri(content.uri), + mimeType=content.media_type, + ) + return [block] + case "function_result": + if content.items: + blocks: list[types.ContentBlock] = [] + for item in content.items: + blocks.extend(_content_to_mcp(item)) + return blocks + return [types.TextContent(type="text", text=str(content.result or ""))] + case "error": + return [types.TextContent(type="text", text=content.message or content.error_details or "")] + case _: + logger.warning("MCPChannel does not support content type: %s. Omitted.", content.type) + return [] + + +def _value_to_mcp(value: Any) -> list[types.ContentBlock]: + """Convert a workflow output or fallback value into MCP content blocks.""" + if isinstance(value, Content): + return _content_to_mcp(value) + if isinstance(value, Message): + blocks: list[types.ContentBlock] = [] + for content in value.contents: + blocks.extend(_content_to_mcp(content)) + return blocks + if isinstance(value, str): + return [types.TextContent(type="text", text=value)] + if isinstance(value, bytes): + data = base64.b64encode(value).decode("utf-8") + return [ + types.EmbeddedResource( + type="resource", + resource=types.BlobResourceContents( + uri=_mcp_uri("data:application/octet-stream;base64," + data), + mimeType="application/octet-stream", + blob=data, + ), + ) + ] + return [types.TextContent(type="text", text=json.dumps(_json_safe(value), default=str))] + + +class MCPChannel: + """Exposes the hosted target as a single MCP tool over Streamable HTTP. + + Mounts the MCP Streamable-HTTP transport at ``path`` (default ``/mcp``). + The advertised tool accepts ``{"input": str, "session_id": str?}`` and + returns the target's reply as MCP content blocks. Agent structured outputs + are returned as MCP ``structuredContent``. + """ + + name = "mcp" + + def __init__( + self, + *, + path: str = "/mcp", + tool_name: str = _DEFAULT_TOOL_NAME, + tool_description: str = _DEFAULT_TOOL_DESCRIPTION, + server_name: str | None = None, + server_version: str | None = None, + streaming: bool = True, + json_response: bool = False, + stateless: bool = False, + run_hook: ChannelRunHook | None = None, + response_hook: ChannelResponseHook | None = None, + ) -> None: + """Create an MCP tool channel. + + Keyword Args: + path: Mount path for the Streamable-HTTP transport. Default ``/mcp``. + tool_name: Name of the advertised tool. Default ``run_agent``. + tool_description: Human-readable description advertised to clients. + server_name: MCP server name reported in the initialize handshake. + Defaults to the hosted target's ``name`` attribute when available. + server_version: Optional MCP server version string. + streaming: When ``True`` (default) the channel consumes the target + via :meth:`ChannelContext.run_stream` and forwards incremental + text to the client as MCP progress notifications (when the + client supplied a ``progressToken``). The full reply is always + returned as the tool result regardless of this flag. + json_response: Forwarded to :class:`StreamableHTTPSessionManager`. + When ``True`` the transport returns a single JSON response + instead of an SSE stream for each request. + stateless: Forwarded to :class:`StreamableHTTPSessionManager`. When + ``True`` the transport does not retain per-session state between + requests. + run_hook: Optional :data:`ChannelRunHook` invoked with the parsed + :class:`ChannelRequest` before the target runs. + response_hook: Optional :data:`ChannelResponseHook` invoked before + the channel serializes an originating reply into tool content. + """ + self.path = path + self.response_hook = response_hook + self._tool_name = tool_name + self._tool_description = tool_description + self._server_name = server_name + self._server_version = server_version + self._streaming = streaming + self._json_response = json_response + self._stateless = stateless + self._hook = run_hook + self._ctx: ChannelContext | None = None + self._server: Server[Any, Any] | None = None + self._session_manager: StreamableHTTPSessionManager | None = None + self._run_cm: AbstractAsyncContextManager[None] | None = None + + def contribute(self, context: ChannelContext) -> ChannelContribution: + """Capture the host context and mount the Streamable-HTTP transport.""" + self._ctx = context + self._server = self._build_server() + self._session_manager = StreamableHTTPSessionManager( + app=self._server, + json_response=self._json_response, + stateless=self._stateless, + ) + # StreamableHTTPSessionManager owns MCP initialize/session/progress semantics; + # mounting it keeps the channel on the real MCP HTTP transport. + return ChannelContribution( + routes=[Mount("/", app=self._handle_asgi)], + on_startup=[self._on_startup], + on_shutdown=[self._on_shutdown], + ) + + async def _handle_asgi(self, scope: Scope, receive: Receive, send: Send) -> None: + """ASGI entrypoint delegating to the MCP Streamable-HTTP session manager.""" + if self._session_manager is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("MCPChannel transport not initialized") + await self._session_manager.handle_request(scope, receive, send) + + async def _on_startup(self) -> None: + """Enter the session-manager task-group lifecycle on host startup.""" + if self._session_manager is None: # pragma: no cover - guarded by lifecycle + return + self._run_cm = self._session_manager.run() + await self._run_cm.__aenter__() + + async def _on_shutdown(self) -> None: + """Exit the session-manager task-group lifecycle on host shutdown.""" + if self._run_cm is not None: + await self._run_cm.__aexit__(None, None, None) + self._run_cm = None + + def _build_server(self) -> Server[Any, Any]: + """Build the low-level MCP server with the single host-routed tool.""" + target_name = getattr(self._ctx.target, "name", None) if self._ctx is not None else None + server_name = self._server_name or (target_name if isinstance(target_name, str) and target_name else None) + server: Server[Any, Any] = Server(name=server_name or "agent-framework-hosting", version=self._server_version) + tool = types.Tool( + name=self._tool_name, + description=self._tool_description, + inputSchema={ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The request to send to the hosted agent or workflow.", + }, + "session_id": { + "type": "string", + "description": "Optional conversation id to continue a prior session.", + }, + }, + "required": ["input"], + }, + ) + + @server.list_tools() # type: ignore[no-untyped-call, untyped-decorator, misc] + async def _list_tools() -> list[types.Tool]: # noqa: RUF029 # pyright: ignore[reportUnusedFunction] + return [tool] + + @server.call_tool() # type: ignore[no-untyped-call, untyped-decorator, misc] + async def _call_tool(name: str, arguments: Mapping[str, Any]) -> types.CallToolResult: # pyright: ignore[reportUnusedFunction] + return await self._invoke_tool(arguments) + + return server + + async def _invoke_tool(self, arguments: Mapping[str, Any]) -> types.CallToolResult: + """Route a single ``tool/call`` through the host pipeline.""" + if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle + raise RuntimeError("MCPChannel not initialized") + + text_input = arguments.get("input") + if not isinstance(text_input, str) or not text_input: + return types.CallToolResult( + content=[types.TextContent(type="text", text="Error: 'input' must be a non-empty string.")], + isError=True, + ) + session_id = arguments.get("session_id") + session = ChannelSession(isolation_key=session_id) if isinstance(session_id, str) and session_id else None + identity = ( + ChannelIdentity(channel=self.name, native_id=session_id) + if isinstance(session_id, str) and session_id + else None + ) + + channel_request = ChannelRequest( + channel=self.name, + operation="message.create", + input=text_input, + session=session, + stream=self._streaming, + identity=identity, + attributes={"tool_name": self._tool_name}, + ) + + if channel_request.stream: + result = await self._run_streaming(channel_request, protocol_request=dict(arguments)) + else: + result = await self._ctx.run( + channel_request, + run_hook=self._hook, + protocol_request=dict(arguments), + response_hook=self.response_hook, + channel_name=self.name, + ) + + return self._result_to_content(result) + + async def _run_streaming( + self, request: ChannelRequest, *, protocol_request: Mapping[str, Any] + ) -> HostedRunResult[Any]: + """Consume the target as a stream, forwarding progress, returning the full reply.""" + if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle + raise RuntimeError("MCPChannel not initialized") + + progress_token, request_id = self._progress_context() + progress = 0.0 + stream = await self._ctx.run_stream( + request, + run_hook=self._hook, + protocol_request=protocol_request, + response_hook=self.response_hook, + channel_name=self.name, + ) + async for update in stream: + chunk = getattr(update, "text", None) + if not chunk: + continue + if progress_token is not None: + progress += 1.0 + try: + await self._send_progress(progress_token, progress, chunk, request_id) + except Exception: # pragma: no cover - progress is best-effort + logger.exception("MCPChannel progress notification failed") + return HostedRunResult(await stream.get_final_response()) + + def _progress_context(self) -> tuple[str | int | None, str | None]: + """Best-effort lookup of the active request's progress token + id.""" + if self._server is None: # pragma: no cover - guarded by lifecycle + return None, None + try: + ctx = self._server.request_context + except Exception: # pragma: no cover - no active request context + return None, None + token = ctx.meta.progressToken if ctx.meta is not None else None + request_id = str(ctx.request_id) + return token, request_id + + async def _send_progress( + self, + progress_token: str | int, + progress: float, + message: str, + request_id: str | None, + ) -> None: + """Send a single MCP progress notification for streamed text.""" + if self._server is None: # pragma: no cover - guarded by lifecycle + return + await self._server.request_context.session.send_progress_notification( + progress_token=progress_token, + progress=progress, + message=message, + related_request_id=request_id, + ) + + def _result_to_content(self, result: HostedRunResult[Any]) -> types.CallToolResult: + """Convert a host result into an MCP tool result.""" + response = result.result + content: list[types.ContentBlock] = [] + + messages = cast("Sequence[Any] | None", getattr(response, "messages", None)) + if messages: + for message in messages: + for item in cast("Sequence[Any]", getattr(message, "contents", None) or ()): + if isinstance(item, Content): + content.extend(_content_to_mcp(item)) + else: + content.append(types.TextContent(type="text", text=str(item))) + + get_outputs = getattr(response, "get_outputs", None) + if callable(get_outputs): + for output in cast("Sequence[Any]", get_outputs()): + content.extend(_value_to_mcp(output)) + + structured = _structured_content(getattr(response, "value", None)) + if not content: + text = getattr(response, "text", None) + if isinstance(text, str) and text: + content.append(types.TextContent(type="text", text=text)) + elif structured is not None: + content.append(types.TextContent(type="text", text=json.dumps(structured, indent=2))) + else: + content.append(types.TextContent(type="text", text="")) + + return types.CallToolResult(content=content, structuredContent=structured, isError=False) diff --git a/python/packages/hosting-mcp/pyproject.toml b/python/packages/hosting-mcp/pyproject.toml new file mode 100644 index 00000000000..39ac2ff27ab --- /dev/null +++ b/python/packages/hosting-mcp/pyproject.toml @@ -0,0 +1,102 @@ +[project] +name = "agent-framework-hosting-mcp" +description = "Model Context Protocol (MCP) tool channel for agent-framework-hosting." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0a260424" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.2.0,<2", + "agent-framework-hosting>=1.0.0a260424,<2", + "mcp>=1.12,<2", + "starlette>=0.37", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_hosting_mcp"] +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_hosting_mcp"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_mcp" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_mcp --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" + +[dependency-groups] +dev = [] diff --git a/python/packages/hosting-mcp/tests/hosting_mcp/test_channel.py b/python/packages/hosting-mcp/tests/hosting_mcp/test_channel.py new file mode 100644 index 00000000000..f7874c0315b --- /dev/null +++ b/python/packages/hosting-mcp/tests/hosting_mcp/test_channel.py @@ -0,0 +1,390 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for :class:`MCPChannel`.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Awaitable, Sequence +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from typing import Any + +import mcp.types as types +import uvicorn +from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message, ResponseStream +from agent_framework_hosting import AgentFrameworkHost, ChannelRequest, HostedRunResult +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.memory import create_connected_server_and_client_session +from starlette.types import ASGIApp + +from agent_framework_hosting_mcp import MCPChannel + +# --------------------------------------------------------------------------- # +# Fakes # +# --------------------------------------------------------------------------- # + + +@dataclass +class _FakeResp: + text: str + messages: list[Message] = field(default_factory=list) + value: Any | None = None + + +@dataclass +class _FakeUpdate: + text: str + contents: list[Content] = field(default_factory=list) + message_id: str | None = None + + +class _FakeStream: + def __init__(self, chunks: list[str], final: _FakeResp | None = None) -> None: + self._chunks = chunks + self._final = final or _FakeResp(text="".join(chunks)) + + def __aiter__(self) -> AsyncIterator[_FakeUpdate]: + async def _gen() -> AsyncIterator[_FakeUpdate]: + for c in self._chunks: + yield _FakeUpdate(text=c) + + return _gen() + + async def get_final_response(self) -> _FakeResp: + return self._final + + +@dataclass +class _FakeTarget: + name: str = "Assistant" + description: str = "A helpful assistant." + + +class _FakeContext: + """Minimal stand-in for :class:`ChannelContext`.""" + + def __init__( + self, + *, + reply: str = "hello", + chunks: list[str] | None = None, + contents: list[Content] | None = None, + structured: Any | None = None, + ) -> None: + self.target = _FakeTarget() + self._reply = reply + self._chunks = chunks or [reply] + self._contents = contents or [Content.from_text(text=reply)] + self._structured = structured + self.requests: list[ChannelRequest] = [] + + async def run( + self, + request: ChannelRequest, + *, + run_hook: Any | None = None, + protocol_request: Any | None = None, + response_hook: Any | None = None, + channel_name: str | None = None, + ) -> HostedRunResult[Any]: + if run_hook is not None: + maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request) + if isinstance(maybe_request, Awaitable): + request = await maybe_request + else: + request = maybe_request + self.requests.append(request) + message = Message(role="assistant", contents=self._contents) + result = HostedRunResult(_FakeResp(text=self._reply, messages=[message], value=self._structured)) + if response_hook is not None: + maybe_result = response_hook(result, request=request, channel_name=channel_name or request.channel) + if isinstance(maybe_result, Awaitable): + return await maybe_result + return maybe_result + return result + + async def run_stream( + self, + request: ChannelRequest, + *, + run_hook: Any | None = None, + protocol_request: Any | None = None, + stream_update_hook: Any | None = None, + response_hook: Any | None = None, + channel_name: str | None = None, + ) -> _FakeStream: + if run_hook is not None: + maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request) + if isinstance(maybe_request, Awaitable): + request = await maybe_request + else: + request = maybe_request + self.requests.append(request) + result = HostedRunResult(_FakeResp(text="".join(self._chunks), value=self._structured)) + if response_hook is not None: + maybe_result = response_hook(result, request=request, channel_name=channel_name or request.channel) + if isinstance(maybe_result, Awaitable): + result = await maybe_result + else: + result = maybe_result + return _FakeStream(self._chunks, final=result.result) + + +def _make_channel(ctx: _FakeContext, **kwargs: Any) -> MCPChannel: + channel = MCPChannel(**kwargs) + channel.contribute(ctx) # type: ignore[arg-type] + return channel + + +class _HostedAgent: + name = "HostedAssistant" + description = "A hosted test assistant." + + async def run(self, messages: Any = None, *, stream: bool = False, **_kwargs: Any) -> Any: + text = messages.text if isinstance(messages, Message) else str(messages) + if stream: + updates = [AgentResponseUpdate(contents=[Content.from_text(text=f"host: {text}")], role="assistant")] + + async def _gen() -> AsyncIterator[AgentResponseUpdate]: + for update in updates: + yield update + + async def _finalize(items: Sequence[AgentResponseUpdate]) -> AgentResponse: # noqa: RUF029 + return AgentResponse.from_updates(items) + + return ResponseStream[AgentResponseUpdate, AgentResponse](_gen(), finalizer=_finalize) + return AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(text=f"host: {text}")])]) + + +@asynccontextmanager +async def _serve_app(app: ASGIApp, *, port: int) -> AsyncIterator[str]: + config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning", lifespan="on") + server = uvicorn.Server(config) + task = asyncio.create_task(server.serve()) + try: + for _ in range(100): + if server.started: + break + await asyncio.sleep(0.01) + else: + raise RuntimeError("Test MCP server did not start") + yield f"http://127.0.0.1:{port}" + finally: + server.should_exit = True + await task + + +# --------------------------------------------------------------------------- # +# Tests # +# --------------------------------------------------------------------------- # + + +async def test_list_tools_advertises_single_configured_tool() -> None: + ctx = _FakeContext() + channel = _make_channel(ctx, tool_name="ask", tool_description="Ask the assistant.") + async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type] + result = await client.list_tools() + assert len(result.tools) == 1 + tool = result.tools[0] + assert tool.name == "ask" + assert tool.description == "Ask the assistant." + assert tool.inputSchema["required"] == ["input"] + assert set(tool.inputSchema["properties"]) == {"input", "session_id"} + + +async def test_initialize_uses_target_name_by_default() -> None: + ctx = _FakeContext() + channel = _make_channel(ctx) + async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type] + result = await client.initialize() + assert result.serverInfo.name == "Assistant" + + +async def test_call_tool_routes_through_host_and_returns_text() -> None: + ctx = _FakeContext(reply="hi back", chunks=["hi", " back"]) + channel = _make_channel(ctx, streaming=False) + async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type] + result = await client.call_tool("run_agent", {"input": "hello", "session_id": "conv-1"}) + assert not result.isError + assert isinstance(result.content[0], types.TextContent) + assert result.content[0].text == "hi back" + # The channel built a channel-neutral request routed through the host. + assert len(ctx.requests) == 1 + request = ctx.requests[0] + assert request.channel == "mcp" + assert request.operation == "message.create" + assert request.input == "hello" + assert request.session is not None + assert request.session.isolation_key == "conv-1" + assert request.identity is not None + assert request.identity.native_id == "conv-1" + + +async def test_call_tool_returns_rich_content_and_structured_output() -> None: + ctx = _FakeContext( + contents=[ + Content.from_text(text="text"), + Content.from_data(data=b"image-bytes", media_type="image/png"), + Content.from_data(data=b"audio-bytes", media_type="audio/wav"), + Content.from_data(data=b"raw-bytes", media_type="application/octet-stream"), + Content.from_uri(uri="https://example.com/file.json", media_type="application/json"), + ], + structured={"answer": 42}, + ) + channel = _make_channel(ctx, streaming=False) + async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type] + result = await client.call_tool("run_agent", {"input": "hello"}) + + assert result.structuredContent == {"answer": 42} + assert [item.type for item in result.content] == ["text", "image", "audio", "resource", "resource_link"] + assert result.content[0].text == "text" # type: ignore[union-attr] + + +async def test_call_tool_streaming_aggregates_chunks() -> None: + ctx = _FakeContext(chunks=["foo", "bar", "baz"]) + channel = _make_channel(ctx, streaming=True) + async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type] + result = await client.call_tool("run_agent", {"input": "hello"}) + assert result.content[0].text == "foobarbaz" # type: ignore[union-attr] + # No session_id supplied -> no session / identity. + assert ctx.requests[0].session is None + assert ctx.requests[0].identity is None + + +async def test_call_tool_rejects_empty_input() -> None: + ctx = _FakeContext() + channel = _make_channel(ctx) + async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type] + result = await client.call_tool("run_agent", {"input": ""}) + assert result.isError + assert "non-empty string" in result.content[0].text # type: ignore[union-attr] + assert ctx.requests == [] + + +async def test_run_hook_can_reshape_request() -> None: + ctx = _FakeContext(reply="ok") + + async def _hook(request: ChannelRequest, *, target: Any, protocol_request: Any) -> ChannelRequest: + import dataclasses + + return dataclasses.replace(request, attributes={**dict(request.attributes), "hooked": True}) + + channel = _make_channel(ctx, streaming=False, run_hook=_hook) + async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type] + await client.call_tool("run_agent", {"input": "hello"}) + assert ctx.requests[0].attributes.get("hooked") is True + + +async def test_response_hook_can_shape_originating_reply() -> None: + ctx = _FakeContext(reply="original") + + async def _hook( + result: HostedRunResult[Any], + *, + request: ChannelRequest, + channel_name: str, + ) -> HostedRunResult[Any]: + assert channel_name == "mcp" + assert request.channel == "mcp" + return HostedRunResult(_FakeResp(text="hooked")) + + channel = _make_channel(ctx, streaming=False, response_hook=_hook) + async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type] + result = await client.call_tool("run_agent", {"input": "hello"}) + assert result.content[0].text == "hooked" # type: ignore[union-attr] + + +async def test_streaming_response_hook_shapes_final_reply() -> None: + ctx = _FakeContext(chunks=["raw"]) + + async def _hook( + result: HostedRunResult[Any], + *, + request: ChannelRequest, + channel_name: str, + ) -> HostedRunResult[Any]: + return HostedRunResult(_FakeResp(text=f"{channel_name}:{request.channel}:{result.result.text}")) + + channel = _make_channel(ctx, streaming=True, response_hook=_hook) + async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type] + result = await client.call_tool("run_agent", {"input": "hello"}) + assert result.content[0].text == "mcp:mcp:raw" # type: ignore[union-attr] + + +def test_default_path_and_name() -> None: + channel = MCPChannel() + assert channel.name == "mcp" + assert channel.path == "/mcp" + + +def test_content_conversion_handles_non_text_shapes() -> None: + from agent_framework_hosting_mcp._channel import _content_to_mcp, _structured_content, _value_to_mcp + + @dataclass + class StructuredValue: + answer: int + + circular: list[Any] = [] + circular.append(circular) + + assert _structured_content(None) is None + assert _structured_content(StructuredValue(answer=42)) == {"answer": 42} + assert _structured_content(circular) == {"value": "[[...]]"} + assert _content_to_mcp(Content("data", uri="not-a-data-uri", media_type="application/octet-stream")) == [] + assert _content_to_mcp(Content("text_reasoning", text="because"))[0].text == "because" # type: ignore[union-attr] + assert ( + _content_to_mcp(Content.from_function_result("call-1", result=[Content.from_text("nested")]))[0].text + == "nested" + ) # type: ignore[union-attr] + assert _content_to_mcp(Content.from_function_result("call-1", result={"x": 1}))[0].text == '{"x": 1}' # type: ignore[union-attr] + assert _content_to_mcp(Content.from_error(message="bad"))[0].text == "bad" # type: ignore[union-attr] + assert _content_to_mcp(Content.from_function_call("call-1", "tool")) == [] + assert _value_to_mcp(Message(role="assistant", contents=[Content.from_text("message")]))[0].text == "message" # type: ignore[union-attr] + assert _value_to_mcp(b"bytes")[0].type == "resource" + assert _value_to_mcp({"x": 1})[0].text == '{"x": 1}' # type: ignore[union-attr] + + +def test_result_conversion_handles_workflow_and_fallback_shapes() -> None: + class WorkflowResult: + value = None + + def get_outputs(self) -> list[Message]: + return [Message(role="assistant", contents=[Content.from_text("workflow")])] + + @dataclass + class TextOnlyResult: + text: str + value: Any | None = None + + channel = MCPChannel() + + workflow_result = channel._result_to_content(HostedRunResult(WorkflowResult())) + assert workflow_result.content[0].text == "workflow" # type: ignore[union-attr] + + text_result = channel._result_to_content(HostedRunResult(TextOnlyResult(text="fallback"))) + assert text_result.content[0].text == "fallback" # type: ignore[union-attr] + + structured_result = channel._result_to_content(HostedRunResult(TextOnlyResult(text="", value={"x": 1}))) + assert structured_result.structuredContent == {"x": 1} + assert structured_result.content[0].text == '{\n "x": 1\n}' # type: ignore[union-attr] + + empty_result = channel._result_to_content(HostedRunResult(TextOnlyResult(text=""))) + assert empty_result.content[0].text == "" # type: ignore[union-attr] + + +async def test_http_mcp_client_can_call_hosted_channel(unused_tcp_port: int) -> None: + host = AgentFrameworkHost(target=_HostedAgent(), channels=[MCPChannel(streaming=False)]) + + async with ( + _serve_app(host.app, port=unused_tcp_port) as base_url, + streamable_http_client(f"{base_url}/mcp/") as (read_stream, write_stream, _), + ClientSession(read_stream, write_stream) as session, + ): + await session.initialize() + tools = await session.list_tools() + result = await session.call_tool("run_agent", {"input": "hello", "session_id": "conv-1"}) + + assert [tool.name for tool in tools.tools] == ["run_agent"] + assert result.content[0].text == "host: hello" # type: ignore[union-attr] diff --git a/python/packages/hosting-responses/LICENSE b/python/packages/hosting-responses/LICENSE new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/python/packages/hosting-responses/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/hosting-responses/README.md b/python/packages/hosting-responses/README.md new file mode 100644 index 00000000000..ae03d364af3 --- /dev/null +++ b/python/packages/hosting-responses/README.md @@ -0,0 +1,21 @@ +# agent-framework-hosting-responses + +OpenAI Responses-shaped channel for `agent-framework-hosting`. + +Exposes a single `POST /responses` endpoint that accepts the OpenAI +Responses API request body and returns either a Responses-shaped JSON +body or a Server-Sent-Events stream when `stream=True`. + +```python +from agent_framework.openai import OpenAIChatClient +from agent_framework_hosting import AgentFrameworkHost +from agent_framework_hosting_responses import ResponsesChannel + +agent = OpenAIChatClient().as_agent(name="Assistant") + +host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()]) +host.serve(port=8000) +``` + +The base host plumbing lives in +[`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/). diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py b/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py new file mode 100644 index 00000000000..cd221b56824 --- /dev/null +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""OpenAI Responses-shaped channel for ``agent-framework-hosting``.""" + +import importlib.metadata + +from ._channel import ResponsesChannel +from ._parsing import ( + messages_from_responses_input, + parse_responses_identity, + parse_responses_request, +) + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = [ + "ResponsesChannel", + "__version__", + "messages_from_responses_input", + "parse_responses_identity", + "parse_responses_request", +] diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py b/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py new file mode 100644 index 00000000000..20eabdfc105 --- /dev/null +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py @@ -0,0 +1,363 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""``ResponsesChannel`` — OpenAI Responses-shaped HTTP surface. + +Exposes a single ``POST /responses`` endpoint that accepts +``{"input": "...", "stream": false}`` (and the rest of the Responses API +request body) and returns either a Responses-shaped JSON body +(``stream=False``, default) or a Server-Sent-Events stream +(``stream=True``). + +Payload construction reuses the ``openai.types.responses`` Pydantic +models so the OpenAI Python SDK ``stream=True`` consumer parses every +required field without surprises. +""" + +from __future__ import annotations + +import time +import uuid +from collections.abc import AsyncIterator, Callable, Mapping +from typing import Any + +from agent_framework_hosting import ( + ChannelContext, + ChannelContribution, + ChannelRequest, + ChannelResponseHook, + ChannelRunHook, + ChannelSession, + ChannelStreamUpdateHook, + get_current_isolation_keys, + logger, +) +from openai.types.responses import ( + Response as OpenAIResponse, +) +from openai.types.responses import ( + ResponseCompletedEvent, + ResponseCreatedEvent, + ResponseError, + ResponseFailedEvent, + ResponseOutputMessage, + ResponseOutputText, + ResponseTextDeltaEvent, +) +from starlette.requests import Request +from starlette.responses import JSONResponse, Response, StreamingResponse +from starlette.routing import Route + +from ._parsing import ( + parse_responses_identity, + parse_responses_request, +) + + +class ResponsesChannel: + """Minimal OpenAI-Responses-shaped surface. + + Mounts ``POST /responses`` (default path ``/responses`` so the + full route is ``/responses/responses`` when the channel is prefixed, + or just ``/`` when ``path=""``). + """ + + name = "responses" + + def __init__( + self, + *, + path: str = "/responses", + run_hook: ChannelRunHook | None = None, + response_hook: ChannelResponseHook | None = None, + stream_update_hook: ChannelStreamUpdateHook | None = None, + response_id_factory: Callable[..., str] | None = None, + ) -> None: + """Create a Responses channel. + + Keyword Args: + path: Endpoint path on the host. Default ``"/responses"`` matches + the upstream OpenAI surface; use ``""`` to expose this channel + at the app root. + run_hook: Optional :data:`ChannelRunHook` the host invokes with + the parsed :class:`ChannelRequest` before the agent target + runs. May return a replacement request. + response_hook: Optional :data:`ChannelResponseHook` the host invokes + before the channel serializes an originating + :class:`HostedRunResult` into a Responses envelope. + stream_update_hook: Optional per-update hook + applied while streaming Server-Sent Events. Return a + replacement update, or ``None`` to drop the update. + response_id_factory: Optional callable that mints the + per-request response id. Default produces + ``resp_`` which matches the OpenAI Responses + wire shape. Override when the host backing storage + requires a different id format (e.g. Foundry storage, + whose partition keys are encoded in the id and which + rejects free-form ``resp_*`` ids with a server error). + The same id is used for the channel envelope and for + the host-side anchoring (``ChannelRequest.attributes``) + so storage and replay agree. + + Security note on partition co-location: when a caller + supplies ``previous_response_id`` we forward it to the + factory so id backends that embed partition keys can + co-locate the new record with the chain's existing + partition. The factory passes that hint through to the + storage layer; **partition ownership is enforced at + the storage layer**, not in the channel: the Foundry + storage provider, for example, validates the request + against the bound user/chat isolation keys and rejects + writes whose embedded partition does not match the + authenticated caller's isolation. Channel-level + forwarding is therefore a performance hint, not a + security boundary; the host's isolation middleware + must establish the caller's identity before this + route is entered. + """ + self.path = path + self._hook = run_hook + self.response_hook = response_hook + self._stream_update_hook = stream_update_hook + self._ctx: ChannelContext | None = None + self._response_id_factory: Callable[..., str] = ( + response_id_factory if response_id_factory is not None else (lambda *_a, **_kw: f"resp_{uuid.uuid4().hex}") + ) + + def contribute(self, context: ChannelContext) -> ChannelContribution: + """Capture the host-supplied context and register the endpoint route.""" + self._ctx = context + return ChannelContribution(routes=[Route("/", self._handle, methods=["POST"])]) + + async def _handle(self, request: Request) -> Response: + """Handle a single Responses API call. + + Parses the OpenAI Responses-shaped body into ``Message`` / + ``options`` / ``ChannelSession`` triples via :mod:`._parsing`, + applies the optional ``run_hook``, and either streams an SSE + response stream or returns a one-shot OpenAI ``Response`` envelope. + """ + if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle + return JSONResponse({"error": "channel not initialized"}, status_code=500) + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "invalid json"}, status_code=400) + + try: + messages, options, session = parse_responses_request(body) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=422) + + # When no ``previous_response_id`` chain anchor is on the body, + # surface the isolation key the **host** lifted off the request + # (via ``_FoundryIsolationASGIMiddleware`` for the default + # Foundry-platform deployment, or whatever middleware the + # operator configured in front of the host) as the channel + # session id, so callers without an explicit anchor still get + # a stable per-conversation session id (used by non-Foundry + # history providers, routing/idempotency, etc.). + # + # Security note: we consume the host-bound contextvar set by the + # ASGI isolation middleware, NOT the raw header off the wire. + # That middleware is the operator's place to enforce auth and + # gate which callers get to set isolation. If you mount the host + # in front of a custom auth boundary, your middleware should + # validate the caller before stamping ``set_current_isolation_keys``; + # never trust raw wire headers to identify a session bucket. + # The chat-iso value is *not* a valid storage anchor: the + # Foundry history provider deliberately ignores it — multi-turn + # storage chaining goes through the ``previous_response_id`` / + # bound ``response_id`` pair on ``ChannelRequest.attributes``. + bound_keys = get_current_isolation_keys() + chat_iso = bound_keys.chat_key if bound_keys is not None else None + if session is None and chat_iso: + session = ChannelSession(isolation_key=chat_iso) + + # Mint the response id once per request so the channel envelope + # (one-shot or streamed) and any host-side anchoring (e.g. the + # Foundry history provider's ``bind_request_context``) agree on + # the same handle. The next turn arrives with this value as + # ``previous_response_id`` and the storage chain walks. We pass + # both anchors via ``ChannelRequest.attributes`` so the host + # can pick them up without a channel-specific contract. + previous_response_id: str | None = None + prev_raw = body.get("previous_response_id") + if isinstance(prev_raw, str) and prev_raw: + previous_response_id = prev_raw + # Pass the previous id (if any) as a hint to the factory so id + # backends that embed partition keys (e.g. Foundry storage) can + # co-locate the new record with the chain's existing partition. + # No-arg factories continue to work via ``Callable[..., str]``. + response_id = self._response_id_factory(previous_response_id) + + attributes: dict[str, Any] = {"response_id": response_id} + if previous_response_id is not None: + attributes["previous_response_id"] = previous_response_id + + # Honor the OpenAI-Responses ``stream`` flag — non-streaming by + # default, SSE when the caller opts in. The channel chooses the + # transport before run hooks execute. + channel_request = ChannelRequest( + channel=self.name, + operation="message.create", + input=messages, + session=session, + options=options or None, + stream=bool(body.get("stream", False)), + identity=parse_responses_identity(body, self.name), + attributes=attributes, + ) + + if channel_request.stream: + return StreamingResponse( + self._stream_events(channel_request, body, response_id=response_id), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + result = await self._ctx.run( + channel_request, + run_hook=self._hook, + protocol_request=body, + response_hook=self.response_hook, + channel_name=self.name, + ) + text = result.result.text + envelope = self._build_response(body, text, status="completed", response_id=response_id) + return JSONResponse(envelope.model_dump(mode="json", exclude_none=True)) + + def _build_response( + self, + body: Mapping[str, Any], + text: str, + *, + status: str, + response_id: str | None = None, + ) -> OpenAIResponse: + """Construct an OpenAI ``Response`` for a finished (non-streaming) run. + + ``status`` mirrors the top-level Response status set values + (``in_progress`` / ``completed`` / ``failed`` / ``incomplete`` / + ``cancelled``). The nested ``ResponseOutputMessage.status`` field + only accepts ``in_progress`` / ``completed`` / ``incomplete``, so + terminal-but-non-success states collapse to ``incomplete`` there + — the failure detail still travels via the top-level ``status`` + and (for streamed errors) the ``error`` field. + + ``response_id``: the per-request id minted in :meth:`_handle`. + Passed in so envelope and storage agree on a single handle per + turn (see :meth:`_handle` notes). Falls back to a fresh uuid + when callers (e.g. :meth:`_stream_events`'s skeleton path + before this argument was introduced) don't supply one. + """ + message_status = status if status in ("in_progress", "completed", "incomplete") else "incomplete" + return OpenAIResponse( + id=response_id or self._response_id_factory(None), + object="response", + created_at=time.time(), + status=status, # type: ignore[arg-type] + model=body.get("model", "agent"), + output=[ + ResponseOutputMessage( + id=f"msg_{uuid.uuid4().hex}", + type="message", + role="assistant", + status=message_status, # type: ignore[arg-type] + content=[ResponseOutputText(type="output_text", text=text, annotations=[])], + ) + ], + parallel_tool_calls=False, + tool_choice="auto", + tools=[], + metadata={}, + ) + + async def _stream_events( + self, + request: ChannelRequest, + body: Mapping[str, Any], + *, + response_id: str, + ) -> AsyncIterator[str]: + """Yield SSE events shaped like the OpenAI Responses streaming protocol. + + Emits ``response.created`` → many ``response.output_text.delta`` + → ``response.completed`` (or ``response.failed`` on error). + """ + if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle + return + + msg_id = f"msg_{uuid.uuid4().hex}" + seq = 0 + + def next_seq() -> int: + nonlocal seq + seq += 1 + return seq + + def sse(event: Any) -> str: + return f"event: {event.type}\ndata: {event.model_dump_json(exclude_none=True)}\n\n" + + skeleton = self._build_response(body, "", status="in_progress", response_id=response_id) + yield sse(ResponseCreatedEvent(type="response.created", response=skeleton, sequence_number=next_seq())) + + accumulated = "" + try: + stream = await self._ctx.run_stream( + request, + run_hook=self._hook, + protocol_request=body, + stream_update_hook=self._stream_update_hook, + response_hook=self.response_hook, + channel_name=self.name, + ) + async for update in stream: + chunk = getattr(update, "text", None) + if chunk: + accumulated += chunk + yield sse( + ResponseTextDeltaEvent( + type="response.output_text.delta", + item_id=msg_id, + output_index=0, + content_index=0, + delta=chunk, + logprobs=[], + sequence_number=next_seq(), + ) + ) + try: + # Finalize so context-provider / history hooks on the agent + # still run even though we are emitting our own SSE. + final_response = await stream.get_final_response() + except Exception: # pragma: no cover - finalize is best-effort + logger.exception("Responses stream finalize failed") + final_response = None + except Exception as exc: + logger.exception("Responses stream consumption failed") + failed = self._build_response(body, accumulated, status="failed", response_id=response_id) + failed.error = ResponseError(code="server_error", message=str(exc)) + yield sse( + ResponseFailedEvent( + type="response.failed", + response=failed, + sequence_number=next_seq(), + ) + ) + return + + completed_text = getattr(final_response, "text", None) or accumulated + completed = self._build_response(body, completed_text, status="completed", response_id=response_id) + # Reuse the same message id we emitted deltas under. + if completed.output and isinstance(completed.output[0], ResponseOutputMessage): + completed.output[0].id = msg_id + yield sse( + ResponseCompletedEvent( + type="response.completed", + response=completed, + sequence_number=next_seq(), + ) + ) + + +__all__ = ["ResponsesChannel"] diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py new file mode 100644 index 00000000000..51d4ea4ecbe --- /dev/null +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py @@ -0,0 +1,169 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Parsing helpers for the OpenAI Responses-API request body. + +The Responses API accepts ``input`` as either a string or a list of "input +items". An item is either a content part (``input_text`` / ``input_image`` +/ ``input_file``) or a message envelope ``{type: "message", role, +content: [...]}``. We translate that into an Agent Framework ``Message`` +list and split out the ChatOptions-shaped fields the API also carries. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +from agent_framework import Content, Message +from agent_framework_hosting import ChannelIdentity, ChannelSession + +# OpenAI Responses field name → Agent Framework ChatOptions field name. +_RESPONSES_OPTION_REMAP = { + "max_output_tokens": "max_tokens", + "parallel_tool_calls": "allow_multiple_tool_calls", +} +# Fields we forward to ChatOptions verbatim. ``instructions`` stays here +# because Agent Framework exposes it as a ChatOptions field; it must not be +# lifted into a synthetic system message. +_RESPONSES_OPTION_PASSTHROUGH = { + "instructions", + "temperature", + "top_p", + "metadata", + "user", + "safety_identifier", + "tool_choice", + "tools", + "store", + "response_format", + "stop", + "seed", + "frequency_penalty", + "presence_penalty", + "logit_bias", +} +# Fields the Responses transport owns; they must not be forwarded as options. +_RESPONSES_TRANSPORT_KEYS = {"input", "model", "stream", "previous_response_id"} + + +def parse_responses_identity(body: Mapping[str, Any], channel_name: str) -> ChannelIdentity | None: + """Surface the caller as a :class:`ChannelIdentity` so the host can record it. + + OpenAI Responses replaced ``user`` with ``safety_identifier`` — we use + that as the native id, falling back to the legacy ``user`` field. + """ + native = body.get("safety_identifier") or body.get("user") + if not isinstance(native, str) or not native: + return None + return ChannelIdentity(channel=channel_name, native_id=native) + + +def _content_from_input_item(item: Mapping[str, Any]) -> Content: + """Convert a single OpenAI Responses ``input`` item into a :class:`Content` part. + + Handles the ``input_text``/``output_text``/``text`` text variants, + ``input_image`` URL references, and ``input_file`` references via either + a public URL or a hosted ``file_id``. Raises ``ValueError`` for any + unsupported item type so the surrounding parser can return a 422. + """ + item_type = item.get("type") + if item_type in ("input_text", "output_text", "text"): + return Content.from_text(text=str(item.get("text", ""))) + if item_type == "input_image": + image_url: Any = item.get("image_url") + if isinstance(image_url, Mapping): + image_url = cast("Mapping[str, Any]", image_url).get("url") + if not isinstance(image_url, str): + raise ValueError("input_image requires `image_url`") + return Content.from_uri(uri=image_url, media_type="image/*") + if item_type == "input_file": + if (uri := item.get("file_url")) and isinstance(uri, str): + return Content.from_uri(uri=uri, media_type=item.get("mime_type")) + if file_id := item.get("file_id"): + return Content(type="hosted_file", file_id=str(file_id)) + raise ValueError("input_file requires `file_url` or `file_id`") + raise ValueError(f"Unsupported Responses input content type: {item_type!r}") + + +def messages_from_responses_input(value: Any) -> list[Message]: + """Translate ``input`` (string or list of items) into :class:`Message` objects.""" + if isinstance(value, str): + return [Message("user", [Content.from_text(text=value)])] + if not isinstance(value, list) or not value: + raise ValueError("`input` must be a non-empty string or list") + + messages: list[Message] = [] + pending_user_parts: list[Content] = [] + + def flush() -> None: + """Emit any buffered loose user content as a single user message.""" + if pending_user_parts: + messages.append(Message("user", list(pending_user_parts))) + pending_user_parts.clear() + + for item in cast("list[Any]", value): # type: ignore[redundant-cast] + if not isinstance(item, Mapping): + raise ValueError("each `input` item must be an object") + item_map = cast("Mapping[str, Any]", item) + if item_map.get("type") == "message": + flush() + role = str(item_map.get("role") or "user") + content: Any = item_map.get("content") or [] + parts: list[Content] + if isinstance(content, str): + parts = [Content.from_text(text=content)] + elif isinstance(content, list): + parts = [ + _content_from_input_item(cast("Mapping[str, Any]", c)) + for c in cast("list[Any]", content) # type: ignore[redundant-cast] + if isinstance(c, Mapping) + ] + else: + parts = [] + messages.append(Message(role, parts)) + else: + pending_user_parts.append(_content_from_input_item(item_map)) + + flush() + if not messages: + raise ValueError("`input` produced no messages") + return messages + + +def parse_responses_request( + body: Mapping[str, Any], +) -> tuple[list[Message], dict[str, Any], ChannelSession | None]: + """Translate a Responses-API request body into Agent Framework constructs. + + Returns a triple ``(messages, options, session)`` where: + + - ``messages`` is the parsed conversation. + - ``options`` is a ``ChatOptions``-shaped dict with the model-tunable + fields the channel lifted off the body. + - ``session`` is a :class:`ChannelSession` keyed by + ``previous_response_id`` when one was supplied, else ``None``. + """ + messages = messages_from_responses_input(body.get("input")) + + options: dict[str, Any] = {} + for key, value in body.items(): + if key in _RESPONSES_TRANSPORT_KEYS or value is None: + continue + if (mapped := _RESPONSES_OPTION_REMAP.get(key)) is not None: + options[mapped] = value + elif key in _RESPONSES_OPTION_PASSTHROUGH: + options[key] = value + # silently drop everything else (truncation, reasoning, include, ...) + + session: ChannelSession | None = None + if (prev := body.get("previous_response_id")) and isinstance(prev, str): + session = ChannelSession(isolation_key=prev) + + return messages, options, session + + +__all__ = [ + "messages_from_responses_input", + "parse_responses_identity", + "parse_responses_request", +] diff --git a/python/packages/hosting-responses/pyproject.toml b/python/packages/hosting-responses/pyproject.toml new file mode 100644 index 00000000000..6606c94455a --- /dev/null +++ b/python/packages/hosting-responses/pyproject.toml @@ -0,0 +1,98 @@ +[project] +name = "agent-framework-hosting-responses" +description = "OpenAI Responses-shaped channel for agent-framework-hosting." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0a260424" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.2.0,<2", + "agent-framework-hosting==1.0.0a260424", + "openai>=1.99.0,<3", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_hosting_responses"] +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_hosting_responses"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_responses" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_responses --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/hosting-responses/tests/__init__.py b/python/packages/hosting-responses/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/hosting-responses/tests/test_channel.py b/python/packages/hosting-responses/tests/test_channel.py new file mode 100644 index 00000000000..6bd224f1393 --- /dev/null +++ b/python/packages/hosting-responses/tests/test_channel.py @@ -0,0 +1,272 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""End-to-end tests for :class:`ResponsesChannel` via Starlette's ``TestClient``.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Any + +from agent_framework_hosting import ( + AgentFrameworkHost, + HostedRunResult, +) +from starlette.testclient import TestClient + +from agent_framework_hosting_responses import ResponsesChannel + +# --------------------------------------------------------------------------- # +# Fakes # +# --------------------------------------------------------------------------- # + + +@dataclass +class _FakeAgentResponse: + text: str + + +@dataclass +class _FakeUpdate: + text: str + + +class _FakeStream: + """Minimal stand-in for AF's ``ResponseStream`` returned by ``run(stream=True)``.""" + + def __init__(self, chunks: list[str]) -> None: + self._chunks = chunks + self._final = _FakeAgentResponse(text="".join(chunks)) + + def __aiter__(self) -> AsyncIterator[_FakeUpdate]: + async def _gen() -> AsyncIterator[_FakeUpdate]: + for c in self._chunks: + yield _FakeUpdate(c) + + return _gen() + + async def get_final_response(self) -> _FakeAgentResponse: + return self._final + + +class _FakeAgent: + def __init__(self, reply: str = "hello", chunks: list[str] | None = None) -> None: + self._reply = reply + self._chunks = chunks or [reply] + self.calls: list[dict[str, Any]] = [] + + def create_session(self, *, session_id: str | None = None) -> Any: + return {"session_id": session_id} + + def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any: + self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs}) + if stream: + return _FakeStream(self._chunks) + + async def _coro() -> _FakeAgentResponse: + return _FakeAgentResponse(text=self._reply) + + return _coro() + + +# --------------------------------------------------------------------------- # +# Tests # +# --------------------------------------------------------------------------- # + + +def _make_client( + agent: _FakeAgent | None = None, + *, + path: str = "/responses", +) -> tuple[TestClient, AgentFrameworkHost, _FakeAgent]: + agent = agent or _FakeAgent() + host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(path=path)]) + return TestClient(host.app), host, agent + + +class TestResponsesChannelNonStreaming: + def test_post_responses_returns_completed_envelope(self) -> None: + client, _host, agent = _make_client(_FakeAgent(reply="hi back")) + with client: + r = client.post("/responses", json={"input": "hi"}) + assert r.status_code == 200 + body = r.json() + assert body["status"] == "completed" + assert body["object"] == "response" + assert body["id"].startswith("resp_") + assert body["output"][0]["content"][0]["text"] == "hi back" + assert len(agent.calls) == 1 + + def test_empty_path_mounts_at_app_root(self) -> None: + client, _host, _agent = _make_client(_FakeAgent(reply="hi back"), path="") + with client: + r = client.post("/", json={"input": "hi"}) + assert r.status_code == 200 + assert r.json()["output"][0]["content"][0]["text"] == "hi back" + + def test_invalid_json_returns_400(self) -> None: + client, *_ = _make_client() + with client: + r = client.post("/responses", content=b"{not json", headers={"content-type": "application/json"}) + assert r.status_code == 400 + + def test_invalid_input_returns_422(self) -> None: + client, *_ = _make_client() + with client: + r = client.post("/responses", json={"input": 42}) + assert r.status_code == 422 + + def test_options_propagate_to_target_run(self) -> None: + client, _host, agent = _make_client() + with client: + r = client.post("/responses", json={"input": "x", "temperature": 0.5, "max_output_tokens": 64}) + assert r.status_code == 200 + opts = agent.calls[0]["kwargs"]["options"] + assert opts == {"temperature": 0.5, "max_tokens": 64} + + def test_previous_response_id_creates_session(self) -> None: + client, _host, agent = _make_client() + with client: + client.post("/responses", json={"input": "x", "previous_response_id": "resp_42"}) + # AgentFrameworkHost converts the channel session into an AgentSession. + sess = agent.calls[0]["kwargs"].get("session") + assert sess is not None + # _FakeAgent.create_session stashes the session_id on the dict it returns. + assert sess["session_id"] == "resp_42" + + def test_chat_isolation_header_ignored_outside_foundry(self) -> None: + client, _host, agent = _make_client() + with client: + client.post( + "/responses", + json={"input": "x"}, + headers={"x-agent-chat-isolation-key": "chat-abc"}, + ) + assert "session" not in agent.calls[0]["kwargs"] + + def test_chat_isolation_header_creates_session_in_foundry(self, monkeypatch: Any) -> None: + """Foundry-style ``x-agent-chat-isolation-key`` falls back to a session anchor. + + First-turn requests have no ``previous_response_id`` (the client + doesn't have one yet), but Foundry Hosted Agents always inject + the isolation headers. The channel must derive a session from the + chat key so the host can build a stable per-conversation session + that history providers persist under. + """ + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + client, _host, agent = _make_client() + with client: + client.post( + "/responses", + json={"input": "x"}, + headers={"x-agent-chat-isolation-key": "chat-abc"}, + ) + sess = agent.calls[0]["kwargs"].get("session") + assert sess is not None + assert sess["session_id"] == "chat-abc" + + def test_prev_response_id_wins_over_chat_isolation_header(self, monkeypatch: Any) -> None: + """When both anchors are present, ``previous_response_id`` wins. + + ``previous_response_id`` is the protocol-native chain anchor; the + header fallback is only meant to bootstrap when no protocol + anchor exists. + """ + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + client, _host, agent = _make_client() + with client: + client.post( + "/responses", + json={"input": "x", "previous_response_id": "resp_99"}, + headers={"x-agent-chat-isolation-key": "chat-abc"}, + ) + sess = agent.calls[0]["kwargs"].get("session") + assert sess is not None + assert sess["session_id"] == "resp_99" + + def test_response_hook_can_rewrite_originating_reply(self) -> None: + seen_kwargs: list[dict[str, Any]] = [] + + def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult: + seen_kwargs.append(dict(kwargs)) + return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session) + + agent = _FakeAgent(reply="hooked") + host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(response_hook=hook)]) + + with TestClient(host.app) as client: + r = client.post("/responses", json={"input": "hi"}) + + assert r.status_code == 200 + body = r.json() + assert body["output"][0]["content"][0]["text"] == "HOOKED" + assert seen_kwargs + assert seen_kwargs[0]["channel_name"] == "responses" + + +class TestResponsesChannelStreaming: + def test_sse_emits_created_delta_completed(self) -> None: + agent = _FakeAgent(reply="hello world", chunks=["hello", " ", "world"]) + host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()]) + with TestClient(host.app) as client: + r = client.post("/responses", json={"input": "hi", "stream": True}) + assert r.status_code == 200 + body = r.text + + # SSE event lines look like "event: \ndata: \n\n". + events = [line[len("event: ") :] for line in body.splitlines() if line.startswith("event: ")] + assert events[0] == "response.created" + assert events[-1] == "response.completed" + assert events.count("response.output_text.delta") == 3 + + def test_sse_transform_hook_can_rewrite_chunks(self) -> None: + agent = _FakeAgent(reply="hello", chunks=["he", "llo"]) + + def transform(update: _FakeUpdate) -> _FakeUpdate: + return _FakeUpdate(text=update.text.upper()) + + host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(stream_update_hook=transform)]) + with TestClient(host.app) as client: + r = client.post("/responses", json={"input": "hi", "stream": True}) + + assert r.status_code == 200 + assert '"delta":"HE"' in r.text + assert '"delta":"LLO"' in r.text + # Stream update hooks are update-only; they do not rewrite get_final_response(). + assert '"text":"hello"' in r.text + + def test_sse_emits_failed_when_stream_raises(self) -> None: + # Regression: ResponseOutputMessage.status only accepts in_progress/ + # completed/incomplete, so building an OpenAIResponse with status="failed" + # used to crash with a pydantic ValidationError. The channel must map the + # nested message status to "incomplete" while keeping the top-level + # Response.status="failed". + class _BoomStream: + def __aiter__(self) -> AsyncIterator[_FakeUpdate]: + async def _gen() -> AsyncIterator[_FakeUpdate]: + yield _FakeUpdate("partial") + raise RuntimeError("upstream blew up") + + return _gen() + + async def get_final_response(self) -> _FakeAgentResponse: # pragma: no cover + return _FakeAgentResponse(text="") + + class _BoomAgent(_FakeAgent): + def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any: + self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs}) + if stream: + return _BoomStream() + raise AssertionError("non-streaming path not exercised here") + + host = AgentFrameworkHost(target=_BoomAgent(), channels=[ResponsesChannel()]) + with TestClient(host.app) as client: + r = client.post("/responses", json={"input": "hi", "stream": True}) + assert r.status_code == 200 + body = r.text + + events = [line[len("event: ") :] for line in body.splitlines() if line.startswith("event: ")] + assert events[0] == "response.created" + assert events[-1] == "response.failed" + # The failed envelope must serialize cleanly — i.e. no ValidationError raised. + assert "upstream blew up" in body diff --git a/python/packages/hosting-responses/tests/test_parsing.py b/python/packages/hosting-responses/tests/test_parsing.py new file mode 100644 index 00000000000..e8d47f7f7f1 --- /dev/null +++ b/python/packages/hosting-responses/tests/test_parsing.py @@ -0,0 +1,144 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for the OpenAI Responses request-body parser.""" + +from __future__ import annotations + +import pytest + +from agent_framework_hosting_responses import ( + messages_from_responses_input, + parse_responses_identity, + parse_responses_request, +) + + +class TestMessagesFromResponsesInput: + def test_string_input_becomes_single_user_message(self) -> None: + msgs = messages_from_responses_input("hello") + assert len(msgs) == 1 + assert msgs[0].role == "user" + assert msgs[0].text == "hello" + + def test_input_text_items_collapse_into_one_user_message(self) -> None: + msgs = messages_from_responses_input([{"type": "input_text", "text": "a"}, {"type": "input_text", "text": "b"}]) + assert len(msgs) == 1 + assert msgs[0].role == "user" + assert msgs[0].text == "a b" + + def test_message_envelope_with_string_content(self) -> None: + msgs = messages_from_responses_input([ + {"type": "message", "role": "system", "content": "be brief"}, + {"type": "message", "role": "user", "content": "hi"}, + ]) + assert [m.role for m in msgs] == ["system", "user"] + assert msgs[0].text == "be brief" + + def test_message_envelope_with_content_parts(self) -> None: + msgs = messages_from_responses_input([ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "describe this"}], + } + ]) + assert msgs[0].text == "describe this" + + def test_pending_text_flushes_before_message_envelope(self) -> None: + msgs = messages_from_responses_input([ + {"type": "input_text", "text": "first"}, + {"type": "message", "role": "user", "content": "second"}, + ]) + assert len(msgs) == 2 + assert msgs[0].text == "first" + assert msgs[1].text == "second" + + def test_image_url_via_string(self) -> None: + msgs = messages_from_responses_input([{"type": "input_image", "image_url": "https://example.com/cat.png"}]) + assert len(msgs) == 1 + # Image content present. + assert any(getattr(c, "uri", None) == "https://example.com/cat.png" for c in msgs[0].contents) + + def test_image_url_via_object(self) -> None: + msgs = messages_from_responses_input([ + {"type": "input_image", "image_url": {"url": "https://example.com/cat.png"}} + ]) + assert any(getattr(c, "uri", None) == "https://example.com/cat.png" for c in msgs[0].contents) + + def test_unknown_input_type_raises(self) -> None: + with pytest.raises(ValueError, match="Unsupported"): + messages_from_responses_input([{"type": "weird"}]) + + def test_empty_list_raises(self) -> None: + with pytest.raises(ValueError, match="non-empty"): + messages_from_responses_input([]) + + def test_non_string_non_list_raises(self) -> None: + with pytest.raises(ValueError): + messages_from_responses_input(42) # type: ignore[arg-type] + + def test_image_url_missing_raises(self) -> None: + with pytest.raises(ValueError, match="image_url"): + messages_from_responses_input([{"type": "input_image"}]) + + +class TestParseResponsesRequest: + def test_instructions_are_forwarded_as_chat_options(self) -> None: + msgs, opts, sess = parse_responses_request({"input": "hi", "instructions": "be brief"}) + assert len(msgs) == 1 + assert msgs[0].role == "user" + assert msgs[0].text == "hi" + assert opts["instructions"] == "be brief" + assert sess is None + + def test_options_passthrough(self) -> None: + _, opts, _ = parse_responses_request({"input": "x", "temperature": 0.4, "top_p": 0.9, "tool_choice": "auto"}) + assert opts["temperature"] == 0.4 + assert opts["top_p"] == 0.9 + assert opts["tool_choice"] == "auto" + + def test_options_remap(self) -> None: + _, opts, _ = parse_responses_request({"input": "x", "max_output_tokens": 256, "parallel_tool_calls": False}) + assert opts == {"max_tokens": 256, "allow_multiple_tool_calls": False} + + def test_transport_keys_not_forwarded(self) -> None: + _, opts, _ = parse_responses_request({ + "input": "x", + "model": "gpt-x", + "stream": True, + "previous_response_id": "r", + }) + for key in ("input", "model", "stream", "previous_response_id"): + assert key not in opts + + def test_unknown_keys_silently_dropped(self) -> None: + _, opts, _ = parse_responses_request({"input": "x", "truncation": "auto", "reasoning": {"effort": "low"}}) + assert opts == {} + + def test_none_values_dropped(self) -> None: + _, opts, _ = parse_responses_request({"input": "x", "temperature": None}) + assert "temperature" not in opts + + def test_previous_response_id_becomes_session(self) -> None: + _, _, sess = parse_responses_request({"input": "x", "previous_response_id": "resp_42"}) + assert sess is not None + assert sess.isolation_key == "resp_42" + + +class TestParseResponsesIdentity: + def test_safety_identifier_preferred(self) -> None: + ident = parse_responses_identity({"safety_identifier": "abc", "user": "legacy"}, "responses") + assert ident is not None + assert ident.native_id == "abc" + assert ident.channel == "responses" + + def test_fallback_to_user(self) -> None: + ident = parse_responses_identity({"user": "legacy"}, "responses") + assert ident is not None + assert ident.native_id == "legacy" + + def test_returns_none_when_absent(self) -> None: + assert parse_responses_identity({}, "responses") is None + + def test_returns_none_for_non_string(self) -> None: + assert parse_responses_identity({"safety_identifier": 42}, "responses") is None diff --git a/python/packages/hosting-telegram/LICENSE b/python/packages/hosting-telegram/LICENSE new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/python/packages/hosting-telegram/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/hosting-telegram/README.md b/python/packages/hosting-telegram/README.md new file mode 100644 index 00000000000..aa97e074173 --- /dev/null +++ b/python/packages/hosting-telegram/README.md @@ -0,0 +1,29 @@ +# agent-framework-hosting-telegram + +Telegram channel for [agent-framework-hosting](../hosting). Supports both +**polling** (default — no public URL required, perfect for local dev) and +**webhook** transports, multi-content messages (text + media), command +registration, and end-to-end SSE-style streaming via Telegram message edits. + +## Usage + +```python +from agent_framework_hosting import AgentFrameworkHost +from agent_framework_hosting_telegram import TelegramChannel + +host = AgentFrameworkHost( + target=my_agent, + channels=[TelegramChannel(bot_token="...")], +) +host.serve() +``` + +For production, configure `webhook_url="https://…"` and the channel will +register the webhook on startup and receive updates over HTTPS. + +## Identity & sessions + +Each Telegram chat is mapped to an opaque isolation key +(`telegram:`) so other channels can opt into the same per-chat +session by reusing the same key. The helper `telegram_isolation_key(chat_id)` +is exported for that purpose. diff --git a/python/packages/hosting-telegram/agent_framework_hosting_telegram/__init__.py b/python/packages/hosting-telegram/agent_framework_hosting_telegram/__init__.py new file mode 100644 index 00000000000..0be4a26381b --- /dev/null +++ b/python/packages/hosting-telegram/agent_framework_hosting_telegram/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Telegram channel for :mod:`agent_framework_hosting`.""" + +from ._channel import TelegramChannel, telegram_isolation_key + +__all__ = ["TelegramChannel", "telegram_isolation_key"] diff --git a/python/packages/hosting-telegram/agent_framework_hosting_telegram/_channel.py b/python/packages/hosting-telegram/agent_framework_hosting_telegram/_channel.py new file mode 100644 index 00000000000..4a87e6fc83f --- /dev/null +++ b/python/packages/hosting-telegram/agent_framework_hosting_telegram/_channel.py @@ -0,0 +1,814 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Built-in channel: Telegram (polling + webhook transports). + +Inspired by PR #5393's Telegram sample. Two transports are supported: + +- ``polling`` (default when no ``webhook_url`` is set): the channel runs a + background ``getUpdates`` long-poll loop. No public URL required — + perfect for local development. This is what ``python-telegram-bot`` + uses by default. +- ``webhook``: when ``webhook_url`` is set, the channel registers it via + ``setWebhook`` on startup and receives updates over HTTPS POSTs to the + mounted ``/webhook`` route. This is the production-recommended mode. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import time +from collections.abc import Awaitable, Callable, Mapping, Sequence +from typing import Any, Literal + +import httpx +from agent_framework import ( + AgentResponse, + AgentResponseUpdate, + Content, + Message, + ResponseStream, +) +from agent_framework_hosting import ( + ChannelCommand, + ChannelCommandContext, + ChannelContext, + ChannelContribution, + ChannelIdentity, + ChannelRequest, + ChannelResponseHook, + ChannelRunHook, + ChannelSession, + ChannelStreamUpdateHook, + logger, +) +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import BaseRoute, Route + +# Telegram update parsing ------------------------------------------------------ +# +# A Telegram message can carry text, a caption, and one of several media kinds +# (photo, document, voice, audio, video). For media we resolve the file_id +# into a public bot-file URL via ``getFile`` and emit a ``Content.from_uri``; +# the agent then receives a multi-content Message with text + media side by +# side, the same as it would over the Responses API. + +_TELEGRAM_MEDIA_DEFAULT_MIMETYPE = { + "photo": "image/jpeg", + "document": "application/octet-stream", + "voice": "audio/ogg", + "audio": "audio/mpeg", + "video": "video/mp4", +} + +# Telegram's hard limit on a single message body. Past this, sendMessage / +# editMessageText return 400. We truncate interim and final edits at this +# boundary; if the agent emits more, callers can split into a follow-up +# sendMessage in their run hook. +_TELEGRAM_MAX_TEXT_LEN = 4096 + + +def telegram_isolation_key(chat_id: Any) -> str: + """Build the namespaced isolation key the Telegram channel writes under. + + Exposed at module scope so other channels' ``run_hook`` callbacks can opt + into the same per-chat session (e.g. a Responses caller resuming a + Telegram conversation by passing the chat id). + """ + return f"telegram:{chat_id}" + + +def _telegram_media_file_id(message: Mapping[str, Any]) -> tuple[str, str] | None: + """Return ``(file_id, fallback_media_type)`` for any media on the message.""" + photo = message.get("photo") + if isinstance(photo, list) and photo: + # Telegram delivers photos as an array of progressively-larger sizes. + largest = photo[-1] + if isinstance(largest, Mapping) and (fid := largest.get("file_id")): + return str(fid), _TELEGRAM_MEDIA_DEFAULT_MIMETYPE["photo"] + for kind in ("document", "voice", "audio", "video"): + media = message.get(kind) + if media and isinstance(media, Mapping) and (fid := media.get("file_id")): + return str(fid), str(media.get("mime_type") or _TELEGRAM_MEDIA_DEFAULT_MIMETYPE[kind]) + return None + + +async def _parse_telegram_message( + message: Mapping[str, Any], + resolve_file_url: Callable[[str], Awaitable[str | None]], +) -> Message: + """Translate one Telegram ``message`` object into an Agent Framework Message.""" + parts: list[Content] = [] + if (text := message.get("text") or message.get("caption")) and isinstance(text, str): + parts.append(Content.from_text(text=text)) + + if (media := _telegram_media_file_id(message)) is not None: + file_id, media_type = media + if (uri := await resolve_file_url(file_id)) is not None: + parts.append(Content.from_uri(uri=uri, media_type=media_type)) + + if not parts: + # Edge case: no recognizable content — emit an empty placeholder so the + # agent contract still receives a Message and can react gracefully. + parts.append(Content.from_text(text="")) + return Message("user", parts) + + +class TelegramChannel: + """Telegram channel with both polling and webhook transports. + + Update kinds handled (both transports): + - ``message`` / ``edited_message`` — text, captions, and media + (photo/document/voice/audio/video). + - ``callback_query`` — inline-button presses; the ``data`` payload is + treated as the user's next utterance and the click is acknowledged. + + Streaming + --------- + The channel defaults to ``stream=True`` on every ``ChannelRequest``: it + drives ``ChannelContext.run_stream`` and progressively edits a single + Telegram message as ``AgentResponseUpdate`` chunks arrive (Telegram has + no native streaming primitive). Pass ``stream=False`` on the constructor + to opt out for all messages, or override per-request inside the + the constructor. A ``stream_update_hook`` can rewrite or drop individual + updates before they hit the wire — useful for redaction, formatting, or + merging tool-call deltas. + """ + + name = "telegram" + + def __init__( + self, + *, + bot_token: str, + path: str = "/telegram/webhook", + commands: Sequence[ChannelCommand] = (), + register_native_commands: bool = True, + run_hook: ChannelRunHook | None = None, + response_hook: ChannelResponseHook | None = None, + api_base: str = "https://api.telegram.org", + webhook_url: str | None = None, + secret_token: str | None = None, + delete_webhook_on_shutdown: bool = False, + parse_mode: str | None = None, + send_typing_action: bool = True, + transport: Literal["auto", "polling", "webhook"] = "auto", + polling_timeout: int = 30, + stream: bool = True, + stream_update_hook: ChannelStreamUpdateHook | None = None, + stream_edit_min_interval: float = 0.4, + ) -> None: + self.path = path + self._token = bot_token + self._commands = list(commands) + self._register = register_native_commands + self._hook = run_hook + self.response_hook = response_hook + self._stream_default = stream + self._stream_update_hook = stream_update_hook + self._stream_edit_min_interval = stream_edit_min_interval + self._api = f"{api_base}/bot{bot_token}" + self._webhook_url = webhook_url + self._secret_token = secret_token + self._delete_webhook_on_shutdown = delete_webhook_on_shutdown + self._parse_mode = parse_mode + self._send_typing_action = send_typing_action + if transport == "auto": + transport = "webhook" if webhook_url else "polling" + if transport == "webhook" and not webhook_url: + raise ValueError("transport='webhook' requires webhook_url") + self._transport: Literal["polling", "webhook"] = transport + self._polling_timeout = polling_timeout + self._ctx: ChannelContext | None = None + self._http: httpx.AsyncClient | None = None + self._poll_task: asyncio.Task[None] | None = None + # Tracks all in-flight tasks (per-chat workers + webhook-spawned + # dispatcher tasks). Drained on shutdown. + self._update_tasks: set[asyncio.Task[None]] = set() + # Per-chat serial workers preserve in-chat ordering: each + # chat_id has its own asyncio.Queue + worker task. Updates for + # different chats run in parallel; updates for the same chat + # run strictly in arrival order. + self._chat_queues: dict[int, asyncio.Queue[Mapping[str, Any]]] = {} + self._chat_workers: dict[int, asyncio.Task[None]] = {} + + def contribute(self, context: ChannelContext) -> ChannelContribution: + """Register the webhook route (only in ``webhook`` transport) plus lifecycle hooks. + + Polling-mode hosts intentionally expose no HTTP route — adding one + would just confuse readers who expect inbound HTTP traffic to do + something. + """ + self._ctx = context + routes: list[BaseRoute] = [] + if self._transport == "webhook": + routes.append(Route("/", self._handle, methods=["POST"])) + return ChannelContribution( + routes=routes, + commands=self._commands, + on_startup=[self._on_startup], + on_shutdown=[self._on_shutdown], + ) + + # -- lifecycle --------------------------------------------------------- # + + async def _on_startup(self) -> None: + """Open the HTTP client, optionally register slash commands, and start the transport. + + - Polling: clears any previously-set webhook (Telegram refuses + ``getUpdates`` while one is registered) and launches the + long-poll task. + - Webhook: ``setWebhook`` to the configured URL, including the + optional secret token used to authenticate inbound calls. + """ + # ``getUpdates`` blocks for up to ``polling_timeout`` seconds, so the + # client timeout has to comfortably exceed it. Skip when a client has + # been pre-injected (e.g. by tests). + if self._http is None: + self._http = httpx.AsyncClient(timeout=self._polling_timeout + 15) + if self._register and self._commands: + cmd_payload: dict[str, Any] = { + "commands": [{"command": c.name, "description": c.description} for c in self._commands] + } + await self._http.post(f"{self._api}/setMyCommands", json=cmd_payload) + logger.info("Registered %d Telegram commands", len(self._commands)) + + if self._transport == "webhook": + payload: dict[str, Any] = { + "url": self._webhook_url, + "allowed_updates": ["message", "edited_message", "callback_query"], + } + if self._secret_token: + payload["secret_token"] = self._secret_token + response = await self._http.post(f"{self._api}/setWebhook", json=payload) + response.raise_for_status() + logger.info("Telegram webhook registered: %s", self._webhook_url) + else: + # Telegram refuses getUpdates while a webhook is set, so clear it. + await self._http.post(f"{self._api}/deleteWebhook", json={"drop_pending_updates": False}) + self._poll_task = asyncio.create_task(self._poll_loop(), name="telegram-poll") + logger.info("Telegram polling started (long-poll timeout=%ss)", self._polling_timeout) + + async def _on_shutdown(self) -> None: + """Stop the polling task, drain in-flight workers, close HTTP. + + Drain order: + 1. Cancel the poll task so no new updates are admitted. + 2. Cancel + await per-chat worker tasks so any currently-running + agent invocations can finish before we yank the HTTP client + out from under them. + 3. Cancel + await any webhook-dispatched tasks tracked in + ``_update_tasks`` (the webhook handler returns 200 immediately + and runs the agent in a background task, which the previous + shutdown ignored entirely). + 4. Close the HTTP client. + + The webhook registration is intentionally **left in place** on + shutdown. A Telegram webhook is a single global resource, so + deleting it here races rolling redeploys: the new revision calls + ``setWebhook`` on startup, then the old revision's shutdown would + delete it, silently breaking inbound delivery until the next boot. + ``setWebhook`` is overwriting/idempotent, so the next startup + re-asserts it anyway. Set ``delete_webhook_on_shutdown=True`` to opt + into best-effort teardown (e.g. for a one-off/ephemeral deployment); + failures are logged but never raised so app shutdown can complete. + """ + if self._poll_task is not None: + self._poll_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._poll_task + self._poll_task = None + # Cancel per-chat workers; their queues are no longer being fed. + for worker in list(self._chat_workers.values()): + worker.cancel() + for worker in list(self._chat_workers.values()): + with contextlib.suppress(asyncio.CancelledError, Exception): + await worker + self._chat_workers.clear() + self._chat_queues.clear() + # Webhook-spawned dispatcher tasks (the ack-before-run path) live + # in _update_tasks alongside any leftover poll-spawned tasks. + for task in list(self._update_tasks): + task.cancel() + for task in list(self._update_tasks): + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + self._update_tasks.clear() + if self._http is not None: + if self._transport == "webhook" and self._delete_webhook_on_shutdown: + try: + await self._http.post(f"{self._api}/deleteWebhook") + except Exception: # pragma: no cover - best-effort cleanup + logger.exception("deleteWebhook failed") + await self._http.aclose() + + # -- polling loop ------------------------------------------------------ # + + async def _poll_loop(self) -> None: + """Long-poll ``getUpdates`` until cancelled. + + Each batch advances the ``offset`` by the highest seen + ``update_id`` so processed updates aren't redelivered. Updates + are routed to per-chat serial workers via :meth:`_enqueue_update` + — this preserves in-chat ordering (Telegram only guarantees + ordering up to ``getUpdates``; the previous fan-out into one + task per update broke that guarantee for adjacent updates). + Different chats still process in parallel because each has its + own worker. Transient errors back off for 2 seconds before + retrying. + """ + if self._http is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("telegram channel not started") + offset: int | None = None + while True: + try: + params: dict[str, Any] = { + "timeout": self._polling_timeout, + "allowed_updates": '["message","edited_message","callback_query"]', + } + if offset is not None: + params["offset"] = offset + response = await self._http.get(f"{self._api}/getUpdates", params=params) + response.raise_for_status() + payload = response.json() + if not payload.get("ok"): + logger.warning("Telegram getUpdates returned error: %s", payload) + await asyncio.sleep(1.0) + continue + for update in payload.get("result", []) or []: + update_id = update.get("update_id") + if isinstance(update_id, int): + offset = update_id + 1 + self._enqueue_update(update) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Telegram polling iteration failed; retrying in 2s") + await asyncio.sleep(2.0) + + def _chat_id_for_update(self, update: Mapping[str, Any]) -> int | None: + """Best-effort extraction of the chat id from any supported update shape.""" + message = update.get("message") or update.get("edited_message") + if isinstance(message, Mapping): + chat = message.get("chat") + if isinstance(chat, Mapping): + cid = chat.get("id") + if isinstance(cid, int): + return cid + callback = update.get("callback_query") + if isinstance(callback, Mapping): + inner = callback.get("message") + if isinstance(inner, Mapping): + chat = inner.get("chat") + if isinstance(chat, Mapping): + cid = chat.get("id") + if isinstance(cid, int): + return cid + return None + + def _enqueue_update(self, update: Mapping[str, Any]) -> None: + """Route an update to its per-chat serial worker. + + Updates with no resolvable chat_id (malformed payloads, unknown + update types) fall back to a one-shot dispatcher task so they + can't deadlock the main loop. + """ + chat_id = self._chat_id_for_update(update) + if chat_id is None: + # No chat to serialise on — fire and forget, but still track + # so shutdown can drain. + task = asyncio.create_task(self._safe_process_update(update)) + self._update_tasks.add(task) + task.add_done_callback(self._update_tasks.discard) + return + queue = self._chat_queues.get(chat_id) + if queue is None: + queue = asyncio.Queue() + self._chat_queues[chat_id] = queue + worker = asyncio.create_task( + self._chat_worker(chat_id, queue), + name=f"telegram-chat-worker-{chat_id}", + ) + self._chat_workers[chat_id] = worker + # Ensure shutdown can drain this worker too. + self._update_tasks.add(worker) + worker.add_done_callback(self._update_tasks.discard) + queue.put_nowait(update) + + async def _chat_worker(self, chat_id: int, queue: asyncio.Queue[Mapping[str, Any]]) -> None: + """Drain a single chat's queue serially. + + Per-chat ordering is preserved by processing one update at a + time. Exceptions in :meth:`_safe_process_update` are already + swallowed, so the worker keeps running. The worker is cancelled + on channel shutdown. + """ + try: + while True: + update = await queue.get() + try: + await self._safe_process_update(update) + finally: + queue.task_done() + except asyncio.CancelledError: + raise + + async def _safe_process_update(self, update: Mapping[str, Any]) -> None: + """Wrap :meth:`_process_update` so a failure on one update never escapes a task.""" + try: + await self._process_update(update) + except Exception: + logger.exception("Telegram update processing failed: %s", update.get("update_id")) + + # -- request handling -------------------------------------------------- # + + async def _handle(self, request: Request) -> Response: + """Webhook endpoint — verifies the secret token then queues the update. + + Telegram includes the configured secret in the + ``X-Telegram-Bot-Api-Secret-Token`` header on every webhook delivery; + we reject mismatches so leaked URLs alone aren't enough to inject + traffic. + + **Acks before running the agent.** Telegram redelivers any update + the webhook doesn't 200 within ~60 seconds, so a streamed agent + reply that runs longer than that would otherwise trigger a + retry storm and duplicate replies. We enqueue onto the + per-chat serial worker (preserving ordering with polling-mode) + and immediately return 200; the actual processing happens in + the worker task tracked by ``_update_tasks`` and drained on + shutdown. + """ + if self._secret_token is not None: + received = request.headers.get("x-telegram-bot-api-secret-token") + if received != self._secret_token: + logger.warning("Telegram webhook secret token mismatch — rejecting update") + return JSONResponse({"ok": False, "error": "invalid secret"}, status_code=401) + + try: + update = await request.json() + except Exception: + logger.warning("Telegram webhook received malformed JSON; returning 400") + return JSONResponse({"ok": False, "error": "invalid json"}, status_code=400) + if not isinstance(update, Mapping): + logger.warning("Telegram webhook received non-object payload; returning 400") + return JSONResponse({"ok": False, "error": "invalid payload"}, status_code=400) + # Ack immediately, route through per-chat worker so ordering with + # polling-mode is identical and shutdown drains all in-flight work. + self._enqueue_update(update) + return JSONResponse({"ok": True}) + + async def _process_update(self, update: Mapping[str, Any]) -> None: + """Convert one Telegram update into a :class:`ChannelRequest` and dispatch. + + Branches: + - ``callback_query`` — inline-button click; handled separately so we + can ack the click and treat the button payload as the next user + utterance. + - ``message`` / ``edited_message`` — the common text-and-attachment + case; runs slash commands when present, otherwise builds a + message and dispatches to the agent. + """ + if self._ctx is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("telegram channel not started") + + # Inline-button presses: ack the click, treat the payload as input. + if (callback := update.get("callback_query")) is not None: + await self._handle_callback_query(callback) + return + + # message and edited_message share the same shape. + message = update.get("message") or update.get("edited_message") or {} + chat_id = (message.get("chat") or {}).get("id") + text = message.get("text") or message.get("caption") + has_media = any(k in message for k in ("photo", "document", "voice", "audio", "video")) + if chat_id is None or (not isinstance(text, str) and not has_media): + return # Nothing actionable. + + # Native command dispatch — bypasses the agent. + if isinstance(text, str) and text.startswith("/"): + command_name = text[1:].split()[0].split("@", 1)[0] + handler = next((c for c in self._commands if c.name == command_name), None) + if handler is not None: + channel_request = ChannelRequest( + channel=self.name, + operation="command.invoke", + input=text, + session=ChannelSession(isolation_key=telegram_isolation_key(chat_id)), + attributes={"chat_id": chat_id}, + identity=ChannelIdentity(channel=self.name, native_id=str(chat_id)), + ) + ctx = ChannelCommandContext( + request=channel_request, + reply=lambda body, cid=chat_id: self._send(cid, body), + ) + await handler.handle(ctx) + return + + # Plain message → agent run. Build a multi-content Message with the + # text/caption alongside any attached media (photo, document, ...). + parsed = await _parse_telegram_message(message, self._resolve_file_url) + channel_request = ChannelRequest( + channel=self.name, + operation="message.create", + input=[parsed], + session=ChannelSession(isolation_key=telegram_isolation_key(chat_id)), + attributes={"chat_id": chat_id}, + stream=self._stream_default, + identity=ChannelIdentity(channel=self.name, native_id=str(chat_id)), + ) + await self._dispatch(chat_id, channel_request, protocol_request=update) + + async def _handle_callback_query(self, callback: Mapping[str, Any]) -> None: + """Handle an inline-button click. + + Always answers the callback query to clear the spinner on the user's + client, then treats the button's ``data`` payload as the user's + next utterance and dispatches it as if they had typed it. + Callbacks without a chat or string ``data`` are silently dropped. + """ + if self._ctx is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("telegram channel not started") + if self._http is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("telegram channel not started") + callback_id = callback.get("id") + data = callback.get("data") + message = callback.get("message") or {} + chat_id = (message.get("chat") or {}).get("id") + + if callback_id is not None: + # Always answer to remove the loading spinner on the user's client. + try: + await self._http.post(f"{self._api}/answerCallbackQuery", json={"callback_query_id": callback_id}) + except Exception: # pragma: no cover - defensive + logger.exception("answerCallbackQuery failed") + + if chat_id is None or not isinstance(data, str): + return + + channel_request = ChannelRequest( + channel=self.name, + operation="message.create", + input=data, + session=ChannelSession(isolation_key=telegram_isolation_key(chat_id)), + attributes={"chat_id": chat_id, "callback_query_id": callback_id}, + stream=self._stream_default, + identity=ChannelIdentity(channel=self.name, native_id=str(chat_id)), + ) + await self._dispatch(chat_id, channel_request, protocol_request=callback) + + async def _resolve_file_url(self, file_id: str) -> str | None: + """Resolve a Telegram file_id into an HTTPS URL via getFile.""" + if self._http is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("telegram channel not started") + try: + response = await self._http.get(f"{self._api}/getFile", params={"file_id": file_id}) + response.raise_for_status() + file_path = response.json().get("result", {}).get("file_path") + except Exception: # pragma: no cover - defensive: bad token, network, etc. + logger.exception("getFile failed for %s", file_id) + return None + return f"{self._api.replace('/bot', '/file/bot')}/{file_path}" if file_path else None + + # -- outbound helpers -------------------------------------------------- # + + async def _dispatch(self, chat_id: int, request: ChannelRequest, *, protocol_request: Any | None = None) -> None: + """Run the request and forward results to ``chat_id``.""" + if self._ctx is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("telegram channel not started") + if not request.stream: + if self._send_typing_action: + await self._send_chat_action(chat_id, "typing") + result = await self._ctx.run( + request, + run_hook=self._hook, + protocol_request=protocol_request, + response_hook=self.response_hook, + channel_name=self.name, + ) + await self._reply_with_result(chat_id, result.result) + return + + stream = await self._ctx.run_stream( + request, + run_hook=self._hook, + protocol_request=protocol_request, + stream_update_hook=self._stream_update_hook, + response_hook=self.response_hook, + channel_name=self.name, + ) + await self._stream_to_chat(chat_id, request, stream) + + async def _stream_to_chat( + self, + chat_id: int, + request: ChannelRequest, + stream: ResponseStream[AgentResponseUpdate, AgentResponse], + ) -> None: + """Iterate the agent's ResponseStream and progressively edit a Telegram message. + + Smoothness recipe: + + 1. Send the placeholder message up front so the user sees instant + activity (a "…" bubble) instead of waiting for the first edit. + 2. Token consumption never awaits the network — a background + ``edit_worker`` watches an asyncio.Event, coalesces accumulated + text, rate-limits itself to ``stream_edit_min_interval`` (default + 0.4s — well under Telegram's per-chat edit limits), and only sends + when the text actually changed. + 3. Interim edits are sent as **plain text** even if a ``parse_mode`` + is configured. Partial Markdown/HTML mid-stream is invalid and + Telegram rejects it with 400 ``can't parse entities``. The final + edit re-applies the configured ``parse_mode`` so the user ends up + with formatted output. + 4. ``sendChatAction("typing")`` is re-issued every 4s while the + stream is live so the typing bubble doesn't disappear on long + responses (Telegram clears it after ~5s). + """ + if self._http is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("telegram channel not started") + # Pin to a local so mypy narrows inside the nested closures below. + http = self._http + + accumulated = "" + last_sent = "" + last_edit_at = 0.0 + message_id: int | None = None + worker_done = asyncio.Event() + wake = asyncio.Event() + + async def send_initial_placeholder() -> None: + nonlocal message_id, last_edit_at + try: + response = await http.post( + f"{self._api}/sendMessage", + json={"chat_id": chat_id, "text": "…"}, + ) + response.raise_for_status() + message_id = response.json().get("result", {}).get("message_id") + last_edit_at = time.monotonic() + except Exception: # pragma: no cover - placeholder is best-effort + logger.exception("Telegram placeholder send failed") + + async def edit_worker() -> None: + nonlocal last_sent, last_edit_at + while not (worker_done.is_set() and accumulated == last_sent): + await wake.wait() + wake.clear() + if message_id is None or accumulated == last_sent: + continue + elapsed = time.monotonic() - last_edit_at + if elapsed < self._stream_edit_min_interval: + try: + await asyncio.wait_for(wake.wait(), timeout=self._stream_edit_min_interval - elapsed) + wake.clear() + except asyncio.TimeoutError: + pass + snapshot = accumulated[:_TELEGRAM_MAX_TEXT_LEN] + if snapshot == last_sent: + continue + # Interim edits go out as plain text — partial Markdown/HTML + # is invalid mid-stream and Telegram returns 400. + try: + await http.post( + f"{self._api}/editMessageText", + json={"chat_id": chat_id, "message_id": message_id, "text": snapshot}, + ) + except Exception: # pragma: no cover - keep streaming on error + logger.exception("Telegram interim edit failed") + last_sent = snapshot + last_edit_at = time.monotonic() + + async def typing_worker() -> None: + while not worker_done.is_set(): + await self._send_chat_action(chat_id, "typing") + try: + await asyncio.wait_for(worker_done.wait(), timeout=4.0) + except asyncio.TimeoutError: + continue + + await send_initial_placeholder() + edit_task = asyncio.create_task(edit_worker(), name="telegram-edit-worker") + typing_task = asyncio.create_task(typing_worker(), name="telegram-typing-worker") + + try: + async for update in stream: + chunk = getattr(update, "text", None) + if chunk: + accumulated += chunk + wake.set() + except Exception: + logger.exception("Telegram streaming consumption failed") + finally: + worker_done.set() + wake.set() + try: + await edit_task + except Exception: # pragma: no cover + logger.exception("Telegram edit worker crashed") + typing_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await typing_task + + # Always finalize so context providers / history hooks run. + try: + final = await stream.get_final_response() + except Exception: # pragma: no cover - finalize is best-effort + logger.exception("Stream finalize failed") + final = None + + # Final edit applies parse_mode (if configured) to the full text. + final_text = (getattr(final, "text", None) or accumulated or last_sent)[:_TELEGRAM_MAX_TEXT_LEN] + if message_id is not None and final_text and final_text != last_sent: + payload: dict[str, Any] = { + "chat_id": chat_id, + "message_id": message_id, + "text": final_text, + } + if self._parse_mode: + payload["parse_mode"] = self._parse_mode + try: + response = await self._http.post(f"{self._api}/editMessageText", json=payload) + # If parse_mode rejected the final edit, retry as plain text + # so the user still sees the answer. + if response.status_code == 400 and self._parse_mode: + payload.pop("parse_mode", None) + await self._http.post(f"{self._api}/editMessageText", json=payload) + except Exception: # pragma: no cover + logger.exception("Telegram final edit failed") + + # If nothing ever streamed (no text chunks at all), fall back to the + # full result so images / tool outputs still reach the user. + if not accumulated: + await self._reply_with_result(chat_id, final) + + async def _reply_with_result(self, chat_id: int, result: Any) -> None: + """Forward an AgentRunResponse back to Telegram. + + Sends any image attachments on the last assistant message as photos, + then the text body via ``sendMessage``. Falls back to a ``"(no + response)"`` placeholder if neither text nor images are present so + the user is never left hanging. + """ + sent_photo = False + last_message = None + messages = getattr(result, "messages", None) or [] + for msg in reversed(messages): + if getattr(msg, "role", None) == "assistant": + last_message = msg + break + + if last_message is not None: + for content in getattr(last_message, "contents", []) or []: + uri = getattr(content, "uri", None) + media_type = getattr(content, "media_type", "") or "" + if uri and isinstance(media_type, str) and media_type.startswith("image/"): + await self._send_photo(chat_id, uri) + sent_photo = True + + text = getattr(result, "text", None) + if text: + await self._send(chat_id, text) + elif not sent_photo: + await self._send(chat_id, "(no response)") + + async def _send(self, chat_id: int, text: str, **extra: Any) -> None: + """POST a ``sendMessage`` to Telegram, applying the configured ``parse_mode`` by default. + + Extra kwargs are merged into the payload after ``parse_mode`` so + callers can override any field per-call (e.g. drop ``parse_mode`` + for a known-unsafe interim text). + """ + if self._http is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("telegram channel not started") + payload: dict[str, Any] = {"chat_id": chat_id, "text": text} + if self._parse_mode and "parse_mode" not in extra: + payload["parse_mode"] = self._parse_mode + payload.update(extra) + await self._http.post(f"{self._api}/sendMessage", json=payload) + + async def _send_photo(self, chat_id: int, photo_url: str, caption: str | None = None) -> None: + """POST a ``sendPhoto`` to Telegram with an optional caption.""" + if self._http is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("telegram channel not started") + payload: dict[str, Any] = {"chat_id": chat_id, "photo": photo_url} + if caption: + payload["caption"] = caption + await self._http.post(f"{self._api}/sendPhoto", json=payload) + + async def _send_chat_action(self, chat_id: int, action: str) -> None: + """Fire a ``sendChatAction`` (typing, upload_photo, …); errors are logged and swallowed. + + Chat actions are pure UX hints — Telegram clears them after ~5s + — so failures should never propagate to the caller. + """ + if self._http is None: # pragma: no cover - guarded by lifecycle + raise RuntimeError("telegram channel not started") + try: + await self._http.post(f"{self._api}/sendChatAction", json={"chat_id": chat_id, "action": action}) + except Exception: # pragma: no cover - non-critical UX + logger.exception("sendChatAction failed") + + +__all__ = ["TelegramChannel", "telegram_isolation_key"] diff --git a/python/packages/hosting-telegram/pyproject.toml b/python/packages/hosting-telegram/pyproject.toml new file mode 100644 index 00000000000..41f8d7ea0cd --- /dev/null +++ b/python/packages/hosting-telegram/pyproject.toml @@ -0,0 +1,107 @@ +[project] +name = "agent-framework-hosting-telegram" +description = "Telegram channel for agent-framework-hosting." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0a260424" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.2.0,<2", + "agent-framework-hosting==1.0.0a260424", + "httpx>=0.27,<1", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_hosting_telegram"] +exclude = ['tests'] +# Telegram's API delivers loosely-typed JSON-ish maps (chat, message, photo, +# media, callback_query). Strict ``Unknown`` reporting on every ``.get(...)`` +# adds noise without catching real bugs — narrowing happens via runtime +# isinstance checks instead. Other type checks remain strict. +reportUnknownArgumentType = "none" +reportUnknownMemberType = "none" +reportUnknownVariableType = "none" +reportUnknownLambdaType = "none" +reportOptionalMemberAccess = "none" + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_hosting_telegram"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_telegram" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_telegram --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/hosting-telegram/tests/__init__.py b/python/packages/hosting-telegram/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/hosting-telegram/tests/test_channel.py b/python/packages/hosting-telegram/tests/test_channel.py new file mode 100644 index 00000000000..ca3119ce7ec --- /dev/null +++ b/python/packages/hosting-telegram/tests/test_channel.py @@ -0,0 +1,430 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for :mod:`agent_framework_hosting_telegram`. + +These tests exercise the internal parsing helpers and the webhook entry-point +without spinning up a real Telegram bot. The polling loop and HTTP-side +helpers are excluded from coverage because they require a live bot token. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import Awaitable, Mapping +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from agent_framework_hosting import ( + AgentFrameworkHost, + ChannelCommand, + ChannelCommandContext, + ChannelRequest, + HostedRunResult, +) +from starlette.testclient import TestClient + +from agent_framework_hosting_telegram import TelegramChannel, telegram_isolation_key +from agent_framework_hosting_telegram._channel import ( + _parse_telegram_message, + _telegram_media_file_id, +) + +# --------------------------------------------------------------------------- # +# Pure helpers # +# --------------------------------------------------------------------------- # + + +def test_telegram_isolation_key_format() -> None: + assert telegram_isolation_key(42) == "telegram:42" + assert telegram_isolation_key("abc") == "telegram:abc" + + +class TestMediaFileId: + def test_no_media(self) -> None: + assert _telegram_media_file_id({"text": "hi"}) is None + + def test_photo_picks_largest(self) -> None: + assert _telegram_media_file_id({"photo": [{"file_id": "small"}, {"file_id": "large"}]}) == ( + "large", + "image/jpeg", + ) + + def test_photo_empty_list(self) -> None: + assert _telegram_media_file_id({"photo": []}) is None + + def test_document_uses_mime_type(self) -> None: + result = _telegram_media_file_id({"document": {"file_id": "f1", "mime_type": "application/pdf"}}) + assert result == ("f1", "application/pdf") + + def test_voice_default_mime(self) -> None: + result = _telegram_media_file_id({"voice": {"file_id": "v1"}}) + assert result == ("v1", "audio/ogg") + + +class TestParseTelegramMessage: + async def test_text_only(self) -> None: + async def resolve(_: str) -> str | None: + return None + + msg = await _parse_telegram_message({"text": "hello"}, resolve) + assert msg.role == "user" + assert msg.text == "hello" + + async def test_text_and_photo(self) -> None: + async def resolve(file_id: str) -> str | None: + return f"https://files.telegram.org/{file_id}" + + msg = await _parse_telegram_message({"caption": "look", "photo": [{"file_id": "p1"}]}, resolve) + assert msg.text == "look" + # Image content present. + assert any((getattr(c, "uri", None) or "").endswith("/p1") for c in msg.contents) + + async def test_unresolvable_media_falls_back_to_text(self) -> None: + async def resolve(_: str) -> str | None: + return None + + msg = await _parse_telegram_message({"text": "x", "voice": {"file_id": "v1"}}, resolve) + # Resolver returned None — the contents should still include the + # text without crashing. + assert msg.text == "x" + + +# --------------------------------------------------------------------------- # +# Webhook entry point # +# --------------------------------------------------------------------------- # + + +@dataclass +class _FakeAgentResponse: + text: str + + +class _FakeAgent: + def __init__(self, reply: str = "ok") -> None: + self._reply = reply + self.runs: list[Any] = [] + + def create_session(self, *, session_id: str | None = None) -> Any: + return {"session_id": session_id} + + def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any: + self.runs.append({"messages": messages, "stream": stream, "kwargs": kwargs}) + + async def _coro() -> _FakeAgentResponse: + return _FakeAgentResponse(text=self._reply) + + return _coro() + + +def _make_telegram( + stream_default: bool = False, *, path: str = "/telegram/webhook" +) -> tuple[TelegramChannel, _FakeAgent]: + agent = _FakeAgent("hi") + ch = TelegramChannel( + bot_token="123:abc", + path=path, + webhook_url="https://example.com/hook", + secret_token="s3cr3t", + stream=stream_default, + ) + # Replace the internal HTTP client with an AsyncMock so the channel + # never tries to call the real Telegram API. + fake_http = MagicMock() + # post() returns a response object whose raise_for_status() is sync. + response_mock = MagicMock() + response_mock.json = MagicMock(return_value={"ok": True, "result": {}}) + fake_http.post = AsyncMock(return_value=response_mock) + fake_http.get = AsyncMock(return_value=response_mock) + fake_http.aclose = AsyncMock() + ch._http = fake_http + return ch, agent + + +class TestTelegramWebhook: + def test_webhook_accepts_text_message_and_dispatches_to_agent(self) -> None: + ch, agent = _make_telegram() + host = AgentFrameworkHost(target=agent, channels=[ch]) + # Skip lifespan so polling/setWebhook are not invoked. + with TestClient(host.app) as client: + r = client.post( + "/telegram/webhook", + json={"update_id": 1, "message": {"chat": {"id": 99}, "text": "hello"}}, + headers={"x-telegram-bot-api-secret-token": "s3cr3t"}, + ) + assert r.status_code == 200 + assert agent.runs, "expected the agent to be invoked" + + def test_empty_path_mounts_at_app_root(self) -> None: + ch, agent = _make_telegram(path="") + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app) as client: + r = client.post( + "/", + json={"update_id": 1, "message": {"chat": {"id": 99}, "text": "hello"}}, + headers={"x-telegram-bot-api-secret-token": "s3cr3t"}, + ) + assert r.status_code == 200 + assert agent.runs, "expected the agent to be invoked" + + def test_webhook_rejects_bad_secret(self) -> None: + ch, agent = _make_telegram() + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app) as client: + r = client.post( + "/telegram/webhook", + json={"update_id": 1, "message": {"chat": {"id": 99}, "text": "hi"}}, + headers={"x-telegram-bot-api-secret-token": "WRONG"}, + ) + assert r.status_code == 401 + assert not agent.runs + + async def test_response_hook_can_rewrite_originating_reply(self) -> None: + seen_kwargs: list[dict[str, Any]] = [] + + def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult: + seen_kwargs.append(dict(kwargs)) + return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session) + + ch, agent = _make_telegram() + ch.response_hook = hook + + class _Ctx: + target: Any = agent + + async def run( + self, + _request: ChannelRequest, + *, + run_hook: Any | None = None, + protocol_request: Any | None = None, + response_hook: Any | None = None, + channel_name: str | None = None, + ) -> HostedRunResult: + result = HostedRunResult(_FakeAgentResponse(text="hi")) + if response_hook is None: + return result + shaped = response_hook(result, request=_request, channel_name=channel_name or _request.channel) + if isinstance(shaped, Awaitable): + return await shaped + return shaped + + ch._ctx = _Ctx() # type: ignore[assignment] # pyright: ignore[reportPrivateUsage] + + request = ChannelRequest(channel="telegram", operation="message.create", input="hi", stream=False) + await ch._dispatch(99, request) # pyright: ignore[reportPrivateUsage] + + assert ch._http is not None + args, kwargs = ch._http.post.call_args # type: ignore[attr-defined] + assert args[0].endswith("/sendMessage") + assert kwargs["json"]["text"] == "HI" + assert seen_kwargs + assert seen_kwargs[0]["channel_name"] == "telegram" + + +class TestCommand: + async def test_command_handler_invoked(self) -> None: + captured: list[ChannelCommandContext] = [] + + async def handler(ctx: ChannelCommandContext) -> None: + captured.append(ctx) + await ctx.reply("pong") + + ch = TelegramChannel( + bot_token="123:abc", + webhook_url="https://example.com/hook", + commands=[ChannelCommand(name="ping", description="ping", handle=handler)], + register_native_commands=False, + ) + fake_http = MagicMock() + response_mock = MagicMock() + response_mock.json = MagicMock(return_value={"ok": True, "result": {}}) + fake_http.post = AsyncMock(return_value=response_mock) + fake_http.get = AsyncMock(return_value=response_mock) + fake_http.aclose = AsyncMock() + ch._http = fake_http + host = AgentFrameworkHost(target=_FakeAgent(), channels=[ch]) + + with TestClient(host.app) as client: + r = client.post( + "/telegram/webhook", + json={"update_id": 2, "message": {"chat": {"id": 7}, "text": "/ping"}}, + ) + assert r.status_code == 200 + assert captured and captured[0].request.operation == "command.invoke" + + +# --------------------------------------------------------------------------- # +# Per-chat serial ordering # +# --------------------------------------------------------------------------- # + + +class TestPerChatOrdering: + async def test_updates_for_same_chat_run_serially(self) -> None: + """Two updates for the same chat must process in arrival order.""" + ch, _ = _make_telegram() + order: list[int] = [] + slow_event = asyncio.Event() + + async def fake_process(update: Mapping[str, Any]) -> None: + uid = update.get("update_id") + assert isinstance(uid, int) + if uid == 1: + # Block the first update so the second is queued behind it. + await slow_event.wait() + order.append(uid) + + ch._process_update = fake_process # type: ignore[method-assign] + + ch._enqueue_update({"update_id": 1, "message": {"chat": {"id": 100}, "text": "first"}}) + ch._enqueue_update({"update_id": 2, "message": {"chat": {"id": 100}, "text": "second"}}) + + # Let the worker start the first update. + await asyncio.sleep(0) + assert order == [] # blocked on slow_event + slow_event.set() + # Drain. + worker = ch._chat_workers[100] + # Wait for the queue to empty. + await ch._chat_queues[100].join() + # Cleanup + worker.cancel() + with contextlib.suppress(asyncio.CancelledError): + await worker + + assert order == [1, 2] + + async def test_updates_for_different_chats_run_in_parallel(self) -> None: + """Different chats get separate workers and can interleave freely.""" + ch, _ = _make_telegram() + started: list[int] = [] + gate_a = asyncio.Event() + + async def fake_process(update: Mapping[str, Any]) -> None: + uid = update.get("update_id") + assert isinstance(uid, int) + started.append(uid) + if uid == 1: + await gate_a.wait() + + ch._process_update = fake_process # type: ignore[method-assign] + + ch._enqueue_update({"update_id": 1, "message": {"chat": {"id": 1}, "text": "a"}}) + ch._enqueue_update({"update_id": 2, "message": {"chat": {"id": 2}, "text": "b"}}) + + # Both should be admitted into their respective workers despite + # update 1 being blocked. + await asyncio.sleep(0) + # Update 2 finishes; update 1 still blocked. + assert 2 in started + gate_a.set() + for cid in (1, 2): + await ch._chat_queues[cid].join() + for w in ch._chat_workers.values(): + w.cancel() + with contextlib.suppress(asyncio.CancelledError): + await w + + +# --------------------------------------------------------------------------- # +# Webhook ack-before-run + shutdown drains workers # +# --------------------------------------------------------------------------- # + + +class TestWebhookAckBeforeRun: + async def test_webhook_returns_200_before_agent_completes(self) -> None: + """The webhook must ack before the agent runs, to dodge Telegram's 60s redelivery.""" + ch, _ = _make_telegram() + from starlette.requests import Request + + agent_started = asyncio.Event() + agent_release = asyncio.Event() + + async def fake_process(update: Mapping[str, Any]) -> None: + agent_started.set() + await agent_release.wait() + + ch._process_update = fake_process # type: ignore[method-assign] + + async def receive() -> Any: + payload = b'{"update_id":1,"message":{"chat":{"id":42},"text":"hi"}}' + return {"type": "http.request", "body": payload, "more_body": False} + + scope = { + "type": "http", + "method": "POST", + "path": "/telegram/webhook", + "headers": [(b"x-telegram-bot-api-secret-token", b"s3cr3t")], + "query_string": b"", + } + request = Request(scope, receive=receive) + + # Drive the webhook handler. Even though the agent won't complete + # (gate_a still cleared) the webhook must still 200 promptly. + resp = await ch._handle(request) + assert resp.status_code == 200 + # The agent task is in flight but not finished — proves ack came first. + await asyncio.wait_for(agent_started.wait(), timeout=1.0) + assert not agent_release.is_set() + + # Cleanup: release the agent and drain. + agent_release.set() + await ch._chat_queues[42].join() + for w in list(ch._chat_workers.values()): + w.cancel() + with contextlib.suppress(asyncio.CancelledError): + await w + + +class TestShutdownDrainsWorkers: + async def test_shutdown_cancels_in_flight_chat_workers(self) -> None: + """`_on_shutdown` must drain per-chat workers, not leak them.""" + ch, _ = _make_telegram() + forever = asyncio.Event() + + async def stuck(update: Mapping[str, Any]) -> None: + await forever.wait() + + ch._process_update = stuck # type: ignore[method-assign] + ch._enqueue_update({"update_id": 9, "message": {"chat": {"id": 1}, "text": "a"}}) + await asyncio.sleep(0) + assert ch._chat_workers and ch._update_tasks + + await ch._on_shutdown() + assert not ch._chat_workers + assert not ch._update_tasks + + +def _deletewebhook_called(http_mock: MagicMock) -> bool: + return any(call.args and str(call.args[0]).endswith("/deleteWebhook") for call in http_mock.post.call_args_list) + + +class TestWebhookShutdownTeardown: + async def test_shutdown_keeps_webhook_by_default(self) -> None: + """Default: shutdown must NOT delete the webhook (avoids redeploy races).""" + ch, _ = _make_telegram() + assert ch._transport == "webhook" + await ch._on_shutdown() + assert not _deletewebhook_called(ch._http) # type: ignore[arg-type] + ch._http.aclose.assert_awaited() # type: ignore[union-attr] + + async def test_shutdown_deletes_webhook_when_opted_in(self) -> None: + """Opt-in: ``delete_webhook_on_shutdown=True`` performs best-effort teardown.""" + ch = TelegramChannel( + bot_token="123:abc", + webhook_url="https://example.com/hook", + secret_token="s3cr3t", + delete_webhook_on_shutdown=True, + stream=False, + ) + fake_http = MagicMock() + response_mock = MagicMock() + response_mock.json = MagicMock(return_value={"ok": True, "result": {}}) + fake_http.post = AsyncMock(return_value=response_mock) + fake_http.get = AsyncMock(return_value=response_mock) + fake_http.aclose = AsyncMock() + ch._http = fake_http + await ch._on_shutdown() + assert _deletewebhook_called(fake_http) + fake_http.aclose.assert_awaited() diff --git a/python/packages/hosting/LICENSE b/python/packages/hosting/LICENSE new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/python/packages/hosting/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/hosting/README.md b/python/packages/hosting/README.md new file mode 100644 index 00000000000..e77a5953301 --- /dev/null +++ b/python/packages/hosting/README.md @@ -0,0 +1,103 @@ +# agent-framework-hosting + +Multi-channel hosting for Microsoft Agent Framework agents. + +`agent-framework-hosting` lets you serve a single agent or workflow target +through one or more **channels**. The host owns one Starlette ASGI app, +route/lifecycle composition, and per-`isolation_key` session resolution. +Each channel owns its protocol parsing and response rendering. + +The base package contains only channel-neutral plumbing: + +- `AgentFrameworkHost` — the Starlette host. +- `Channel` — the channel protocol. +- `ChannelRequest` / `ChannelSession` / `ChannelIdentity` — the request + envelope and optional channel metadata. +- `ChannelContext` / `ChannelContribution` / `ChannelCommand` — channel-side + hooks for invoking the target and contributing routes, commands, and + lifecycle callbacks. +- `ChannelRunHook` / `ChannelResponseHook` / `ChannelStreamUpdateHook` — + host-invoked customization seams. + +`ChannelStreamUpdateHook` applies to streamed updates only. It is not a +substitute for final-response redaction. + +Concrete channels live in their own packages so you only install what you use: + +| Package | Transport | +|---|---| +| `agent-framework-hosting-responses` | OpenAI Responses API | +| `agent-framework-hosting-invocations` | Foundry-native invocation envelope | +| `agent-framework-hosting-telegram` | Telegram Bot API | +| `agent-framework-hosting-activity-protocol` | Bot Framework Activity Protocol | +| `agent-framework-hosting-discord` | Discord HTTP Interactions | + +## Install + +```bash +pip install agent-framework-hosting agent-framework-hosting-responses +# or with Hypercorn pre-installed for the demo `host.serve(...)` helper +pip install "agent-framework-hosting[serve]" agent-framework-hosting-responses +# add the [disk] extra to persist reset-session aliases +pip install "agent-framework-hosting[disk]" +``` + +## Quickstart + +```python +from agent_framework.openai import OpenAIChatClient +from agent_framework_hosting import AgentFrameworkHost, Channel + +agent = OpenAIChatClient().as_agent(name="Assistant") + +# Add channels from sibling packages, e.g. `agent-framework-hosting-responses` +# exposes a `ResponsesChannel` that serves the OpenAI Responses API. +channels: list[Channel] = [] + +host = AgentFrameworkHost(target=agent, channels=channels) +host.serve(port=8000) +``` + +## Session state and workflow checkpoints + +By default the host keeps live `AgentSession` objects and reset-session aliases +in memory. Channels opt into continuity by setting +`ChannelRequest.session = ChannelSession(isolation_key=...)`; requests with the +same isolation key reuse the same host-created session. + +For long-running deployments that need `reset_session(...)` aliases to survive +restart, pass `state_dir`: + +```python +host = AgentFrameworkHost( + target=agent, + channels=channels, + state_dir="./.host-state", +) +``` + +This creates `./.host-state/sessions/` and stores only lightweight alias +bookkeeping. Live `AgentSession` objects are still rehydrated lazily by the +configured history provider on the next turn. + +For workflow targets, `checkpoint_location=...` is the clearest way to enable +checkpoint persistence. As a convenience, `state_dir="./.host-state"` also +derives `./.host-state/checkpoints/` for workflow targets. Use the mapping form +when you want only one component: + +```python +from agent_framework_hosting import HostStatePaths + +host = AgentFrameworkHost( + target=workflow, + channels=channels, + state_dir=HostStatePaths( + sessions="/var/lib/myapp/sessions", + checkpoints="/var/lib/myapp/checkpoints", + ), +) +``` + +Cross-channel identity linking, multicast delivery, background runs, +continuation tokens, and durable delivery runners are follow-up enhancements, +not part of this v1 host contract. diff --git a/python/packages/hosting/agent_framework_hosting/__init__.py b/python/packages/hosting/agent_framework_hosting/__init__.py new file mode 100644 index 00000000000..ff03530dd45 --- /dev/null +++ b/python/packages/hosting/agent_framework_hosting/__init__.py @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Multi-channel hosting for Microsoft Agent Framework agents. + +Serve a single agent target through one or more **channels** — pluggable +adapters that expose the target over different transports such as the +OpenAI Responses API, Microsoft Teams, Telegram, and others. The base +package contains only the channel-neutral plumbing; concrete channels +ship in their own packages (``agent-framework-hosting-responses``, +``agent-framework-hosting-telegram``, …) so users install only what +they need. +""" + +import importlib.metadata + +from ._host import AgentFrameworkHost, ChannelContext, logger +from ._isolation import ( + ISOLATION_HEADER_CHAT, + ISOLATION_HEADER_USER, + IsolationKeys, + get_current_isolation_keys, + reset_current_isolation_keys, + set_current_isolation_keys, +) +from ._types import ( + Channel, + ChannelCommand, + ChannelCommandContext, + ChannelContribution, + ChannelIdentity, + ChannelRequest, + ChannelResponseHook, + ChannelRunHook, + ChannelSession, + ChannelStreamUpdateHook, + HostedRunResult, + HostStatePaths, +) + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = [ + "ISOLATION_HEADER_CHAT", + "ISOLATION_HEADER_USER", + "AgentFrameworkHost", + "Channel", + "ChannelCommand", + "ChannelCommandContext", + "ChannelContext", + "ChannelContribution", + "ChannelIdentity", + "ChannelRequest", + "ChannelResponseHook", + "ChannelRunHook", + "ChannelSession", + "ChannelStreamUpdateHook", + "HostStatePaths", + "HostedRunResult", + "IsolationKeys", + "__version__", + "get_current_isolation_keys", + "logger", + "reset_current_isolation_keys", + "set_current_isolation_keys", +] diff --git a/python/packages/hosting/agent_framework_hosting/_host.py b/python/packages/hosting/agent_framework_hosting/_host.py new file mode 100644 index 00000000000..3d5287f38e0 --- /dev/null +++ b/python/packages/hosting/agent_framework_hosting/_host.py @@ -0,0 +1,1373 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""The :class:`AgentFrameworkHost` and its :class:`ChannelContext` bridge. + +The host is a small Starlette wrapper: + +- ``__init__`` accepts a hostable target (``SupportsAgentRun`` agent or + ``Workflow``) and a sequence of channels. +- :meth:`AgentFrameworkHost.app` lazily builds a Starlette app by calling + every channel's ``contribute`` and mounting the returned routes under + the channel's ``path`` (empty path → mount at the app root). +- :class:`ChannelContext` exposes ``run`` / ``run_stream`` for channels to + invoke; the host handles hook invocation and per-``isolation_key`` session + caching. + +Per SPEC-002 (and ADR-0026), the host is intentionally thin so the bulk +of channel-specific behaviour stays in the channel package. Identity +linking, multicast delivery, background runs, and durable delivery are +follow-up enhancements layered outside this v1 host contract. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import uuid +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence +from contextlib import AbstractContextManager, ExitStack, asynccontextmanager +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +from agent_framework import ( + AgentResponse, + AgentResponseUpdate, + CheckpointStorage, + Content, + FileCheckpointStorage, + Message, + ResponseStream, + SupportsAgentRun, + Workflow, + WorkflowEvent, +) +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.requests import Request +from starlette.responses import PlainTextResponse +from starlette.routing import BaseRoute, Mount, Route, WebSocketRoute +from starlette.types import ASGIApp, Receive, Scope, Send + +from ._isolation import ( + ISOLATION_HEADER_CHAT, + ISOLATION_HEADER_USER, + IsolationKeys, + reset_current_isolation_keys, + set_current_isolation_keys, +) +from ._persistence import normalize_state_dir +from ._state_store import SessionsStateStore, build_session_aliases +from ._types import ( + Channel, + ChannelRequest, + ChannelResponseHook, + ChannelRunHook, + ChannelStreamUpdateHook, + HostedRunResult, + HostStatePaths, +) + +if TYPE_CHECKING: + from agent_framework._workflows._workflow import WorkflowRunResult + +logger = logging.getLogger("agent_framework.hosting") + + +def _exact_path_route(path: str, route: BaseRoute) -> BaseRoute | None: + """Clone a root route so ``Mount('/x', Route('/'))`` also handles ``/x`` without a redirect.""" + if isinstance(route, Route) and route.path == "/": + return Route( + path, + route.endpoint, + methods=route.methods, + name=route.name, + include_in_schema=route.include_in_schema, + ) + if isinstance(route, WebSocketRoute) and route.path == "/": + return WebSocketRoute(path, route.endpoint, name=route.name) + return None + + +def _checkpoint_path_for_isolation_key(root: Path, isolation_key: str) -> Path: + r"""Return ``root / isolation_key`` after rejecting path-traversal patterns. + + Isolation keys are intentionally caller-controlled: they originate from + inbound HTTP headers (``x-agent-{user,chat}-isolation-key`` injected by + the Foundry runtime), from channel-supplied derivations such as + ``telegram:`` / ``entra:``, or from a channel ``run_hook`` + that may read body fields. Joining such a value into a filesystem path + without validation is CWE-22: a value such as ``../../../etc/foo`` or + ``\\foo`` (Windows UNC) would let the resulting checkpoint directory + escape the configured root. + + The check intentionally uses a denylist so legitimate namespaced keys + (``telegram:42``, ``entra:abc-def``) are preserved as-is. Rejected: + + * any key containing ``/``, ``\\``, or NUL; + * keys that reduce to empty after stripping dots (``.``, ``..``, ``...``, + ...); + * absolute paths (``os.path.isabs``); + * keys carrying a drive letter prefix (``os.path.splitdrive`` — catches + Windows ``C:/...`` and single-letter ``X:foo`` constructs that + ``Path("/root") / "X:foo"`` would otherwise interpret as drive-rooted). + + After joining, both ``root`` and the resolved target are normalised and + the target is verified to stay under the resolved root as defence in + depth — if the denylist ever misses a pattern, this final check still + refuses the join. + + Raises: + ValueError: If ``isolation_key`` is not a non-empty string or fails + any of the validation steps above. + """ + if not isinstance(isolation_key, str) or not isolation_key: + raise ValueError("isolation_key must be a non-empty string") + if ( + "/" in isolation_key + or "\\" in isolation_key + or "\x00" in isolation_key + or isolation_key.strip(".") == "" + or os.path.isabs(isolation_key) + or os.path.splitdrive(isolation_key)[0] + # ``splitdrive`` only recognises drive letters on Windows; reject + # the ``X:rest`` pattern explicitly so a payload crafted on a + # POSIX host still fails closed if the resulting directory ever + # round-trips to Windows storage. + or (len(isolation_key) >= 2 and isolation_key[0].isalpha() and isolation_key[1] == ":") + ): + raise ValueError(f"Invalid isolation_key for checkpoint path: {isolation_key!r}") + + root_resolved = root.resolve() + target = (root_resolved / isolation_key).resolve() + if not target.is_relative_to(root_resolved): + raise ValueError(f"Invalid isolation_key for checkpoint path: {isolation_key!r}") + return target + + +def _workflow_output_to_text(value: Any) -> str: + """Render a single workflow ``output`` payload as plain text. + + Used by the streaming path (``_workflow_event_to_update``) when an + executor emits an arbitrary Python object that the host then has to + serialise into an :class:`AgentResponseUpdate` content for the SSE + stream. ``AgentResponse`` and ``AgentResponseUpdate`` carry text + natively; everything else is best-effort ``str()``. + """ + text = getattr(value, "text", None) + if isinstance(text, str): + return text + return str(value) + + +async def _apply_run_hook( + hook: ChannelRunHook, + request: ChannelRequest, + *, + target: SupportsAgentRun | Workflow, + protocol_request: Any | None, +) -> ChannelRequest: + """Invoke a run hook with the host-owned calling convention.""" + result = hook(request, target=target, protocol_request=protocol_request) + if isinstance(result, Awaitable): + return await result + return result + + +async def _apply_response_hook( + hook: ChannelResponseHook, + result: HostedRunResult[Any], + *, + request: ChannelRequest, + channel_name: str | None, +) -> HostedRunResult[Any]: + """Invoke a response hook with the host-owned calling convention.""" + out = hook(result, request=request, channel_name=channel_name or request.channel) + if isinstance(out, Awaitable): + return await out + return out + + +def _workflow_event_to_update(event: WorkflowEvent[Any]) -> AgentResponseUpdate | None: + """Map a :class:`WorkflowEvent` to a channel-friendly :class:`AgentResponseUpdate`. + + Returns ``None`` for events the host should drop (anything that is not + user-visible output). The original event is preserved on the update's + ``raw_representation`` so consumers can recover full workflow context. + """ + if event.type != "output": + return None + payload: Any = event.data + if isinstance(payload, AgentResponseUpdate): + # Already a streaming update — pass through but tag the source so + # downstream hooks can tell it came from a workflow executor. + if payload.raw_representation is None: + payload.raw_representation = event + return payload + if isinstance(payload, Content): + # Preserve the original content (image, function call, audio, …) + # rather than stringifying — the host stays modality-agnostic + # and lets each destination channel decide what it can render. + return AgentResponseUpdate( + contents=[payload], + role="assistant", + author_name=event.executor_id, + raw_representation=event, + ) + text = _workflow_output_to_text(payload) + return AgentResponseUpdate( + contents=[Content.from_text(text=text)], + role="assistant", + author_name=event.executor_id, + raw_representation=event, + ) + + +@asynccontextmanager +async def _suppress_already_consumed() -> AsyncIterator[None]: + """Yield, swallowing finalizer failures so consumer cleanup never crashes the host. + + The bridge stream calls ``get_final_response()`` after iterating the + workflow stream so the workflow's cleanup hooks run; on some paths the + stream considers itself already finalized (or its inner stream was + closed by ``__anext__`` auto-finalization) and the finalizer raises. + We are inside an async-generator ``finally`` block during teardown, + so we MUST NOT propagate — that would mask the iteration's real + result and cascade into the channel's own cleanup. We always log + with ``exc_info=True`` so the swallowed failure is observable in + operator logs (a regression in the workflow's own cleanup hooks + would otherwise vanish into a clean run). + """ + try: + yield + except RuntimeError as exc: + # Narrow match: only the two documented benign messages produced + # by ``ResponseStream`` / async-iteration teardown should be + # swallowed. Anything else (executor-side ``RuntimeError`` from a + # ``raise RuntimeError(...)`` in user code, runner-context state + # error, checkpoint-store ``RuntimeError`` during the post-run + # flush, …) is a real bug and is escalated to the unexpected-error + # branch so it's logged with a full stack trace at ERROR. We + # still don't propagate (we're in an async-generator ``finally`` + # during teardown) — see the docstring. + message = str(exc) + if "Inner stream not available" in message or "Event loop is closed" in message: + logger.warning("workflow stream finalize raised RuntimeError; cleanup skipped", exc_info=True) + else: + logger.exception("workflow stream finalize raised an unexpected RuntimeError; cleanup skipped") + except Exception: + # Anything else (checkpoint write failure, context-provider + # error in a cleanup hook, executor-side bug, …) is a real + # problem. ``logger.exception`` includes the traceback and + # routes at ERROR so it's grep-able in production. We still + # don't propagate — see the docstring. + logger.exception("workflow stream finalize raised an unexpected error; cleanup skipped") + + +class _BoundResponseStream: + """Adapter that keeps an :class:`ExitStack` open across stream iteration. + + Streaming runs return a :class:`ResponseStream` synchronously, but + consumption happens later (the channel iterates). For host-bound + request context (e.g. Foundry response-id binding) to survive that + gap, we hold the stack open until the underlying stream is exhausted + or :meth:`aclose` is called. We forward awaitable + async-iterator + + ``get_final_response`` semantics so the channel sees a normal + ``ResponseStream``-shaped object. + + Lifecycle: + + * Async iteration (``async for u in stream``) — the stack is closed + in the iterator's ``finally`` after the inner stream is drained. + * ``await stream`` — convenience for ``await get_final_response()``; + the stack is closed when ``get_final_response`` runs because that + path also routes through :meth:`_close`. + * ``await stream.get_final_response()`` — closes the stack in + ``finally``. + * Manual cleanup — call :meth:`aclose` (idempotent). Safe to call + from a ``finally`` even after iteration / ``get_final_response`` + already closed the stack. + """ + + def __init__(self, inner: Any, stack: ExitStack) -> None: + self._inner = inner + self._stack = stack + self._closed = False + + def _close(self) -> None: + if self._closed: + return + self._closed = True + self._stack.close() + + async def aclose(self) -> None: + """Idempotently release the bound request context. + + Channels that abandon the stream without iterating it (e.g. + early-return on a validation failure) MUST call this in a + ``finally`` so the host-bound contextvars don't leak for the + lifetime of the host. Calling after the stack already closed + (via iteration / ``get_final_response``) is a no-op. + """ + self._close() + + def __await__(self) -> Any: + # Convenience: ``await stream`` ≡ ``await stream.get_final_response()``. + # We route through ``get_final_response`` so the stack closes in + # its ``finally`` block, instead of leaking the binding for the + # host's lifetime as the previous direct-await delegation did. + return self.get_final_response().__await__() + + def __aiter__(self) -> AsyncIterator[Any]: + return self._wrap() + + async def _wrap(self) -> AsyncIterator[Any]: + try: + async for item in self._inner: + yield item + finally: + self._close() + + async def get_final_response(self) -> Any: + try: + return await self._inner.get_final_response() + finally: + self._close() + + def __getattr__(self, name: str) -> Any: + return getattr(self._inner, name) + + +class _HostResponseStream: + """Adapter that applies host-owned stream and final-response hooks.""" + + def __init__( + self, + inner: Any, + *, + request: ChannelRequest, + stream_update_hook: ChannelStreamUpdateHook | None = None, + response_hook: ChannelResponseHook | None = None, + channel_name: str | None = None, + ) -> None: + self._inner = inner + self._request = request + self._stream_update_hook = stream_update_hook + self._response_hook = response_hook + self._channel_name = channel_name + + def __await__(self) -> Any: + return self.get_final_response().__await__() + + def __aiter__(self) -> AsyncIterator[Any]: + return self._wrap() + + async def _wrap(self) -> AsyncIterator[Any]: + async for update in self._inner: + if self._stream_update_hook is None: + yield update + continue + transformed = self._stream_update_hook(update) + if isinstance(transformed, Awaitable): + transformed = await transformed + if transformed is None: + continue + yield transformed + + async def get_final_response(self) -> Any: + result = await self._inner.get_final_response() + if self._response_hook is None: + return result + shaped = await _apply_response_hook( + self._response_hook, + HostedRunResult(result), + request=self._request, + channel_name=self._channel_name, + ) + return shaped.result + + async def aclose(self) -> None: + close = getattr(self._inner, "aclose", None) + if close is not None: + await close() + + def __getattr__(self, name: str) -> Any: + return getattr(self._inner, name) + + +class ChannelContext: + """Host-owned bridge that channels call to invoke the target.""" + + def __init__(self, host: AgentFrameworkHost) -> None: + """Bind the context to its owning :class:`AgentFrameworkHost`. + + The host instance is the source of truth for the target, registered + channels, sessions, and lifecycle state. Channels only ever receive a + context; they never see the host directly. + """ + self._host = host + + @property + def target(self) -> SupportsAgentRun | Workflow: + """The hostable target the channel should invoke.""" + return self._host.target + + async def run( + self, + request: ChannelRequest, + *, + run_hook: ChannelRunHook | None = None, + protocol_request: Any | None = None, + response_hook: ChannelResponseHook | None = None, + channel_name: str | None = None, + ) -> HostedRunResult[Any]: + """Invoke the target for ``request`` and return a channel-neutral result. + + For agent targets the return type narrows to + ``HostedRunResult[AgentResponse]``; for workflow targets to + ``HostedRunResult[WorkflowRunResult]``. The static return is left + as ``HostedRunResult[Any]`` because :class:`ChannelContext` is + agnostic to which target shape the host was constructed with; + channels narrow at the call site if they need it. + + Args: + request: The channel-built request envelope. + + Keyword Args: + run_hook: Optional channel-supplied hook the host applies before + invoking the target. + protocol_request: Raw channel-native payload passed to + ``run_hook``. + response_hook: Optional channel-supplied hook the host applies to + the completed result before returning it. + channel_name: Channel name passed to ``response_hook``. Defaults + to ``request.channel``. + """ + prepared = await self._host._apply_run_hook( # pyright: ignore[reportPrivateUsage] + request, + hook=run_hook, + protocol_request=protocol_request, + ) + result = await self._host._invoke(prepared) # pyright: ignore[reportPrivateUsage] + return await self._host._apply_response_hook( # pyright: ignore[reportPrivateUsage] + result, + request=prepared, + hook=response_hook, + channel_name=channel_name, + ) + + async def run_stream( + self, + request: ChannelRequest, + *, + run_hook: ChannelRunHook | None = None, + protocol_request: Any | None = None, + stream_update_hook: ChannelStreamUpdateHook | None = None, + response_hook: ChannelResponseHook | None = None, + channel_name: str | None = None, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: + """Apply host-owned hooks and invoke the target with ``stream=True``. + + Channels iterate the stream directly (it acts like an AsyncGenerator) + and are responsible for delivering updates to their wire protocol. + When ``stream_update_hook`` is supplied, the host applies it during + iteration to rewrite or drop individual updates before they hit the wire. + + Args: + request: The channel-built request envelope. + + Keyword Args: + run_hook: Optional channel-supplied hook the host applies before + opening the target stream. + protocol_request: Raw channel-native payload passed to + ``run_hook``. + stream_update_hook: Optional host-applied update transform. + response_hook: Optional host-applied final-response transform. + channel_name: Channel name passed to ``response_hook``. Defaults + to ``request.channel``. + """ + prepared = await self._host._apply_run_hook( # pyright: ignore[reportPrivateUsage] + request, + hook=run_hook, + protocol_request=protocol_request, + ) + stream = self._host._invoke_stream(prepared) # pyright: ignore[reportPrivateUsage] + if stream_update_hook is None and response_hook is None: + return stream + return _HostResponseStream( + stream, + request=prepared, + stream_update_hook=stream_update_hook, + response_hook=response_hook, + channel_name=channel_name, + ) # type: ignore[return-value] + + +class _FoundryIsolationASGIMiddleware: + """Lift the two well-known Foundry isolation headers into a contextvar. + + The Foundry Hosted Agents runtime injects + ``x-agent-{user,chat}-isolation-key`` on every inbound HTTP request. + Storage providers that need partition-aware writes (notably + :class:`FoundryHostedAgentHistoryProvider`) read those keys via + :func:`get_current_isolation_keys` to avoid every channel having to + parse Foundry-specific headers itself. We intentionally inspect + only HTTP scopes; lifespan/websocket scopes are forwarded + untouched. When neither header is present the contextvar stays at + its default ``None``, so local-dev requests behave as before. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + user_key: str | None = None + chat_key: str | None = None + for raw_name, raw_value in scope.get("headers") or (): + name = raw_name.decode("latin-1").lower() + if name == ISOLATION_HEADER_USER: + user_key = raw_value.decode("latin-1") or None + elif name == ISOLATION_HEADER_CHAT: + chat_key = raw_value.decode("latin-1") or None + if user_key is None and chat_key is None: + await self.app(scope, receive, send) + return + token = set_current_isolation_keys(IsolationKeys(user_key=user_key, chat_key=chat_key)) + try: + await self.app(scope, receive, send) + finally: + reset_current_isolation_keys(token) + + +class AgentFrameworkHost: + """Owns one Starlette app, one hostable target, and a sequence of channels.""" + + def __init__( + self, + target: SupportsAgentRun | Workflow, + *, + channels: Sequence[Channel], + debug: bool = False, + checkpoint_location: str | os.PathLike[str] | CheckpointStorage | None = None, + state_dir: str | os.PathLike[str] | HostStatePaths | Mapping[str, str | os.PathLike[str]] | None = None, + ) -> None: + """Create a host for ``target`` and its channels. + + Args: + target: The hostable target to invoke from channels — either a + ``SupportsAgentRun``-compatible agent or a ``Workflow``. The + host detects the kind and dispatches to the appropriate + execution seam (``agent.run(...)`` vs ``workflow.run(message=...)``). + For workflow targets, channels (or their ``run_hook``) are + responsible for shaping ``ChannelRequest.input`` into the + workflow start executor's typed input. + + Keyword Args: + channels: The channels to expose. Each channel contributes routes + and commands that are mounted under ``channel.path`` (defaulting + to the channel name). + debug: Whether to enable Starlette's debug mode (stack traces in + responses, etc.) and per-channel debug logging. + checkpoint_location: When ``target`` is a :class:`Workflow`, the + location used to persist workflow checkpoints across requests. + Either a filesystem path (``str`` / ``PathLike``) — the host + creates a per-conversation + :class:`~agent_framework.FileCheckpointStorage` rooted at + ``checkpoint_location / `` — or a + :class:`~agent_framework.CheckpointStorage` instance the host + uses as-is (caller owns scoping). Per-request behaviour: + requests without ``ChannelRequest.session.isolation_key`` + are run without checkpointing. When set on a workflow that + already has its own checkpoint storage configured + (``WorkflowBuilder(checkpoint_storage=...)``), the host + refuses to start so ownership of checkpointing is + unambiguous. Ignored for ``SupportsAgentRun`` targets (a + warning is emitted). Takes precedence over + ``state_dir['checkpoints']`` (or the auto-derived + ``state_dir/checkpoints/`` subfolder); a warning surfaces + the double-configuration. + state_dir: Opt-in disk persistence for host-managed state. + When set, the host writes session aliases created by + :meth:`reset_session` to a :mod:`diskcache`-backed store + under ``state_dir``. When the target is a + :class:`Workflow`, the auto-derived + ``state_dir/checkpoints/`` subfolder (or the + ``checkpoints`` key of the mapping form) is also used + as the workflow checkpoint location (equivalent to + passing ``checkpoint_location`` directly). Accepts: + + * ``None`` (default) — everything stays in memory; the + process owns its state and loses it on exit. Matches + today's behaviour exactly. + * ``str`` / :class:`os.PathLike` — the host derives + default subpaths ``state_dir/sessions/`` and + (for workflow targets) ``state_dir/checkpoints/``. + Recommended for most + long-running-host deployments — one path, no extra + config, all components persist together. Note: when + the target is a Workflow this enables workflow + checkpoint persistence; use the mapping form below + and omit ``checkpoints`` to opt out. + * :class:`HostStatePaths` typed dict / plain + ``Mapping`` — per-component overrides for callers that + want each component on a different volume (fast local + SSD for checkpoints, network-attached volume for + sessions, …). Components missing from the mapping fall + back to in-memory (or, for ``checkpoints``, to no + checkpoint persistence). Unknown keys raise + ``ValueError`` to surface typos early. + + The ``sessions`` component requires the + optional ``diskcache`` dependency (install with + ``pip install 'agent-framework-hosting[disk]'``); + ``checkpoints`` uses the core + :class:`~agent_framework.FileCheckpointStorage` and has + no extra dependency. The disk-cache-backed sessions + component acquires an OS-level advisory lock on its + directory; a second host pointed at the same path raises + :class:`RuntimeError` at construction so two processes + do not race session-alias writes. When + ``checkpoint_location`` is supplied explicitly, the + ``checkpoints`` sub-path is ignored. + """ + self.target: SupportsAgentRun | Workflow = target + self._is_workflow = isinstance(target, Workflow) + self.channels = list(channels) + self._debug = debug + self._app: Starlette | None = None + self._state_paths: dict[str, Path | None] = normalize_state_dir(state_dir) + checkpoints_explicit_in_mapping = isinstance(state_dir, Mapping) and "checkpoints" in state_dir + derived_checkpoint_path = self._state_paths.get("checkpoints") + self._checkpoint_location: Path | CheckpointStorage | None = None + effective_checkpoint_source: str | os.PathLike[str] | CheckpointStorage | None = checkpoint_location + if checkpoint_location is None and derived_checkpoint_path is not None: + # Only consume the derived path when the target is a + # Workflow; non-workflow targets get a warning (explicit + # mapping case) or a silent ignore (single-path case). + if self._is_workflow: + effective_checkpoint_source = derived_checkpoint_path + elif checkpoints_explicit_in_mapping: + logger.warning("state_dir['checkpoints'] is set but target is not a Workflow; ignoring.") + elif checkpoint_location is not None and derived_checkpoint_path is not None: + # Both the legacy parameter and the new state_dir component + # configure the same thing. Keep the explicit one and + # surface the double-config so the user notices the no-op. + logger.warning( + "Both checkpoint_location and state_dir['checkpoints'] are set " + "(state_dir['checkpoints']=%s); the explicit checkpoint_location " + "takes precedence and the state_dir sub-path is ignored. " + "Use the HostStatePaths mapping form and omit 'checkpoints' to " + "configure session-alias persistence without also enabling " + "host-managed workflow checkpointing.", + derived_checkpoint_path, + ) + if effective_checkpoint_source is not None: + if not self._is_workflow: + # Only the legacy parameter path can reach here for a + # non-workflow target (the derived path was already + # short-circuited above). Preserve the historical + # warning text so existing users see the same message. + logger.warning("checkpoint_location is set but target is not a Workflow; ignoring.") + else: + workflow: Workflow = target # type: ignore[assignment] + if workflow._runner_context.has_checkpointing(): # type: ignore[reportPrivateUsage] + raise RuntimeError( + "Workflow already has checkpoint storage configured " + "(WorkflowBuilder(checkpoint_storage=...)). The host " + "manages checkpoints when checkpoint_location (or " + "state_dir['checkpoints']) is set; remove one of the " + "two configurations." + ) + if isinstance(effective_checkpoint_source, (str, os.PathLike)): + self._checkpoint_location = Path(os.fspath(effective_checkpoint_source)) + else: + # Anything else is treated as a CheckpointStorage instance. + # ``CheckpointStorage`` is a non-runtime-checkable Protocol, + # so we cannot ``isinstance``-check it directly. + self._checkpoint_location = effective_checkpoint_source + self._sessions: dict[str, Any] = {} + sessions_path = self._state_paths.get("sessions") + self._sessions_store: SessionsStateStore | None + if sessions_path is not None: + self._sessions_store = SessionsStateStore(sessions_path) + self._session_aliases: dict[str, str] = build_session_aliases(self._sessions_store) + else: + self._sessions_store = None + self._session_aliases = {} + # Set by ``serve()`` so the lifespan startup handler doesn't + # double-log the banner; remains ``False`` when callers mount + # ``host.app`` under their own ASGI server. + self._startup_logged: bool = False + + @property + def app(self) -> Starlette: + """Lazily build (and cache) the Starlette application.""" + if self._app is None: + self._app = self._build_app() + return self._app + + def serve( + self, + *, + host: str = "127.0.0.1", + port: int = 8000, + workers: int = 1, + **config_kwargs: Any, + ) -> None: + """Start the host on ``host:port`` using Hypercorn. + + Hypercorn is the same ASGI server the Foundry Hosted Agents + runtime uses for production deployments, so running locally with + the same server keeps dev/prod parity (Trio fallbacks, lifespan + semantics, HTTP/2 support, …). Install with the ``serve`` extra + (``pip install agent-framework-hosting[serve]``). + + Args: + host: Interface to bind. Defaults to ``127.0.0.1``. + port: TCP port to bind. Defaults to ``8000``. + workers: Number of worker processes. Defaults to ``1``; + Hypercorn's process model only kicks in for ``>1``. + **config_kwargs: Forwarded to :class:`hypercorn.config.Config` + via attribute assignment, so any documented Hypercorn + config field (e.g. ``keep_alive_timeout=...``, + ``access_log_format=...``) can be set directly. + """ + try: + from hypercorn.asyncio import ( # pyright: ignore[reportMissingImports] + serve as _hypercorn_serve, # pyright: ignore[reportUnknownVariableType] + ) + from hypercorn.config import Config # pyright: ignore[reportMissingImports, reportUnknownVariableType] + except ImportError as exc: # pragma: no cover - exercised at runtime + raise RuntimeError( + "AgentFrameworkHost.serve() requires hypercorn. " + "Install with `pip install agent-framework-hosting[serve]` or `pip install hypercorn`." + ) from exc + + config = Config() # pyright: ignore[reportUnknownVariableType] + config.bind = [f"{host}:{port}"] # pyright: ignore[reportUnknownMemberType] + config.workers = workers # pyright: ignore[reportUnknownMemberType] + for key, value in config_kwargs.items(): + setattr(config, key, value) # pyright: ignore[reportUnknownArgumentType] + + # Touch ``self.app`` so the lifespan startup log fires once before + # we hand off to hypercorn — gives a single, readable banner of + # what the host is exposing without requiring channels to log + # individually. + app = self.app + self._log_startup(host=host, port=port, workers=workers) + # Mark as already logged so the lifespan startup handler does not + # double-log the same banner. + self._startup_logged = True + + # ``hypercorn.asyncio.serve`` has a complex partially-typed signature + # (multiple ASGI/WSGI app overloads) and its ``Scope`` definition + # diverges from Starlette's; cast both sides to ``Any`` to keep the + # call site readable without sprinkling per-error suppressions. + serve_callable = cast(Any, _hypercorn_serve) + asyncio.run(serve_callable(app, config)) + + def reset_session(self, isolation_key: str) -> None: + """Rotate ``isolation_key`` to a fresh session id without deleting history. + + Old turns are preserved on disk under their original session id and + remain accessible by passing that id explicitly (e.g. as + ``previous_response_id``). Future requests using ``isolation_key`` + get a new, empty ``AgentSession``. + """ + new_id = f"{isolation_key}#{uuid.uuid4().hex[:8]}" + self._session_aliases[isolation_key] = new_id + self._sessions.pop(isolation_key, None) + + # -- internals --------------------------------------------------------- # + + def _log_startup( + self, + *, + host: str | None = None, + port: int | None = None, + workers: int | None = None, + ) -> None: + """Emit a single human-friendly startup banner. + + Mirrors the ``AgentServerHost`` convention from + ``azure.ai.agentserver.core``: one INFO line that captures the + target type, every channel + its endpoint path, the bind address + (when known), whether we're running inside a Foundry Hosted + Agents container, and the worker count. Keeps log noise low + while still giving an operator a single grep-able anchor when + triaging. + + Called from both :meth:`serve` (which knows the bind triple) + and the ASGI lifespan ``startup`` phase (which does not — the + host may be embedded under any caller-managed ASGI server). + Bind fields are omitted from the log line when unknown. + """ + target_kind = "Workflow" if isinstance(self.target, Workflow) else type(self.target).__name__ + target_name = getattr(self.target, "name", None) or target_kind + channels_repr = ", ".join( + f"{ch.name}@{ch.path or '/'}" # blank path means "mounted at root" + for ch in self.channels + ) + is_hosted = bool(os.environ.get("FOUNDRY_HOSTING_ENVIRONMENT")) + bind = f"{host}:{port}" if host is not None and port is not None else "" + logger.info( + "AgentFrameworkHost starting: target=%s (%s) bind=%s workers=%s hosted=%s channels=[%s]", + target_name, + target_kind, + bind, + workers if workers is not None else "", + is_hosted, + channels_repr or "", + ) + + def _build_app(self) -> Starlette: + context = ChannelContext(self) + routes: list[BaseRoute] = [] + on_startup: list[Callable[[], Awaitable[None]]] = [] + on_shutdown: list[Callable[[], Awaitable[None]]] = [] + + # ``/readiness`` is the standard probe path the Foundry Hosted Agents + # runtime hits to gate traffic. We expose it unconditionally — once the + # ASGI app is up the host considers itself ready (channels register + # their own startup hooks and may run before the first request, but + # readiness is intentionally cheap so the platform's probe never times + # out on transient channel work). Mounted first so a channel cannot + # accidentally shadow it. + async def _readiness(_request: Request) -> PlainTextResponse: # noqa: RUF029 + """Liveness/readiness probe handler used by Foundry Hosted Agents.""" + return PlainTextResponse("ok") + + routes.append(Route("/readiness", _readiness, methods=["GET"])) + + for channel in self.channels: + contribution = channel.contribute(context) + # Channels publish routes relative to their root; mount under channel.path. + # An empty path means "mount at the app root" — useful when an external + # platform requires the channel endpoint at "/" or at a route contributed + # by the channel. + if contribution.routes: + if channel.path: + channel_routes = list(contribution.routes) + exact_routes = [ + exact_route + for route in channel_routes + if (exact_route := _exact_path_route(channel.path, route)) is not None + ] + routes.extend(exact_routes) + routes.append(Mount(channel.path, routes=channel_routes)) + else: + routes.extend(contribution.routes) + on_startup.extend(contribution.on_startup) + on_shutdown.extend(contribution.on_shutdown) + + @asynccontextmanager + async def lifespan(_app: Starlette) -> AsyncIterator[None]: + # Emit the startup banner once. ``serve()`` may have already + # logged it (it logs eagerly so the banner appears before + # control passes to hypercorn); the lifespan still logs it + # for callers that mount ``host.app`` directly under their + # own ASGI server. + if not self._startup_logged: + self._log_startup() + self._startup_logged = True + # Run every startup callback; collect (don't propagate) so + # one bad channel doesn't leave its peers half-initialised + # AND deny us a chance to pair-up shutdown calls. After all + # callbacks have been attempted, raise the FIRST error so + # Starlette / the ASGI server still aborts boot — and log + # every other failure so operators can see them all in one + # log scrape rather than discovering them turn-by-turn. + startup_errors: list[tuple[str, BaseException]] = [] + for cb in on_startup: + try: + await cb() + except Exception as exc: + name = getattr(cb, "__qualname__", repr(cb)) + logger.exception("lifespan startup: callback %s failed", name) + startup_errors.append((name, exc)) + if startup_errors: + _, first_exc = startup_errors[0] + if len(startup_errors) > 1: + logger.error( + "lifespan startup: %d callback(s) failed; first error re-raised, " + "remaining failures already logged above (%s)", + len(startup_errors), + ", ".join(n for n, _ in startup_errors[1:]), + ) + raise first_exc + try: + yield + finally: + # Same shape on the shutdown side: walk every callback + # so a bad one can't leave its peers leaking + # tasks/sockets/sessions, then raise the first if any + # failed so the server's exit code reflects the failure. + shutdown_errors: list[tuple[str, BaseException]] = [] + for cb in on_shutdown: + try: + await cb() + except Exception as exc: + name = getattr(cb, "__qualname__", repr(cb)) + logger.exception("lifespan shutdown: callback %s failed", name) + shutdown_errors.append((name, exc)) + if self._sessions_store is not None: + try: + self._sessions_store.close() + except Exception as exc: # pragma: no cover - defensive + logger.exception("lifespan shutdown: sessions store close failed") + shutdown_errors.append(("SessionsStateStore.close", exc)) + if shutdown_errors: + _, first_exc = shutdown_errors[0] + if len(shutdown_errors) > 1: + logger.error( + "lifespan shutdown: %d callback(s) failed; first error re-raised, " + "remaining failures already logged above (%s)", + len(shutdown_errors), + ", ".join(n for n, _ in shutdown_errors[1:]), + ) + raise first_exc + + middleware = ( + [Middleware(_FoundryIsolationASGIMiddleware)] if os.environ.get("FOUNDRY_HOSTING_ENVIRONMENT") else [] + ) + return Starlette( + debug=self._debug, + routes=routes, + lifespan=lifespan, + middleware=middleware, + ) + + def _build_run_kwargs(self, request: ChannelRequest) -> dict[str, Any]: + # The host keys a per-isolation_key AgentSession off the channel's + # session hint so context providers (FileHistoryProvider, …) on the + # target see one session per end user. + session = None + if request.session_mode != "disabled" and request.session is not None: + isolation_key = request.session.isolation_key + if isolation_key is not None and hasattr(self.target, "create_session"): + session_id = self._session_aliases.get(isolation_key, isolation_key) + session = self._sessions.get(isolation_key) + if session is None: + # Concurrency note: ``create_session`` is sync today, + # so the get/set window has no await point and CPython + # serialises us against other tasks. ``setdefault`` is + # the atomic primitive that keeps us safe even if a + # future ``create_session`` ever yields — both racers + # would see ``session is None``, both construct a new + # session, but only the first ``setdefault`` wins; the + # loser's just-built session is discarded (one + # transient orphan max per race window) instead of + # silently overwriting a peer-bound session that + # other in-flight requests are already using. + # ``create_session`` lives on agent-typed targets but not on + # ``Workflow``; the ``hasattr`` above guards the call site. + new_session = self.target.create_session( # pyright: ignore[reportAttributeAccessIssue, reportUnknownVariableType, reportUnknownMemberType] + session_id=session_id + ) + session = self._sessions.setdefault(isolation_key, new_session) # pyright: ignore[reportUnknownArgumentType] + + run_kwargs: dict[str, Any] = {} + if session is not None: + run_kwargs["session"] = session + if request.options: + run_kwargs["options"] = request.options + return run_kwargs + + async def _apply_run_hook( + self, + request: ChannelRequest, + *, + hook: ChannelRunHook | None, + protocol_request: Any | None, + ) -> ChannelRequest: + """Apply a channel-supplied run hook under host ownership.""" + if hook is None: + return request + return await _apply_run_hook( + hook, + request, + target=self.target, + protocol_request=protocol_request, + ) + + async def _apply_response_hook( + self, + result: HostedRunResult[Any], + *, + request: ChannelRequest, + hook: ChannelResponseHook | None, + channel_name: str | None, + ) -> HostedRunResult[Any]: + """Apply a channel-supplied response hook under host ownership.""" + if hook is None: + return result + return await _apply_response_hook(hook, result, request=request, channel_name=channel_name) + + def _log_incoming(self, request: ChannelRequest, *, stream: bool) -> None: + """Emit a structured INFO summary for every incoming target invocation. + + When ``debug=True`` is set on the host, also dump the channel-native + settings the channel attached to the ``ChannelRequest`` — ``options`` + (the ChatOptions-shaped fields the channel parsed from its protocol + payload, e.g. temperature/tools/tool_choice for Responses), plus + ``attributes`` / ``metadata`` (the channel's protocol-specific bag, + e.g. ``chat_id`` / ``callback_query_id`` for Telegram). + + Uses ``extra={...}`` so structured-logging consumers (the + Foundry hosted-agent log shipper, OpenTelemetry handlers, …) + can index per-field rather than re-parsing a template string. + """ + isolation_key = request.session.isolation_key if request.session is not None else None + logger.info( + "channel request", + extra={ + "channel": request.channel, + "operation": request.operation, + "stream": stream, + "session": isolation_key, + "session_mode": request.session_mode, + }, + ) + logger.debug( + "channel request details", + extra={ + "channel": request.channel, + "options": dict(request.options) if request.options else {}, + "attributes": dict(request.attributes) if request.attributes else {}, + "metadata": dict(request.metadata) if request.metadata else {}, + }, + ) + + def _bind_request_context(self, request: ChannelRequest) -> ExitStack: + """Bind any per-request anchors a target's context-providers expose. + + Channels announce per-request anchors (currently ``response_id`` + and ``previous_response_id``) via ``ChannelRequest.attributes``. + Some history providers — notably the Foundry hosted-agent history + provider — need to write storage under the same ``response_id`` + the channel surfaces on its envelope so the next turn's + ``previous_response_id`` walks the chain. Rather than the host + knowing about specific provider classes, we duck-type: any + context provider on the target that exposes a + ``bind_request_context(response_id=..., previous_response_id=..., + **_)`` context-manager gets it called with the request's + attribute values. Per-request platform isolation keys are handled + separately by :class:`_FoundryIsolationASGIMiddleware` (lifted + off the inbound headers into a contextvar) so providers don't + depend on channels to forward them. Bindings are scoped to the + returned :class:`ExitStack` which the caller must enter before + invoking the target and leave after the run completes. + """ + stack = ExitStack() + attrs = request.attributes or {} + response_id = attrs.get("response_id") + if not isinstance(response_id, str) or not response_id: + return stack + previous_response_id = attrs.get("previous_response_id") + if previous_response_id is not None and not isinstance(previous_response_id, str): + previous_response_id = None + + providers: Sequence[Any] = getattr(self.target, "context_providers", None) or () + + for provider in providers: + bind = getattr(provider, "bind_request_context", None) + if not callable(bind): + continue + stack.enter_context( + cast( + "AbstractContextManager[Any]", + bind( + response_id=response_id, + previous_response_id=previous_response_id, + ), + ) + ) + return stack + + async def _invoke(self, request: ChannelRequest) -> HostedRunResult[AgentResponse]: + self._log_incoming(request, stream=False) + if self._is_workflow: + # Workflow targets follow a separate path; the dedicated dispatch + # is parameterised on ``WorkflowRunResult`` so the static return + # type of ``_invoke`` itself stays the agent-shaped envelope. + return await self._invoke_workflow(request) # type: ignore[return-value] + run_kwargs = self._build_run_kwargs(request) + with self._bind_request_context(request): + # ``_is_workflow`` is False here so ``self.target`` is an + # ``Agent``-shaped target whose ``.run`` returns + # :class:`AgentResponse`. Narrow back to keep ``result.messages`` + # well-typed without conditional imports of ``Agent``. + agent_target = cast("SupportsAgentRun", self.target) + result = await agent_target.run(self._wrap_input(request), **run_kwargs) + # Carry the full :class:`AgentResponse` as the typed envelope + # ``result`` so channels (and developer-supplied response hooks) + # can read ``messages``, ``value``, ``usage_details``, + # ``response_id`` … directly off the target output without the + # host pre-shaping any of it. The bound session (if any) is + # surfaced so channels that want to render session metadata + # don't have to re-resolve it. + return HostedRunResult(result, session=run_kwargs.get("session")) + + def _invoke_stream(self, request: ChannelRequest) -> ResponseStream[AgentResponseUpdate, AgentResponse]: + self._log_incoming(request, stream=True) + if self._is_workflow: + return self._invoke_workflow_stream(request) + run_kwargs = self._build_run_kwargs(request) + # ``run(stream=True)`` returns a ResponseStream synchronously (it is + # itself awaitable / async-iterable). We hand it back to the channel + # so the channel can drive iteration and apply its transform hook. + # Streaming flows iterate after this method returns, which is + # *outside* a sync ``with`` block — so we wrap the underlying + # stream in an adapter that holds the binding open across the + # iteration lifecycle. + binder = self._bind_request_context(request) + return _BoundResponseStream( # type: ignore[return-value] + self.target.run(self._wrap_input(request), stream=True, **run_kwargs), + binder, + ) + + def _resolve_checkpoint_storage(self, request: ChannelRequest) -> CheckpointStorage | None: + """Build (or return) the per-request checkpoint storage, or ``None``. + + Returns ``None`` when no ``checkpoint_location`` is configured or + when the request lacks a stable session key — without a key we + cannot scope checkpoints per conversation, and we'd rather skip + checkpointing than pollute a single shared store. + + When ``checkpoint_location`` is a path, the per-conversation + directory is built via :func:`_checkpoint_path_for_isolation_key` + which rejects path-traversal patterns in ``isolation_key`` and + verifies the resolved directory stays under the configured root + (CWE-22 defence). Invalid keys cause the request to skip + checkpointing with a WARNING rather than escape the root or + crash the request. + """ + if self._checkpoint_location is None: + return None + if request.session is None or not request.session.isolation_key: + return None + if isinstance(self._checkpoint_location, Path): + try: + target = _checkpoint_path_for_isolation_key(self._checkpoint_location, request.session.isolation_key) + except ValueError as exc: + logger.warning( + "Skipping checkpoint storage for request: %s", + exc, + ) + return None + return FileCheckpointStorage(str(target)) + # Caller-supplied storage — used as-is; caller owns scoping. + return self._checkpoint_location + + async def _invoke_workflow(self, request: ChannelRequest) -> HostedRunResult[WorkflowRunResult]: + """Dispatch to ``Workflow.run`` and wrap the result in a typed envelope. + + The channel's ``run_hook`` is the canonical adapter for shaping + ``request.input`` into the workflow start executor's typed input + (free-form text from a Telegram message, structured ``Responses`` + ``input`` items, …). When no hook is wired, ``request.input`` is + forwarded verbatim — appropriate for workflows whose start executor + accepts the channel's native input type (commonly ``str``). + + When ``checkpoint_location`` is configured on the host, a + per-conversation checkpoint storage is resolved, the workflow is + restored from its latest checkpoint (if any) and then re-run with + the new input — mirroring the resume semantics of the Foundry + Responses host. + + The full :class:`~agent_framework._workflows._workflow.WorkflowRunResult` + is carried unchanged on :attr:`HostedRunResult.result` so + destination channels can iterate :meth:`WorkflowRunResult.get_outputs`, + inspect :meth:`WorkflowRunResult.get_final_state`, or pull other + per-executor events themselves. The host intentionally does not + map outputs onto messages — channels (and developer-supplied + response hooks) own that projection because what counts as a + "renderable output" is wire-format-specific. + + Workflows do not own session state in the agent sense, so + ``HostedRunResult.session`` is ``None`` for workflow targets. + """ + # Workflows do not own session state in the agent sense and do not + # accept ``session=`` / ``options=`` kwargs. The channel's run_hook is + # the seam for any per-run customization; nothing flows through here. + workflow: Workflow = self.target # type: ignore[assignment] + storage = self._resolve_checkpoint_storage(request) + await self._restore_workflow_checkpoint(workflow, storage) + result = ( + await workflow.run(request.input, checkpoint_storage=storage) + if storage is not None + else await workflow.run(request.input) + ) + return HostedRunResult(result) + + @staticmethod + async def _restore_workflow_checkpoint( + workflow: Workflow, + storage: CheckpointStorage | None, + ) -> None: + """Rehydrate ``workflow`` from its latest checkpoint, if any. + + Shared between the blocking and streaming workflow paths so the + restore step stays in lockstep across both — both must observe + the same in-memory state when they apply the new input. + + If ``storage.get_latest`` returns ``None`` (no prior checkpoint + recorded) the call is a benign no-op. A non-``None`` checkpoint + whose stored events are empty (stale or partially-written + ``checkpoint_id``) is logged at WARNING so operators can detect + the silent-state-loss case without sifting through INFO logs. + """ + if storage is None: + return + latest = await storage.get_latest(workflow_name=workflow.name) + if latest is None: + return + # The blocking restore call is a no-op invocation that just + # rehydrates state; the streaming path drains the same + # restoration stream below to achieve the same effect. + result = await workflow.run(checkpoint_id=latest.checkpoint_id, checkpoint_storage=storage) + events = getattr(result, "events", None) + if events is not None and not events: + logger.warning( + "workflow checkpoint restore produced zero events " + "(workflow=%s checkpoint_id=%s) — state may not be rehydrated", + workflow.name, + latest.checkpoint_id, + ) + + def _invoke_workflow_stream(self, request: ChannelRequest) -> ResponseStream[AgentResponseUpdate, AgentResponse]: + """Bridge ``Workflow.run(stream=True)`` to a channel-facing ``ResponseStream``. + + Wraps the workflow's ``ResponseStream[WorkflowEvent, WorkflowRunResult]`` + in a new ``ResponseStream[AgentResponseUpdate, AgentResponse]`` so + channels can iterate it identically to an agent stream and apply + their ``stream_update_hook`` callables. + + Mapping rules: + + - ``output`` events whose ``data`` is already an + :class:`AgentResponseUpdate` (the common case for workflows + containing :class:`AgentExecutor`) pass through unchanged. + - ``output`` events with any other ``data`` are wrapped into a + single-text-content :class:`AgentResponseUpdate`. + - All other event types (``status``, ``executor_invoked``, + ``superstep_*``, lifecycle, …) are filtered out — channels only + care about user-visible text. Hooks can opt back in by inspecting + ``raw_representation`` on the produced updates. + + The original :class:`WorkflowEvent` is stashed on + ``AgentResponseUpdate.raw_representation`` so advanced consumers + (telemetry, debug UIs) can recover the full workflow timeline. + + Checkpoint restoration (when ``checkpoint_location`` is set) runs + before the input stream is opened so the new turn observes the + restored state. + """ + workflow: Workflow = self.target # type: ignore[assignment] + storage = self._resolve_checkpoint_storage(request) + + async def _bridge() -> AsyncIterator[AgentResponseUpdate]: + # Same restore step the blocking path runs (see + # ``_restore_workflow_checkpoint``) — kept inside the bridge + # so the in-memory state is rehydrated lazily on first + # iteration rather than at stream-construction time. + await self._restore_workflow_checkpoint_streaming(workflow, storage) + workflow_stream = workflow.run(request.input, stream=True, checkpoint_storage=storage) + try: + async for event in workflow_stream: + update = _workflow_event_to_update(event) + if update is not None: + yield update + finally: + async with _suppress_already_consumed(): + await workflow_stream.get_final_response() + + async def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse: # noqa: RUF029 + return AgentResponse.from_updates(updates) + + return ResponseStream[AgentResponseUpdate, AgentResponse](_bridge(), finalizer=_finalize) + + @staticmethod + async def _restore_workflow_checkpoint_streaming( + workflow: Workflow, + storage: CheckpointStorage | None, + ) -> None: + """Streaming-path counterpart to :meth:`_restore_workflow_checkpoint`. + + ``Workflow.run(stream=True, checkpoint_id=...)`` returns a stream + whose updates we don't care about — we just need the side-effect + of rehydration. Drained inline so the new-input run that follows + observes the restored state. + + A latest checkpoint that drains to zero events (stale or + partially-written ``checkpoint_id``) is logged at WARNING so + operators can detect the silent-state-loss case, mirroring the + blocking helper. + """ + if storage is None: + return + latest = await storage.get_latest(workflow_name=workflow.name) + if latest is None: + return + drained = 0 + async for _ in workflow.run( + stream=True, + checkpoint_id=latest.checkpoint_id, + checkpoint_storage=storage, + ): + drained += 1 + if drained == 0: + logger.warning( + "workflow checkpoint restore stream produced zero events " + "(workflow=%s checkpoint_id=%s) — state may not be rehydrated", + workflow.name, + latest.checkpoint_id, + ) + + def _wrap_input(self, request: ChannelRequest) -> Message | list[Message]: + """Promote ``request.input`` to ``Message``(s) carrying channel metadata. + + Channels deliver inputs as plain text, a single ``Message``, or a list + of ``Message`` (e.g. a Responses-API request that includes a ``system`` + instruction plus the user turn). To preserve channel provenance and + optional identity metadata on the persisted history record (and make it + visible to context providers, evals, audits), we attach a ``hosting`` + block under ``additional_properties``. AF's + ``Message.to_dict`` round-trips ``additional_properties`` through any + ``HistoryProvider`` that serializes via ``to_dict`` (e.g. + ``FileHistoryProvider``) and the framework explicitly does *not* + forward these fields to model providers, so they are safe to attach. + + For a list of messages we attach the metadata to the LAST message that + will be persisted (typically the user turn) — this keeps a single, + searchable record of where the inbound message came from. + """ + hosting_meta: dict[str, Any] = {"channel": request.channel} + if request.identity is not None: + hosting_meta["identity"] = { + "channel": request.identity.channel, + "native_id": request.identity.native_id, + "attributes": dict(request.identity.attributes) if request.identity.attributes else {}, + } + raw = request.input + if isinstance(raw, Message): + raw.additional_properties = {**(raw.additional_properties or {}), "hosting": hosting_meta} + return raw + if isinstance(raw, list) and raw and all(isinstance(m, Message) for m in raw): + messages: list[Message] = [m for m in raw if isinstance(m, Message)] + last = messages[-1] + last.additional_properties = {**(last.additional_properties or {}), "hosting": hosting_meta} + return messages + # ``raw`` is typed as ``AgentRunInputs`` (str | Content | Message | Sequence[…]). + # The remaining cases are str / Content / Mapping — wrap as a single user message. + return Message( + role="user", + contents=[raw], # type: ignore[list-item] + additional_properties={"hosting": hosting_meta}, + ) + + +__all__ = ["AgentFrameworkHost", "ChannelContext", "logger"] diff --git a/python/packages/hosting/agent_framework_hosting/_isolation.py b/python/packages/hosting/agent_framework_hosting/_isolation.py new file mode 100644 index 00000000000..53fb2f1e548 --- /dev/null +++ b/python/packages/hosting/agent_framework_hosting/_isolation.py @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Per-request isolation keys read from inbound HTTP headers. + +The Foundry Hosted Agents runtime injects two well-known headers on every +request it forwards to the user's container: + +* ``x-agent-user-isolation-key`` — opaque per-user partition key +* ``x-agent-chat-isolation-key`` — opaque per-conversation partition key + +When the headers are present we are running inside (or being driven by) the +Foundry runtime; when they are absent we are running in plain local dev. The +host installs an ASGI middleware in :meth:`AgentFrameworkHost._build_app` +that reads both headers off every inbound HTTP request and pushes them into +the :data:`current_isolation_keys` contextvar for the duration of the +request, then resets it. Providers that need partition-aware storage (most +notably ``FoundryHostedAgentHistoryProvider``) read the contextvar via +:func:`get_current_isolation_keys` and apply the keys to their backend +calls — so app authors don't have to wire any middleware themselves and +channels stay free of Foundry-specific header knowledge. + +The contextvar holds a plain :class:`IsolationKeys` mapping; conversion to +provider-specific types (e.g. Foundry's ``IsolationContext``) happens at +the consuming provider so this module has no provider dependencies. +""" + +from __future__ import annotations + +from contextvars import ContextVar, Token + +__all__ = [ + "ISOLATION_HEADER_CHAT", + "ISOLATION_HEADER_USER", + "IsolationKeys", + "current_isolation_keys", + "get_current_isolation_keys", + "reset_current_isolation_keys", + "set_current_isolation_keys", +] + + +ISOLATION_HEADER_USER = "x-agent-user-isolation-key" +ISOLATION_HEADER_CHAT = "x-agent-chat-isolation-key" + + +class IsolationKeys: + """Per-request Foundry isolation keys lifted off the inbound headers.""" + + def __init__(self, user_key: str | None = None, chat_key: str | None = None) -> None: + self.user_key = user_key + self.chat_key = chat_key + + @property + def is_empty(self) -> bool: + return self.user_key is None and self.chat_key is None + + +current_isolation_keys: ContextVar[IsolationKeys | None] = ContextVar( + "agent_framework_hosting_isolation_keys", + default=None, +) + + +def get_current_isolation_keys() -> IsolationKeys | None: + """Return the isolation keys bound to the current request, if any.""" + return current_isolation_keys.get() + + +def set_current_isolation_keys(keys: IsolationKeys | None) -> Token[IsolationKeys | None]: + """Bind ``keys`` to the current async context and return a reset token.""" + return current_isolation_keys.set(keys) + + +def reset_current_isolation_keys(token: Token[IsolationKeys | None]) -> None: + """Restore the isolation contextvar to its prior value.""" + current_isolation_keys.reset(token) diff --git a/python/packages/hosting/agent_framework_hosting/_persistence.py b/python/packages/hosting/agent_framework_hosting/_persistence.py new file mode 100644 index 00000000000..9ecc4dd23e0 --- /dev/null +++ b/python/packages/hosting/agent_framework_hosting/_persistence.py @@ -0,0 +1,128 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Shared persistence primitives for the hosting package. + +The simplified hosting core keeps disk persistence only for session aliases +created by :meth:`AgentFrameworkHost.reset_session` and for workflow +checkpoint path derivation. The on-disk session-alias store uses the optional +``diskcache`` package installed via the ``[disk]`` extra. +""" + +from __future__ import annotations + +import contextlib +import os +import sys +from collections.abc import Mapping +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ._types import HostStatePaths + +_KNOWN_COMPONENTS: tuple[str, ...] = ("sessions", "checkpoints") + + +def load_diskcache() -> Any: + """Lazy-import :mod:`diskcache` with a helpful error when missing.""" + try: + import diskcache # type: ignore[import-untyped] + except ImportError as exc: # pragma: no cover - exercised via tests by monkeypatching + raise ImportError( + "agent-framework-hosting was asked to persist session aliases to disk " + "(state_dir['sessions'] is set) but the optional `diskcache` dependency " + "is not installed. Install the disk extra: " + "`pip install 'agent-framework-hosting[disk]`." + ) from exc + return diskcache + + +def acquire_state_dir_lock(component_dir: Path) -> Any: + """Acquire an exclusive single-owner lock on a component's state dir. + + Raises: + RuntimeError: If another process already holds the lock. + """ + component_dir.mkdir(parents=True, exist_ok=True) + lock_path = component_dir / ".lock" + fh = open(lock_path, "a+", encoding="utf-8") # noqa: SIM115 - kept open for lifetime + try: + if sys.platform == "win32": + import msvcrt + + try: + msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1) + except OSError as exc: + fh.close() + raise RuntimeError( + f"Another process already holds the hosting state lock at {lock_path}. " + "Point each host at its own state_dir." + ) from exc + else: + import fcntl + + try: + fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + fh.close() + raise RuntimeError( + f"Another process already holds the hosting state lock at {lock_path}. " + "Point each host at its own state_dir." + ) from exc + except RuntimeError: + raise + except Exception: + fh.close() + raise + return fh + + +def release_state_dir_lock(handle: Any) -> None: + """Release a lock previously acquired by :func:`acquire_state_dir_lock`.""" + if handle is None: + return + with contextlib.suppress(Exception): + handle.close() + + +def normalize_state_dir( + state_dir: str | os.PathLike[str] | HostStatePaths | Mapping[str, str | os.PathLike[str]] | None, +) -> dict[str, Path | None]: + """Resolve the host-level ``state_dir`` parameter into a per-component map. + + Accepts ``None``, a single root path, or a mapping with ``sessions`` and + ``checkpoints`` keys. Unknown keys raise ``ValueError`` so obsolete + ``runner`` / ``links`` configuration is rejected instead of silently + doing nothing. + """ + result: dict[str, Path | None] = {name: None for name in _KNOWN_COMPONENTS} + if state_dir is None: + return result + + if isinstance(state_dir, (str, os.PathLike)): + root = Path(os.fspath(state_dir)) + for name in _KNOWN_COMPONENTS: + result[name] = root / name + return result + + if isinstance(state_dir, Mapping): + unknown = [k for k in state_dir if k not in _KNOWN_COMPONENTS] + if unknown: + raise ValueError( + f"state_dir mapping contains unknown component key(s): {unknown!r}. " + f"Known components are: {list(_KNOWN_COMPONENTS)!r}." + ) + for name in _KNOWN_COMPONENTS: + raw_value: Any = state_dir.get(name) + if raw_value is None: + result[name] = None + continue + if isinstance(raw_value, (str, os.PathLike)): + result[name] = Path(os.fspath(raw_value)) + else: + raise TypeError(f"state_dir[{name!r}] must be a str or PathLike — got {type(raw_value).__name__}") + return result + + raise TypeError( + f"state_dir must be a str, PathLike, HostStatePaths mapping, or None — got {type(state_dir).__name__}" + ) diff --git a/python/packages/hosting/agent_framework_hosting/_state_store.py b/python/packages/hosting/agent_framework_hosting/_state_store.py new file mode 100644 index 00000000000..817f9bd52e1 --- /dev/null +++ b/python/packages/hosting/agent_framework_hosting/_state_store.py @@ -0,0 +1,146 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Disk-backed wrapper for the host's session-alias map. + +``AgentFrameworkHost.reset_session(isolation_key)`` rotates future requests for +that isolation key onto a new session id. Persisting the alias map lets that +rotation survive a host restart without introducing cross-channel identity or +delivery state into the core host. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Mapping +from pathlib import Path +from typing import Any, TypeVar + +from ._persistence import ( + acquire_state_dir_lock, + load_diskcache, + release_state_dir_lock, +) + +logger = logging.getLogger(__name__) + +_V = TypeVar("_V") +_ALIASES_PREFIX = "aliases:" + + +class SessionsStateStore: + """One disk cache + lock for host-side session aliases.""" + + def __init__(self, sessions_dir: str | os.PathLike[str]) -> None: + self._sessions_dir: Path = Path(os.fspath(sessions_dir)) + diskcache = load_diskcache() + self._lock_handle: Any = acquire_state_dir_lock(self._sessions_dir) + try: + self._cache: Any = diskcache.Cache(str(self._sessions_dir)) + except Exception: + release_state_dir_lock(self._lock_handle) + self._lock_handle = None + raise + + @property + def cache(self) -> Any: + """Return the underlying :mod:`diskcache` Cache.""" + return self._cache + + def close(self) -> None: + """Close the cache and release the directory lock.""" + if self._cache is not None: + try: + self._cache.close() + except Exception: # pragma: no cover - close errors aren't actionable + logger.exception("SessionsStateStore: failed to close cache cleanly") + self._cache = None + if self._lock_handle is not None: + release_state_dir_lock(self._lock_handle) + self._lock_handle = None + + +class _PersistedDict(dict[str, _V]): + """Drop-in :class:`dict` whose mutations mirror to a diskcache prefix.""" + + def __init__( + self, + store: SessionsStateStore, + key_prefix: str, + initial: Mapping[str, _V] | None = None, + ) -> None: + super().__init__() + self._store = store + self._prefix = key_prefix + cache: Any = store.cache + for raw_key in cache.iterkeys(): + if not isinstance(raw_key, str) or not raw_key.startswith(key_prefix): + continue + try: + value: Any = cache.get(raw_key) + except Exception: + logger.exception("SessionsStateStore: failed to rehydrate %s; skipping", raw_key) + continue + logical_key = raw_key[len(key_prefix) :] + super().__setitem__(logical_key, value) + if initial: + for key, value in initial.items(): + self[key] = value + + def __setitem__(self, key: str, value: _V) -> None: + super().__setitem__(key, value) + try: + self._store.cache.set(self._prefix + key, value) + except Exception: # pragma: no cover - cache write failures aren't actionable + logger.exception("SessionsStateStore: failed to persist %s%s", self._prefix, key) + + def __delitem__(self, key: str) -> None: + super().__delitem__(key) + try: + del self._store.cache[self._prefix + key] + except KeyError: + pass + except Exception: # pragma: no cover - cache write failures aren't actionable + logger.exception("SessionsStateStore: failed to evict %s%s", self._prefix, key) + + def pop(self, key: str, *args: Any) -> _V: + """Mirror ``dict.pop`` to disk.""" + value: _V = super().pop(key, *args) + try: + del self._store.cache[self._prefix + key] + except KeyError: + pass + except Exception: # pragma: no cover + logger.exception("SessionsStateStore: failed to evict %s%s", self._prefix, key) + return value + + def clear(self) -> None: + """Mirror ``dict.clear`` to disk.""" + keys = list(self.keys()) + super().clear() + cache = self._store.cache + for key in keys: + try: + del cache[self._prefix + key] + except KeyError: + pass + except Exception: # pragma: no cover + logger.exception("SessionsStateStore: failed to evict %s%s during clear", self._prefix, key) + + def update( # type: ignore[override] + self, + other: Mapping[str, _V] | None = None, + /, + **kwargs: _V, + ) -> None: + """Mirror ``dict.update`` to disk one item at a time.""" + if other is not None: + for key in other: + self[key] = other[key] + for key, value in kwargs.items(): + self[key] = value + + +def build_session_aliases(store: SessionsStateStore) -> dict[str, str]: + """Return the disk-backed session-alias map for ``store``.""" + return _PersistedDict[str](store, _ALIASES_PREFIX) diff --git a/python/packages/hosting/agent_framework_hosting/_types.py b/python/packages/hosting/agent_framework_hosting/_types.py new file mode 100644 index 00000000000..23c172d9a70 --- /dev/null +++ b/python/packages/hosting/agent_framework_hosting/_types.py @@ -0,0 +1,212 @@ +# Copyright (c) Microsoft. All rights reserved. + +# ``ChannelRequest`` is the only intentional dataclass here (callers use +# ``dataclasses.replace`` on it in run hooks). The other types are plain +# Python classes by preference, so the "could be a dataclass" lint is muted +# at the file level. +# ruff: noqa: B903 + +"""Channel-neutral request envelope and channel protocol types. + +These types form the boundary between the host and individual channels. +A channel parses its native payload, builds a :class:`ChannelRequest`, and +hands it to :class:`ChannelContext.run` (or ``run_stream``) on the host. +The channel owns rendering the result back onto its originating protocol. +""" + +from __future__ import annotations + +import os +from collections.abc import Awaitable, Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Generic, Protocol, TypedDict, TypeVar, runtime_checkable + +from agent_framework import ( + AgentResponseUpdate, + AgentRunInputs, +) +from starlette.routing import BaseRoute + +if TYPE_CHECKING: + from ._host import ChannelContext + + +class ChannelSession: + """Channel-supplied session hint. + + The host turns this into an ``AgentSession`` keyed by ``isolation_key`` so + every distinct end user gets their own context-provider state (e.g. one + ``FileHistoryProvider`` JSONL file per user). + """ + + def __init__(self, isolation_key: str | None = None) -> None: + self.isolation_key = isolation_key + + +class ChannelIdentity: + """Channel-native identity metadata observed on a request. + + The simplified hosting core records this only on the persisted input + message's ``additional_properties["hosting"]`` block and forwards it + through run/response hooks. Cross-channel linking and recipient lookup are + follow-up concerns, not part of the v1 host contract. + """ + + def __init__( + self, + channel: str, + native_id: str, + attributes: Mapping[str, Any] | None = None, + ) -> None: + self.channel = channel + self.native_id = native_id + self.attributes: Mapping[str, Any] = attributes if attributes is not None else dict() + + +@dataclass +class ChannelRequest: + """Uniform invocation envelope every channel produces from its native payload. + + Kept as a dataclass so app authors can use ``dataclasses.replace(...)`` in + run hooks to produce a modified envelope without re-listing every field. + """ + + channel: str + operation: str + input: AgentRunInputs + session: ChannelSession | None = None + options: Mapping[str, Any] | None = None + session_mode: str = "auto" + metadata: Mapping[str, Any] = field(default_factory=lambda: {}) + attributes: Mapping[str, Any] = field(default_factory=lambda: {}) + stream: bool = False + identity: ChannelIdentity | None = None + + +class ChannelCommand: + """A discoverable command a channel exposes to its users (e.g. ``/reset``).""" + + def __init__( + self, + name: str, + description: str, + handle: Callable[[ChannelCommandContext], Awaitable[None]], + ) -> None: + self.name = name + self.description = description + self.handle = handle + + +class ChannelCommandContext: + """Context passed to a :class:`ChannelCommand` handler.""" + + def __init__( + self, + request: ChannelRequest, + reply: Callable[[str], Awaitable[None]], + ) -> None: + self.request = request + self.reply = reply + + +_EMPTY_ROUTES: tuple[BaseRoute, ...] = () +_EMPTY_COMMANDS: tuple[ChannelCommand, ...] = () +_EMPTY_LIFECYCLE: tuple[Callable[[], Awaitable[None]], ...] = () + + +class ChannelContribution: + """Routes, commands, and lifecycle hooks a channel contributes to the host.""" + + def __init__( + self, + routes: Sequence[BaseRoute] = _EMPTY_ROUTES, + commands: Sequence[ChannelCommand] = _EMPTY_COMMANDS, + on_startup: Sequence[Callable[[], Awaitable[None]]] = _EMPTY_LIFECYCLE, + on_shutdown: Sequence[Callable[[], Awaitable[None]]] = _EMPTY_LIFECYCLE, + ) -> None: + self.routes = routes + self.commands = commands + self.on_startup = on_startup + self.on_shutdown = on_shutdown + + +class _Unset: + """Sentinel for ``HostedRunResult.replace`` overrides. + + Distinguishes "caller did not pass this kwarg" from "caller passed + ``None`` explicitly" — needed because ``session`` is ``None`` in + many envelopes and we want the no-arg call to preserve it. + """ + + +_UNSET = _Unset() + + +TResult = TypeVar("TResult") + + +class HostedRunResult(Generic[TResult]): + """Channel-neutral envelope around the target's full-fidelity result. + + The host does not flatten or pre-shape the target output. Channels and + response hooks read the underlying result type directly and serialize the + subset their wire format can carry. + """ + + def __init__( + self, + result: TResult, + *, + session: Any | None = None, + ) -> None: + self.result = result + self.session = session + + def replace( + self, + *, + result: TResult | _Unset = _UNSET, + session: Any | _Unset | None = _UNSET, + ) -> HostedRunResult[TResult]: + """Return a shallow copy with the supplied fields overridden.""" + new: HostedRunResult[TResult] = HostedRunResult.__new__(HostedRunResult) # pyright: ignore[reportUnknownVariableType] + new.result = self.result if isinstance(result, _Unset) else result + new.session = self.session if isinstance(session, _Unset) else session + return new + + +class HostStatePaths(TypedDict, total=False): + """Per-component disk paths for host-managed state. + + Only session aliases and workflow checkpoints remain in the simplified + host. Linking stores, active-channel maps, identity registries, and runner + queues are follow-up concerns. + """ + + sessions: str | os.PathLike[str] + """Where the host persists session aliases created by ``reset_session``.""" + + checkpoints: str | os.PathLike[str] + """Where the host persists workflow checkpoints for ``Workflow`` targets.""" + + +ChannelStreamUpdateHook = Callable[ + [AgentResponseUpdate], + "AgentResponseUpdate | Awaitable[AgentResponseUpdate | None] | None", +] + + +ChannelRunHook = Callable[..., "Awaitable[ChannelRequest] | ChannelRequest"] + + +ChannelResponseHook = Callable[..., "Awaitable[HostedRunResult[Any]] | HostedRunResult[Any]"] + + +@runtime_checkable +class Channel(Protocol): + """A pluggable adapter that exposes one transport on the host.""" + + name: str + path: str + + def contribute(self, context: ChannelContext) -> ChannelContribution: ... diff --git a/python/packages/hosting/pyproject.toml b/python/packages/hosting/pyproject.toml new file mode 100644 index 00000000000..f412c84c293 --- /dev/null +++ b/python/packages/hosting/pyproject.toml @@ -0,0 +1,110 @@ +[project] +name = "agent-framework-hosting" +description = "Multi-channel hosting for Microsoft Agent Framework agents." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0a260424" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.2.0,<2", + "starlette>=0.37", +] + +[project.optional-dependencies] +serve = [ + "hypercorn>=0.17", +] +disk = [ + "diskcache>=5.6", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_hosting"] +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_hosting"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_hosting --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" + +[dependency-groups] +dev = [ + "httpx>=0.28.1", +] diff --git a/python/packages/hosting/tests/__init__.py b/python/packages/hosting/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/hosting/tests/_workflow_fixtures.py b/python/packages/hosting/tests/_workflow_fixtures.py new file mode 100644 index 00000000000..f59bb8cab8e --- /dev/null +++ b/python/packages/hosting/tests/_workflow_fixtures.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Workflow fixtures for hosting tests. + +Defined in a module that does not use ``from __future__ import annotations`` +because the workflow handler validation reflects on real annotation objects +rather than stringified forms. +""" + +from agent_framework import Executor, Workflow, WorkflowBuilder, WorkflowContext, handler + + +class _UpperExecutor(Executor): + @handler + async def handle(self, text: str, ctx: WorkflowContext[str]) -> None: + await ctx.yield_output(text.upper()) + + +class _EchoExecutor(Executor): + @handler + async def handle(self, text: str, ctx: WorkflowContext[str]) -> None: + await ctx.yield_output(text) + + +def build_upper_workflow() -> Workflow: + return WorkflowBuilder(start_executor=_UpperExecutor(id="upper")).build() + + +def build_echo_workflow() -> Workflow: + return WorkflowBuilder(start_executor=_EchoExecutor(id="echo")).build() + + +class _MultiChunkExecutor(Executor): + """Yields three separate ``output`` events so streaming has something to chew on.""" + + @handler + async def handle(self, text: str, ctx: WorkflowContext[str]) -> None: + for chunk in (f"{text}-1", f"{text}-2", f"{text}-3"): + await ctx.yield_output(chunk) + + +def build_multi_chunk_workflow() -> Workflow: + return WorkflowBuilder(start_executor=_MultiChunkExecutor(id="multi")).build() diff --git a/python/packages/hosting/tests/conftest.py b/python/packages/hosting/tests/conftest.py new file mode 100644 index 00000000000..aa677567126 --- /dev/null +++ b/python/packages/hosting/tests/conftest.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Pytest configuration for hosting tests.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +def pytest_configure() -> None: + """Make local workflow fixtures importable in package and aggregate test modes.""" + module_name = "tests._workflow_fixtures" + if module_name in sys.modules: + return + + fixture_path = Path(__file__).with_name("_workflow_fixtures.py") + spec = importlib.util.spec_from_file_location(module_name, fixture_path) + if spec is None or spec.loader is None: + raise ImportError(f"Unable to load workflow fixtures from {fixture_path}") + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) diff --git a/python/packages/hosting/tests/test_host.py b/python/packages/hosting/tests/test_host.py new file mode 100644 index 00000000000..1bbf53a7cde --- /dev/null +++ b/python/packages/hosting/tests/test_host.py @@ -0,0 +1,1337 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for :class:`AgentFrameworkHost` invocation, session, and delivery routing.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Sequence +from dataclasses import dataclass, field +from typing import Any + +import pytest +from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message, ResponseStream +from agent_framework._workflows._events import WorkflowEvent +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import BaseRoute, Route +from starlette.testclient import TestClient + +from agent_framework_hosting import ( + AgentFrameworkHost, + Channel, + ChannelContext, + ChannelContribution, + ChannelIdentity, + ChannelRequest, + ChannelSession, + HostedRunResult, +) +from agent_framework_hosting._host import _workflow_event_to_update + + +async def _ping(_request: Request) -> JSONResponse: + return JSONResponse({"ok": True}) + + +# --------------------------------------------------------------------------- # +# Fakes # +# --------------------------------------------------------------------------- # + + +@dataclass +class _FakeAgentSession: + session_id: str | None = None + service_session_id: str | None = None + + +@dataclass +class _FakeAgentResponse: + text: str + + @property + def messages(self) -> list[Message]: + # Real ``AgentResponse`` carries a list of messages; the host's + # ``_invoke`` forwards them on the ``HostedRunResult``. Synthesise + # a single assistant text message so tests that assert on + # ``payload.text`` keep working unchanged. + return [Message(role="assistant", contents=[Content.from_text(text=self.text)])] + + +class _FakeAgent: + """Minimal :class:`SupportsAgentRun` implementation that records invocations.""" + + def __init__(self, reply: str = "ok") -> None: + self._reply = reply + self.calls: list[dict[str, Any]] = [] + self.created_sessions: list[_FakeAgentSession] = [] + + def create_session(self, *, session_id: str | None = None) -> _FakeAgentSession: + s = _FakeAgentSession(session_id=session_id) + self.created_sessions.append(s) + return s + + def run(self, messages: Any = None, *, stream: bool = False, session: Any = None, **kwargs: Any) -> Any: + self.calls.append({"messages": messages, "stream": stream, "session": session, "kwargs": kwargs}) + if stream: + updates = [AgentResponseUpdate(contents=[Content.from_text(text=self._reply)], role="assistant")] + + async def _gen() -> AsyncIterator[AgentResponseUpdate]: + for update in updates: + yield update + + async def _finalize(items: Sequence[AgentResponseUpdate]) -> AgentResponse: # noqa: RUF029 + return AgentResponse.from_updates(items) + + return ResponseStream[AgentResponseUpdate, AgentResponse](_gen(), finalizer=_finalize) + + async def _coro() -> _FakeAgentResponse: + return _FakeAgentResponse(text=self._reply) + + return _coro() + + +class _RecordingChannel: + """Minimal :class:`Channel` for host tests.""" + + def __init__(self, name: str = "fake", path: str = "/fake") -> None: + self.name = name + self.path = path + self.context: ChannelContext | None = None + # Provide a single trivial route so contribute() exercises the endpoint path. + self._routes: Sequence[BaseRoute] = (Route("/ping", _ping),) + + def contribute(self, context: ChannelContext) -> ChannelContribution: + self.context = context + return ChannelContribution(routes=self._routes) + + +def _assistant_response(text: str) -> AgentResponse: + """Build a one-message ``AgentResponse`` to use as a ``HostedRunResult.result``.""" + return AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(text=text)])]) + + +def _make_reply(text: str = "reply") -> HostedRunResult[AgentResponse]: + """Build a ``HostedRunResult[AgentResponse]`` carrying a single assistant text message. + + Test ergonomic mirroring what the host's ``_invoke`` produces for an + agent target — channels (and our delivery tests) receive a typed + envelope whose ``result`` is a real :class:`AgentResponse`. + """ + return HostedRunResult(_assistant_response(text)) + + +@dataclass +class _LifecycleChannel: + name: str = "lifecycle" + path: str = "" + started: list[str] = field(default_factory=list) + stopped: list[str] = field(default_factory=list) + + def contribute(self, context: ChannelContext) -> ChannelContribution: + async def on_start() -> None: + self.started.append("up") + + async def on_stop() -> None: + self.stopped.append("down") + + return ChannelContribution(on_startup=[on_start], on_shutdown=[on_stop]) + + +# --------------------------------------------------------------------------- # +# Host wiring # +# --------------------------------------------------------------------------- # + + +class TestHostWiring: + def test_channel_is_recognized(self) -> None: + ch = _RecordingChannel() + assert isinstance(ch, Channel) + + def test_app_mounts_channel_routes_under_path(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel(path="/fake") + host = AgentFrameworkHost(target=agent, channels=[ch]) + + with TestClient(host.app) as client: + r = client.get("/fake/ping") + assert r.status_code == 200 + assert r.json() == {"ok": True} + + def test_app_mounts_root_route_at_exact_channel_path(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel(path="/fake") + ch._routes = (Route("/", _ping),) + host = AgentFrameworkHost(target=agent, channels=[ch]) + + with TestClient(host.app, follow_redirects=False) as client: + r = client.get("/fake") + assert r.status_code == 200 + assert r.json() == {"ok": True} + assert client.get("/fake/").status_code == 200 + + def test_app_mounts_at_root_when_path_is_empty(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel(path="") + host = AgentFrameworkHost(target=agent, channels=[ch]) + + with TestClient(host.app) as client: + r = client.get("/ping") + assert r.status_code == 200 + + def test_app_is_cached(self) -> None: + host = AgentFrameworkHost(target=_FakeAgent(), channels=[_RecordingChannel()]) + assert host.app is host.app + + def test_lifespan_invokes_startup_and_shutdown(self) -> None: + agent = _FakeAgent() + ch = _LifecycleChannel() + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app): + assert ch.started == ["up"] + assert ch.stopped == ["down"] + + def test_app_exposes_readiness_probe(self) -> None: + host = AgentFrameworkHost(target=_FakeAgent(), channels=[_RecordingChannel()]) + with TestClient(host.app) as client: + r = client.get("/readiness") + assert r.status_code == 200 + assert r.text == "ok" + + +# --------------------------------------------------------------------------- # +# Invoke + sessions # +# --------------------------------------------------------------------------- # + + +class TestHostInvoke: + async def test_invoke_wraps_input_with_hosting_metadata(self) -> None: + agent = _FakeAgent(reply="hello") + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + # Force ``app`` build to trigger ``contribute``. + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="message.create", + input="hi", + session=ChannelSession(isolation_key="user:1"), + identity=ChannelIdentity(channel="responses", native_id="user:1"), + ) + result = await ch.context.run(req) + + assert result.result.text == "hello" + assert len(agent.calls) == 1 + msg = agent.calls[0]["messages"] + assert msg.role == "user" + assert msg.additional_properties["hosting"]["channel"] == "responses" + assert msg.additional_properties["hosting"]["identity"] == { + "channel": "responses", + "native_id": "user:1", + "attributes": {}, + } + + async def test_invoke_caches_session_per_isolation_key(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req_a = ChannelRequest( + channel=ch.name, operation="op", input="1", session=ChannelSession(isolation_key="alice") + ) + req_b = ChannelRequest( + channel=ch.name, operation="op", input="2", session=ChannelSession(isolation_key="alice") + ) + req_c = ChannelRequest(channel=ch.name, operation="op", input="3", session=ChannelSession(isolation_key="bob")) + + await ch.context.run(req_a) + await ch.context.run(req_b) + await ch.context.run(req_c) + + # Two distinct sessions created (alice, bob) — never re-created. + assert len(agent.created_sessions) == 2 + assert agent.calls[0]["session"] is agent.calls[1]["session"] + assert agent.calls[0]["session"] is not agent.calls[2]["session"] + + async def test_session_disabled_does_not_create_session(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel=ch.name, + operation="op", + input="x", + session=ChannelSession(isolation_key="alice"), + session_mode="disabled", + ) + await ch.context.run(req) + assert agent.created_sessions == [] + assert agent.calls[0]["session"] is None + + async def test_reset_session_rotates_id_and_drops_cache(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest(channel=ch.name, operation="op", input="x", session=ChannelSession(isolation_key="alice")) + await ch.context.run(req) + first_session = agent.calls[-1]["session"] + assert first_session.session_id == "alice" + + host.reset_session("alice") + await ch.context.run(req) + second_session = agent.calls[-1]["session"] + # New session, new id (alias rotation), distinct object. + assert second_session is not first_session + assert second_session.session_id != "alice" + assert second_session.session_id.startswith("alice#") + + async def test_options_propagates_to_target_run(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel=ch.name, + operation="op", + input="x", + session=ChannelSession(isolation_key="alice"), + options={"temperature": 0.4}, + ) + await ch.context.run(req) + assert agent.calls[0]["kwargs"]["options"] == {"temperature": 0.4} + + +class TestHostOwnedHooks: + async def test_context_run_applies_run_hook_before_invocation(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + captured: dict[str, Any] = {} + + async def hook(request: ChannelRequest, **kwargs: Any) -> ChannelRequest: + captured["target"] = kwargs["target"] + captured["protocol_request"] = kwargs["protocol_request"] + return ChannelRequest( + channel=request.channel, + operation=request.operation, + input="rewritten", + session=request.session, + ) + + req = ChannelRequest(channel=ch.name, operation="op", input="original", session=ChannelSession("alice")) + await ch.context.run(req, run_hook=hook, protocol_request={"raw": True}) + + assert captured["target"] is agent + assert captured["protocol_request"] == {"raw": True} + assert agent.calls[0]["messages"].text == "rewritten" + + async def test_context_run_stream_applies_run_hook_before_opening_stream(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + def hook(request: ChannelRequest, **_: Any) -> ChannelRequest: + return ChannelRequest(channel=request.channel, operation=request.operation, input="streamed") + + stream = await ch.context.run_stream( + ChannelRequest(channel=ch.name, operation="op", input="original"), + run_hook=hook, + stream_update_hook=lambda update: AgentResponseUpdate( + contents=[Content.from_text(text=update.text.upper())], + role="assistant", + ), + ) + + chunks = [update.text async for update in stream] + assert chunks == ["OK"] + assert agent.calls[0]["messages"].text == "streamed" + + +# --------------------------------------------------------------------------- # +# Workflow target # +# --------------------------------------------------------------------------- # + + +class TestHostWorkflowTarget: + """The host accepts a ``Workflow`` and dispatches to ``workflow.run(...)``.""" + + async def test_invoke_workflow_collapses_outputs_to_hosted_run_result(self) -> None: + from ._workflow_fixtures import build_upper_workflow + + workflow = build_upper_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch]) + _ = host.app + assert ch.context is not None + + # The channel's run_hook is the canonical adapter from a free-form input + # to a workflow's typed input; here the start executor accepts ``str`` + # already so the channel forwards ``input`` verbatim. + req = ChannelRequest(channel="fake", operation="message.create", input="hello") + result = await ch.context.run(req) + + assert list(result.result.get_outputs()) == ["HELLO"] + # No session caching for workflow targets — Workflow has no + # ``create_session`` and the host must not invent one. + assert host._sessions == {} + + async def test_stream_workflow_yields_updates_and_finalizes(self) -> None: + from ._workflow_fixtures import build_echo_workflow + + workflow = build_echo_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest(channel="fake", operation="message.create", input="hi") + stream = await ch.context.run_stream(req) + + updates: list[AgentResponseUpdate] = [] + async for update in stream: + updates.append(update) + + # The echo workflow yields a single ``output`` event whose payload is + # the original string; the host wraps non-update payloads into a + # one-shot ``AgentResponseUpdate`` carrying the text. + assert [u.text for u in updates] == ["hi"] + # ``raw_representation`` preserves the source ``WorkflowEvent`` so + # advanced consumers (telemetry, debug UIs) can recover the full + # workflow timeline. + assert all(u.raw_representation is not None for u in updates) + + final = await stream.get_final_response() + assert final.text == "hi" + + async def test_stream_workflow_yields_one_update_per_output_event(self) -> None: + from ._workflow_fixtures import build_multi_chunk_workflow + + workflow = build_multi_chunk_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest(channel="fake", operation="message.create", input="x") + stream = await ch.context.run_stream(req) + + chunks: list[str] = [] + async for update in stream: + chunks.append(update.text) + # The originating ``executor_id`` is propagated via author_name so + # multi-agent workflows can route per-author rendering downstream. + assert update.author_name == "multi" + + assert chunks == ["x-1", "x-2", "x-3"] + final = await stream.get_final_response() + assert final.text == "x-1x-2x-3" + + def test_workflow_event_to_update_drops_non_output_events(self) -> None: + event = WorkflowEvent("intermediate", executor_id="worker", data="hidden") + + assert _workflow_event_to_update(event) is None + + def test_workflow_event_to_update_preserves_agent_response_update_payload(self) -> None: + event = WorkflowEvent( + "output", + executor_id="worker", + data=AgentResponseUpdate(contents=[Content.from_text("chunk")], role="assistant"), + ) + + update = _workflow_event_to_update(event) + + assert update is event.data + assert update.raw_representation is event + + def test_workflow_event_to_update_preserves_content_payload(self) -> None: + content = Content.from_data(data=b"\x89PNG", media_type="image/png", raw_representation={"source": "test"}) + event = WorkflowEvent("output", executor_id="worker", data=content) + + update = _workflow_event_to_update(event) + + assert update is not None + assert update.contents == [content] + assert update.contents[0].raw_representation == {"source": "test"} + assert update.author_name == "worker" + assert update.raw_representation is event + + +class TestHostWorkflowCheckpointing: + """The host scopes per-conversation checkpoints when ``checkpoint_location`` is set.""" + + def test_rejects_workflow_with_existing_checkpoint_storage(self, tmp_path: Any) -> None: + from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder + + from ._workflow_fixtures import _UpperExecutor + + workflow = WorkflowBuilder( + start_executor=_UpperExecutor(id="upper"), + checkpoint_storage=InMemoryCheckpointStorage(), + ).build() + with pytest.raises(RuntimeError, match="already has checkpoint storage"): + AgentFrameworkHost( + target=workflow, + channels=[_RecordingChannel()], + checkpoint_location=tmp_path, + ) + + def test_warns_when_target_is_agent(self, tmp_path: Any, caplog: Any) -> None: + import logging as _logging + + agent = _FakeAgent() + with caplog.at_level(_logging.WARNING, logger="agent_framework.hosting"): + host = AgentFrameworkHost(target=agent, channels=[_RecordingChannel()], checkpoint_location=tmp_path) + assert host._checkpoint_location is None + assert any("checkpoint_location" in rec.message for rec in caplog.records) + + async def test_invoke_skips_checkpointing_when_no_isolation_key(self, tmp_path: Any) -> None: + from ._workflow_fixtures import build_upper_workflow + + workflow = build_upper_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch], checkpoint_location=tmp_path) + _ = host.app + assert ch.context is not None + + # No session -> no scoping key -> no checkpoint storage written. + req = ChannelRequest(channel="fake", operation="message.create", input="hi") + result = await ch.context.run(req) + + assert list(result.result.get_outputs()) == ["HI"] + assert list(tmp_path.iterdir()) == [] + + async def test_invoke_writes_checkpoint_under_isolation_key(self, tmp_path: Any) -> None: + from ._workflow_fixtures import build_upper_workflow + + workflow = build_upper_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch], checkpoint_location=tmp_path) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="fake", + operation="message.create", + input="hi", + session=ChannelSession(isolation_key="alice"), + ) + result = await ch.context.run(req) + assert list(result.result.get_outputs()) == ["HI"] + + # FileCheckpointStorage rooted at / should + # have produced at least one checkpoint file scoped to that user. + scoped = tmp_path / "alice" + assert scoped.exists() + assert any(scoped.iterdir()), "expected at least one checkpoint to be written under the per-user dir" + + async def test_stream_writes_checkpoint_under_isolation_key(self, tmp_path: Any) -> None: + from ._workflow_fixtures import build_echo_workflow + + workflow = build_echo_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch], checkpoint_location=tmp_path) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="fake", + operation="message.create", + input="hi", + session=ChannelSession(isolation_key="bob"), + ) + stream = await ch.context.run_stream(req) + async for _ in stream: + pass + await stream.get_final_response() + + scoped = tmp_path / "bob" + assert scoped.exists() + assert any(scoped.iterdir()) + + async def test_caller_supplied_checkpoint_storage_used_as_is(self, tmp_path: Any) -> None: + from agent_framework import InMemoryCheckpointStorage + + from ._workflow_fixtures import build_upper_workflow + + storage = InMemoryCheckpointStorage() + workflow = build_upper_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch], checkpoint_location=storage) + _ = host.app + assert ch.context is not None + assert host._checkpoint_location is storage + + req = ChannelRequest( + channel="fake", + operation="message.create", + input="hi", + session=ChannelSession(isolation_key="carol"), + ) + await ch.context.run(req) + + # The caller-owned storage is used directly (no per-user scoping + # applied by the host); a checkpoint should appear in it. + checkpoints = await storage.list_checkpoints(workflow_name=workflow.name) + assert checkpoints, "expected the caller-supplied storage to receive a checkpoint" + # And nothing should have been written into the tmp_path tree. + assert list(tmp_path.iterdir()) == [] + + +class TestCheckpointPathForIsolationKey: + """Path-traversal hardening for isolation keys joined into checkpoint paths.""" + + @pytest.mark.parametrize( + "isolation_key", + [ + "alice", + "telegram:42", + "entra:abc-def_0123", + "responses:user.name", + "x" * 200, + ], + ) + def test_accepts_legitimate_keys(self, tmp_path: Any, isolation_key: str) -> None: + from agent_framework_hosting._host import _checkpoint_path_for_isolation_key + + target = _checkpoint_path_for_isolation_key(tmp_path, isolation_key) + assert target == (tmp_path / isolation_key).resolve() + assert target.is_relative_to(tmp_path.resolve()) + + @pytest.mark.parametrize( + "isolation_key", + [ + "", + ".", + "..", + "...", + "../etc", + "../../etc/passwd", + "a/b", + "a\\b", + "with\x00nul", + "/abs/path", + "C:/foo", + "C:foo", + ], + ) + def test_rejects_traversal_patterns(self, tmp_path: Any, isolation_key: str) -> None: + from agent_framework_hosting._host import _checkpoint_path_for_isolation_key + + with pytest.raises(ValueError, match="isolation_key"): + _checkpoint_path_for_isolation_key(tmp_path, isolation_key) + + def test_rejects_non_string(self, tmp_path: Any) -> None: + from agent_framework_hosting._host import _checkpoint_path_for_isolation_key + + with pytest.raises(ValueError, match="non-empty string"): + _checkpoint_path_for_isolation_key(tmp_path, None) # type: ignore[arg-type] + + +class TestHostWorkflowCheckpointingPathTraversal: + """End-to-end: malicious isolation keys must not escape ``checkpoint_location``.""" + + async def test_traversal_key_skips_checkpointing_with_warning(self, tmp_path: Any, caplog: Any) -> None: + import logging as _logging + + from ._workflow_fixtures import build_upper_workflow + + workflow = build_upper_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch], checkpoint_location=tmp_path) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="fake", + operation="message.create", + input="hi", + session=ChannelSession(isolation_key="../escape"), + ) + with caplog.at_level(_logging.WARNING, logger="agent_framework.hosting"): + result = await ch.context.run(req) + + assert list(result.result.get_outputs()) == ["HI"] + # Nothing should have been written under tmp_path. + assert list(tmp_path.iterdir()) == [] + assert any( + "Skipping checkpoint storage" in rec.message and "isolation_key" in rec.message for rec in caplog.records + ) + + async def test_separator_in_key_skips_checkpointing(self, tmp_path: Any) -> None: + from ._workflow_fixtures import build_upper_workflow + + workflow = build_upper_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch], checkpoint_location=tmp_path) + _ = host.app + assert ch.context is not None + + # A literal separator in the key is a configuration smell at best + # and an attack at worst; either way it must not create a sub-path. + req = ChannelRequest( + channel="fake", + operation="message.create", + input="hi", + session=ChannelSession(isolation_key="evil/sub"), + ) + result = await ch.context.run(req) + + assert list(result.result.get_outputs()) == ["HI"] + assert list(tmp_path.iterdir()) == [] + + +# --------------------------------------------------------------------------- # +# HostedRunResult — generic typed envelope # +# --------------------------------------------------------------------------- # + + +class TestHostedRunResult: + """The envelope is a thin generic wrapper around the target's + full-fidelity ``result`` plus an optional session reference. The + host does NOT pre-shape or flatten ``result.messages`` / + ``result.get_outputs()`` — channels read the canonical accessor on + the underlying result type themselves.""" + + def test_result_field_carries_full_fidelity_payload(self) -> None: + resp = AgentResponse( + messages=[Message(role="assistant", contents=[Content.from_text("hello")])], + response_id="r-1", + ) + env: HostedRunResult[AgentResponse] = HostedRunResult(resp) + # ``result`` is the canonical accessor; metadata like + # ``response_id`` round-trips through unchanged because the host + # never re-shapes the payload. + assert env.result is resp + assert env.result.text == "hello" + assert env.result.response_id == "r-1" + assert env.session is None + + def test_session_field_attached_and_optional(self) -> None: + resp = _assistant_response("ok") + session = _FakeAgentSession(session_id="sess-1") + env = HostedRunResult(resp, session=session) + assert env.session is session + + def test_replace_clones_envelope_without_touching_result_by_default(self) -> None: + resp = _assistant_response("orig") + original = HostedRunResult(resp, session=_FakeAgentSession(session_id="s")) + clone = original.replace() + # Clone is a distinct envelope but the inner ``result`` is the + # same object — channels that need a deep copy of ``result`` + # itself do the copy themselves. + assert clone is not original + assert clone.result is original.result + assert clone.session is original.session + + def test_replace_rebinds_result_without_perturbing_original(self) -> None: + original = HostedRunResult(_assistant_response("orig")) + clone = original.replace(result=_assistant_response("shaped")) + assert original.result.text == "orig" + assert clone.result.text == "shaped" + + def test_replace_supports_explicit_none_session(self) -> None: + original = HostedRunResult(_assistant_response("x"), session=_FakeAgentSession(session_id="s")) + clone = original.replace(session=None) + assert clone.session is None + # Source envelope untouched. + assert original.session is not None + + async def test_invoke_preserves_full_agent_response_on_result(self) -> None: + """The host's ``_invoke`` carries the agent's ``AgentResponse`` + through unchanged on ``result``. Channels see image / tool / + structured content alongside text — and metadata like + ``response_id`` — without the host pre-shaping anything.""" + + class _MultiModalResponse: + def __init__(self) -> None: + self.text = "summary" + self.response_id = "resp-xyz" + self.messages = [ + Message( + role="assistant", + contents=[ + Content.from_text("summary"), + # Non-text content the host must NOT drop. + Content.from_data(data=b"\x89PNG", media_type="image/png"), + ], + ), + ] + + class _MultiModalAgent: + def create_session(self, *, session_id: str | None = None) -> _FakeAgentSession: + return _FakeAgentSession(session_id=session_id) + + async def run(self, *_args: Any, **_kwargs: Any) -> Any: + return _MultiModalResponse() + + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=_MultiModalAgent(), channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest(channel="responses", operation="op", input="hi") + env = await ch.context.run(req) + # Full agent response carried through verbatim — no flattening. + assert env.result.text == "summary" + assert env.result.response_id == "resp-xyz" + assert len(env.result.messages) == 1 + types = [c.type for c in env.result.messages[0].contents] + assert "text" in types and "data" in types + + +# --------------------------------------------------------------------------- # +# Bind request context — duck-typed hook on context providers # +# --------------------------------------------------------------------------- # + + +from contextlib import contextmanager # noqa: E402 + + +class _RecordingContextProvider: + """Stand-in for a ``HistoryProvider`` that exposes the duck-typed + ``bind_request_context(response_id=..., previous_response_id=..., **_)`` + seam the host calls. Records (event, payload) pairs so tests can + assert call ordering relative to the agent run + stream lifecycle. + """ + + def __init__(self, *, name: str = "rec") -> None: + self.name = name + # (event, payload) tuples — events: "enter", "exit", "agent_start", + # "agent_end", "stream_yield", "stream_done". + self.events: list[tuple[str, Any]] = [] + + @contextmanager + def bind_request_context(self, **kwargs: Any) -> Any: + # Snapshot the call kwargs on enter (so tests can assert + # response_id / previous_response_id forwarding) and the same + # snapshot on exit so we can verify the SAME payload bracketed + # the agent run. + snapshot = dict(kwargs) + self.events.append(("enter", snapshot)) + try: + yield + finally: + self.events.append(("exit", snapshot)) + + +class _ProvidersAgent: + """Agent stand-in that exposes ``context_providers`` so the host's + ``_flat_context_providers`` finds the recording provider. + + Mirrors the real :class:`agent_framework.Agent.run` shape: a sync + ``def`` that returns either an ``Awaitable[AgentResponse]`` (for + ``stream=False``) or a :class:`ResponseStream` synchronously (for + ``stream=True``). The host's ``_invoke_stream`` relies on the sync + return so it can wrap the stream in ``_BoundResponseStream`` and + hand it to channels for later iteration. + """ + + def __init__(self, providers: Sequence[Any], *, reply: str = "ok") -> None: + self.context_providers = list(providers) + self._reply = reply + self.calls: list[dict[str, Any]] = [] + + def create_session(self, *, session_id: str | None = None) -> _FakeAgentSession: + return _FakeAgentSession(session_id=session_id) + + def run( + self, + messages: Any = None, + *, + stream: bool = False, + session: Any = None, + **kwargs: Any, + ) -> Any: + self.calls.append({"messages": messages, "stream": stream, "session": session, "kwargs": kwargs}) + + if stream: + providers = self.context_providers + updates = [ + AgentResponseUpdate(contents=[Content.from_text("chunk-1")], role="assistant"), + AgentResponseUpdate(contents=[Content.from_text("chunk-2")], role="assistant"), + ] + + async def _gen() -> AsyncIterator[AgentResponseUpdate]: + # ``agent_start`` is only recorded once iteration begins; + # if the channel abandons the stream without iterating + # we expect to see neither ``agent_start`` nor any + # ``stream_yield`` events. + for prov in providers: + if isinstance(prov, _RecordingContextProvider): + prov.events.append(("agent_start", None)) + for u in updates: + for prov in providers: + if isinstance(prov, _RecordingContextProvider): + prov.events.append(("stream_yield", u.text)) + yield u + + async def _finalize(items: Sequence[AgentResponseUpdate]) -> AgentResponse: # noqa: RUF029 + for prov in providers: + if isinstance(prov, _RecordingContextProvider): + prov.events.append(("stream_done", len(items))) + return AgentResponse.from_updates(items) + + return ResponseStream[AgentResponseUpdate, AgentResponse](_gen(), finalizer=_finalize) + + async def _coro() -> _FakeAgentResponse: + for prov in self.context_providers: + if isinstance(prov, _RecordingContextProvider): + prov.events.append(("agent_start", None)) + prov.events.append(("agent_end", None)) + return _FakeAgentResponse(text=self._reply) + + return _coro() + + +class _ProviderWrapper: + """Wrap children in a ``providers`` attribute (mirrors the + ``ContextProviderBase`` aggregation shape).""" + + def __init__(self, providers: Sequence[Any]) -> None: + self.providers = list(providers) + + +class TestBindRequestContext: + """The host walks ``target.context_providers``, descends one level + when a provider exposes a ``providers`` attribute, and calls + ``bind_request_context(response_id=..., previous_response_id=...)`` + on every provider that supports it. Foundry response-id chaining + plugs into this exact seam — a regression that mistypes the kwarg + name, drops the descent, or fails to keep the binding open across + the agent run silently breaks chained writes.""" + + async def test_bind_called_with_request_attributes(self) -> None: + prov = _RecordingContextProvider() + agent = _ProvidersAgent([prov]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="op", + input="hi", + session=ChannelSession(isolation_key="alice"), + attributes={"response_id": "resp_abc", "previous_response_id": "resp_prev"}, + ) + result = await ch.context.run(req) + assert result.result.text == "ok" + + # Bind ↔ unbind brackets the agent run. + events = [name for name, _ in prov.events] + assert events == ["enter", "agent_start", "agent_end", "exit"] + + # Both response_id and previous_response_id forwarded by name. + _, enter_payload = prov.events[0] + assert enter_payload["response_id"] == "resp_abc" + assert enter_payload["previous_response_id"] == "resp_prev" + + async def test_bind_skipped_when_no_response_id_attribute(self) -> None: + """Without a ``response_id`` attribute on the request, the host + skips the binding entirely — the contract requires one to anchor + the chain.""" + prov = _RecordingContextProvider() + agent = _ProvidersAgent([prov]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest(channel="responses", operation="op", input="hi") + await ch.context.run(req) + assert prov.events == [("agent_start", None), ("agent_end", None)] + + async def test_bind_does_not_descend_into_providers_attribute(self) -> None: + """The host does not introspect ``ContextProviderBase`` aggregator + wrappers. Aggregator providers are responsible for forwarding the + bind to their children themselves (``AggregateContextProvider`` + already does this). The host treats whatever ``agent.context_providers`` + exposes as the final, flat list.""" + prov = _RecordingContextProvider(name="inner") + wrapper = _ProviderWrapper([prov]) + agent = _ProvidersAgent([wrapper]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="op", + input="hi", + attributes={"response_id": "resp_xyz"}, + ) + await ch.context.run(req) + # The wrapper does not implement ``response_context``, so the + # inner provider must NOT have been entered by the host. + assert ("enter", {"response_id": "resp_xyz", "previous_response_id": None}) not in prov.events + + async def test_bind_held_open_until_stream_exhaustion(self) -> None: + """Streaming runs return a ``ResponseStream`` synchronously but + consumption happens later. The binding must survive that gap and + only release after the iterator drains so the provider sees + every yielded chunk under the bound context.""" + prov = _RecordingContextProvider() + agent = _ProvidersAgent([prov]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="op", + input="hi", + stream=True, + attributes={"response_id": "resp_stream"}, + ) + stream = await ch.context.run_stream(req) + + # As soon as run_stream returns, the binding must already be open + # so any provider work that happens during iteration sees it. + names_after_create = [name for name, _ in prov.events] + assert names_after_create.count("enter") == 1 + assert "exit" not in names_after_create + + chunks: list[str] = [] + async for u in stream: + chunks.append(u.text) + assert chunks == ["chunk-1", "chunk-2"] + + # After exhaustion the binding must be released — exactly once. + names_after_drain = [name for name, _ in prov.events] + assert names_after_drain.count("enter") == 1 + assert names_after_drain.count("exit") == 1 + # Brackets surround every stream_yield. + enter_idx = names_after_drain.index("enter") + exit_idx = names_after_drain.index("exit") + yield_idxs = [i for i, name in enumerate(names_after_drain) if name == "stream_yield"] + assert all(enter_idx < i < exit_idx for i in yield_idxs) + + +# --------------------------------------------------------------------------- # +# Agent-target streaming — `_BoundResponseStream` adapter behaviour # +# --------------------------------------------------------------------------- # + + +class TestBoundResponseStream: + """The ``_BoundResponseStream`` adapter holds the bind-context + ``ExitStack`` open across iteration. Cover the iterator-finally + close, ``get_final_response`` close, double-close idempotence, + ``aclose()``, ``__getattr__`` forwarding, and the awaitable path + (which now routes through ``get_final_response`` so it doesn't + leak the binding).""" + + async def test_get_final_response_closes_binding(self) -> None: + prov = _RecordingContextProvider() + agent = _ProvidersAgent([prov]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="op", + input="hi", + stream=True, + attributes={"response_id": "resp_get_final"}, + ) + stream = await ch.context.run_stream(req) + # Skip iteration and go straight to ``get_final_response``; + # the adapter must drain the inner stream itself and close + # the binding in ``finally``. + final = await stream.get_final_response() + assert final.text == "chunk-1chunk-2" + names = [n for n, _ in prov.events] + assert names.count("enter") == 1 + assert names.count("exit") == 1 + + async def test_double_close_is_idempotent(self) -> None: + prov = _RecordingContextProvider() + agent = _ProvidersAgent([prov]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="op", + input="hi", + stream=True, + attributes={"response_id": "resp_idem"}, + ) + stream = await ch.context.run_stream(req) + async for _u in stream: + pass + # Iteration's finally already closed; an explicit ``aclose`` + # afterwards must be a no-op (no second exit event). + await stream.aclose() # type: ignore[attr-defined] + await stream.aclose() # type: ignore[attr-defined] + names = [n for n, _ in prov.events] + assert names.count("exit") == 1 + + async def test_aclose_releases_binding_when_stream_abandoned(self) -> None: + """A channel that abandons the stream without iterating must + be able to call ``aclose()`` so the host-bound contextvars + don't leak for the host's lifetime.""" + prov = _RecordingContextProvider() + agent = _ProvidersAgent([prov]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="op", + input="hi", + stream=True, + attributes={"response_id": "resp_abandon"}, + ) + stream = await ch.context.run_stream(req) + await stream.aclose() # type: ignore[attr-defined] + + # Binding released without iterating. + names = [n for n, _ in prov.events] + assert names.count("enter") == 1 + assert names.count("exit") == 1 + # Agent never ran — we abandoned before iteration. + assert "agent_start" not in names + + async def test_getattr_forwards_to_inner_stream(self) -> None: + """``_BoundResponseStream.__getattr__`` forwards unknown + attributes to the inner ``ResponseStream``; channels that + check, e.g., ``stream.add_result_hook(...)`` must keep working.""" + prov = _RecordingContextProvider() + agent = _ProvidersAgent([prov]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="op", + input="hi", + stream=True, + attributes={"response_id": "resp_getattr"}, + ) + stream = await ch.context.run_stream(req) + # ``with_result_hook`` is a real method on ``ResponseStream``; + # if forwarding broke this would AttributeError. + try: + assert callable(stream.with_result_hook) # type: ignore[attr-defined] + finally: + await stream.aclose() # type: ignore[attr-defined] + + async def test_await_path_routes_through_get_final_response(self) -> None: + """``await stream`` is a convenience for ``await + get_final_response()``. The previous direct delegation leaked + the binding for the host's lifetime; the new routing closes the + stack in the same ``finally`` as ``get_final_response``.""" + prov = _RecordingContextProvider() + agent = _ProvidersAgent([prov]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="op", + input="hi", + stream=True, + attributes={"response_id": "resp_await"}, + ) + stream = await ch.context.run_stream(req) + final = await stream # exercises __await__ + assert final.text == "chunk-1chunk-2" + names = [n for n, _ in prov.events] + assert names.count("enter") == 1 + assert names.count("exit") == 1 + + +# --------------------------------------------------------------------------- # +# `_wrap_input` — list[Message] LAST-message metadata stamping # +# --------------------------------------------------------------------------- # + + +class TestWrapInputListMessages: + """The ``hosting`` block lands on the LAST message of a list — the + contract is load-bearing: the user turn (typically last) must + carry the channel provenance + identity for history correlation; + a regression stamping ``messages[0]`` instead silently breaks + every multi-message payload.""" + + async def test_metadata_lands_on_last_message_only(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + # Responses-API style: a system instruction followed by a user + # turn. Only the user turn (LAST) gets stamped. + system = Message(role="system", contents=[Content.from_text("be concise")]) + user = Message(role="user", contents=[Content.from_text("hi")]) + req = ChannelRequest( + channel="responses", + operation="op", + input=[system, user], + identity=ChannelIdentity(channel="responses", native_id="user:1"), + ) + await ch.context.run(req) + + forwarded = agent.calls[0]["messages"] + assert isinstance(forwarded, list) + assert len(forwarded) == 2 + # System stays clean. + assert (system.additional_properties or {}).get("hosting") is None + # User turn carries the metadata. + hosting = forwarded[-1].additional_properties["hosting"] + assert hosting["channel"] == "responses" + assert hosting["identity"]["native_id"] == "user:1" + + async def test_single_message_payload_still_works(self) -> None: + """Regression guard: the single-``Message`` branch must be + unchanged by the LAST-of-list logic above.""" + agent = _FakeAgent() + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + only = Message(role="user", contents=[Content.from_text("hi")]) + req = ChannelRequest(channel="responses", operation="op", input=only) + await ch.context.run(req) + forwarded = agent.calls[0]["messages"] + assert isinstance(forwarded, Message) + assert forwarded.additional_properties["hosting"]["channel"] == "responses" + + +# --------------------------------------------------------------------------- # +# Lifespan callback aggregation # +# --------------------------------------------------------------------------- # + + +class _RaisingLifecycleChannel: + """Channel whose startup OR shutdown callback raises a controlled error.""" + + def __init__(self, name: str, *, fail_on: str) -> None: + self.name = name + self.path = "" + self._fail_on = fail_on # "startup" | "shutdown" + self.start_calls: list[str] = [] + self.stop_calls: list[str] = [] + + def contribute(self, _context: ChannelContext) -> ChannelContribution: + async def _start() -> None: + self.start_calls.append("up") + if self._fail_on == "startup": + raise RuntimeError(f"startup-boom-{self.name}") + + async def _stop() -> None: + self.stop_calls.append("down") + if self._fail_on == "shutdown": + raise RuntimeError(f"shutdown-boom-{self.name}") + + return ChannelContribution(on_startup=[_start], on_shutdown=[_stop]) + + +class _OkLifecycleChannel: + def __init__(self, name: str) -> None: + self.name = name + self.path = "" + self.start_calls: list[str] = [] + self.stop_calls: list[str] = [] + + def contribute(self, _context: ChannelContext) -> ChannelContribution: + async def _start() -> None: + self.start_calls.append("up") + + async def _stop() -> None: + self.stop_calls.append("down") + + return ChannelContribution(on_startup=[_start], on_shutdown=[_stop]) + + +class TestLifespanAggregation: + """One bad startup / shutdown callback must NOT abort the rest — + every channel gets a chance to wire / unwire so half-initialised + state doesn't leak. The first error is still raised so the + process exits with a failure; remaining errors are logged so + operators see them all in one log scrape.""" + + def test_shutdown_failure_does_not_skip_peer_shutdowns(self, caplog: Any) -> None: + import logging as _logging + + agent = _FakeAgent() + bad = _RaisingLifecycleChannel("bad", fail_on="shutdown") + ok1 = _OkLifecycleChannel("ok1") + ok2 = _OkLifecycleChannel("ok2") + # Order: bad first so that without aggregation, ok1+ok2 would + # never get to run their shutdown callbacks. + host = AgentFrameworkHost(target=agent, channels=[bad, ok1, ok2]) + + with caplog.at_level(_logging.ERROR, logger="agent_framework.hosting"): # noqa: SIM117 + with pytest.raises(RuntimeError, match="shutdown-boom-bad"), TestClient(host.app): + pass + + # Every channel had its shutdown attempted, even though `bad` raised. + assert bad.stop_calls == ["down"] + assert ok1.stop_calls == ["down"] + assert ok2.stop_calls == ["down"] + + def test_startup_failure_aggregates_logs_and_raises_first(self, caplog: Any) -> None: + import logging as _logging + + agent = _FakeAgent() + ok1 = _OkLifecycleChannel("ok1") + bad = _RaisingLifecycleChannel("bad", fail_on="startup") + ok2 = _OkLifecycleChannel("ok2") + another_bad = _RaisingLifecycleChannel("bad2", fail_on="startup") + host = AgentFrameworkHost( + target=agent, + channels=[ok1, bad, ok2, another_bad], + ) + + with caplog.at_level(_logging.ERROR, logger="agent_framework.hosting"): # noqa: SIM117 + # The first failing callback's error is the one that + # propagates; remaining failures are logged. + with pytest.raises(RuntimeError, match="startup-boom-bad"), TestClient(host.app): + pass + + # Every startup callback ran (even ok2 / another_bad after the + # first failure) so we get a complete picture in the logs. + assert ok1.start_calls == ["up"] + assert bad.start_calls == ["up"] + assert ok2.start_calls == ["up"] + assert another_bad.start_calls == ["up"] + + # Both failures show up in operator logs. ``logger.exception`` puts + # the exception payload in ``record.exc_text``; the formatted summary + # of the second failure goes into ``record.message`` via the + # aggregate "N callback(s) failed" line. + log_messages = [rec.getMessage() for rec in caplog.records] + log_exc_texts = [rec.exc_text or "" for rec in caplog.records] + log_text = "\n".join(log_messages + log_exc_texts) + assert "startup-boom-bad" in log_text + assert "startup-boom-bad2" in log_text or "callback(s) failed" in log_text diff --git a/python/packages/hosting/tests/test_host_disk.py b/python/packages/hosting/tests/test_host_disk.py new file mode 100644 index 00000000000..47c78d2edc2 --- /dev/null +++ b/python/packages/hosting/tests/test_host_disk.py @@ -0,0 +1,227 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for narrowed ``state_dir`` support in :class:`AgentFrameworkHost`.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from agent_framework_hosting import AgentFrameworkHost, ChannelContext, ChannelContribution + +pytest.importorskip("diskcache") + + +class _AgentStub: + """Bare-minimum SupportsAgentRun stub for host construction.""" + + async def run(self, *_args: Any, **_kwargs: Any) -> None: # pragma: no cover - unused + return None + + +class _ChannelStub: + name = "stub" + path = "/stub" + + def contribute(self, _context: ChannelContext) -> ChannelContribution: + return ChannelContribution() + + +def _close_host_disk(host: AgentFrameworkHost) -> None: + """Release any session-alias store held by ``host``.""" + if host._sessions_store is not None: + host._sessions_store.close() + + +def test_state_dir_none_keeps_plain_alias_dict(tmp_path: Path) -> None: + """No store, no alias persistence, no files written.""" + host = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()]) + assert host._sessions_store is None + assert isinstance(host._session_aliases, dict) + assert list(tmp_path.iterdir()) == [] + + +def test_string_state_dir_creates_sessions_subfolder_only(tmp_path: Path) -> None: + """Passing a single path expands to ``sessions/`` plus lazy checkpoint path.""" + host = AgentFrameworkHost( + target=_AgentStub(), + channels=[_ChannelStub()], + state_dir=tmp_path, + ) + try: + assert host._sessions_store is not None + assert (tmp_path / "sessions").is_dir() + assert not (tmp_path / "runner").exists() + assert not (tmp_path / "links").exists() + # Checkpoint path is derived but not created for agent targets. + assert not (tmp_path / "checkpoints").exists() + finally: + _close_host_disk(host) + + +def test_per_component_session_path(tmp_path: Path) -> None: + """Dict form lets callers route session aliases to a specific root.""" + sessions_dir = tmp_path / "state" + host = AgentFrameworkHost( + target=_AgentStub(), + channels=[_ChannelStub()], + state_dir={"sessions": sessions_dir}, + ) + try: + assert sessions_dir.is_dir() + assert host._sessions_store is not None + assert host._checkpoint_location is None + finally: + _close_host_disk(host) + + +@pytest.mark.parametrize("key", ["runner", "links", "active", "identities"]) +def test_removed_state_dir_component_keys_raise(tmp_path: Path, key: str) -> None: + """Obsolete follow-up components should fail loudly instead of becoming no-ops.""" + with pytest.raises(ValueError, match="unknown"): + AgentFrameworkHost( + target=_AgentStub(), + channels=[_ChannelStub()], + state_dir={key: tmp_path / key}, # type: ignore[dict-item] + ) + + +def test_session_aliases_survive_restart(tmp_path: Path) -> None: + """Aliases written on host #1 must be visible to host #2.""" + state_dir = tmp_path / "state" + + host1 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir) + host1._session_aliases["user-1"] = "sess-abc" + host1._session_aliases["user-2"] = "sess-def" + _close_host_disk(host1) + + host2 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir) + try: + assert host2._session_aliases["user-1"] == "sess-abc" + assert host2._session_aliases["user-2"] == "sess-def" + finally: + _close_host_disk(host2) + + +def _build_simple_workflow() -> Any: + """Build a no-op workflow for checkpoint-wiring tests.""" + from ._workflow_fixtures import build_upper_workflow + + return build_upper_workflow() + + +def test_single_path_state_dir_wires_workflow_checkpoints(tmp_path: Path) -> None: + """``state_dir="/foo"`` + workflow target → ``/foo/checkpoints/`` is used.""" + workflow = _build_simple_workflow() + host = AgentFrameworkHost( + target=workflow, + channels=[_ChannelStub()], + state_dir=tmp_path, + ) + try: + assert host._checkpoint_location == tmp_path / "checkpoints" + finally: + _close_host_disk(host) + + +def test_mapping_state_dir_checkpoints_key_wires_workflow_checkpoints(tmp_path: Path) -> None: + """``state_dir={"checkpoints": ...}`` + workflow target → that path is used.""" + workflow = _build_simple_workflow() + ckpt_dir = tmp_path / "ck" + host = AgentFrameworkHost( + target=workflow, + channels=[_ChannelStub()], + state_dir={"checkpoints": ckpt_dir}, + ) + try: + assert host._checkpoint_location == ckpt_dir + assert host._sessions_store is None + finally: + _close_host_disk(host) + + +def test_mapping_state_dir_omits_checkpoints_for_workflow(tmp_path: Path) -> None: + """Mapping form lets workflow callers opt out of checkpoint persistence.""" + workflow = _build_simple_workflow() + host = AgentFrameworkHost( + target=workflow, + channels=[_ChannelStub()], + state_dir={"sessions": tmp_path / "s"}, + ) + try: + assert host._checkpoint_location is None + finally: + _close_host_disk(host) + + +def test_explicit_checkpoint_location_wins_over_state_dir(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + """``checkpoint_location`` + ``state_dir`` → explicit param wins + warn.""" + workflow = _build_simple_workflow() + explicit = tmp_path / "explicit-ck" + with caplog.at_level("WARNING", logger="agent_framework.hosting"): + host = AgentFrameworkHost( + target=workflow, + channels=[_ChannelStub()], + checkpoint_location=explicit, + state_dir=tmp_path, + ) + try: + assert host._checkpoint_location == explicit + assert any( + "state_dir['checkpoints']" in rec.message and "checkpoint_location" in rec.message for rec in caplog.records + ) + finally: + _close_host_disk(host) + + +def test_state_dir_checkpoints_for_agent_target_silent_for_single_path(tmp_path: Path) -> None: + """Single-path state_dir + agent target → no checkpoint, no warning.""" + host = AgentFrameworkHost( + target=_AgentStub(), + channels=[_ChannelStub()], + state_dir=tmp_path, + ) + try: + assert host._checkpoint_location is None + assert not (tmp_path / "checkpoints").exists() + finally: + _close_host_disk(host) + + +def test_state_dir_checkpoints_for_agent_target_warns_when_explicit( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Mapping form with ``checkpoints`` + agent target → warn.""" + with caplog.at_level("WARNING", logger="agent_framework.hosting"): + host = AgentFrameworkHost( + target=_AgentStub(), + channels=[_ChannelStub()], + state_dir={"checkpoints": tmp_path / "ck"}, + ) + try: + assert host._checkpoint_location is None + assert any( + "state_dir['checkpoints']" in rec.message and "not a Workflow" in rec.message for rec in caplog.records + ) + finally: + _close_host_disk(host) + + +def test_state_dir_checkpoints_conflicts_with_workflow_own_storage(tmp_path: Path) -> None: + """Derived checkpoint path triggers the same conflict guard as explicit.""" + from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder + + from ._workflow_fixtures import _UpperExecutor + + workflow = WorkflowBuilder( + start_executor=_UpperExecutor(id="upper"), + checkpoint_storage=InMemoryCheckpointStorage(), + ).build() + with pytest.raises(RuntimeError, match="already has checkpoint storage"): + AgentFrameworkHost( + target=workflow, + channels=[_ChannelStub()], + state_dir=tmp_path, + ) diff --git a/python/packages/hosting/tests/test_isolation.py b/python/packages/hosting/tests/test_isolation.py new file mode 100644 index 00000000000..84fcd35e299 --- /dev/null +++ b/python/packages/hosting/tests/test_isolation.py @@ -0,0 +1,303 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for the per-request isolation contextvar surface in +:mod:`agent_framework_hosting._isolation`. + +The isolation keys are the ONLY seam Foundry-aware providers use to +find partition keys, and the host's ASGI middleware lifts them off the +two well-known headers on every inbound HTTP request. A regression +that drops the lookup, mistypes a header name, or fails to reset the +contextvar would silently misroute writes / leak per-request state +across requests, with zero unit-test signal — so cover the surface +fully here. +""" + +from __future__ import annotations + +import asyncio + +import pytest +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import BaseRoute, Route +from starlette.testclient import TestClient + +from agent_framework_hosting import ( + Channel, + ChannelContext, + ChannelContribution, + IsolationKeys, + get_current_isolation_keys, + reset_current_isolation_keys, + set_current_isolation_keys, +) +from agent_framework_hosting._isolation import ( # pyright: ignore[reportPrivateUsage] + ISOLATION_HEADER_CHAT, + ISOLATION_HEADER_USER, + current_isolation_keys, +) + + +class TestIsolationKeys: + def test_defaults_to_none_pair(self) -> None: + keys = IsolationKeys() + assert keys.user_key is None + assert keys.chat_key is None + assert keys.is_empty is True + + def test_partial_with_only_user_is_not_empty(self) -> None: + keys = IsolationKeys(user_key="alice") + assert keys.user_key == "alice" + assert keys.chat_key is None + assert keys.is_empty is False + + def test_partial_with_only_chat_is_not_empty(self) -> None: + keys = IsolationKeys(chat_key="general") + assert keys.is_empty is False + + def test_full_pair_is_not_empty(self) -> None: + keys = IsolationKeys(user_key="alice", chat_key="general") + assert keys.is_empty is False + + +class TestContextVarHelpers: + def test_default_is_none(self) -> None: + # Each test gets a fresh contextvar value because pytest runs + # tests in fresh contexts. ``get`` returns the default. + assert get_current_isolation_keys() is None + + def test_set_and_get_round_trip(self) -> None: + token = set_current_isolation_keys(IsolationKeys(user_key="alice", chat_key="general")) + try: + current = get_current_isolation_keys() + assert current is not None + assert current.user_key == "alice" + assert current.chat_key == "general" + finally: + reset_current_isolation_keys(token) + # Reset restores prior value (None in the default context). + assert get_current_isolation_keys() is None + + def test_set_with_none_clears(self) -> None: + outer = set_current_isolation_keys(IsolationKeys(user_key="alice")) + try: + inner = set_current_isolation_keys(None) + try: + assert get_current_isolation_keys() is None + finally: + reset_current_isolation_keys(inner) + # Reset surfaces the outer value again. + current = get_current_isolation_keys() + assert current is not None + assert current.user_key == "alice" + finally: + reset_current_isolation_keys(outer) + + def test_module_level_contextvar_is_the_same_instance(self) -> None: + """Direct contextvar access (used by the ASGI middleware) and the + public `get_current_isolation_keys()` helper read from the SAME + underlying contextvar. A regression that introduced a second + contextvar would silently break the middleware → provider hop.""" + token = current_isolation_keys.set(IsolationKeys(user_key="bob")) + try: + via_helper = get_current_isolation_keys() + assert via_helper is not None + assert via_helper.user_key == "bob" + finally: + current_isolation_keys.reset(token) + + +class TestHeaderConstants: + """The two header names are part of the public contract — they + match the ones the Foundry Hosted Agents runtime stamps on every + inbound request. A typo here would silently misroute partition + writes.""" + + def test_user_header_value(self) -> None: + assert ISOLATION_HEADER_USER == "x-agent-user-isolation-key" + + def test_chat_header_value(self) -> None: + assert ISOLATION_HEADER_CHAT == "x-agent-chat-isolation-key" + + +# --------------------------------------------------------------------------- # +# End-to-end: ASGI middleware lifts the headers into the contextvar. +# --------------------------------------------------------------------------- # + + +class _IsolationProbeChannel: + """A minimal Channel that exposes a single GET route which captures + the contextvar value INSIDE the request and returns it as JSON. + + Tests use this to exercise the full middleware → contextvar → + handler hop end-to-end. + """ + + name = "probe" + path = "" + + def __init__(self) -> None: + self.captured: list[IsolationKeys | None] = [] + + async def _handler(_request: Request) -> JSONResponse: + keys = get_current_isolation_keys() + self.captured.append(keys) + payload = ( + {"user": keys.user_key, "chat": keys.chat_key} + if keys is not None + else {"user": None, "chat": None, "_present": False} + ) + return JSONResponse(payload) + + self._routes: list[BaseRoute] = [Route("/probe", _handler)] + + def contribute(self, _context: ChannelContext) -> ChannelContribution: + return ChannelContribution(routes=self._routes) + + +def _make_host_with_probe() -> tuple[object, _IsolationProbeChannel]: + from agent_framework_hosting import AgentFrameworkHost + + class _NoopAgent: + async def run(self, *_args: object, **_kwargs: object) -> object: # pragma: no cover - never called + raise RuntimeError("not invoked") + + probe = _IsolationProbeChannel() + assert isinstance(probe, Channel) + host = AgentFrameworkHost(target=_NoopAgent(), channels=[probe]) # type: ignore[arg-type] + return host, probe + + +class TestIsolationMiddlewareEndToEnd: + def test_headers_ignored_outside_foundry_environment(self) -> None: + host, probe = _make_host_with_probe() + with TestClient(host.app) as client: # type: ignore[attr-defined] + r = client.get( + "/probe", + headers={ + ISOLATION_HEADER_USER: "alice-uid", + ISOLATION_HEADER_CHAT: "general-cid", + }, + ) + assert r.status_code == 200 + assert r.json() == {"user": None, "chat": None, "_present": False} + assert probe.captured == [None] + + def test_both_headers_lifted_into_contextvar(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + host, probe = _make_host_with_probe() + with TestClient(host.app) as client: # type: ignore[attr-defined] + r = client.get( + "/probe", + headers={ + ISOLATION_HEADER_USER: "alice-uid", + ISOLATION_HEADER_CHAT: "general-cid", + }, + ) + assert r.status_code == 200 + assert r.json() == {"user": "alice-uid", "chat": "general-cid"} + assert len(probe.captured) == 1 + captured = probe.captured[0] + assert captured is not None + assert captured.user_key == "alice-uid" + assert captured.chat_key == "general-cid" + + def test_only_user_header_lifted(self, monkeypatch: pytest.MonkeyPatch) -> None: + """One-header-only branch: the middleware still binds (chat=None).""" + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + host, probe = _make_host_with_probe() + with TestClient(host.app) as client: # type: ignore[attr-defined] + r = client.get("/probe", headers={ISOLATION_HEADER_USER: "alice-uid"}) + assert r.status_code == 200 + assert r.json() == {"user": "alice-uid", "chat": None} + + def test_only_chat_header_lifted(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + host, probe = _make_host_with_probe() + with TestClient(host.app) as client: # type: ignore[attr-defined] + r = client.get("/probe", headers={ISOLATION_HEADER_CHAT: "general-cid"}) + assert r.status_code == 200 + assert r.json() == {"user": None, "chat": "general-cid"} + + def test_no_headers_keeps_contextvar_none(self) -> None: + """Local-dev path: with neither header present the middleware is + a no-op and the contextvar stays at its default ``None`` — + providers see "no isolation" and route to the in-memory + fallback rather than picking up stale per-request state.""" + host, probe = _make_host_with_probe() + with TestClient(host.app) as client: # type: ignore[attr-defined] + r = client.get("/probe") + assert r.status_code == 200 + assert r.json() == {"user": None, "chat": None, "_present": False} + assert probe.captured == [None] + + def test_empty_header_value_treated_as_absent(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A header that's present but empty must not bind an empty key — + ``IsolationContext`` rejects empty strings on the read side.""" + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + host, probe = _make_host_with_probe() + with TestClient(host.app) as client: # type: ignore[attr-defined] + r = client.get( + "/probe", + headers={ + ISOLATION_HEADER_USER: "", + ISOLATION_HEADER_CHAT: "general-cid", + }, + ) + assert r.status_code == 200 + # Empty user header decodes to None; chat key stays bound. + assert r.json() == {"user": None, "chat": "general-cid"} + + def test_contextvar_resets_after_request(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The middleware must call ``reset_current_isolation_keys`` in + a ``finally`` so per-request state never leaks across requests + or back into the calling thread's context.""" + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + host, probe = _make_host_with_probe() + with TestClient(host.app) as client: # type: ignore[attr-defined] + r1 = client.get("/probe", headers={ISOLATION_HEADER_USER: "alice-uid"}) + assert r1.status_code == 200 + # Reading the contextvar OUTSIDE the request scope must see + # the default — not the value the prior request bound. + assert get_current_isolation_keys() is None + # And a follow-up request without headers gets a clean + # ``None`` rather than inheriting alice-uid. + r2 = client.get("/probe") + assert r2.json() == {"user": None, "chat": None, "_present": False} + + def test_concurrent_requests_get_isolated_contextvars(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Different requests run in different async contexts; binding + from request A must NOT leak into a concurrent request B.""" + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + host, probe = _make_host_with_probe() + + async def _drive() -> None: + # Run two requests in parallel asyncio tasks against the + # same TestClient and assert their captures don't bleed + # into each other. + async def _hit(user_key: str) -> dict[str, str | None]: + with TestClient(host.app) as client: # type: ignore[attr-defined] + r = client.get("/probe", headers={ISOLATION_HEADER_USER: user_key}) + return r.json() # type: ignore[no-any-return] + + r_alice, r_bob = await asyncio.gather(_hit("alice-uid"), _hit("bob-uid")) + assert r_alice == {"user": "alice-uid", "chat": None} + assert r_bob == {"user": "bob-uid", "chat": None} + + asyncio.run(_drive()) + + +class TestNonHttpScopesPassThrough: + """The middleware intentionally only inspects ``http`` scopes; + lifespan / websocket scopes are forwarded untouched. A regression + that touched lifespan scopes here would crash boot.""" + + async def test_lifespan_scope_does_not_consult_headers(self) -> None: + # The TestClient context manager exercises the lifespan scope + # implicitly; if the middleware tried to decode headers on a + # non-http scope this would raise. Exercise it without binding + # any contextvar work. + host, _probe = _make_host_with_probe() + with TestClient(host.app): # type: ignore[attr-defined] + # Just enter / exit; no requests. + pass diff --git a/python/packages/hosting/tests/test_types.py b/python/packages/hosting/tests/test_types.py new file mode 100644 index 00000000000..2c77cbee6b5 --- /dev/null +++ b/python/packages/hosting/tests/test_types.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for the channel-neutral envelope types in :mod:`agent_framework_hosting._types`.""" + +from __future__ import annotations + +from agent_framework_hosting import ( + ChannelIdentity, + ChannelRequest, + ChannelSession, +) + + +class TestChannelRequest: + def test_required_fields_only(self) -> None: + req = ChannelRequest(channel="responses", operation="message.create", input="hi") + assert req.channel == "responses" + assert req.operation == "message.create" + assert req.input == "hi" + assert req.session is None + assert req.options is None + assert req.session_mode == "auto" + assert req.metadata == {} + assert req.attributes == {} + assert req.stream is False + assert req.identity is None + + def test_with_session_and_identity(self) -> None: + req = ChannelRequest( + channel="telegram", + operation="message.create", + input="hi", + session=ChannelSession(isolation_key="user:42"), + identity=ChannelIdentity(channel="telegram", native_id="42"), + ) + assert req.session is not None + assert req.session.isolation_key == "user:42" + assert req.identity is not None + assert req.identity.channel == "telegram" + assert req.identity.native_id == "42" + + +class TestChannelIdentity: + def test_attributes_default_empty_mapping(self) -> None: + ident = ChannelIdentity(channel="teams", native_id="abc") + assert dict(ident.attributes) == {} + + def test_attributes_passthrough(self) -> None: + ident = ChannelIdentity(channel="teams", native_id="abc", attributes={"role": "user"}) + assert dict(ident.attributes) == {"role": "user"} diff --git a/python/pyproject.toml b/python/pyproject.toml index 0a4e6f34a93..c6191f58f04 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -87,6 +87,13 @@ agent-framework-foundry-hosting = { workspace = true } agent-framework-foundry-local = { workspace = true } agent-framework-gemini = { workspace = true } agent-framework-github-copilot = { workspace = true } +agent-framework-hosting = { workspace = true } +agent-framework-hosting-invocations = { workspace = true } +agent-framework-hosting-telegram = { workspace = true } +agent-framework-hosting-activity-protocol = { workspace = true } +agent-framework-hosting-discord = { workspace = true } +agent-framework-hosting-a2a = { workspace = true } +agent-framework-hosting-mcp = { workspace = true } agent-framework-hyperlight = { workspace = true } agent-framework-lab = { workspace = true } agent-framework-mem0 = { workspace = true } @@ -210,6 +217,12 @@ executionEnvironments = [ { root = "packages/foundry/tests", reportPrivateUsage = "none" }, { root = "packages/foundry_local/tests", reportPrivateUsage = "none" }, { root = "packages/github_copilot/tests", reportPrivateUsage = "none" }, + { root = "packages/hosting/tests", reportPrivateUsage = "none" }, + { root = "packages/hosting-invocations/tests", reportPrivateUsage = "none" }, + { root = "packages/hosting-telegram/tests", reportPrivateUsage = "none" }, + { root = "packages/hosting-activity-protocol/tests", reportPrivateUsage = "none" }, + { root = "packages/hosting-a2a/tests", reportPrivateUsage = "none" }, + { root = "packages/hosting-mcp/tests", reportPrivateUsage = "none" }, { root = "packages/lab/gaia/tests", reportPrivateUsage = "none" }, { root = "packages/lab/lightning/tests", reportPrivateUsage = "none" }, { root = "packages/lab/tau2/tests", reportPrivateUsage = "none" }, diff --git a/python/samples/04-hosting/af-hosting/README.md b/python/samples/04-hosting/af-hosting/README.md new file mode 100644 index 00000000000..777b15db1ff --- /dev/null +++ b/python/samples/04-hosting/af-hosting/README.md @@ -0,0 +1,51 @@ +# Multi-channel hosting samples + +End-to-end samples for serving an `agent-framework` agent (or workflow) +through one or more **channels** with `agent-framework-hosting`. + +The general hosting plumbing lives in +[`agent-framework-hosting`](../../../packages/hosting); each channel is +its own package (`agent-framework-hosting-responses`, +`agent-framework-hosting-invocations`, +`agent-framework-hosting-telegram`, `agent-framework-hosting-activity-protocol`, +`agent-framework-hosting-discord`). + +| Sample | What it shows | Packaging | +|---|---|---| +| [`local_responses/`](./local_responses) | The minimal shape: one agent + one `@tool` + `ResponsesChannel` + a single `run_hook` that strips caller-supplied options and forces a `reasoning` preset. | **Local only.** Start here to learn the run-hook seam. | +| [`local_responses_workflow/`](./local_responses_workflow) | A 4-step `Workflow` (typed `SloganBrief` intake → writer → legal → formatter) hosted behind **both** the Responses and Invocations channels via a shared `run_hook` that parses inbound text/JSON into the workflow's typed input. The host writes per-conversation checkpoints via `checkpoint_location=…`. Demonstrates workflow targets + structured input adaptation + multi-channel + resume-across-turns. Includes a `call_server.rest` file with REST examples for both endpoints. | **Local only.** | +| [`foundry_hosted_agent/`](./foundry_hosted_agent) | One Foundry agent, **Responses + Invocations only** — the minimal shape that is **runtime-compatible with the Foundry Hosted Agents platform**. | Ships with `Dockerfile` + `agent.yaml` + `agent.manifest.yaml` + `azure.yaml` so the same image runs locally **or** as a Foundry Hosted Agent (`azd up`). | +| [`foundry_telegram_invocations_weather/`](./foundry_telegram_invocations_weather) | Experimental Telegram weather bot that mounts `TelegramChannel` at `POST /invocations`, registers the Foundry Hosted Agents Invocations URL as the Telegram webhook, and uses `FoundryHostedAgentHistoryProvider` for storage. | Ships with `Dockerfile` + `agent.yaml` + `agent.manifest.yaml` + `azure.yaml`; used to validate whether a non-Responses channel can run under Foundry Invocations. | +| [`local_telegram/`](./local_telegram) | Adds Telegram, a `@tool`, `FileHistoryProvider`, run hooks (per-user / per-chat session keying), and extra Telegram commands. Runs under Hypercorn with multiple workers. | **Local only.** No Dockerfile / Foundry packaging. | + +Each sample is fully self-contained — its own `pyproject.toml`, `uv.lock`, +server `app.py`, calling script(s), and `storage/` directory. Every +sample uses `[tool.uv.sources]` to wire its `agent-framework-hosting*` +dependencies to the +[`feature/python-hosting`](https://github.com/microsoft/agent-framework/tree/feature/python-hosting) +branch of the upstream repo via git refs, so they install cleanly outside +the monorepo while the hosting packages are still pre-PyPI. Once those +packages publish, drop the `[tool.uv.sources]` block and let the +declared deps resolve from PyPI. + +## Relationship to `../foundry-hosted-agents/` + +The sibling [`../foundry-hosted-agents/`](../foundry-hosted-agents) directory +contains samples for the **`agent-framework-hosted`** stack — agents +that run **inside** the Foundry Hosted Agents platform using its +built-in protocol surface (Responses, Invocations, conversation store, +isolation, identity), with **no `agent-framework-hosting` package +involved**. + +| Aspect | `af-hosting/` (this directory) | `foundry-hosted-agents/` | +|---|---|---| +| Server stack | `agent-framework-hosting` + per-channel packages (`-responses`, `-invocations`, `-telegram`, `-activity-protocol`, `-discord`) | `agent-framework-hosted` only — the Foundry Hosted Agents runtime owns the HTTP surface | +| Channels other than Responses / Invocations | Yes — Telegram, Activity Protocol (Teams), Discord | No — the platform exposes Responses + Invocations only | +| Run target | Local Hypercorn (`local_responses/`, `local_telegram/`); Hosted Agents *or* local (`foundry_hosted_agent/`) | Hosted Agents *or* local container; targets the Hosted Agents platform contract | +| When to pick this | You need extra channels (Telegram/Teams via Activity Protocol/…), custom hosting middleware, or want to run outside the Foundry runtime | You only need Responses/Invocations and want zero hosting boilerplate, leveraging the Foundry-managed surface | + +`foundry_hosted_agent/` is the bridge sample: it uses the +`agent-framework-hosting` stack but is packaged so the Foundry Hosted +Agents platform can run it as one of its own. + +The table above summarizes the cross-sample story. diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/.gitignore b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/.gitignore new file mode 100644 index 00000000000..ea567ea3592 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/.gitignore @@ -0,0 +1,419 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates +*.env + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.trx + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Approval Tests result files +*.received.* + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.idb +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +**/.paket/paket.exe +paket-files/ + +# FAKE - F# Make +**/.fake/ + +# CodeRush personal settings +**/.cr/personal + +# Python Tools for Visual Studio (PTVS) +**/__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +#tools/** +#!tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +**/.mfractor/ + +# Local History for Visual Studio +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +**/.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp +.azure diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/Dockerfile b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/Dockerfile new file mode 100644 index 00000000000..9e30a2c3a66 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/Dockerfile @@ -0,0 +1,25 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +# Build context for this Dockerfile is THIS folder (see ``azure.yaml`` -> +# ``services..project: .``). The workspace packages this sample +# depends on are fetched from GitHub by ``uv sync`` (see the ``[tool.uv.sources]`` +# git refs in ``pyproject.toml``). The build needs network access to GitHub +# during ``uv sync`` — no local vendoring step is required. +# +# ``Dockerfile.dockerignore`` (adjacent file, BuildKit) trims the upload to +# just the files COPYed below. + +WORKDIR /app + +COPY pyproject.toml ./ +COPY app.py ./ + +# ``--no-dev`` skips the dev group (which only contains ``openai`` for +# ``call_server.py``). Locks fresh against the GitHub-hosted hosting +# packages declared in ``[tool.uv.sources]``. +RUN uv sync --no-dev + +ENV PORT=8000 +EXPOSE 8000 + +CMD ["uv", "run", "python", "app.py"] diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/Dockerfile.dockerignore b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/Dockerfile.dockerignore new file mode 100644 index 00000000000..87a3e83667b --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/Dockerfile.dockerignore @@ -0,0 +1,28 @@ +# BuildKit per-Dockerfile ignore (sibling file: .dockerignore). +# Build context for this image is THIS folder. Trim everything except the +# files the Dockerfile actually COPYs. + +# Local virtualenv & python caches. +.venv/ +**/.venv/ +**/__pycache__/ +**/*.pyc +**/*.pyo +**/.pytest_cache/ +**/.mypy_cache/ +**/.ruff_cache/ + +# azd / git / IDE. +.azure/ +.git/ +.gitignore +.vscode/ +.idea/ + +# Sample-specific files not needed at runtime. +README.md +call_server.py +agent.yaml +agent.manifest.yaml +azure.yaml +infra/ diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/README.md b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/README.md new file mode 100644 index 00000000000..97e174455dc --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/README.md @@ -0,0 +1,134 @@ +# foundry_hosted_agent — Responses + Invocations (Foundry Hosted Agents compatible) + +Smallest end-to-end hosting sample. One Foundry-backed agent, two +channels, no human-chat surface — and that minimal shape is the whole +point: a host configured with at least the **Responses** and +**Invocations** channels under their default endpoints is +**runtime-compatible with the Foundry Hosted Agents platform**. The +same container image runs locally, behind any ASGI server, or as a +Hosted Agent — no protocol shim, no extra adapter. + +| Route | Channel | Used by | +| ------------------------------ | -------------------- | ------------------------------------------- | +| `POST /responses` | `ResponsesChannel` | OpenAI Responses clients (`call_server.py`) | +| `POST /invocations` | `InvocationsChannel` | Host-native JSON envelope (Hosted Agents) | + +## Conversation history + +The agent is wired with `FoundryHostedAgentHistoryProvider` (from +`agent-framework-foundry-hosting`). When a Responses request supplies +`previous_response_id`, the channel uses it as the session id and the +provider fetches the prior turn chain directly from +`{FOUNDRY_PROJECT_ENDPOINT}/storage/...` using the same managed-identity +credential as the chat client. Locally (when `FOUNDRY_HOSTING_ENVIRONMENT` +is unset) it transparently falls back to an in-memory store, so the same +code runs in dev. Writes are a no-op — Foundry persists Responses turns +authoritatively as the runtime executes them. + +For richer local scenarios (custom tools, history providers, run hooks, +Telegram, and Activity Protocol) see [`../local_telegram`](../local_telegram). + +## Layout + +``` +foundry_hosted_agent/ +├── app.py # the host (ResponsesChannel + InvocationsChannel) +├── call_server.py # client: openai SDK / agent framework / FoundryAgent +├── agent.yaml # Foundry Hosted Agents minimal definition +├── agent.manifest.yaml # Foundry Hosted Agents full deployment manifest +├── azure.yaml # azd service config (build context = this folder) +├── Dockerfile # built from this folder; uv fetches deps from GitHub +├── Dockerfile.dockerignore # BuildKit allowlist that trims the context +├── pyproject.toml # depends on the hosting packages via GitHub git refs +└── README.md # this file +``` + +## Run locally + +```bash +export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com +export MODEL_DEPLOYMENT_NAME=gpt-4.1-mini +az login # any DefaultAzureCredential source + +uv sync +uv run python app.py # binds 0.0.0.0:8000 +``` + +The env var names match `agent.manifest.yaml` so the same shell +environment works for both local runs and Hosted Agent deployments. + +## Call locally + +```bash +uv sync --group dev + +# OpenAI SDK pointed at the local /responses endpoint. +uv run python call_server.py --via openai "hello there" + +# The same call via the Agent Framework Agent + OpenAIChatClient stack. +uv run python call_server.py --via af "hello there" + +# Once deployed as a Hosted Agent: target the Foundry-managed endpoint. +export FOUNDRY_HOSTED_AGENT_NAME=agent-framework-hosting-sample +uv run python call_server.py --via foundry "hello there" +``` + +## Docker + +The Docker build context is **this sample folder**. `pyproject.toml` +declares the in-tree `agent-framework-hosting*` packages via +[`[tool.uv.sources]` git refs](./pyproject.toml) pointing at the +``feature/python-hosting`` branch of +[microsoft/agent-framework](https://github.com/microsoft/agent-framework), +so `uv sync` inside the image fetches them directly. No vendoring step is +required — the build just needs network access to GitHub. Once the +hosting packages publish to PyPI you can drop the `[tool.uv.sources]` +overrides and rely on PyPI resolution. + +```bash +# From this folder — context = `.` (sample folder). +DOCKER_BUILDKIT=1 docker build -t hosting-sample-hosted-agent . + +docker run -p 8000:8000 \ + -e FOUNDRY_PROJECT_ENDPOINT -e MODEL_DEPLOYMENT_NAME \ + -e AZURE_CLIENT_ID -e AZURE_TENANT_ID -e AZURE_CLIENT_SECRET \ + hosting-sample-hosted-agent +``` + +## Hosted Agent deployment + +`azure.yaml` keeps `project: .` and uses `docker.remoteBuild: true` — +the remote builder receives only this sample folder and runs +`uv sync` to pull the hosting packages from GitHub. + +The two YAMLs follow the same convention as the +[`foundry-hosted-agents/`](../../foundry-hosted-agents/) reference +samples — `agent.yaml` is the minimal kind/protocols/resources card, +`agent.manifest.yaml` is the full template + environment-variable + +model-resource binding used during deployment. + +```bash +azd up # provisions infra/ + builds + pushes + deploys +azd deploy # rebuild + redeploy only +``` + +### Required Foundry RBAC + +The container runs as the Hosted Agent's managed identity. That identity +needs permission to call the Foundry project's agent/Responses endpoints +— without it the call returns 401 ``PermissionDenied``. Grant the +**Azure AI Project Manager** role (or the more granular +``Microsoft.CognitiveServices/accounts/AIServices/agents/*`` data +actions) on the Foundry project to the Hosted Agent's managed identity. +See for the full role list. + +### Health probe + +The Foundry Hosted Agents runtime probes ``GET /readiness``; +``AgentFrameworkHost`` exposes that route automatically (returns +``200 ok``). No extra wiring needed. + +The host code never imports anything Foundry-specific beyond the chat +client itself — swapping `FoundryChatClient` for `OpenAIChatClient` (or +any other client) flips this sample from a Hosted Agent target to a +non-Foundry deployment without touching the channels. diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/agent.manifest.yaml b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/agent.manifest.yaml new file mode 100644 index 00000000000..1e16c51d7ac --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/agent.manifest.yaml @@ -0,0 +1,31 @@ +name: agent-framework-hosting-sample +description: > + Minimal Agent Framework multi-channel hosting sample (Responses + Invocations) + packaged for the Foundry Hosted Agents runtime. Demonstrates that an + ``AgentFrameworkHost`` configured with the Responses and Invocations channels + under their default mounts is a drop-in Hosted Agent image — no protocol + shim, no Foundry-specific server. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Invocations Protocol + - Streaming + - Multi-Channel +template: + name: agent-framework-hosting-sample + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + - protocol: invocations + version: 1.0.0 + environment_variables: + - name: MODEL_DEPLOYMENT_NAME + value: "{{MODEL_DEPLOYMENT_NAME}}" +resources: + - kind: model + id: gpt-5.4-nano + name: MODEL_DEPLOYMENT_NAME diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/agent.yaml b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/agent.yaml new file mode 100644 index 00000000000..efef14b2c4c --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/agent.yaml @@ -0,0 +1,26 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml + +kind: hosted +name: agent-framework-hosting-sample +description: | + Minimal Agent Framework multi-channel hosting sample (Responses + Invocations) packaged for the Foundry Hosted Agents runtime. Demonstrates that an ``AgentFrameworkHost`` configured with the Responses and Invocations channels under their default mounts is a drop-in Hosted Agent image — no protocol shim, no Foundry-specific server. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Invocations Protocol + - Streaming + - Multi-Channel +protocols: + - protocol: responses + version: 1.0.0 + - protocol: invocations + version: 1.0.0 +resources: + cpu: "1" + memory: 2Gi +environment_variables: + - name: MODEL_DEPLOYMENT_NAME + value: gpt-5.4-nano diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/app.py b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/app.py new file mode 100644 index 00000000000..2c1eaa8e15a --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/app.py @@ -0,0 +1,185 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Smallest hosting sample — Responses + Invocations only. + +This sample is intentionally minimal and is **runtime-compatible with the +Foundry Hosted Agents platform**: a host that exposes the Responses and +Invocations channels under their default endpoints can be packaged as a +container image and deployed to Foundry Hosted Agents without any protocol +shim. The same image runs locally, behind any ASGI server, or as a Hosted +Agent. + +History +------- +The agent uses :class:`FoundryHostedAgentHistoryProvider` so that conversation +history is loaded from the Foundry Hosted Agent storage backend when the +container runs inside Foundry. When ``previous_response_id`` is supplied on +an incoming Responses request, the channel routes it through to the +provider as the ``session_id``, and the provider fetches the prior turn +chain from ``{FOUNDRY_PROJECT_ENDPOINT}/storage/...``. Locally +(``FOUNDRY_HOSTING_ENVIRONMENT`` unset) the provider falls back to an +in-memory store so the same code runs in dev. + +Setup +----- +- ``FOUNDRY_PROJECT_ENDPOINT`` — Foundry project endpoint URL. +- ``MODEL_DEPLOYMENT_NAME`` — model deployment name (the same env var + the Foundry Hosted Agents manifest binds via the ``model`` resource — + see ``agent.manifest.yaml``). +- ``FOUNDRY_HOSTING_ENVIRONMENT`` — set automatically by the Hosted Agents + runtime; signals the history provider to talk to the Foundry storage API + instead of the local in-memory fallback. +- ``APPLICATIONINSIGHTS_CONNECTION_STRING`` — when present, the sample + wires Azure Monitor OpenTelemetry export at import time. Foundry Hosted + Agents inject this when an Application Insights resource is bound to + the project; locally it's optional. + +Auth uses ``DefaultAzureCredential`` so any standard Azure auth chain +works (``az login`` locally, managed identity in Hosted Agents, +``AZURE_*`` env vars in CI, ...). + +Run +--- +- Local: ``python app.py`` (binds ``0.0.0.0:8000``) +- ASGI: ``hypercorn app:app --bind 0.0.0.0:8000`` +- Docker: ``docker build -t hosting-sample-hosted-agent . && \\ + docker run -p 8000:8000 \\ + -e FOUNDRY_PROJECT_ENDPOINT -e MODEL_DEPLOYMENT_NAME \\ + hosting-sample-hosted-agent`` +- Hosted Agent: build & push the image, then deploy via ``agent.yaml`` / + ``agent.manifest.yaml`` in this folder. + +Routes +------ +- ``POST /responses`` — OpenAI Responses-shaped surface. +- ``POST /invocations`` — host-native JSON envelope. +""" + +from __future__ import annotations + +import logging +import os + +from agent_framework import Agent +from agent_framework.observability import enable_instrumentation +from agent_framework_foundry import FoundryChatClient +from agent_framework_foundry_hosting import ( + FoundryHostedAgentHistoryProvider, + foundry_response_id, +) +from agent_framework_hosting import AgentFrameworkHost +from agent_framework_hosting_invocations import InvocationsChannel +from agent_framework_hosting_responses import ResponsesChannel +from azure.identity.aio import DefaultAzureCredential + +# Configure root logging early so library log records (in particular +# ``agent_framework_foundry_hosting._history_provider``) are captured by +# the container's stderr stream and surfaced in the Foundry portal / +# Azure Monitor. ``LOG_LEVEL`` overrides this for production tightening. +logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +# Quiet noisy transports unless explicitly cranked up. +for _noisy in ( + "httpx", + "httpcore", + "azure.core.pipeline.policies.http_logging_policy", + "urllib3", +): + logging.getLogger(_noisy).setLevel(logging.WARNING) + +logger = logging.getLogger(__name__) + + +def _configure_observability() -> None: + """Wire Azure Monitor OpenTelemetry when a connection string is present. + + Foundry Hosted Agents inject ``APPLICATIONINSIGHTS_CONNECTION_STRING`` + into the container at runtime when an Application Insights resource is + bound to the project. We honor the same env var locally so the same + code path lights up in both environments. When the var is absent + (typical local dev without an AI binding) we silently skip — the host + still serves traffic, just without OTel export. + """ + conn_str = os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING") + if not conn_str: + logger.info( + "APPLICATIONINSIGHTS_CONNECTION_STRING not set — skipping Azure Monitor OpenTelemetry configuration.", + ) + return + # Imported lazily so the sample still starts when the optional + # ``azure-monitor-opentelemetry`` dependency isn't installed (e.g. an + # ultra-thin local dev image stripped of observability extras). + from azure.monitor.opentelemetry import configure_azure_monitor + + configure_azure_monitor(connection_string=conn_str) + logger.info("Azure Monitor OpenTelemetry configured.") + + +def build_host() -> AgentFrameworkHost: + # Single credential is shared by the chat client and the history + # provider so we only authenticate (and refresh tokens) once. + credential = DefaultAzureCredential() + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + + agent = Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=os.environ["MODEL_DEPLOYMENT_NAME"], + credential=credential, + ), + name="HostedAgentSample", + instructions="You are called Jarvis, a friendly assistant. Keep answers brief.", + # Loads history from Foundry storage when running inside a Hosted + # Agent (FOUNDRY_HOSTING_ENVIRONMENT set); falls back to an in- + # memory store for local dev. + context_providers=[ + FoundryHostedAgentHistoryProvider( + credential=credential, + endpoint=project_endpoint, + ), + ], + ) + + return AgentFrameworkHost( + target=agent, + channels=[ + # Mint Foundry-storage-compatible response ids + # (``caresp_{18charPartitionKey}{32charEntropy}``). The + # Foundry storage backend partitions records by extracting + # this segment from the id; free-form ``resp_`` ids + # are rejected with an opaque ``HTTP 500 server_error``. + ResponsesChannel(response_id_factory=foundry_response_id), + InvocationsChannel(), + ], + ) + + +# `app` is the canonical ASGI surface — hand it to any ASGI server, or let +# the Foundry Hosted Agents runtime pick it up via the standard entry point. +# Observability is configured at import time so trace/log export is wired +# before the host starts handling requests. Per-request Foundry isolation +# (the platform-injected ``x-agent-{user,chat}-isolation-key`` headers) +# is read by the host's installed ASGI middleware off every inbound HTTP +# request and lifted into a contextvar that +# :class:`FoundryHostedAgentHistoryProvider` consults on each storage call. +# Multi-turn persistence works out of the box in both local dev and the +# Hosted Agents container — no manual middleware wiring needed. +_configure_observability() +enable_instrumentation(enable_sensitive_data=True) +app = build_host().app + + +if __name__ == "__main__": + # Serve the host's ASGI app directly. The Foundry isolation headers + # are read by the host's installed ASGI middleware and threaded + # through the storage provider via a contextvar; nothing extra to wire. + import asyncio + + import hypercorn.asyncio + import hypercorn.config + + config = hypercorn.config.Config() + config.bind = [f"0.0.0.0:{int(os.environ.get('PORT', '8000'))}"] + asyncio.run(hypercorn.asyncio.serve(app, config)) # type: ignore[arg-type] diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/azure.yaml b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/azure.yaml new file mode 100644 index 00000000000..2dddbe96564 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/azure.yaml @@ -0,0 +1,32 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +requiredVersions: + extensions: + azure.ai.agents: '>=0.1.0-preview' +name: ai-foundry-starter-basic +services: + agent-framework-hosting-sample: + project: . + host: azure.ai.agent + language: docker + docker: + remoteBuild: true + config: + container: + resources: + cpu: "1" + memory: 2Gi + scale: + maxReplicas: 1 + deployments: + - model: + format: OpenAI + name: gpt-5.4-nano + version: "2026-03-17" + name: gpt-5.4-nano + sku: + capacity: 250 + name: GlobalStandard +infra: + provider: bicep + path: ./infra diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/call_server.py b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/call_server.py new file mode 100644 index 00000000000..9114f61f1be --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/call_server.py @@ -0,0 +1,126 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Call the foundry_hosted_agent server three ways. + +The foundry_hosted_agent host exposes ``POST /responses`` (OpenAI Responses-shaped) and +``POST /invocations`` (host-native), and that minimal contract is +**runtime-compatible with the Foundry Hosted Agents platform** — so the same +agent code that calls the local server also calls the same image deployed +as a Hosted Agent. + +Modes +----- +``--via openai`` (default) + Plain ``openai`` SDK against the local ``/responses``. Uses + ``api_key="not-needed"`` because the local sample has no auth. + +``--via af`` + Agent Framework ``Agent`` wrapping ``OpenAIChatClient`` pointed at the + local ``BASE_URL``. ``OpenAIChatClient`` already speaks the Responses + surface natively. + +``--via foundry`` + Agent Framework ``FoundryAgent`` against a Hosted Agent that this image + has been deployed as. Requires:: + + FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com + FOUNDRY_HOSTED_AGENT_NAME= + + Auth uses ``AzureCliCredential`` (run ``az login`` first). + +Start the server first (in another shell):: + + uv run python app.py + +Then:: + + uv run python call_server.py "Who are you?" + uv run python call_server.py --via af "What's the weather in Seattle?" + FOUNDRY_PROJECT_ENDPOINT=... FOUNDRY_HOSTED_AGENT_NAME=... \\ + uv run python call_server.py --via foundry "Who are you?" +""" + +from __future__ import annotations + +import argparse +import asyncio +import os + +from agent_framework import Agent +from agent_framework_foundry import FoundryAgent +from agent_framework_openai import OpenAIChatClient +from azure.identity.aio import AzureCliCredential +from openai import OpenAI + +# Bare server origin — the OpenAI SDK / OpenAIChatClient append ``/responses`` themselves. +BASE_URL = "http://127.0.0.1:8000" + + +def call_via_openai_sdk(prompt: str) -> None: + client = OpenAI(base_url=BASE_URL, api_key="not-needed") + response = client.responses.create(model="agent", input=prompt) + print(f"User: {prompt}") + print(f"Agent: {response.output_text}") + + +async def call_via_agent_framework(prompt: str) -> None: + # Agent + OpenAIChatClient(base_url=...) is the Agent Framework way to + # talk to any Responses-shaped endpoint — including foundry_hosted_agent's `/responses`. + chat_client = OpenAIChatClient(base_url=BASE_URL, api_key="not-needed", model_id="agent") + agent = Agent(client=chat_client) + result = await agent.run(prompt) + print(f"User: {prompt}") + print(f"Agent: {result.text}") + + +async def call_via_foundry_hosted_agent(prompt: str) -> None: + # Once foundry_hosted_agent's image is deployed as a Foundry Hosted Agent, FoundryAgent + # keyed on ``agent_name`` is the AF-native client. The agent's runtime is + # the very same Responses + Invocations contract — Foundry just hosts it. + project_endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") + if not project_endpoint: + raise SystemExit( + "FOUNDRY_PROJECT_ENDPOINT must be set; e.g. https://.services.ai.azure.com/api/projects/agents" + ) + agent_name = os.environ.get("FOUNDRY_HOSTED_AGENT_NAME", "agent-framework-hosting-sample") + # Optional: continue a prior conversation by passing FOUNDRY_HOSTED_SESSION_ID. + session_id = os.environ.get("FOUNDRY_HOSTED_SESSION_ID") + async with AzureCliCredential() as credential: + agent = FoundryAgent( + project_endpoint=project_endpoint, + agent_name=agent_name, + credential=credential, + allow_preview=True, + ) + if session_id: + session = agent.get_session(service_session_id=session_id) + result = await agent.run(prompt, session=session) + else: + result = await agent.run(prompt) + print(f"User: {prompt}") + print(f"Agent: {result.text}") + print(f"Session ID (for history continuity): {result.response_id}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + "--via", + choices=("openai", "af", "foundry"), + default="openai", + help="Calling client to use.", + ) + parser.add_argument("prompt", nargs="*") + args = parser.parse_args() + prompt = " ".join(args.prompt) or "Who are you?" + + if args.via == "openai": + call_via_openai_sdk(prompt) + elif args.via == "af": + asyncio.run(call_via_agent_framework(prompt)) + else: + asyncio.run(call_via_foundry_hosted_agent(prompt)) + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/af-hosting/foundry_hosted_agent/pyproject.toml b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/pyproject.toml new file mode 100644 index 00000000000..685c07ef78d --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_hosted_agent/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "agent-framework-hosting-sample-hosted-agent" +version = "0.0.1" +description = "Hosted-Agent-compatible hosting sample (Responses + Invocations)." +requires-python = ">=3.10" +dependencies = [ + "agent-framework-foundry", + "agent-framework-foundry-hosting", + "agent-framework-hosting", + "agent-framework-hosting-invocations", + "agent-framework-hosting-responses", + "azure-identity", + "aiohttp>=3.13.5", + "hypercorn>=0.17", + "azure-monitor-opentelemetry>=1.6", +] + +[dependency-groups] +dev = ["openai>=1.99"] + +[tool.uv] +package = false + +[tool.uv.sources] +agent-framework-foundry-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/foundry_hosting" } +agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" } +agent-framework-hosting-invocations = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-invocations" } +agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" } diff --git a/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/Dockerfile b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/Dockerfile new file mode 100644 index 00000000000..612ab1854d0 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/Dockerfile @@ -0,0 +1,19 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +WORKDIR /app + +# The sample depends on hosting packages from Git refs until they publish to +# PyPI, so the remote builder needs git available during `uv sync`. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml ./ +COPY app.py ./ + +RUN uv sync --no-dev + +ENV PORT=8000 +EXPOSE 8000 + +CMD ["uv", "run", "python", "app.py"] diff --git a/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/Dockerfile.dockerignore b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/Dockerfile.dockerignore new file mode 100644 index 00000000000..df8255e305b --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/Dockerfile.dockerignore @@ -0,0 +1,4 @@ +* +!app.py +!pyproject.toml +!Dockerfile diff --git a/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/README.md b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/README.md new file mode 100644 index 00000000000..6f3eba95a39 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/README.md @@ -0,0 +1,66 @@ +# foundry_telegram_invocations_weather + +Telegram weather bot sample for validating a non-Responses channel on Foundry +Hosted Agents. The sample configures `TelegramChannel(path="/invocations")` so +the webhook handler runs at the container endpoint `POST /invocations`; Foundry +exposes that route publicly as: + +```text +{FOUNDRY_PROJECT_ENDPOINT}/agents/agent-framework-telegram-invocations-weather/endpoint/protocols/invocations?api-version=2025-11-15-preview +``` + +| Route | Channel | Used by | +|---|---|---| +| `POST /responses` | `ResponsesChannel` | Quick hosted-agent sanity checks | +| `POST /invocations` | `TelegramChannel` | Telegram webhook payloads | + +The agent uses `FoundryHostedAgentHistoryProvider` and a small +`lookup_weather` tool so Telegram requests exercise model calls, tool calls, +and Foundry-hosted storage. + +## Important platform note + +This is an intentional experiment. Current Foundry Hosted Agents behavior +requires Entra bearer auth before a request reaches the container. Telegram +cannot attach that bearer token to webhook deliveries, so webhook registration +can succeed while live Telegram deliveries fail at the Foundry front door with +`401`. Authenticated calls to the Invocations endpoint are still useful for +validating the channel and storage behavior inside the container. + +The sample does not configure `TELEGRAM_WEBHOOK_SECRET` because prior probing +showed Foundry strips Telegram's `X-Telegram-Bot-Api-Secret-Token` header before +the request reaches the container. + +## Run locally + +```bash +export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com +export MODEL_DEPLOYMENT_NAME=gpt-5.4-nano +export TELEGRAM_BOT_TOKEN= +export TELEGRAM_WEBHOOK_URL=https:///invocations +az login + +uv sync +uv run python app.py +``` + +## Deploy + +```bash +set -a +. ../../../../.env +set +a + +azd env set TELEGRAM_BOT_TOKEN "$TELEGRAM_BOT_TOKEN" +azd env set MODEL_DEPLOYMENT_NAME "${MODEL_DEPLOYMENT_NAME:-gpt-5.4-nano}" +azd env set HOSTING_INVOCATIONS_API_VERSION 2025-11-15-preview +azd up +``` + +If you connect this sample to an existing Foundry project instead of running +`azd provision`, make sure the azd environment has `AZURE_AI_PROJECT_ID` and the +project's ACR connection values set before running `azd deploy`. + +On startup, `TelegramChannel` calls `setWebhook` using the Foundry public +Invocations URL derived from `FOUNDRY_PROJECT_ENDPOINT` and +`FOUNDRY_AGENT_NAME`. diff --git a/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/agent.manifest.yaml b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/agent.manifest.yaml new file mode 100644 index 00000000000..900f33547c4 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/agent.manifest.yaml @@ -0,0 +1,38 @@ +name: agent-framework-telegram-invocations-weather +description: > + Telegram weather bot sample hosted by Agent Framework. The Telegram webhook + handler is mounted at /invocations so the Foundry Hosted Agents Invocations + protocol endpoint can be registered as the bot's webhook URL. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Invocations Protocol + - Telegram +template: + name: agent-framework-telegram-invocations-weather + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + - protocol: invocations + version: 1.0.0 + environment_variables: + - name: MODEL_DEPLOYMENT_NAME + value: "{{MODEL_DEPLOYMENT_NAME}}" + - name: TELEGRAM_BOT_TOKEN + value: "{{TELEGRAM_BOT_TOKEN}}" + - name: HOSTING_INVOCATIONS_API_VERSION + value: "{{HOSTING_INVOCATIONS_API_VERSION}}" +resources: + - kind: model + id: gpt-5.4-nano + name: MODEL_DEPLOYMENT_NAME +parameters: + properties: + - name: TELEGRAM_BOT_TOKEN + secret: true + - name: HOSTING_INVOCATIONS_API_VERSION + secret: false diff --git a/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/agent.yaml b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/agent.yaml new file mode 100644 index 00000000000..5206c10385c --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/agent.yaml @@ -0,0 +1,31 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml + +kind: hosted +name: agent-framework-telegram-invocations-weather +description: | + Telegram weather bot sample hosted by Agent Framework. The Telegram webhook + handler is mounted at /invocations so the Foundry Hosted Agents Invocations + protocol endpoint can be registered as the bot's webhook URL. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Invocations Protocol + - Telegram +protocols: + - protocol: responses + version: 1.0.0 + - protocol: invocations + version: 1.0.0 +resources: + cpu: "1" + memory: 2Gi +environment_variables: + - name: MODEL_DEPLOYMENT_NAME + value: ${MODEL_DEPLOYMENT_NAME} + - name: TELEGRAM_BOT_TOKEN + value: ${TELEGRAM_BOT_TOKEN} + - name: HOSTING_INVOCATIONS_API_VERSION + value: ${HOSTING_INVOCATIONS_API_VERSION} diff --git a/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/app.py b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/app.py new file mode 100644 index 00000000000..8c5ebf44cf5 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/app.py @@ -0,0 +1,193 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Telegram weather bot hosted behind Foundry Hosted Agents Invocations. + +This sample intentionally mounts the Telegram webhook handler at the container's +``/invocations`` route so the Foundry public Invocations protocol URL can be +registered as the Telegram webhook URL: + +``{FOUNDRY_PROJECT_ENDPOINT}/agents/{FOUNDRY_AGENT_NAME}/endpoint/protocols/invocations`` + +It uses ``FoundryHostedAgentHistoryProvider`` for conversation history and a +small weather tool to validate that a normal channel can run under the +Hosted Agents runtime. The sample also exposes Responses for a quick platform +sanity check. + +Sample output after sending "weather in Amsterdam" to the Telegram bot: +Assistant:> Amsterdam is cloudy with a high of 16 C. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import replace +from typing import Annotated + +from agent_framework import Agent, tool +from agent_framework.observability import enable_instrumentation +from agent_framework_foundry import FoundryChatClient +from agent_framework_foundry_hosting import FoundryHostedAgentHistoryProvider, foundry_response_id +from agent_framework_hosting import ( + AgentFrameworkHost, + ChannelCommand, + ChannelCommandContext, + ChannelRequest, +) +from agent_framework_hosting_responses import ResponsesChannel +from agent_framework_hosting_telegram import TelegramChannel, telegram_isolation_key +from azure.identity.aio import DefaultAzureCredential + +AGENT_NAME = "agent-framework-telegram-invocations-weather" +DEFAULT_MODEL_DEPLOYMENT = "gpt-5.4-nano" +DEFAULT_INVOCATIONS_API_VERSION = "2025-11-15-preview" + +logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +for _noisy in ( + "httpx", + "httpcore", + "azure.core.pipeline.policies.http_logging_policy", + "urllib3", +): + logging.getLogger(_noisy).setLevel(logging.WARNING) + +logger = logging.getLogger(__name__) + + +@tool(approval_mode="never_require") +def lookup_weather(location: Annotated[str, "The city to look up weather for."]) -> str: + """Return a deterministic weather report for a city.""" + reports = { + "seattle": "Seattle is rainy with a high of 12 C.", + "amsterdam": "Amsterdam is cloudy with a high of 16 C.", + "tokyo": "Tokyo is clear with a high of 22 C.", + "london": "London is misty with a high of 11 C.", + } + normalized = location.strip().lower() + return reports.get(normalized, f"{location} is sunny with a high of 20 C.") + + +def _foundry_invocations_webhook_url() -> str: + """Build the public Foundry Invocations URL used as Telegram's webhook.""" + explicit = os.environ.get("TELEGRAM_WEBHOOK_URL") + if explicit: + return explicit + + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/") + agent_name = os.environ.get("FOUNDRY_AGENT_NAME", AGENT_NAME) + api_version = os.environ.get("HOSTING_INVOCATIONS_API_VERSION", DEFAULT_INVOCATIONS_API_VERSION) + return f"{project_endpoint}/agents/{agent_name}/endpoint/protocols/invocations?api-version={api_version}" + + +def _configure_observability() -> None: + """Wire Azure Monitor OpenTelemetry when Foundry injects a connection string.""" + conn_str = os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING") + if not conn_str: + logger.info("APPLICATIONINSIGHTS_CONNECTION_STRING not set; skipping Azure Monitor export.") + return + + from azure.monitor.opentelemetry import configure_azure_monitor # pyright: ignore[reportUnknownVariableType] + + configure_azure_monitor(connection_string=conn_str) + logger.info("Azure Monitor OpenTelemetry configured.") + + +def telegram_hook(request: ChannelRequest, **_: object) -> ChannelRequest: + """Clamp request options for Telegram-originating runs.""" + options = dict(request.options or {}) + options.pop("store", None) + options["reasoning"] = {"effort": "high", "summary": "auto"} + return replace(request, options=options) + + +def make_commands() -> list[ChannelCommand]: + """Create Telegram slash commands used by the sample.""" + + async def handle_start(ctx: ChannelCommandContext) -> None: + await ctx.reply("Hi! Ask me for weather in Seattle, Amsterdam, Tokyo, London, or any city.") + + async def handle_help(ctx: ChannelCommandContext) -> None: + await ctx.reply( + "/weather - call the weather tool directly\n" + "/whoami - show your Telegram session key\n" + "/help - show this message" + ) + + async def handle_whoami(ctx: ChannelCommandContext) -> None: + await ctx.reply(f"Your session key is {telegram_isolation_key(ctx.request.attributes.get('chat_id'))}.") + + async def handle_weather(ctx: ChannelCommandContext) -> None: + command_text = ctx.request.input if isinstance(ctx.request.input, str) else "" + _, _, location = command_text.partition(" ") + await ctx.reply(lookup_weather(location=(location.strip() or "Seattle"))) + + return [ + ChannelCommand("start", "Introduce the bot", handle_start), + ChannelCommand("help", "List available commands", handle_help), + ChannelCommand("whoami", "Show the Telegram session key", handle_whoami), + ChannelCommand("weather", "Call the weather tool: /weather ", handle_weather), + ] + + +def build_host() -> AgentFrameworkHost: + """Build the Foundry-hosted Telegram weather agent.""" + # 1. Create a shared credential for model calls and Foundry storage. + credential = DefaultAzureCredential() + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + + # 2. Create the agent with a simple weather tool and Foundry-backed history. + agent = Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=os.environ.get("MODEL_DEPLOYMENT_NAME", DEFAULT_MODEL_DEPLOYMENT), + credential=credential, + ), + name="TelegramInvocationsWeatherAgent", + instructions=( + "You are a concise weather assistant. Use lookup_weather for weather questions " + "and answer in one short sentence." + ), + tools=[lookup_weather], + context_providers=[ + FoundryHostedAgentHistoryProvider( + credential=credential, + endpoint=project_endpoint, + ), + ], + ) + + # 3. Register Telegram at /invocations and keep Responses available for sanity checks. + return AgentFrameworkHost( + target=agent, + channels=[ + ResponsesChannel(response_id_factory=foundry_response_id), + TelegramChannel( + bot_token=os.environ["TELEGRAM_BOT_TOKEN"], + path="/invocations", + transport="webhook", + webhook_url=_foundry_invocations_webhook_url(), + parse_mode="Markdown", + commands=make_commands(), + run_hook=telegram_hook, + ), + ], + ) + + +_configure_observability() +enable_instrumentation(enable_sensitive_data=True) +app = build_host().app + + +if __name__ == "__main__": + import asyncio + + import hypercorn.asyncio + import hypercorn.config + + config = hypercorn.config.Config() + config.bind = [f"0.0.0.0:{int(os.environ.get('PORT', '8000'))}"] + asyncio.run(hypercorn.asyncio.serve(app, config)) # type: ignore[arg-type] diff --git a/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/azure.yaml b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/azure.yaml new file mode 100644 index 00000000000..b52679db740 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/azure.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +requiredVersions: + extensions: + azure.ai.agents: '>=0.1.0-preview' +name: ai-foundry-telegram-invocations-weather +services: + agent-framework-telegram-invocations-weather: + project: . + host: azure.ai.agent + language: docker + docker: + remoteBuild: true + config: + container: + resources: + cpu: "1" + memory: 2Gi diff --git a/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/pyproject.toml b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/pyproject.toml new file mode 100644 index 00000000000..6cbddf04a21 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/foundry_telegram_invocations_weather/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "agent-framework-hosting-foundry-telegram-invocations-weather" +version = "0.0.1" +description = "Foundry Hosted Agents Telegram weather sample using the Invocations path." +requires-python = ">=3.10" +dependencies = [ + "agent-framework-foundry", + "agent-framework-foundry-hosting", + "agent-framework-hosting", + "agent-framework-hosting-responses", + "agent-framework-hosting-telegram", + "azure-identity", + "aiohttp>=3.13.5", + "hypercorn>=0.17", + "mcp>=1.24,<2", + "azure-monitor-opentelemetry>=1.6", +] + +[tool.uv] +package = false + +[tool.uv.sources] +agent-framework-foundry-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/foundry_hosting" } +agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" } +agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" } +agent-framework-hosting-telegram = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-telegram" } diff --git a/python/samples/04-hosting/af-hosting/local_responses/README.md b/python/samples/04-hosting/af-hosting/local_responses/README.md new file mode 100644 index 00000000000..0e836470ba7 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses/README.md @@ -0,0 +1,49 @@ +# local_responses — Responses-only with a settings-altering hook + +The smallest end-to-end `agent-framework-hosting` shape: one Foundry +agent with a `@tool`, one `ResponsesChannel`, one `run_hook`. Useful as +the entry-point sample for understanding the **channel run-hook** seam +without any multi-channel or identity-link concerns. + +What the run hook demonstrates: + +- **Strips** caller-supplied `temperature` / `store` so the host owns + those settings. +- **Forces** a `reasoning` preset (`effort=medium`, `summary=auto`) on + every turn — caller-side overrides are ignored. + +`app:app` is a module-level Starlette ASGI app; recommended local launch +is Hypercorn. + +## Run + +```bash +export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com +export FOUNDRY_MODEL=gpt-5.4-nano +az login + +uv sync +uv run hypercorn app:app --bind 0.0.0.0:8000 +``` + +Single-process for quick iteration: + +```bash +uv run python app.py +``` + +## Call locally + +```bash +uv sync --group dev + +# Plain call: +uv run python call_server.py "What is the weather in Tokyo?" + +# Continue an existing conversation by its `response.id`: +uv run python call_server.py --previous-response-id "And in Seattle?" +``` + +> This sample is **local-only** — no Dockerfile, no Foundry packaging. +> For a Foundry-Hosted-Agents-compatible packaging see +> [`../foundry_hosted_agent`](../foundry_hosted_agent). diff --git a/python/samples/04-hosting/af-hosting/local_responses/app.py b/python/samples/04-hosting/af-hosting/local_responses/app.py new file mode 100644 index 00000000000..17312786116 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses/app.py @@ -0,0 +1,113 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Minimal Responses-only hosting sample. + +Single agent with one ``@tool`` (``lookup_weather``), single channel +(``ResponsesChannel``), one ``run_hook`` that demonstrates the +settings-mutation seam over caller-supplied options. + +What the hook does +------------------ +On every Responses request the hook receives the ``ChannelRequest`` that +the channel built from the inbound HTTP body. It: + +- strips ``store`` (this agent owns persistence) and ``temperature`` + (the configured model may not honor it), +- forces a ``reasoning`` effort + summary preset so the deployed surface + is consistent regardless of what the caller sent. + +The hook is the documented escape hatch over the uniform +``ChannelRequest`` envelope. + +Run +--- +``app`` is a module-level Starlette ASGI app. Recommended local launch:: + + uv sync + az login + export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com + export FOUNDRY_MODEL=gpt-5.4-nano + uv run hypercorn app:app --bind 0.0.0.0:8000 + +Or use the ``__main__`` block (single-process Hypercorn) for quick +iteration:: + + uv run python app.py + +Then call it:: + + uv run python call_server.py "What is the weather in Tokyo?" +""" + +from __future__ import annotations + +import os +from dataclasses import replace +from pathlib import Path +from random import randint +from typing import Annotated + +from agent_framework import Agent, FileHistoryProvider, tool +from agent_framework_foundry import FoundryChatClient +from agent_framework_hosting import AgentFrameworkHost, ChannelRequest +from agent_framework_hosting_responses import ResponsesChannel +from azure.identity.aio import DefaultAzureCredential + +SESSIONS_DIR = Path(__file__).resolve().parent / "storage" / "sessions" +SESSIONS_DIR.mkdir(parents=True, exist_ok=True) + + +@tool(approval_mode="never_require") +def lookup_weather( + location: Annotated[str, "The city to look up weather for."], +) -> str: + """Return a deterministic weather report for a city.""" + high_temp = randint(5, 25) + reports = { + "Seattle": f"Seattle is rainy with a high of {high_temp}°C.", + "Amsterdam": f"Amsterdam is cloudy with a high of {high_temp}°C.", + "Tokyo": f"Tokyo is clear with a high of {high_temp}°C.", + } + return reports.get(location, f"{location} is sunny with a high of {high_temp}°C.") + + +def responses_hook(request: ChannelRequest, **_: object) -> ChannelRequest: + """Strip caller-supplied options the host should own and force a + reasoning preset.""" + options = dict(request.options or {}) + + # The agent's default_options own ``store``; the model may not honor + # ``temperature``. Strip both so the caller can't override. + options.pop("temperature", None) + options.pop("store", None) + + # Force a consistent reasoning preset on every turn. + options["reasoning"] = {"effort": "medium", "summary": "auto"} + + return replace(request, options=options or None) + + +def build_host() -> AgentFrameworkHost: + agent = Agent( + client=FoundryChatClient(credential=DefaultAzureCredential()), + name="WeatherAgent", + instructions=( + "You are a friendly weather assistant. Use the lookup_weather tool " + "for any weather question and answer in one short sentence." + ), + tools=[lookup_weather], + context_providers=[FileHistoryProvider(SESSIONS_DIR)], + default_options={"store": False}, + ) + return AgentFrameworkHost( + target=agent, + channels=[ResponsesChannel(run_hook=responses_hook)], + debug=True, + ) + + +app = build_host().app + + +if __name__ == "__main__": + build_host().serve(host="0.0.0.0", port=int(os.environ.get("PORT", "8000"))) diff --git a/python/samples/04-hosting/af-hosting/local_responses/call_server.py b/python/samples/04-hosting/af-hosting/local_responses/call_server.py new file mode 100644 index 00000000000..aeeaa39479b --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses/call_server.py @@ -0,0 +1,47 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Local client for the local_responses sample. + +Posts to ``/responses`` using the standard ``openai`` SDK. + +Pass ``--previous-response-id `` to continue a conversation by its +``response.id`` (returned in the prior response). + +Start the server first (in another shell):: + + uv run python app.py + +Then:: + + uv run python call_server.py "What is the weather in Tokyo?" +""" + +from __future__ import annotations + +import sys + +from openai import OpenAI + +BASE_URL = "http://127.0.0.1:8000" + + +def main() -> None: + args = sys.argv[1:] + previous_response_id: str | None = None + if len(args) >= 2 and args[0] == "--previous-response-id": + previous_response_id = args[1] + args = args[2:] + print(f"Resuming response: {previous_response_id}") + prompt = " ".join(args) or "What is the weather in Tokyo?" + client = OpenAI(base_url=BASE_URL, api_key="not-needed") + response = client.responses.create( + model="agent", + input=prompt, + previous_response_id=previous_response_id, + ) + print(f"User: {prompt}") + print(f"Agent: {response.output_text}") + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml b/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml new file mode 100644 index 00000000000..fb96b95e079 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "agent-framework-hosting-sample-local-responses" +version = "0.0.1" +description = "Minimal Responses-only local hosting sample with a settings-altering run hook." +requires-python = ">=3.10" +dependencies = [ + "agent-framework-foundry", + "agent-framework-hosting", + "agent-framework-hosting-responses", + "azure-identity", + "aiohttp>=3.13.5", + "hypercorn>=0.17", +] + +[dependency-groups] +dev = ["openai>=1.99"] + +[tool.uv] +package = false + +[tool.uv.sources] +agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" } +agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" } diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/README.md b/python/samples/04-hosting/af-hosting/local_responses_workflow/README.md new file mode 100644 index 00000000000..fdeebe973c8 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/README.md @@ -0,0 +1,86 @@ +# local_responses_workflow — workflow target with structured intake + checkpoints + +A `Workflow` (intake → writer → legal reviewer → formatter) hosted +behind **both the Responses API and the Invocations API**, with the +host configured to **persist per-conversation checkpoints**. Mirrors +[`../../foundry-hosted-agents/responses/05_workflows/`](../../foundry-hosted-agents/responses/05_workflows/) +but uses the `agent-framework-hosting` stack instead of the +Foundry-Hosted-Agents runtime, and adds a structured intake step +(`SloganBrief` with `topic` / `style` / `audience` fields) at the front +of the workflow. + +## What's interesting + +- `AgentFrameworkHost(target=workflow, …)` — the host detects a + `Workflow` target and dispatches to `workflow.run(...)` (no + `Agent.create_session(...)`). +- Two channels are mounted side-by-side (`ResponsesChannel` at + `/responses`, `InvocationsChannel` at `/invocations`). Both + share the **same `brief_hook`** that **adapts the channel-native + input into the workflow start executor's typed input** — Responses + delivers a `list[Message]`, Invocations delivers a `str`, but the + hook normalises both to text and produces a `SloganBrief`. +- The hook parses the inbound text as JSON + (`{"topic": ..., "style": ..., "audience": ...}`); if parsing fails + it uses the whole text as `topic` with defaults. +- The workflow's first executor (`BriefIntakeExecutor`) accepts + `SloganBrief` directly — that's what gets sent into `workflow.run(...)` + by the host. +- `checkpoint_location=storage/checkpoints/` — the host scopes a + `FileCheckpointStorage` per conversation (Responses keys it on + `previous_response_id` / `conversation_id`; Invocations keys it on + `session_id`) and **restores from the latest checkpoint at the start + of every turn** before applying the new input. Without an isolation + key the host skips checkpointing for that request. +- No `HistoryProvider` — the workflow owns its own state via the + checkpoint store. + +## Run + +```bash +export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com +export FOUNDRY_MODEL=gpt-5.4-nano +az login + +uv sync +uv run hypercorn app:app --bind 0.0.0.0:8000 +``` + +Single-process for quick iteration: + +```bash +uv run python app.py +``` + +## Call locally + +Two clients are provided next to `app.py`: + +- **`call_server.py`** — Python client using the OpenAI SDK (Responses + API only). +- **`call_server.rest`** — raw REST examples for **both** the Responses + and Invocations endpoints (open in VS Code with the REST Client + extension or any compatible HTTP-file runner). + +```bash +uv sync --group dev + +# Structured brief via the OpenAI SDK (Responses API): +uv run python call_server.py \ + '{"topic": "electric SUV", "style": "playful", "audience": "young families"}' + +# Plain topic (style/audience default to "modern" / "general"): +uv run python call_server.py "electric SUV" + +# Continue an existing conversation by its `response.id`: +uv run python call_server.py --previous-response-id \ + '{"topic": "electric SUV", "style": "retro", "audience": "boomers"}' +``` + +After a few turns, inspect `storage/checkpoints//` — +each conversation has its own subdirectory of checkpoint files written +by the host. + +> This sample is **local-only** — no Dockerfile, no Foundry packaging. +> For a Foundry-Hosted-Agents-compatible packaging see +> [`../foundry_hosted_agent`](../foundry_hosted_agent). diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/app.py b/python/samples/04-hosting/af-hosting/local_responses_workflow/app.py new file mode 100644 index 00000000000..c35e8c06ea3 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/app.py @@ -0,0 +1,225 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Hosted workflow sample with a structured intake step + checkpoint location. + +Same three-agent slogan workflow as +``../../foundry-hosted-agents/responses/05_workflows/main.py`` (writer → +legal reviewer → formatter), but with an extra **structured intake** +step at the front and driven through the ``agent-framework-hosting`` +stack instead of the Foundry-Hosted-Agents runtime. + +Workflow shape +-------------- +``BriefIntakeExecutor`` (typed :class:`SloganBrief` input) → ``writer`` +→ ``legal_reviewer`` → ``formatter``. The intake step formats the +structured brief into a prompt the writer agent understands. + +What this sample shows +---------------------- +- A :class:`~agent_framework.Workflow` is a valid hosting target — the + host detects it and dispatches to ``workflow.run(...)`` instead of + ``agent.run(...)``. +- ``ResponsesChannel(run_hook=...)`` (and the same hook on + ``InvocationsChannel``) is the seam for **adapting the channel-native + input into the workflow start executor's typed input**. The hook here + parses the inbound text as JSON + (``{"topic": ..., "style": ..., "audience": ...}``) — if parsing + fails it falls back to using the whole text as ``topic`` with + defaults — and replaces ``ChannelRequest.input`` with a + :class:`SloganBrief`. +- ``AgentFrameworkHost(checkpoint_location=...)`` enables + per-conversation workflow checkpointing. The host scopes the + checkpoint storage by ``ChannelRequest.session.isolation_key`` + (Responses uses ``previous_response_id`` / ``conversation_id`` as the + isolation key), and restores from the latest checkpoint before each + new turn — so a multi-turn workflow can resume across requests. +- No ``HistoryProvider`` is configured: the workflow owns its own state + via the checkpoint store; the agent-history seam is for plain + ``SupportsAgentRun`` agents. + +Run +--- +``app`` is a module-level Starlette ASGI app:: + + uv sync + az login + export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com + export FOUNDRY_MODEL=gpt-5.4-nano + uv run hypercorn app:app --bind 0.0.0.0:8000 + +Or for quick iteration:: + + uv run python app.py + +Then call it with a structured brief:: + + uv run python call_server.py \\ + '{"topic": "electric SUV", "style": "playful", "audience": "young families"}' + +Or with just a topic — the hook fills in defaults:: + + uv run python call_server.py "Create a slogan for an electric SUV." +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, replace +from pathlib import Path + +from agent_framework import ( + Agent, + AgentExecutor, + Executor, + Message, + WorkflowBuilder, + WorkflowContext, + handler, +) +from agent_framework_foundry import FoundryChatClient +from agent_framework_hosting import AgentFrameworkHost, ChannelRequest +from agent_framework_hosting_invocations import InvocationsChannel +from agent_framework_hosting_responses import ResponsesChannel +from azure.identity.aio import DefaultAzureCredential + +CHECKPOINTS_DIR = Path(__file__).resolve().parent / "storage" / "checkpoints" +CHECKPOINTS_DIR.mkdir(parents=True, exist_ok=True) + + +@dataclass +class SloganBrief: + """Typed input for the workflow's first executor.""" + + topic: str + style: str = "modern" + audience: str = "general" + + +class BriefIntakeExecutor(Executor): + """Format a :class:`SloganBrief` into a prompt for the writer agent.""" + + @handler + async def handle(self, brief: SloganBrief, ctx: WorkflowContext[str]) -> None: + prompt = ( + f"Topic: {brief.topic}\n" + f"Style: {brief.style}\n" + f"Audience: {brief.audience}\n\n" + "Write a single short slogan that fits the topic, style, and audience." + ) + await ctx.send_message(prompt) + + +def _extract_text(value: object) -> str: + """Pull plain text out of whatever the Responses channel produced. + + The channel hands the host either a ``str`` (rare on the Responses + surface) or a list of :class:`Message`. The hook collapses both to + a single concatenated string before attempting to parse a brief. + """ + if isinstance(value, str): + return value + if isinstance(value, Message): + return value.text + if isinstance(value, list): + return "\n".join(_extract_text(item) for item in value) + return "" + + +def _parse_brief(text: str) -> SloganBrief: + """Parse user text into a :class:`SloganBrief`. + + Accepts a JSON object with ``topic`` / ``style`` / ``audience`` + keys; falls back to using the whole text as ``topic`` with the + other fields defaulted. + """ + text = text.strip() + if text.startswith("{"): + try: + data = json.loads(text) + except json.JSONDecodeError: + data = None + if isinstance(data, dict) and "topic" in data: + return SloganBrief( + topic=str(data["topic"]), + style=str(data.get("style", "modern")), + audience=str(data.get("audience", "general")), + ) + return SloganBrief(topic=text or "a generic product") + + +def brief_hook(request: ChannelRequest, **_: object) -> ChannelRequest: + """Adapt the channel's free-form text into the workflow's typed input. + + This is the canonical seam for shaping ``ChannelRequest.input`` into + the workflow start executor's input type — here :class:`SloganBrief` + instead of ``str`` / ``list[Message]``. Shared between the Responses + channel (which delivers a list of :class:`Message`) and the + Invocations channel (which delivers a plain ``str``). + """ + brief = _parse_brief(_extract_text(request.input)) + return replace(request, input=brief) + + +def build_host() -> AgentFrameworkHost: + client = FoundryChatClient(credential=DefaultAzureCredential()) + + writer = Agent( + client=client, + name="writer", + instructions=("You are an excellent slogan writer. You create new slogans based on the given topic."), + ) + legal = Agent( + client=client, + name="legal_reviewer", + instructions=( + "You are an excellent legal reviewer. " + "Make necessary corrections to the slogan so that it is legally compliant." + ), + ) + formatter = Agent( + client=client, + name="formatter", + instructions=( + "You are an excellent content formatter. " + "You take the slogan and format it in a cool retro style when printing to a terminal." + ), + ) + + intake_ex = BriefIntakeExecutor(id="intake") + # ``context_mode="last_agent"`` ensures each agent only sees the + # previous executor's output — matching the Foundry sample. + writer_ex = AgentExecutor(writer, context_mode="last_agent") + legal_ex = AgentExecutor(legal, context_mode="last_agent") + format_ex = AgentExecutor(formatter, context_mode="last_agent") + + workflow = ( + WorkflowBuilder( + start_executor=intake_ex, + output_executors=[format_ex], + ) + .add_edge(intake_ex, writer_ex) + .add_edge(writer_ex, legal_ex) + .add_edge(legal_ex, format_ex) + .build() + ) + + return AgentFrameworkHost( + target=workflow, + channels=[ + ResponsesChannel(run_hook=brief_hook), + InvocationsChannel(run_hook=brief_hook), + ], + # The host writes a per-conversation FileCheckpointStorage rooted + # at ``CHECKPOINTS_DIR / `` and restores from the + # latest checkpoint at the start of every turn. + checkpoint_location=CHECKPOINTS_DIR, + debug=True, + ) + + +app = build_host().app + + +if __name__ == "__main__": + build_host().serve(host="0.0.0.0", port=int(os.environ.get("PORT", "8000"))) diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.py b/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.py new file mode 100644 index 00000000000..238de564ed5 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Local client for the local_responses_workflow sample. + +The server expects a structured slogan brief. You can either pass a +JSON object or a plain topic string (the server's run hook fills the +other fields with defaults). + +Pass ``--previous-response-id `` to continue a conversation by its +``response.id`` — the host uses that as the workflow checkpoint scope +key, so the workflow resumes from where it left off. + +Start the server first (in another shell):: + + uv run python app.py + +Then:: + + uv run python call_server.py \\ + '{"topic": "electric SUV", "style": "playful", "audience": "young families"}' + + uv run python call_server.py "electric SUV" # uses default style/audience +""" + +from __future__ import annotations + +import sys + +from openai import OpenAI + +BASE_URL = "http://127.0.0.1:8000" + + +def main() -> None: + args = sys.argv[1:] + previous_response_id: str | None = None + if len(args) >= 2 and args[0] == "--previous-response-id": + previous_response_id = args[1] + args = args[2:] + print(f"Resuming response: {previous_response_id}") + prompt = " ".join(args) or '{"topic": "electric SUV", "style": "playful", "audience": "young families"}' + client = OpenAI(base_url=BASE_URL, api_key="not-needed") + response = client.responses.create( + model="agent", + input=prompt, + previous_response_id=previous_response_id, + ) + print(f"User: {prompt}") + print(f"Agent: {response.output_text}") + print(f"response.id: {response.id}") + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.rest b/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.rest new file mode 100644 index 00000000000..005353cfe4e --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.rest @@ -0,0 +1,92 @@ +# local_responses_workflow — REST examples +# +# Use with the VS Code "REST Client" extension (humao.rest-client) or +# JetBrains HTTP Client. Each `###` block is one request. +# +# Start the server in another shell first: +# uv run python app.py + +@host = http://127.0.0.1:8000 + +### +# 1. Responses API — structured brief +POST {{host}}/responses +Content-Type: application/json + +{ + "model": "agent", + "input": "{\"topic\": \"electric SUV\", \"style\": \"playful\", \"audience\": \"young families\"}" +} + +### +# 2. Responses API — plain topic, defaults applied by the run hook +POST {{host}}/responses +Content-Type: application/json + +{ + "model": "agent", + "input": "vintage espresso machine" +} + +### +# 3. Responses API — continue the conversation by previous_response_id +# Replace with `id` from one of the responses above — +# the host uses it as the workflow checkpoint scope key, so the +# workflow resumes from its latest checkpoint before applying the +# new input. +POST {{host}}/responses +Content-Type: application/json + +{ + "model": "agent", + "previous_response_id": "", + "input": "{\"topic\": \"electric SUV\", \"style\": \"retro\", \"audience\": \"boomers\"}" +} + +### +# 4. Invocations API — structured brief +POST {{host}}/invocations +Content-Type: application/json + +{ + "message": "{\"topic\": \"electric SUV\", \"style\": \"playful\", \"audience\": \"young families\"}", + "session_id": "demo-1" +} + +### +# 5. Invocations API — plain topic +POST {{host}}/invocations +Content-Type: application/json + +{ + "message": "noise-cancelling headphones", + "session_id": "demo-2" +} + +### +# 6. Invocations API — resume the same session_id to reuse the +# workflow's per-conversation checkpoint store. +POST {{host}}/invocations +Content-Type: application/json + +{ + "message": "{\"topic\": \"noise-cancelling headphones\", \"style\": \"minimalist\", \"audience\": \"developers\"}", + "session_id": "demo-2" +} + +### +# 7. Invocations API — streaming (SSE; one `data:` line per chunk, +# terminated by `data: [DONE]`). +POST {{host}}/invocations +Content-Type: application/json +Accept: text/event-stream + +{ + "message": "{\"topic\": \"reusable water bottle\", \"style\": \"bold\", \"audience\": \"college students\"}", + "session_id": "demo-3", + "stream": true +} + +### +# 8. Readiness probe +GET {{host}}/readiness diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml b/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml new file mode 100644 index 00000000000..81f91e7eae6 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "agent-framework-hosting-sample-local-responses-workflow" +version = "0.0.1" +description = "Local hosting sample exposing a 3-agent workflow over the Responses API with per-conversation checkpoint storage." +requires-python = ">=3.10" +dependencies = [ + "agent-framework-foundry", + "agent-framework-hosting", + "agent-framework-hosting-invocations", + "agent-framework-hosting-responses", + "azure-identity", + "aiohttp>=3.13.5", + "hypercorn>=0.17", +] + +[dependency-groups] +dev = ["openai>=1.99"] + +[tool.uv] +package = false + +[tool.uv.sources] +agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" } +agent-framework-hosting-invocations = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-invocations" } +agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" } diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/storage/checkpoints/.gitkeep b/python/samples/04-hosting/af-hosting/local_responses_workflow/storage/checkpoints/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/samples/04-hosting/af-hosting/local_telegram/README.md b/python/samples/04-hosting/af-hosting/local_telegram/README.md new file mode 100644 index 00000000000..727d332342e --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_telegram/README.md @@ -0,0 +1,54 @@ +# local_telegram — `@tool`, file-backed history, hooks, Telegram + +Builds on `foundry_hosted_agent/` with the hooks and config most real apps need: + +- A `@tool`-decorated function call (`get_weather`) so streaming and tool + invocation are exercised end-to-end. +- `FileHistoryProvider(./storage/sessions)` so per-user/per-chat history + survives restarts. +- A `responses_hook` that keys each session off the OpenAI + `safety_identifier` field, so multiple users on the Responses endpoint + do not share history. +- A `telegram_hook` that keys per-chat sessions via `telegram_isolation_key`. +- Two extra Telegram commands (`/new`, `/whoami`). + +`app:app` is a module-level Starlette ASGI app, so this sample runs under +Hypercorn (multi-process). + +## Run + +```bash +export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com +export FOUNDRY_MODEL=gpt-4o +export TELEGRAM_BOT_TOKEN=... +az login + +uv sync +uv run hypercorn app:app \ + --bind 0.0.0.0:8000 \ + --workers 4 +``` + +Single-process for quick iteration: + +```bash +uv run python app.py +``` + +## Call locally + +```bash +uv sync --group dev + +# Plain call: +uv run python call_server.py "What is the weather in Tokyo?" + +# Resume an existing session by AgentSession id (works across channels): +uv run python call_server.py --previous-response-id telegram:8741188429 "What did we discuss?" + +``` + +> This sample is **local-only** — it shows the `agent-framework-hosting` +> server stack as a standalone process. For a Foundry-Hosted-Agents-compatible +> packaging (Dockerfile + `agent.yaml` + `azure.yaml`), see +> [`foundry_hosted_agent/`](../foundry_hosted_agent). diff --git a/python/samples/04-hosting/af-hosting/local_telegram/app.py b/python/samples/04-hosting/af-hosting/local_telegram/app.py new file mode 100644 index 00000000000..326073bc2d9 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_telegram/app.py @@ -0,0 +1,227 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Advanced multi-channel hosting sample. + +Builds on ``app.py`` to demonstrate: + +- a function ``@tool`` on the agent (``lookup_weather``), +- per-isolation-key history persisted via ``FileHistoryProvider``, +- a ``ResponsesChannel`` ``run_hook`` that clamps caller-supplied + ``ChatOptions`` and honours the OpenAI ``previous_response_id`` field as + the ``AgentSession`` id — so a Responses caller can resume a Telegram + chat by passing ``previous_response_id="telegram:"`` (or any + other isolation key written by another channel), +- a ``TelegramChannel`` ``run_hook`` that bumps ``temperature`` for a + chattier Telegram persona, +- a richer Telegram command catalog including a ``/new`` command that resets + the cached session for the chat. + +Required env: ``FOUNDRY_PROJECT_ENDPOINT``, ``FOUNDRY_MODEL``, +``TELEGRAM_BOT_TOKEN``. Auth uses ``DefaultAzureCredential``. + +Run +--- +This module exposes ``app`` as the canonical ASGI surface. Recommended +production launch is **Hypercorn**:: + + hypercorn app:app --bind 0.0.0.0:8000 --workers 4 + +The ``__main__`` block below uses ``host.serve(...)`` (single-process +Hypercorn) as a local-dev fallback. + +Note +---- +``FileHistoryProvider`` provides only in-process file-write locking. Running +multiple Hypercorn workers against the same ``./sessions`` directory is fine +for this sample, but a production deployment should swap it for a store with +cross-process consistency. +""" + +from __future__ import annotations + +import os +from dataclasses import replace +from pathlib import Path +from random import randint +from typing import Annotated + +from agent_framework import Agent, FileHistoryProvider, tool +from agent_framework_foundry import FoundryChatClient +from agent_framework_hosting import ( + AgentFrameworkHost, + ChannelCommand, + ChannelCommandContext, + ChannelRequest, + ChannelSession, +) +from agent_framework_hosting_responses import ResponsesChannel +from agent_framework_hosting_telegram import TelegramChannel, telegram_isolation_key +from azure.identity.aio import DefaultAzureCredential + +# import logging +# logging.basicConfig(level=logging.DEBUG) + +SESSIONS_DIR = Path(__file__).resolve().parent / "storage" / "sessions" +SESSIONS_DIR.mkdir(parents=True, exist_ok=True) + + +# --------------------------------------------------------------------------- # +# Tools the agent can call +# --------------------------------------------------------------------------- # + + +@tool(approval_mode="never_require") +def lookup_weather( + location: Annotated[str, "The city to look up weather for."], +) -> str: + """Return a deterministic weather report for a city.""" + high_temp = randint(5, 25) + reports = { + "Seattle": f"Seattle is rainy with a high of {high_temp}°C.", + "Amsterdam": f"Amsterdam is cloudy with a high of {high_temp}°C.", + "Tokyo": f"Tokyo is clear with a high of {high_temp}°C.", + } + return reports.get(location, f"{location} is sunny with a high of {high_temp}°C.") + + +# --------------------------------------------------------------------------- # +# Responses channel run hook +# --------------------------------------------------------------------------- # + + +def responses_hook(request: ChannelRequest, *, protocol_request: dict | None = None, **_: object) -> ChannelRequest: + """Validate, rewrite, and key the channel-built ChannelRequest before invocation. + + The spec calls this out as the developer's runtime escape hatch over the + uniform ``ChannelRequest`` envelope. Things this hook does: + + - **strip** ``store`` and ``temperature`` (the agent owns persistence via ``FileHistoryProvider``), + - **inject a session** keyed on the request body. The OpenAI Responses + ``previous_response_id`` field doubles as our isolation key — the + ``ResponsesChannel`` already lifts it onto ``request.session``, so any + caller can resume an arbitrary AgentSession (including one written by + another channel, e.g. ``telegram:8741188429``) by passing it as + ``previous_response_id``. When the caller doesn't pass one, fall back + to a key derived from the OpenAI ``safety_identifier`` field + (``responses:``). + """ + options = dict(request.options or {}) + + # this agent will only run with models that do not support Temperature, so removing it. + options.pop("temperature", None) + options.pop("store", None) + + body = protocol_request or {} + + if request.session is not None and request.session.isolation_key: + # Caller supplied ``previous_response_id`` — the channel already + # used it as the AgentSession id. Keep it as-is. + session = request.session + else: + safety_id = body.get("safety_identifier") or "anonymous" + session = ChannelSession(isolation_key=f"responses:{safety_id}") + + return replace( + request, + session=session, + options=options or None, + ) + + +def telegram_hook(request: ChannelRequest, **_: object) -> ChannelRequest: + """Telegram users get a chattier model — bump temperature on every turn.""" + options = dict(request.options or {}) + options["reasoning"] = {"effort": "high", "summary": "detailed"} + return replace(request, options=options) + + +# --------------------------------------------------------------------------- # +# Telegram commands +# --------------------------------------------------------------------------- # + + +def _isolation_key(ctx: ChannelCommandContext) -> str: + return telegram_isolation_key(ctx.request.attributes.get("chat_id")) + + +def make_commands(host_ref: dict[str, AgentFrameworkHost]) -> list[ChannelCommand]: + """Build commands that close over the host so ``/new`` can reset state.""" + + async def handle_start(ctx: ChannelCommandContext) -> None: + await ctx.reply("Hi! I'm a multi-channel agent.\nCommands: /new, /whoami, /weather , /help.") + + async def handle_help(ctx: ChannelCommandContext) -> None: + await ctx.reply( + "/new — start a fresh conversation\n" + "/whoami — show your isolation key\n" + "/weather — call the weather tool directly\n" + "/help — this message" + ) + + async def handle_new(ctx: ChannelCommandContext) -> None: + host_ref["host"].reset_session(_isolation_key(ctx)) + await ctx.reply("New session started. Previous history is cleared for this chat.") + + async def handle_whoami(ctx: ChannelCommandContext) -> None: + await ctx.reply(f"Your isolation key on this host is: {_isolation_key(ctx)}") + + async def handle_weather(ctx: ChannelCommandContext) -> None: + # Bypass the agent and call the tool directly to demonstrate that + # commands have full control over how they reply. + command_text = ctx.request.input if isinstance(ctx.request.input, str) else "" + _, _, location = command_text.partition(" ") + location = location.strip() or "Seattle" + await ctx.reply(lookup_weather(location=location)) + + return [ + ChannelCommand("start", "Introduce the bot", handle_start), + ChannelCommand("help", "List available commands", handle_help), + ChannelCommand("new", "Start a new session for this chat", handle_new), + ChannelCommand("whoami", "Show the isolation key for this chat", handle_whoami), + ChannelCommand("weather", "Call the weather tool: /weather ", handle_weather), + ] + + +# --------------------------------------------------------------------------- # +# Host wiring +# --------------------------------------------------------------------------- # + + +def build_host() -> AgentFrameworkHost: + agent = Agent( + client=FoundryChatClient(credential=DefaultAzureCredential()), + name="WeatherAgent", + instructions=( + "You are a friendly weather assistant. Use the lookup_weather tool " + "for any weather question and answer in one short sentence." + ), + tools=[lookup_weather], + context_providers=[FileHistoryProvider(SESSIONS_DIR)], + default_options={"store": False}, + ) + + host_ref: dict[str, AgentFrameworkHost] = {} + host = AgentFrameworkHost( + target=agent, + channels=[ + ResponsesChannel(run_hook=responses_hook), + TelegramChannel( + bot_token=os.environ["TELEGRAM_BOT_TOKEN"], + webhook_url=os.environ.get("TELEGRAM_WEBHOOK_URL"), + secret_token=os.environ.get("TELEGRAM_WEBHOOK_SECRET"), + parse_mode="Markdown", + commands=make_commands(host_ref), + run_hook=telegram_hook, + ), + ], + debug=True, + ) + host_ref["host"] = host + return host + + +app = build_host().app + + +if __name__ == "__main__": + build_host().serve(host="0.0.0.0", port=int(os.environ.get("PORT", "8000"))) diff --git a/python/samples/04-hosting/af-hosting/local_telegram/call_server.py b/python/samples/04-hosting/af-hosting/local_telegram/call_server.py new file mode 100644 index 00000000000..1ae01be65bc --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_telegram/call_server.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Local client for the advanced agent — POSTs to the ``/responses`` endpoint +exposed by ``server/advanced_app.py`` using the standard ``openai`` SDK. + +The advanced server's ``responses_hook`` keys per-user history off the +OpenAI ``safety_identifier`` field, so we pass ``safety_identifier=`` here. + +Pass ``--previous-response-id `` to resume an existing AgentSession by +its isolation key. Because the server uses ``previous_response_id`` directly +as the ``AgentSession`` id, you can resume any session written by any +channel — for example a Telegram chat at +``--previous-response-id telegram:8741188429``. + +Start the server first (in another shell):: + + cd server && uv run python advanced_app.py + +Then:: + + python call_server.py "What is the weather in Tokyo?" + python call_server.py --previous-response-id telegram:8741188429 "What did we discuss?" +""" + +from __future__ import annotations + +import sys + +from openai import OpenAI + +BASE_URL = "http://127.0.0.1:8000" + + +def main() -> None: + args = sys.argv[1:] + previous_response_id: str | None = None + if len(args) >= 2 and args[0] == "--previous-response-id": + previous_response_id = args[1] + args = args[2:] + print(f"Resuming AgentSession: {previous_response_id}") + prompt = " ".join(args) or "What is the weather in Seattle?" + client = OpenAI(base_url=BASE_URL, api_key="not-needed") + response = client.responses.create( + model="agent", + input=prompt, + safety_identifier="local-dev", + previous_response_id=previous_response_id, + ) + print(f"User: {prompt}") + print(f"Agent: {response.output_text}") + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml b/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml new file mode 100644 index 00000000000..39e07048165 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "agent-framework-hosting-sample-advanced" +version = "0.0.1" +description = "Advanced multi-channel hosting sample (Responses + Telegram with @tool, FileHistoryProvider, hooks)." +requires-python = ">=3.10" +dependencies = [ + "agent-framework-foundry", + "agent-framework-hosting", + "agent-framework-hosting-responses", + "agent-framework-hosting-telegram", + "azure-identity", + "aiohttp>=3.13.5", + "hypercorn>=0.17", + "httpx>=0.27", +] + +[dependency-groups] +dev = ["openai>=1.99"] + +[tool.uv] +package = false + +[tool.uv.sources] +agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" } +agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" } +agent-framework-hosting-telegram = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-telegram" } diff --git a/python/uv.lock b/python/uv.lock index d17e55e1a7f..64166061c5f 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -50,6 +50,14 @@ members = [ "agent-framework-foundry-local", "agent-framework-gemini", "agent-framework-github-copilot", + "agent-framework-hosting", + "agent-framework-hosting-a2a", + "agent-framework-hosting-activity-protocol", + "agent-framework-hosting-discord", + "agent-framework-hosting-invocations", + "agent-framework-hosting-mcp", + "agent-framework-hosting-responses", + "agent-framework-hosting-telegram", "agent-framework-hyperlight", "agent-framework-lab", "agent-framework-mem0", @@ -612,6 +620,171 @@ requires-dist = [ { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0,<2" }, ] +[[package]] +name = "agent-framework-hosting" +version = "1.0.0a260424" +source = { editable = "packages/hosting" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.optional-dependencies] +disk = [ + { name = "diskcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +serve = [ + { name = "hypercorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "diskcache", marker = "extra == 'disk'", specifier = ">=5.6" }, + { name = "hypercorn", marker = "extra == 'serve'", specifier = ">=0.17" }, + { name = "starlette", specifier = ">=0.37" }, +] +provides-extras = ["serve", "disk"] + +[package.metadata.requires-dev] +dev = [{ name = "httpx", specifier = ">=0.28.1" }] + +[[package]] +name = "agent-framework-hosting-a2a" +version = "1.0.0a260424" +source = { editable = "packages/hosting-a2a" } +dependencies = [ + { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "a2a-sdk", specifier = ">=1.0.0,<2" }, + { name = "agent-framework-core", editable = "packages/core" }, + { name = "agent-framework-hosting", editable = "packages/hosting" }, + { name = "starlette", specifier = ">=0.37" }, +] + +[package.metadata.requires-dev] +dev = [] + +[[package]] +name = "agent-framework-hosting-activity-protocol" +version = "1.0.0a260424" +source = { editable = "packages/hosting-activity-protocol" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "agent-framework-hosting", editable = "packages/hosting" }, + { name = "azure-identity", specifier = ">=1.20,<2" }, + { name = "httpx", specifier = ">=0.27,<1" }, +] + +[[package]] +name = "agent-framework-hosting-discord" +version = "1.0.0a260526" +source = { editable = "packages/hosting-discord" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pynacl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "agent-framework-hosting", editable = "packages/hosting" }, + { name = "httpx", specifier = ">=0.27,<1" }, + { name = "pynacl", specifier = ">=1.2.0,<2" }, +] + +[[package]] +name = "agent-framework-hosting-invocations" +version = "1.0.0a260424" +source = { editable = "packages/hosting-invocations" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "agent-framework-hosting", editable = "packages/hosting" }, +] + +[[package]] +name = "agent-framework-hosting-mcp" +version = "1.0.0a260424" +source = { editable = "packages/hosting-mcp" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "agent-framework-hosting", editable = "packages/hosting" }, + { name = "mcp", specifier = ">=1.12,<2" }, + { name = "starlette", specifier = ">=0.37" }, +] + +[package.metadata.requires-dev] +dev = [] + +[[package]] +name = "agent-framework-hosting-responses" +version = "1.0.0a260424" +source = { editable = "packages/hosting-responses" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "agent-framework-hosting", editable = "packages/hosting" }, + { name = "openai", specifier = ">=1.99.0,<3" }, +] + +[[package]] +name = "agent-framework-hosting-telegram" +version = "1.0.0a260424" +source = { editable = "packages/hosting-telegram" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "agent-framework-hosting", editable = "packages/hosting" }, + { name = "httpx", specifier = ">=0.27,<1" }, +] + [[package]] name = "agent-framework-hyperlight" version = "1.0.0b260521" @@ -2120,6 +2293,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/c4/da7089cd7aa4ab554f56e18a7fb08dcfed8fd2ae91fa528f5b1be207a148/deepdiff-9.0.0-py3-none-any.whl", hash = "sha256:b1ae0dd86290d86a03de5fbee728fde43095c1472ae4974bdab23ab4656305bd", size = 170540, upload-time = "2026-03-30T05:52:22.008Z" }, ] +[[package]] +name = "diskcache" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, +] + [[package]] name = "distro" version = "1.9.0"