mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c91b88f217 | ||
|
|
5534198142 | ||
|
|
36ce0950e4 | ||
|
|
e5a6e35843 | ||
|
|
e8c22caaeb | ||
|
|
6b822853eb | ||
|
|
fe89da15b6 | ||
|
|
cdea9fa956 | ||
|
|
f0b9ab6733 | ||
|
|
cb1d4a6ee5 | ||
|
|
d75f55ee2c | ||
|
|
4c317eb7cf | ||
|
|
0cb9b52a4b | ||
|
|
e666cdc7c8 | ||
|
|
25692a17a8 | ||
|
|
c82c0133fc | ||
|
|
950673ba47 | ||
|
|
5ac864dfd9 | ||
|
|
b559545fa4 | ||
|
|
8e54f0b0e7 | ||
|
|
afd2739e38 | ||
|
|
c8b8198af1 | ||
|
|
bda40ba0e1 | ||
|
|
46ed66cfd5 |
@@ -137,6 +137,7 @@ jobs:
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
|
||||
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.6.1</VersionPrefix>
|
||||
<VersionPrefix>1.6.2</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260514</DateSuffix>
|
||||
<DateSuffix>260521</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.6.1</GitTag>
|
||||
<GitTag>1.6.2</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -27,9 +27,9 @@ namespace Microsoft.Agents.AI.Foundry;
|
||||
/// Foundry chat-client decorator that unifies the three Foundry chat-client construction
|
||||
/// modes (Responses Agent, Prompt Agent, Agent Endpoint) behind a single type and centralizes
|
||||
/// Foundry-specific concerns: <c>microsoft.foundry</c> telemetry tagging,
|
||||
/// <c>agent-framework-dotnet/{version}</c> User-Agent stamping, and (for Prompt Agents)
|
||||
/// per-request payload mutation that injects the agent reference and strips per-request
|
||||
/// overrides that the server owns.
|
||||
/// <c>agent-framework-dotnet/{version}</c> User-Agent stamping, <c>x-ms-served-model</c>
|
||||
/// response-header capture, and (for Prompt Agents) per-request payload mutation that injects
|
||||
/// the agent reference and strips per-request overrides that the server owns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -78,6 +78,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: modelId);
|
||||
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
|
||||
TryRegisterServedModelPolicy(this.InnerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -96,6 +97,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
|
||||
this._baseChatOptions = baseChatOptions;
|
||||
this.AgentName = agentReference.Name;
|
||||
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
|
||||
TryRegisterServedModelPolicy(this.InnerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -161,6 +163,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
|
||||
this.AgentName = inner.AgentName;
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry");
|
||||
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
|
||||
TryRegisterServedModelPolicy(this.InnerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -212,7 +215,25 @@ public sealed class FoundryChatClient : DelegatingChatClient
|
||||
? this.GetAgentEnabledChatOptions(options)
|
||||
: options;
|
||||
|
||||
return await base.GetResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false);
|
||||
var box = new StrongBox<string?>(null);
|
||||
var previous = ServedModelScope.Current;
|
||||
ServedModelScope.Current = box;
|
||||
|
||||
try
|
||||
{
|
||||
var response = await base.GetResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (box.Value is { } servedModel)
|
||||
{
|
||||
response.ModelId = servedModel;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ServedModelScope.Current = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -222,9 +243,25 @@ public sealed class FoundryChatClient : DelegatingChatClient
|
||||
? this.GetAgentEnabledChatOptions(options)
|
||||
: options;
|
||||
|
||||
await foreach (var chunk in base.GetStreamingResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false))
|
||||
var box = new StrongBox<string?>(null);
|
||||
var previous = ServedModelScope.Current;
|
||||
ServedModelScope.Current = box;
|
||||
|
||||
try
|
||||
{
|
||||
yield return chunk;
|
||||
await foreach (var chunk in base.GetStreamingResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (box.Value is { } servedModel)
|
||||
{
|
||||
chunk.ModelId = servedModel;
|
||||
}
|
||||
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ServedModelScope.Current = previous;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,6 +665,25 @@ public sealed class FoundryChatClient : DelegatingChatClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort registration of <see cref="ServedModelPolicy"/> via the MEAI
|
||||
/// <see cref="OpenAIRequestPolicies"/> hook. The policy captures the
|
||||
/// <c>x-ms-served-model</c> response header from Azure OpenAI and writes it into
|
||||
/// <see cref="ServedModelScope"/> so the <see cref="GetResponseAsync"/> and
|
||||
/// <see cref="GetStreamingResponseAsync"/> overrides can overwrite
|
||||
/// <see cref="ChatResponse.ModelId"/> with the actual model snapshot.
|
||||
/// </summary>
|
||||
private static void TryRegisterServedModelPolicy(IChatClient? innerClient)
|
||||
{
|
||||
if (innerClient?.GetService<OpenAIRequestPolicies>() is { } policies)
|
||||
{
|
||||
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
|
||||
policies,
|
||||
ServedModelPolicy.Instance,
|
||||
PipelinePosition.PerCall);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Default OAuth scope for the Azure AI resource. Matches the scope used by <c>Azure.AI.Extensions.OpenAI</c>'s internal authentication helper so the bearer token is accepted by the Foundry control plane.</summary>
|
||||
private const string AzureAiResourceScope = "https://ai.azure.com/.default";
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline policy that captures the <c>x-ms-served-model</c> response header from Azure OpenAI
|
||||
/// and stores it in <see cref="ServedModelScope"/> for consumption by <see cref="FoundryChatClient"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Azure OpenAI Responses API returns the deployment alias in <c>response.model</c> but the actual
|
||||
/// model snapshot (e.g. <c>gpt-5-nano-2025-08-07</c>) in the <c>x-ms-served-model</c> response header.
|
||||
/// This policy extracts the header after the HTTP roundtrip so the <see cref="FoundryChatClient"/>
|
||||
/// can overwrite <c>ChatResponse.ModelId</c> with the true model name.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Registered once per <c>OpenAIRequestPolicies</c> instance via the MEAI 10.5.1 extension hook.
|
||||
/// When the header is absent (non-Azure endpoints), the scope is not set and the
|
||||
/// <see cref="FoundryChatClient"/> preserves the original model name.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ServedModelPolicy : PipelinePolicy
|
||||
{
|
||||
/// <summary>The Azure OpenAI response header that carries the actual served model name.</summary>
|
||||
internal const string ServedModelHeader = "x-ms-served-model";
|
||||
|
||||
public static ServedModelPolicy Instance { get; } = new ServedModelPolicy();
|
||||
|
||||
private ServedModelPolicy()
|
||||
{
|
||||
}
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
CaptureServedModel(message);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
CaptureServedModel(message);
|
||||
}
|
||||
|
||||
private static void CaptureServedModel(PipelineMessage message)
|
||||
{
|
||||
if (message.Response is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.Response.Headers.TryGetValue(ServedModelHeader, out string? servedModel)
|
||||
&& !string.IsNullOrWhiteSpace(servedModel))
|
||||
{
|
||||
// Write into the box (reference-type mutation) so the value is visible to the
|
||||
// FoundryChatClient that pushed the box before calling the inner client.
|
||||
if (ServedModelScope.Current is { } box)
|
||||
{
|
||||
box.Value = servedModel.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// AsyncLocal carrier that bridges the <c>x-ms-served-model</c> response header value from the
|
||||
/// <see cref="ServedModelPolicy"/> running inside the SCM transport pipeline up to the
|
||||
/// <see cref="FoundryChatClient"/> decorator.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Because <see cref="AsyncLocal{T}"/> mutations inside a child <c>async</c> method do not propagate
|
||||
/// back to the caller (copy-on-write semantics), this scope uses <see cref="StrongBox{T}"/> as an
|
||||
/// indirection layer. The <see cref="FoundryChatClient"/> pushes a fresh box onto the scope
|
||||
/// before calling the inner client; the <see cref="ServedModelPolicy"/> writes into the box's
|
||||
/// <see cref="StrongBox{T}.Value"/> (a reference-type mutation visible to anyone holding the same box).
|
||||
/// After the inner call returns, the client reads the box's value.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class ServedModelScope
|
||||
{
|
||||
private static readonly AsyncLocal<StrongBox<string?>?> s_current = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-async-flow served model box.
|
||||
/// </summary>
|
||||
public static StrongBox<string?>? Current
|
||||
{
|
||||
get => s_current.Value;
|
||||
set => s_current.Value = value;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
#if NET
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
@@ -201,6 +204,14 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
result.Tools.Add(new HostedWebSearchTool());
|
||||
}
|
||||
|
||||
#if NET
|
||||
if (options?.ShellExecutor is ShellExecutor shellExecutor)
|
||||
{
|
||||
result.Tools ??= [];
|
||||
result.Tools.Add(shellExecutor.AsAIFunction());
|
||||
}
|
||||
#endif
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -259,6 +270,13 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
}
|
||||
}
|
||||
|
||||
#if NET
|
||||
if (options?.ShellExecutor is ShellExecutor shellExecutor)
|
||||
{
|
||||
providers.Add(new ShellEnvironmentProvider(shellExecutor, options.ShellEnvironmentProviderOptions));
|
||||
}
|
||||
#endif
|
||||
|
||||
if (options?.AIContextProviders is IEnumerable<AIContextProvider> userProviders)
|
||||
{
|
||||
providers.AddRange(userProviders);
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
#if NET
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
@@ -240,4 +243,27 @@ public sealed class HarnessAgentOptions
|
||||
/// This property is ignored when <see cref="BackgroundAgents"/> is <see langword="null"/> or empty.
|
||||
/// </remarks>
|
||||
public BackgroundAgentsProviderOptions? BackgroundAgentsProviderOptions { get; set; }
|
||||
|
||||
#if NET
|
||||
/// <summary>
|
||||
/// Gets or sets the shell executor used to enable shell tool and environment probing via <see cref="ShellEnvironmentProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When non-null, a <see cref="ShellEnvironmentProvider"/> is automatically included in the agent's context
|
||||
/// providers (injecting OS/shell/CWD information into the system prompt), and the executor's
|
||||
/// <see cref="ShellExecutor.AsAIFunction"/> is registered as a callable tool.
|
||||
/// When <see langword="null"/> (the default), no shell features are enabled.
|
||||
/// </remarks>
|
||||
public ShellExecutor? ShellExecutor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets optional configuration for the <see cref="ShellEnvironmentProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use this to customize which tools are probed, the probe timeout, shell family override,
|
||||
/// or the instructions formatter.
|
||||
/// This property is ignored when <see cref="ShellExecutor"/> is <see langword="null"/>.
|
||||
/// </remarks>
|
||||
public ShellEnvironmentProviderOptions? ShellEnvironmentProviderOptions { get; set; }
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Tools.Shell\Microsoft.Agents.AI.Tools.Shell.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Harness</Title>
|
||||
|
||||
@@ -248,7 +248,7 @@ public sealed class DockerShellExecutor : ShellExecutor
|
||||
/// Build the AIFunction for this tool.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <paramref name="requireApproval"/> is <see langword="null"/>
|
||||
/// When <paramref name="requireApproval"/> is <see langword="true"/>
|
||||
/// (the default), the returned function is wrapped in
|
||||
/// <see cref="ApprovalRequiredAIFunction"/>. The caller must
|
||||
/// explicitly pass <see langword="false"/> to opt out of approval
|
||||
@@ -259,14 +259,12 @@ public sealed class DockerShellExecutor : ShellExecutor
|
||||
/// <param name="name">Function name surfaced to the model.</param>
|
||||
/// <param name="description">Function description for the model.</param>
|
||||
/// <param name="requireApproval">
|
||||
/// <see langword="true"/> or <see langword="null"/> (the default)
|
||||
/// wraps the function in <see cref="ApprovalRequiredAIFunction"/>;
|
||||
/// <see langword="true"/> (the default) wraps the function in
|
||||
/// <see cref="ApprovalRequiredAIFunction"/>;
|
||||
/// <see langword="false"/> opts out and returns the raw function.
|
||||
/// </param>
|
||||
public AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool? requireApproval = null)
|
||||
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
|
||||
{
|
||||
var effectiveRequireApproval = requireApproval ?? true;
|
||||
|
||||
description ??=
|
||||
"Execute a single shell command inside an isolated Docker container and return its " +
|
||||
"stdout, stderr, and exit code. The container has no network, no host filesystem access " +
|
||||
@@ -292,7 +290,7 @@ public sealed class DockerShellExecutor : ShellExecutor
|
||||
},
|
||||
new AIFunctionFactoryOptions { Name = name, Description = description });
|
||||
|
||||
return effectiveRequireApproval ? new ApprovalRequiredAIFunction(fn) : fn;
|
||||
return requireApproval ? new ApprovalRequiredAIFunction(fn) : fn;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -325,7 +325,7 @@ public sealed class LocalShellExecutor : ShellExecutor
|
||||
/// container where the tool itself is the boundary).
|
||||
/// </param>
|
||||
/// <returns>An <see cref="AIFunction"/> wrapping <see cref="RunAsync"/>.</returns>
|
||||
public AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
|
||||
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
|
||||
{
|
||||
if (!requireApproval && !this._acknowledgeUnsafe)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Tools.Shell;
|
||||
|
||||
@@ -65,6 +66,20 @@ public abstract class ShellExecutor : IAsyncDisposable
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public abstract Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Build an <see cref="AIFunction"/> bound to this executor, suitable for
|
||||
/// registering with an agent as a callable tool.
|
||||
/// </summary>
|
||||
/// <param name="name">Function name visible to the model.</param>
|
||||
/// <param name="description">Function description for the model.</param>
|
||||
/// <param name="requireApproval">
|
||||
/// When <see langword="true"/> (the default), wraps the function in
|
||||
/// <see cref="ApprovalRequiredAIFunction"/> so every invocation requires
|
||||
/// explicit user approval before executing.
|
||||
/// </param>
|
||||
/// <returns>An <see cref="AIFunction"/> wrapping <see cref="RunAsync"/>.</returns>
|
||||
public abstract AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true);
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract ValueTask DisposeAsync();
|
||||
}
|
||||
|
||||
+6
-2
@@ -22,9 +22,13 @@ internal static class AgentProviderExtensions
|
||||
{
|
||||
IAsyncEnumerable<AgentResponseUpdate> agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken);
|
||||
|
||||
// Enable "autoSend" behavior if this is the workflow conversation.
|
||||
// Determine whether the target conversation is the workflow conversation
|
||||
// (used below to decide whether to mirror messages into the workflow conversation
|
||||
// when an agent runs against a different conversation). The caller's autoSend
|
||||
// value is honored as-is — when the workflow.yaml specifies autoSend: false the
|
||||
// raw agent output must not be streamed to the caller, even when the agent is
|
||||
// running on the workflow conversation.
|
||||
bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId);
|
||||
autoSend |= isWorkflowConversation;
|
||||
|
||||
// Process the agent response updates.
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
|
||||
+7
@@ -71,6 +71,13 @@ internal abstract class DeclarativeActionExecutor : Executor<ActionExecutorResul
|
||||
[SendsMessage(typeof(ActionExecutorResult))]
|
||||
public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Establish the Foundry ProductContext on the current async logical context before
|
||||
// running any code that reads PropertyPath.VariableName / NamespaceAlias. ObjectModel
|
||||
// resolves those lazily against AsyncLocal<ProductContext>; when the workflow is
|
||||
// hosted (AsAIAgent + AddFoundryResponses) each HTTP request runs on a fresh logical
|
||||
// context where the build-thread setting does not flow.
|
||||
WorkflowDiagnostics.SetFoundryProduct();
|
||||
|
||||
if (this.Model.Disabled)
|
||||
{
|
||||
Debug.WriteLine($"DISABLED {this.GetType().Name} [{this.Id}]");
|
||||
|
||||
+8
-5
@@ -192,13 +192,16 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
|
||||
|
||||
private bool GetAutoSendValue()
|
||||
{
|
||||
if (this.AgentOutput?.AutoSend is null)
|
||||
// AzureAgentOutput.AutoSend is never null — it returns a literal-false default
|
||||
// when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an
|
||||
// explicit autoSend value from the implicit default, and treat the implicit
|
||||
// default as autoSend = true (the historical behavior for actions that omit
|
||||
// autoSend or have no output block at all).
|
||||
if (this.AgentOutput is { AutoSendIsDefaultValue: false } output)
|
||||
{
|
||||
return true;
|
||||
return this.Evaluator.GetValue(output.AutoSend).Value;
|
||||
}
|
||||
|
||||
EvaluationResult<bool> autoSendResult = this.Evaluator.GetValue(this.AgentOutput.AutoSend);
|
||||
|
||||
return autoSendResult.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+68
-3
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
@@ -103,6 +104,24 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
FunctionResultContent? matchingResult = functionResults
|
||||
.FirstOrDefault(r => r.CallId == this.Id);
|
||||
|
||||
// When the caller approved an approval-required function call but didn't execute it
|
||||
// locally (the hosted Foundry scenario, where mcp_approval_response is converted to a
|
||||
// ToolApprovalResponseContent only), invoke the registered AIFunction here so that the
|
||||
// declarative workflow can capture the result and continue (e.g. for downstream
|
||||
// SendActivity/PropertyPath consumers like {Local.Result}).
|
||||
if (matchingResult is null)
|
||||
{
|
||||
ToolApprovalResponseContent? approval = response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalResponseContent>()
|
||||
.FirstOrDefault(r => r.RequestId == this.Id);
|
||||
|
||||
if (approval is { Approved: true })
|
||||
{
|
||||
matchingResult = await this.InvokeRegisteredFunctionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingResult is not null)
|
||||
{
|
||||
// Store the result in output variable
|
||||
@@ -241,6 +260,48 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
return conversationIdValue.Length == 0 ? null : conversationIdValue;
|
||||
}
|
||||
|
||||
private async ValueTask<FunctionResultContent?> InvokeRegisteredFunctionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string functionName = this.GetFunctionName();
|
||||
AIFunction? function = agentProvider.Functions?.FirstOrDefault(
|
||||
f => string.Equals(f.Name, functionName, StringComparison.Ordinal));
|
||||
|
||||
if (function is null)
|
||||
{
|
||||
return new FunctionResultContent(this.Id, result: null)
|
||||
{
|
||||
Exception = new InvalidOperationException(
|
||||
$"Function '{functionName}' is not registered with the agent provider."),
|
||||
};
|
||||
}
|
||||
|
||||
Dictionary<string, object?>? arguments = this.GetArguments();
|
||||
AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments);
|
||||
|
||||
object? result;
|
||||
try
|
||||
{
|
||||
result = await function.InvokeAsync(functionArguments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return new FunctionResultContent(this.Id, result: null) { Exception = ex };
|
||||
}
|
||||
|
||||
// Match FunctionInvokingChatClient's serialization: pass strings through as-is and
|
||||
// JSON-serialize anything else so structured results remain consumable by downstream
|
||||
// PropertyPath consumers such as {Local.RefundResult}. Use AIJsonUtilities so the
|
||||
// same trim/AOT-friendly serializer chain used elsewhere in the framework is applied.
|
||||
string serialized = result switch
|
||||
{
|
||||
null => string.Empty,
|
||||
string s => s,
|
||||
_ => JsonSerializer.Serialize(result, AIJsonUtilities.DefaultOptions.GetTypeInfo(result.GetType())),
|
||||
};
|
||||
|
||||
return new FunctionResultContent(this.Id, serialized);
|
||||
}
|
||||
|
||||
private bool GetRequireApproval()
|
||||
{
|
||||
if (this.Model.RequireApproval is null)
|
||||
@@ -253,12 +314,16 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
|
||||
private bool GetAutoSendValue()
|
||||
{
|
||||
if (this.Model.Output?.AutoSend is null)
|
||||
// InvokeToolOutput.AutoSend is never null — it returns a literal-false default
|
||||
// when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an
|
||||
// explicit autoSend value from the implicit default, and treat the implicit
|
||||
// default as autoSend = true (the historical behavior).
|
||||
if (this.Model.Output is { AutoSendIsDefaultValue: false } output)
|
||||
{
|
||||
return true;
|
||||
return this.Evaluator.GetValue(output.AutoSend).Value;
|
||||
}
|
||||
|
||||
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
private Dictionary<string, object?>? GetArguments()
|
||||
|
||||
+7
-3
@@ -311,12 +311,16 @@ internal sealed class InvokeMcpToolExecutor(
|
||||
|
||||
private bool GetAutoSendValue()
|
||||
{
|
||||
if (this.Model.Output?.AutoSend is null)
|
||||
// InvokeToolOutput.AutoSend is never null — it returns a literal-false default
|
||||
// when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an
|
||||
// explicit autoSend value from the implicit default, and treat the implicit
|
||||
// default as autoSend = true (the historical behavior).
|
||||
if (this.Model.Output is { AutoSendIsDefaultValue: false } output)
|
||||
{
|
||||
return true;
|
||||
return this.Evaluator.GetValue(output.AutoSend).Value;
|
||||
}
|
||||
|
||||
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
private string? GetConnectionName()
|
||||
|
||||
+13
-4
@@ -21,12 +21,21 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt
|
||||
|
||||
await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ChatMessage message = new(ChatRole.Assistant, activityText);
|
||||
|
||||
// Emit an AgentResponseUpdateEvent so chat protocols (e.g. AsAIAgent) receive the
|
||||
// activity text as streaming chat content. This event is yielded by WorkflowSession
|
||||
// unconditionally, mirroring how AgentProviderExtensions surfaces autoSend agent
|
||||
// updates — without it, SendActivity output is dropped whenever the host runs with
|
||||
// includeWorkflowOutputsInResponse = false (the default).
|
||||
AgentResponseUpdate update = new(ChatRole.Assistant, activityText) { AuthorName = this.Id };
|
||||
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Route through YieldOutputAsync so the activity participates in the workflow's
|
||||
// output-filter pipeline. The runner currently special-cases AgentResponse to
|
||||
// produce an AgentResponseEvent identical to the one we'd build by hand, so this
|
||||
// is behavior-preserving today and forward-compatible if filtering is ever
|
||||
// applied to agent responses.
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
|
||||
// produce an AgentResponseEvent identical to the one we'd build by hand, which
|
||||
// is the gated summary surfaced only when includeWorkflowOutputsInResponse = true.
|
||||
AgentResponse response = new([message]);
|
||||
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,8 +70,6 @@ internal class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder))
|
||||
.YieldsOutput<AgentResponseUpdate>()
|
||||
.YieldsOutput<AgentResponse>()
|
||||
.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<ResetChatSignal>(this.ResetChat));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Azure.AI.Projects;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace Foundry.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests validating that the <c>x-ms-served-model</c> response header
|
||||
/// returned by the Azure OpenAI Responses API is surfaced on <see cref="ChatResponse.ModelId"/>.
|
||||
/// </summary>
|
||||
public class ResponsesAgentServedModelTests
|
||||
{
|
||||
// Matches a dated served-model snapshot, e.g. "gpt-5-nano-2025-08-07".
|
||||
private static readonly Regex s_snapshotRegex = new(@"-\d{4}-\d{2}-\d{2}$", RegexOptions.Compiled);
|
||||
|
||||
private static Uri Endpoint => new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
|
||||
|
||||
private static string DeploymentName => TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName);
|
||||
|
||||
private readonly AIProjectClient _client = new(Endpoint, TestAzureCliCredentials.CreateAzureCliCredential());
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_ReturnsServedModelSnapshotOnModelIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatClientAgent agent = this._client.AsAIAgent(
|
||||
model: DeploymentName,
|
||||
instructions: "You are a helpful assistant. Reply with a single short word.",
|
||||
name: "ServedModelTest");
|
||||
|
||||
IChatClient chatClient = agent.ChatClient;
|
||||
|
||||
// Act
|
||||
ChatResponse response = await chatClient.GetResponseAsync(
|
||||
[new ChatMessage(ChatRole.User, "Say hi.")],
|
||||
new ChatOptions { ModelId = DeploymentName });
|
||||
|
||||
// Assert
|
||||
AssertServedModel(response.ModelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AgentResponseRawRepresentationCarriesServedModelAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatClientAgent agent = this._client.AsAIAgent(
|
||||
model: DeploymentName,
|
||||
instructions: "You are a helpful assistant. Reply with a single short word.",
|
||||
name: "ServedModelTestRun");
|
||||
|
||||
// Act
|
||||
AgentResponse agentResponse = await agent.RunAsync("Say hi.");
|
||||
|
||||
// Assert
|
||||
ChatResponse? chatResponse = agentResponse.RawRepresentation as ChatResponse;
|
||||
Assert.NotNull(chatResponse);
|
||||
AssertServedModel(chatResponse!.ModelId);
|
||||
}
|
||||
|
||||
private static void AssertServedModel(string? modelId)
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(modelId), "ChatResponse.ModelId must be populated.");
|
||||
|
||||
// Primary invariant: the served-model value must look like a dated snapshot
|
||||
// (e.g. "gpt-5-nano-2025-08-07"). This is what the x-ms-served-model header carries.
|
||||
// Only when the configured deployment name itself already matches the snapshot pattern
|
||||
// do we fall back to permitting equality with the deployment alias.
|
||||
bool aliasIsSnapshot = s_snapshotRegex.IsMatch(DeploymentName);
|
||||
|
||||
if (aliasIsSnapshot)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.Matches(s_snapshotRegex, modelId!);
|
||||
Assert.NotEqual(DeploymentName, modelId);
|
||||
}
|
||||
}
|
||||
@@ -555,20 +555,20 @@ public sealed class FoundryChatClientTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region AgentFrameworkUserAgentPolicy registration + dedup
|
||||
#region AgentFrameworkUserAgentPolicy + ServedModelPolicy registration + dedup
|
||||
|
||||
[Fact]
|
||||
public void Register_AgentFrameworkUserAgentPolicy_OnUnderlyingOpenAIRequestPolicies()
|
||||
{
|
||||
// Arrange + Act: constructing a FoundryChatClient should register the
|
||||
// AgentFrameworkUserAgentPolicy on the inner chat client's OpenAIRequestPolicies.
|
||||
// AgentFrameworkUserAgentPolicy and ServedModelPolicy on the inner chat client's OpenAIRequestPolicies.
|
||||
var chatClient = new FoundryChatClient(CreateProjectClient(), "gpt-4o-mini");
|
||||
|
||||
// Assert: the inner chat client (MEAI's OpenAIResponsesChatClient) exposes
|
||||
// OpenAIRequestPolicies via GetService, and our policy is present in its entries.
|
||||
// OpenAIRequestPolicies via GetService, and both policies are present in its entries.
|
||||
var policies = chatClient.GetService<OpenAIRequestPolicies>();
|
||||
Assert.NotNull(policies);
|
||||
Assert.Equal(1, EntriesCount(policies!));
|
||||
Assert.Equal(2, EntriesCount(policies!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -576,7 +576,7 @@ public sealed class FoundryChatClientTests
|
||||
{
|
||||
// Arrange: construct via the ProjectsAgentVersion mode-2 variant, which chains via
|
||||
// :this(...) into the AgentReference ctor. If the policy registration code were
|
||||
// inadvertently called twice along the chain, we would see 2 entries.
|
||||
// inadvertently called twice along the chain, we would see more than 2 entries.
|
||||
var projectClient = CreateProjectClient();
|
||||
var agentVersion = ModelReaderWriter.Read<ProjectsAgentVersion>(
|
||||
BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!;
|
||||
@@ -585,10 +585,10 @@ public sealed class FoundryChatClientTests
|
||||
var chatClient = new FoundryChatClient(projectClient, agentVersion, baseChatOptions: null);
|
||||
|
||||
// Assert: even though the version variant funnels through the AgentReference ctor
|
||||
// via :this(...), the policy is registered exactly once on the inner pipeline.
|
||||
// via :this(...), each policy is registered exactly once on the inner pipeline.
|
||||
var policies = chatClient.GetService<OpenAIRequestPolicies>();
|
||||
Assert.NotNull(policies);
|
||||
Assert.Equal(1, EntriesCount(policies!));
|
||||
Assert.Equal(2, EntriesCount(policies!));
|
||||
Assert.Same(agentVersion, chatClient.GetService<ProjectsAgentVersion>());
|
||||
Assert.NotNull(chatClient.GetService<AgentReference>());
|
||||
}
|
||||
|
||||
+4
-1
@@ -14,11 +14,14 @@
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- FoundryEval tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
||||
<!-- Tests requiring net8.0+ (MEAI.Evaluation and some SCM pipeline APIs do not support legacy TFMs) -->
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<Compile Remove="FoundryEvalConverterTests.cs" />
|
||||
<Compile Remove="FoundryEvalsTests.cs" />
|
||||
<Compile Remove="ClientHeadersExtensionsTests.cs" />
|
||||
<Compile Remove="ServedModelTestHelpers.cs" />
|
||||
<Compile Remove="ServedModelScopeTests.cs" />
|
||||
<Compile Remove="ServedModelPolicyTests.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ServedModelPolicy"/>: the SCM pipeline policy that reads the
|
||||
/// <c>x-ms-served-model</c> response header and writes it into the active
|
||||
/// <see cref="ServedModelScope"/> box.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tests drive the policy through a real OpenAI ResponsesClient SCM pipeline against a mock
|
||||
/// HTTP handler so the policy executes in its production configuration.
|
||||
/// </remarks>
|
||||
public sealed class ServedModelPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Instance_IsSingleton()
|
||||
{
|
||||
Assert.Same(ServedModelPolicy.Instance, ServedModelPolicy.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_HeaderPresent_SetsModelIdOnResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: "gpt-5-nano-2025-08-07");
|
||||
IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler);
|
||||
|
||||
// Act
|
||||
var response = await chatClient.GetResponseAsync("hi");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_HeaderAbsent_PreservesModelIdFromBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: null);
|
||||
IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler);
|
||||
|
||||
// Act
|
||||
var response = await chatClient.GetResponseAsync("hi");
|
||||
|
||||
// Assert: ModelId is the deployment alias from the JSON body ("fake").
|
||||
Assert.Equal("fake", response.ModelId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task ProcessAsync_EmptyOrWhitespaceHeader_PreservesModelIdFromBodyAsync(string headerValue)
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: headerValue);
|
||||
IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler);
|
||||
|
||||
// Act
|
||||
var response = await chatClient.GetResponseAsync("hi");
|
||||
|
||||
// Assert: empty/whitespace header is rejected by the policy, ModelId stays as "fake".
|
||||
Assert.Equal("fake", response.ModelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_HeaderWithSurroundingWhitespace_TrimsValueAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: " gpt-5-nano-2025-08-07 ");
|
||||
IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler);
|
||||
|
||||
// Act
|
||||
var response = await chatClient.GetResponseAsync("hi");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ServedModelScope"/>: the AsyncLocal carrier that bridges the
|
||||
/// served-model value from the SCM pipeline policy up to the delegating chat client.
|
||||
/// </summary>
|
||||
public sealed class ServedModelScopeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Current_DefaultIsNull()
|
||||
{
|
||||
Assert.Null(ServedModelScope.Current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Current_SetAndGet_ReturnsBox()
|
||||
{
|
||||
// Arrange
|
||||
var previous = ServedModelScope.Current;
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var box = new StrongBox<string?>("gpt-5-nano-2025-08-07");
|
||||
ServedModelScope.Current = box;
|
||||
|
||||
// Assert
|
||||
Assert.Same(box, ServedModelScope.Current);
|
||||
Assert.Equal("gpt-5-nano-2025-08-07", ServedModelScope.Current!.Value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ServedModelScope.Current = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers and fake clients used by the served-model test suite
|
||||
/// (<see cref="ServedModelScopeTests"/>, <see cref="ServedModelPolicyTests"/>).
|
||||
/// </summary>
|
||||
internal static class ServedModelTestHelpers
|
||||
{
|
||||
public static string MinimalResponseJson() => """
|
||||
{
|
||||
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
|
||||
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
|
||||
}
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="FoundryChatClient"/> backed by a real OpenAI Responses pipeline
|
||||
/// routed through the supplied <paramref name="handler"/>. The <see cref="ServedModelPolicy"/>
|
||||
/// is registered automatically by the <see cref="FoundryChatClient"/> constructor.
|
||||
/// </summary>
|
||||
public static IChatClient CreateChatClientWithPolicy(HttpMessageHandler handler)
|
||||
{
|
||||
#pragma warning disable CA5399
|
||||
var http = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(http) });
|
||||
|
||||
return new FoundryChatClient(projectClient, "fake");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="HttpClientHandler"/> that returns a fixed response body and optionally
|
||||
/// includes the <c>x-ms-served-model</c> response header.
|
||||
/// </summary>
|
||||
public sealed class ServedModelHandler : HttpClientHandler
|
||||
{
|
||||
private readonly string _body;
|
||||
private readonly string? _servedModel;
|
||||
|
||||
public ServedModelHandler(string body, string? servedModel)
|
||||
{
|
||||
this._body = body;
|
||||
this._servedModel = servedModel;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
|
||||
if (this._servedModel is not null)
|
||||
{
|
||||
resp.Headers.Add("x-ms-served-model", this._servedModel);
|
||||
}
|
||||
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Moq;
|
||||
#if NET
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
@@ -39,6 +42,10 @@ public class HarnessAgentOptionsTests
|
||||
Assert.Null(options.AgentSkillsSource);
|
||||
Assert.Null(options.BackgroundAgents);
|
||||
Assert.Null(options.BackgroundAgentsProviderOptions);
|
||||
#if NET
|
||||
Assert.Null(options.ShellExecutor);
|
||||
Assert.Null(options.ShellEnvironmentProviderOptions);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -56,6 +63,10 @@ public class HarnessAgentOptionsTests
|
||||
var skillsSource = new Mock<AgentSkillsSource>().Object;
|
||||
var backgroundAgents = new AIAgent[] { new Mock<AIAgent>().Object };
|
||||
var backgroundAgentsOptions = new BackgroundAgentsProviderOptions();
|
||||
#if NET
|
||||
var shellExecutor = new Mock<ShellExecutor>().Object;
|
||||
var shellEnvOptions = new ShellEnvironmentProviderOptions();
|
||||
#endif
|
||||
|
||||
// Act
|
||||
var options = new HarnessAgentOptions
|
||||
@@ -83,6 +94,10 @@ public class HarnessAgentOptionsTests
|
||||
OpenTelemetrySourceName = "custom-source",
|
||||
BackgroundAgents = backgroundAgents,
|
||||
BackgroundAgentsProviderOptions = backgroundAgentsOptions,
|
||||
#if NET
|
||||
ShellExecutor = shellExecutor,
|
||||
ShellEnvironmentProviderOptions = shellEnvOptions,
|
||||
#endif
|
||||
};
|
||||
|
||||
// Assert
|
||||
@@ -111,5 +126,9 @@ public class HarnessAgentOptionsTests
|
||||
Assert.Equal("custom-source", options.OpenTelemetrySourceName);
|
||||
Assert.Same(backgroundAgents, options.BackgroundAgents);
|
||||
Assert.Same(backgroundAgentsOptions, options.BackgroundAgentsProviderOptions);
|
||||
#if NET
|
||||
Assert.Same(shellExecutor, options.ShellExecutor);
|
||||
Assert.Same(shellEnvOptions, options.ShellEnvironmentProviderOptions);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
#if NET
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
@@ -1347,4 +1350,114 @@ public class HarnessAgentTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#if NET
|
||||
#region Feature: ShellEnvironmentProvider
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ShellEnvironmentProvider is included when ShellExecutor is provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ShellEnvironmentProvider_IncludedWhenExecutorProvided()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var executorMock = new Mock<ShellExecutor>();
|
||||
executorMock.Setup(e => e.AsAIFunction(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<bool>()))
|
||||
.Returns(AIFunctionFactory.Create(() => "test", "run_shell"));
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.ShellExecutor = executorMock.Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(innerAgent?.AIContextProviders);
|
||||
Assert.Contains(innerAgent!.AIContextProviders!, p => p is ShellEnvironmentProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ShellEnvironmentProvider is not included when ShellExecutor is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ShellEnvironmentProvider_ExcludedWhenExecutorNull()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.ShellExecutor = null;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(innerAgent);
|
||||
Assert.NotNull(innerAgent!.AIContextProviders);
|
||||
Assert.DoesNotContain(innerAgent.AIContextProviders!, p => p is ShellEnvironmentProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the shell tool AIFunction is added to ChatOptions.Tools when ShellExecutor is provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ShellExecutor_ToolAddedToChatOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatOptions? capturedOptions = null;
|
||||
var chatClientMock = new Mock<IChatClient>();
|
||||
chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")));
|
||||
|
||||
var executorMock = new Mock<ShellExecutor>();
|
||||
executorMock.Setup(e => e.AsAIFunction(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<bool>()))
|
||||
.Returns(AIFunctionFactory.Create(() => "shell output", "run_shell"));
|
||||
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.DisableWebSearch = true;
|
||||
options.ShellExecutor = executorMock.Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClientMock.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
|
||||
|
||||
// Assert — the shell tool should be present
|
||||
Assert.NotNull(capturedOptions?.Tools);
|
||||
Assert.Contains(capturedOptions!.Tools!, t => t is AIFunction f && f.Name == "run_shell");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ShellEnvironmentProvider is present when ShellEnvironmentProviderOptions is also specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ShellEnvironmentProvider_PresentWhenOptionsProvided()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var executorMock = new Mock<ShellExecutor>();
|
||||
executorMock.Setup(e => e.AsAIFunction(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<bool>()))
|
||||
.Returns(AIFunctionFactory.Create(() => "test", "run_shell"));
|
||||
var envOptions = new ShellEnvironmentProviderOptions
|
||||
{
|
||||
ProbeTools = ["git", "python"],
|
||||
};
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.ShellExecutor = executorMock.Object;
|
||||
options.ShellEnvironmentProviderOptions = envOptions;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — provider should exist (options wiring is validated by the provider's behavior)
|
||||
Assert.NotNull(innerAgent?.AIContextProviders);
|
||||
Assert.Contains(innerAgent!.AIContextProviders!, p => p is ShellEnvironmentProvider);
|
||||
}
|
||||
|
||||
#endregion
|
||||
#endif
|
||||
}
|
||||
|
||||
+7
@@ -6,6 +6,7 @@ using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Tools.Shell.UnitTests;
|
||||
|
||||
@@ -226,6 +227,8 @@ public sealed class ShellEnvironmentProviderTests
|
||||
public override Task InitializeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public override Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(this.Responses.Dequeue());
|
||||
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true) =>
|
||||
throw new NotSupportedException();
|
||||
public override ValueTask DisposeAsync() => default;
|
||||
}
|
||||
|
||||
@@ -280,6 +283,8 @@ public sealed class ShellEnvironmentProviderTests
|
||||
public override Task InitializeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public override Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(this._factory(cancellationToken));
|
||||
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true) =>
|
||||
throw new NotSupportedException();
|
||||
public override ValueTask DisposeAsync() => default;
|
||||
}
|
||||
|
||||
@@ -372,6 +377,8 @@ public sealed class ShellEnvironmentProviderTests
|
||||
this.RunCount++;
|
||||
return Task.FromResult(this.NextResult);
|
||||
}
|
||||
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true) =>
|
||||
throw new NotSupportedException();
|
||||
public override ValueTask DisposeAsync() => default;
|
||||
}
|
||||
}
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.PowerFx.Types;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="AgentProviderExtensions.InvokeAgentAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class AgentProviderExtensionsTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
|
||||
{
|
||||
private const string WorkflowConversationId = "workflow-conv-id";
|
||||
private const string AgentName = "test-agent";
|
||||
|
||||
[Fact]
|
||||
public Task AutoSendFalseOnWorkflowConversationSuppressesResponseEventsAsync() =>
|
||||
this.RunAsync(autoSend: false, conversationId: WorkflowConversationId, expectResponseEvents: false);
|
||||
|
||||
[Fact]
|
||||
public Task AutoSendTrueOnWorkflowConversationEmitsResponseEventsAsync() =>
|
||||
this.RunAsync(autoSend: true, conversationId: WorkflowConversationId, expectResponseEvents: true);
|
||||
|
||||
[Fact]
|
||||
public Task AutoSendFalseOnExternalConversationSuppressesResponseEventsAsync() =>
|
||||
this.RunAsync(autoSend: false, conversationId: "other-conv-id", expectResponseEvents: false);
|
||||
|
||||
[Fact]
|
||||
public Task AutoSendTrueOnExternalConversationEmitsResponseEventsAndCopiesMessagesAsync() =>
|
||||
this.RunAsync(
|
||||
autoSend: true,
|
||||
conversationId: "other-conv-id",
|
||||
expectResponseEvents: true,
|
||||
expectCrossConversationCopy: true);
|
||||
|
||||
private async Task RunAsync(
|
||||
bool autoSend,
|
||||
string conversationId,
|
||||
bool expectResponseEvents,
|
||||
bool expectCrossConversationCopy = false)
|
||||
{
|
||||
// Arrange: seed the workflow conversation id so IsWorkflowConversation can recognize it.
|
||||
this.State.Set(
|
||||
SystemScope.Names.ConversationId,
|
||||
FormulaValue.New(WorkflowConversationId),
|
||||
VariableScopeNames.System);
|
||||
|
||||
MockAgentProvider mockProvider = new();
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new(ChatRole.Assistant, "hello "),
|
||||
new(ChatRole.Assistant, "world"),
|
||||
];
|
||||
mockProvider
|
||||
.Setup(p => p.InvokeAgentAsync(
|
||||
AgentName,
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<IEnumerable<ChatMessage>?>(),
|
||||
It.IsAny<IDictionary<string, object?>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(updates));
|
||||
|
||||
List<(string ConversationId, ChatMessage Message)> copiedMessages = [];
|
||||
mockProvider
|
||||
.Setup(p => p.CreateMessageAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<ChatMessage>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns<string, ChatMessage, CancellationToken>(
|
||||
(convId, msg, _) =>
|
||||
{
|
||||
copiedMessages.Add((convId, msg));
|
||||
return Task.FromResult(msg);
|
||||
});
|
||||
|
||||
string actionId = this.CreateActionId().Value;
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events =
|
||||
await this.ExecuteAsync(
|
||||
actionId,
|
||||
async (IWorkflowContext context, ActionExecutorResult _, CancellationToken cancellationToken) =>
|
||||
{
|
||||
await mockProvider.Object.InvokeAgentAsync(
|
||||
actionId,
|
||||
context,
|
||||
AgentName,
|
||||
conversationId,
|
||||
autoSend,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
// Assert
|
||||
int updateEventCount = events.OfType<AgentResponseUpdateEvent>().Count();
|
||||
int responseEventCount = events.OfType<AgentResponseEvent>().Count();
|
||||
|
||||
if (expectResponseEvents)
|
||||
{
|
||||
Assert.Equal(updates.Length, updateEventCount);
|
||||
Assert.Equal(1, responseEventCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(0, updateEventCount);
|
||||
Assert.Equal(0, responseEventCount);
|
||||
}
|
||||
|
||||
if (expectCrossConversationCopy)
|
||||
{
|
||||
Assert.NotEmpty(copiedMessages);
|
||||
Assert.All(copiedMessages, c => Assert.Equal(WorkflowConversationId, c.ConversationId));
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Empty(copiedMessages);
|
||||
}
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<AgentResponseUpdate> ToAsyncEnumerableAsync(IEnumerable<AgentResponseUpdate> updates)
|
||||
{
|
||||
foreach (AgentResponseUpdate update in updates)
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,21 @@ When making changes to a package, check if the following need updates:
|
||||
- The package's `AGENTS.md` file (adding/removing/renaming public APIs, architecture changes, import path changes)
|
||||
- The agent skills in `.github/skills/` if conventions, commands, or workflows change
|
||||
|
||||
At the end of every run, re-read `AGENTS.md` and the relevant skill files and
|
||||
update any guidance that the conversation revealed to be out of date,
|
||||
incomplete, or misleading (renamed files, changed commands, new conventions
|
||||
the user confirmed, etc.). **Before adding a new principle or rule, ask the
|
||||
user whether they want it captured as a durable principle** — do not invent
|
||||
team norms from a single conversation without explicit confirmation.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Avoid "GA" for Agent Framework code.** Reserve *GA* for hosted services
|
||||
(e.g. "the Foundry service is GA"). For Agent Framework packages, features,
|
||||
and APIs use **"released"** or **"stable"** depending on context — these
|
||||
match the feature-lifecycle stages documented in the
|
||||
`python-feature-lifecycle` skill.
|
||||
|
||||
## Pull Request Description Guidance
|
||||
|
||||
When preparing a PR description:
|
||||
|
||||
+18
-1
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.6.0] - 2026-05-21
|
||||
|
||||
### Added
|
||||
- **agent-framework-core**: Shell tool with support for local and Docker execution ([#5664](https://github.com/microsoft/agent-framework/pull/5664))
|
||||
- **agent-framework-monty**: New Monty-backed CodeAct provider package ([#5915](https://github.com/microsoft/agent-framework/pull/5915))
|
||||
- **agent-framework-foundry**: Add experimental hosted tool factories on `FoundryChatClient` ([#5958](https://github.com/microsoft/agent-framework/pull/5958))
|
||||
- **agent-framework-foundry**: Include tool definitions for Foundry agent evals ([#5974](https://github.com/microsoft/agent-framework/pull/5974))
|
||||
- **agent-framework-a2a**: Use non-streaming transport and `return_immediately` for background ops ([#5963](https://github.com/microsoft/agent-framework/pull/5963))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-core**, **agent-framework-foundry**: [BREAKING] Enable instrumentation by default ([#5865](https://github.com/microsoft/agent-framework/pull/5865))
|
||||
- **agent-framework-foundry**: Show more authentication methods in Foundry Toolbox MCP ([#5719](https://github.com/microsoft/agent-framework/pull/5719))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-core**: Skip MCP prompt loading when unsupported ([#5370](https://github.com/microsoft/agent-framework/pull/5370))
|
||||
|
||||
## [1.5.0] - 2026-05-19
|
||||
|
||||
### Added
|
||||
@@ -1088,7 +1104,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.6.0...HEAD
|
||||
[1.6.0]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...python-1.6.0
|
||||
[1.5.0]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...python-1.5.0
|
||||
[1.4.0]: https://github.com/microsoft/agent-framework/compare/python-1.3.0...python-1.4.0
|
||||
[1.3.0]: https://github.com/microsoft/agent-framework/compare/python-1.2.2...python-1.3.0
|
||||
|
||||
@@ -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` | `beta` |
|
||||
| `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` |
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"a2a-sdk>=1.0.0,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0rc2"
|
||||
version = "1.0.0rc3"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"ag-ui-protocol>=0.1.16,<0.2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<1"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"anthropic>=0.80.0,<0.80.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"azure-search-documents>=11.7.0b2,<11.7.0b3",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Content Understanding integration for Microsoft Agent Frame
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260519"
|
||||
version = "1.0.0a260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-foundry>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-foundry>=1.6.0,<2",
|
||||
"azure-ai-contentunderstanding>=1.0.1,<1.1",
|
||||
"aiohttp>=3.9,<4",
|
||||
"filetype>=1.2,<2",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"azure-cosmos>=4.3.0,<5",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,8 +22,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-durabletask>=1.0.0b260519,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-durabletask>=1.0.0b260521,<2",
|
||||
"azure-functions>=1.24.0,<2",
|
||||
"azure-functions-durable>=1.3.1,<2",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"boto3>=1.35.0,<2.0.0",
|
||||
"botocore>=1.35.0,<2.0.0",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"openai-chatkit>=1.4.1,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"claude-agent-sdk>=0.1.36,<0.1.49",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -2153,15 +2153,14 @@ def _capture_messages(
|
||||
finish_reason: FinishReason | None = None,
|
||||
) -> None:
|
||||
"""Log messages with extra information."""
|
||||
from ._types import normalize_messages, prepend_instructions_to_messages
|
||||
from ._types import normalize_messages
|
||||
|
||||
prepped = prepend_instructions_to_messages(normalize_messages(messages), system_instructions)
|
||||
normalized_messages = normalize_messages(messages)
|
||||
otel_messages: list[dict[str, Any]] = []
|
||||
for index, message in enumerate(prepped):
|
||||
for index, message in enumerate(normalized_messages):
|
||||
# Reuse the otel message representation for logging instead of calling to_dict()
|
||||
# to avoid expensive Pydantic serialization overhead
|
||||
otel_message = _to_otel_message(message)
|
||||
otel_messages.append(otel_message)
|
||||
logger.info(
|
||||
otel_message,
|
||||
extra={
|
||||
@@ -2170,6 +2169,7 @@ def _capture_messages(
|
||||
MessageListTimestampFilter.INDEX_KEY: index,
|
||||
},
|
||||
)
|
||||
otel_messages.append(otel_message)
|
||||
if finish_reason:
|
||||
otel_messages[-1]["finish_reason"] = FINISH_REASON_MAP[finish_reason]
|
||||
span.set_attribute(
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.5.0"
|
||||
version = "1.6.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -290,9 +290,9 @@ async def test_chat_client_observability_with_instructions(
|
||||
assert len(system_instructions) == 1
|
||||
assert system_instructions[0]["content"] == "You are a helpful assistant."
|
||||
|
||||
# Verify input_messages contains system message
|
||||
# Verify input_messages excludes system instructions
|
||||
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert any(msg.get("role") == "system" for msg in input_messages)
|
||||
assert [msg.get("role") for msg in input_messages] == ["user"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
@@ -324,6 +324,40 @@ async def test_chat_client_streaming_observability_with_instructions(
|
||||
assert len(system_instructions) == 1
|
||||
assert system_instructions[0]["content"] == "You are a helpful assistant."
|
||||
|
||||
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert [msg.get("role") for msg in input_messages] == ["user"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_chat_client_observability_with_system_message_and_instructions(
|
||||
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""Test input chat-history system messages stay in input_messages when instructions are separate."""
|
||||
import json
|
||||
|
||||
client = mock_chat_client()
|
||||
|
||||
messages = [
|
||||
Message(role="system", contents=["Original system message"]),
|
||||
Message(role="user", contents=["Test message"]),
|
||||
]
|
||||
options = {"model": "Test", "instructions": "Framework system instruction"}
|
||||
span_exporter.clear()
|
||||
response = await client.get_response(messages=messages, options=options)
|
||||
|
||||
assert response is not None
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
span = spans[0]
|
||||
|
||||
system_instructions = json.loads(span.attributes[OtelAttr.SYSTEM_INSTRUCTIONS])
|
||||
assert system_instructions == [{"type": "text", "content": "Framework system instruction"}]
|
||||
|
||||
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert [msg.get("role") for msg in input_messages] == ["system", "user"]
|
||||
assert input_messages[0]["parts"][0]["content"] == "Original system message"
|
||||
assert input_messages[1]["parts"][0]["content"] == "Test message"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_chat_client_observability_without_instructions(
|
||||
@@ -2981,6 +3015,81 @@ async def test_system_instructions_preserves_non_ascii_characters(span_exporter:
|
||||
system_instructions = json.loads(system_instructions_json)
|
||||
assert system_instructions[0]["content"] == chinese_text
|
||||
|
||||
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert [msg.get("role") for msg in input_messages] == ["user"]
|
||||
|
||||
|
||||
def test_capture_messages_keeps_framework_instructions_out_of_logs_and_span_messages(
|
||||
span_exporter: InMemorySpanExporter,
|
||||
):
|
||||
"""Test separate framework instructions do not appear in chat-history logs or span messages."""
|
||||
import json
|
||||
|
||||
from opentelemetry import trace
|
||||
|
||||
tracer = trace.get_tracer("test")
|
||||
span_exporter.clear()
|
||||
|
||||
with (
|
||||
patch("agent_framework.observability.logger.info") as mock_logger_info,
|
||||
tracer.start_as_current_span("test_span") as span,
|
||||
):
|
||||
_capture_messages(
|
||||
span=span,
|
||||
provider_name="test_provider",
|
||||
messages=[Message(role="user", contents=["Test"])],
|
||||
system_instructions="Framework system instruction",
|
||||
)
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
input_messages = json.loads(spans[0].attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert [msg.get("role") for msg in input_messages] == ["user"]
|
||||
|
||||
assert mock_logger_info.call_count == 1, f"Expected 1 log call, got {mock_logger_info.call_count}"
|
||||
(first_call,) = mock_logger_info.call_args_list
|
||||
assert first_call.args
|
||||
logged_message = first_call.args[0]
|
||||
assert logged_message["role"] == "user"
|
||||
assert logged_message["parts"][0]["content"] == "Test"
|
||||
|
||||
|
||||
def test_capture_messages_logs_only_chat_history_when_framework_instructions_are_separate(
|
||||
span_exporter: InMemorySpanExporter,
|
||||
):
|
||||
"""Test chat-history logging preserves original system messages without prepending framework instructions."""
|
||||
import json
|
||||
|
||||
from opentelemetry import trace
|
||||
|
||||
tracer = trace.get_tracer("test")
|
||||
span_exporter.clear()
|
||||
|
||||
with (
|
||||
patch("agent_framework.observability.logger.info") as mock_logger_info,
|
||||
tracer.start_as_current_span("test_span") as span,
|
||||
):
|
||||
_capture_messages(
|
||||
span=span,
|
||||
provider_name="test_provider",
|
||||
messages=[
|
||||
Message(role="system", contents=["Original system message"]),
|
||||
Message(role="user", contents=["Test"]),
|
||||
],
|
||||
system_instructions="Framework system instruction",
|
||||
)
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
input_messages = json.loads(spans[0].attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert [msg.get("role") for msg in input_messages] == ["system", "user"]
|
||||
|
||||
assert mock_logger_info.call_count == 2, f"Expected 2 log calls, got {mock_logger_info.call_count}"
|
||||
logged_messages = [call.args[0] for call in mock_logger_info.call_args_list]
|
||||
assert [msg["role"] for msg in logged_messages] == ["system", "user"]
|
||||
assert logged_messages[0]["parts"][0]["content"] == "Original system message"
|
||||
assert logged_messages[1]["parts"][0]["content"] == "Test"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_tool_arguments_preserves_non_ascii_characters(span_exporter: InMemorySpanExporter):
|
||||
@@ -3104,6 +3213,40 @@ async def test_agent_instructions_from_default_options(
|
||||
assert len(system_instructions) == 1
|
||||
assert system_instructions[0]["content"] == "Default system instructions."
|
||||
|
||||
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert [msg.get("role") for msg in input_messages] == ["user"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_agent_instructions_preserve_system_messages_in_history(
|
||||
mock_chat_agent, span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""Test agent spans keep chat-history system messages separate from framework instructions."""
|
||||
import json
|
||||
|
||||
agent = mock_chat_agent()
|
||||
agent.default_options = {"model": "TestModel", "instructions": "Default system instructions."}
|
||||
|
||||
messages = [
|
||||
Message(role="system", contents=["Original system message"]),
|
||||
Message(role="user", contents=["Test message"]),
|
||||
]
|
||||
span_exporter.clear()
|
||||
response = await agent.run(messages)
|
||||
|
||||
assert response is not None
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
span = spans[0]
|
||||
|
||||
system_instructions = json.loads(span.attributes[OtelAttr.SYSTEM_INSTRUCTIONS])
|
||||
assert system_instructions == [{"type": "text", "content": "Default system instructions."}]
|
||||
|
||||
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert [msg.get("role") for msg in input_messages] == ["system", "user"]
|
||||
assert input_messages[0]["parts"][0]["content"] == "Original system message"
|
||||
assert input_messages[1]["parts"][0]["content"] == "Test message"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_agent_instructions_from_options_override(
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"httpx>=0.27,<1",
|
||||
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
"opentelemetry-sdk>=1.39.0,<2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"durabletask>=1.4.0,!=1.4.1,!=1.4.2,!=1.4.3,<2",
|
||||
"durabletask-azuremanaged>=1.4.0,<2",
|
||||
"python-dateutil>=2.8.0,<3",
|
||||
|
||||
@@ -75,6 +75,15 @@ _TOOL_EVALUATORS: set[str] = {
|
||||
"builtin.tool_call_success",
|
||||
}
|
||||
|
||||
# Evaluators that accept tool_definitions in their data mapping when the
|
||||
# evaluated items include tools.
|
||||
_TOOL_DEFINITION_EVALUATORS: set[str] = _TOOL_EVALUATORS | {
|
||||
"builtin.intent_resolution",
|
||||
"builtin.task_adherence",
|
||||
"builtin.task_completion",
|
||||
"builtin.task_navigation_efficiency",
|
||||
}
|
||||
|
||||
# Evaluators that require a ground_truth / expected_output field.
|
||||
_GROUND_TRUTH_EVALUATORS: set[str] = {
|
||||
"builtin.similarity",
|
||||
@@ -161,6 +170,7 @@ def _build_testing_criteria(
|
||||
model: str,
|
||||
*,
|
||||
include_data_mapping: bool = False,
|
||||
include_tool_definitions: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build ``testing_criteria`` for ``evals.create()``.
|
||||
|
||||
@@ -169,6 +179,8 @@ def _build_testing_criteria(
|
||||
model: Model deployment for the LLM judge.
|
||||
include_data_mapping: Whether to include field-level data mapping
|
||||
(required for the JSONL data source, not needed for response-based).
|
||||
include_tool_definitions: Whether the mapped data items include tool
|
||||
definitions.
|
||||
"""
|
||||
criteria: list[dict[str, Any]] = []
|
||||
for name in evaluators:
|
||||
@@ -203,7 +215,7 @@ def _build_testing_criteria(
|
||||
mapping["context"] = "{{item.context}}"
|
||||
if qualified in _GROUND_TRUTH_EVALUATORS:
|
||||
mapping["ground_truth"] = "{{item.ground_truth}}"
|
||||
if qualified in _TOOL_EVALUATORS:
|
||||
if include_tool_definitions and qualified in _TOOL_DEFINITION_EVALUATORS:
|
||||
mapping["tool_definitions"] = "{{item.tool_definitions}}"
|
||||
entry["data_mapping"] = mapping
|
||||
|
||||
@@ -713,6 +725,7 @@ class FoundryEvals:
|
||||
evaluators,
|
||||
self._model,
|
||||
include_data_mapping=True,
|
||||
include_tool_definitions=has_tools,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.5.0"
|
||||
version = "1.6.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-openai>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-openai>=1.6.0,<2",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"azure-ai-projects>=2.1.0,<3.0",
|
||||
]
|
||||
|
||||
@@ -745,7 +745,12 @@ class TestBuildTestingCriteria:
|
||||
assert "conversation" not in criteria[1]["data_mapping"]
|
||||
|
||||
def test_tool_evaluator_includes_tool_definitions(self) -> None:
|
||||
criteria = _build_testing_criteria(["relevance", "tool_call_accuracy"], "gpt-4o", include_data_mapping=True)
|
||||
criteria = _build_testing_criteria(
|
||||
["relevance", "tool_call_accuracy"],
|
||||
"gpt-4o",
|
||||
include_data_mapping=True,
|
||||
include_tool_definitions=True,
|
||||
)
|
||||
# relevance: string query/response
|
||||
assert criteria[0]["data_mapping"]["query"] == "{{item.query}}"
|
||||
assert criteria[0]["data_mapping"]["response"] == "{{item.response}}"
|
||||
@@ -762,6 +767,17 @@ class TestBuildTestingCriteria:
|
||||
assert c["data_mapping"]["query"] == "{{item.query_messages}}", f"{c['name']}"
|
||||
assert c["data_mapping"]["response"] == "{{item.response_messages}}", f"{c['name']}"
|
||||
|
||||
def test_agent_evaluators_include_tool_definitions_when_tools_present(self) -> None:
|
||||
agent_evals = ["task_adherence", "intent_resolution", "task_completion", "task_navigation_efficiency"]
|
||||
criteria = _build_testing_criteria(
|
||||
agent_evals,
|
||||
"gpt-4o",
|
||||
include_data_mapping=True,
|
||||
include_tool_definitions=True,
|
||||
)
|
||||
for c in criteria:
|
||||
assert c["data_mapping"]["tool_definitions"] == "{{item.tool_definitions}}", f"{c['name']}"
|
||||
|
||||
def test_quality_evaluators_use_strings(self) -> None:
|
||||
quality_evals = ["coherence", "relevance", "fluency"]
|
||||
criteria = _build_testing_criteria(quality_evals, "gpt-4o", include_data_mapping=True)
|
||||
@@ -781,7 +797,12 @@ class TestBuildTestingCriteria:
|
||||
"tool_output_utilization",
|
||||
"tool_call_success",
|
||||
]
|
||||
criteria = _build_testing_criteria(tool_evals, "gpt-4o", include_data_mapping=True)
|
||||
criteria = _build_testing_criteria(
|
||||
tool_evals,
|
||||
"gpt-4o",
|
||||
include_data_mapping=True,
|
||||
include_tool_definitions=True,
|
||||
)
|
||||
for c in criteria:
|
||||
assert "tool_definitions" in c["data_mapping"], f"{c['name']} missing tool_definitions"
|
||||
|
||||
|
||||
@@ -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 @@ try:
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -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_<uuid>`` 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,
|
||||
"<set>" if isolation else "<None>",
|
||||
)
|
||||
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}_<partitionKey><entropy>``
|
||||
# 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",
|
||||
]
|
||||
@@ -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_<uuid hex>`` 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}_<partitionKey><entropy>``,
|
||||
or ``None`` when *item* is an unrecognised / reference-only type
|
||||
(mirrors the SDK helper's contract).
|
||||
"""
|
||||
return IdGenerator.new_item_id(item, response_id)
|
||||
@@ -3,17 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Generator
|
||||
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
|
||||
from typing import Protocol, cast
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from agent_framework import (
|
||||
ChatOptions,
|
||||
@@ -21,7 +19,6 @@ from agent_framework import (
|
||||
ContextProvider,
|
||||
FileCheckpointStorage,
|
||||
HistoryProvider,
|
||||
Message,
|
||||
RawAgent,
|
||||
SupportsAgentRun,
|
||||
WorkflowAgent,
|
||||
@@ -32,77 +29,10 @@ from azure.ai.agentserver.responses import (
|
||||
ResponseEventStream,
|
||||
ResponseProviderProtocol,
|
||||
ResponsesServerOptions,
|
||||
models,
|
||||
)
|
||||
from azure.ai.agentserver.responses._id_generator import IdGenerator
|
||||
from azure.ai.agentserver.responses.hosting import ResponsesAgentServerHost
|
||||
from azure.ai.agentserver.responses.models import (
|
||||
ApplyPatchToolCallItemParam,
|
||||
ApplyPatchToolCallOutputItemParam,
|
||||
ComputerCallOutputItemParam,
|
||||
ComputerScreenshotContent,
|
||||
CreateResponse,
|
||||
FunctionCallOutputItemParam,
|
||||
FunctionShellAction,
|
||||
FunctionShellCallItemParam,
|
||||
FunctionShellCallOutputContent,
|
||||
FunctionShellCallOutputExitOutcome,
|
||||
FunctionShellCallOutputItemParam,
|
||||
Item,
|
||||
ItemCodeInterpreterToolCall,
|
||||
ItemComputerToolCall,
|
||||
ItemCustomToolCall,
|
||||
ItemCustomToolCallOutput,
|
||||
ItemFileSearchToolCall,
|
||||
ItemFunctionToolCall,
|
||||
ItemImageGenToolCall,
|
||||
ItemLocalShellToolCall,
|
||||
ItemLocalShellToolCallOutput,
|
||||
ItemMcpApprovalRequest,
|
||||
ItemMcpToolCall,
|
||||
ItemMessage,
|
||||
ItemOutputMessage,
|
||||
ItemReasoningItem,
|
||||
ItemWebSearchToolCall,
|
||||
LocalEnvironmentResource,
|
||||
MCPApprovalResponse,
|
||||
MessageContent,
|
||||
MessageContentInputFileContent,
|
||||
MessageContentInputImageContent,
|
||||
MessageContentInputTextContent,
|
||||
MessageContentOutputTextContent,
|
||||
MessageContentReasoningTextContent,
|
||||
MessageContentRefusalContent,
|
||||
OAuthConsentRequestOutputItem,
|
||||
OutputItem,
|
||||
OutputItemApplyPatchToolCall,
|
||||
OutputItemApplyPatchToolCallOutput,
|
||||
OutputItemCodeInterpreterToolCall,
|
||||
OutputItemComputerToolCall,
|
||||
OutputItemComputerToolCallOutputResource,
|
||||
OutputItemCustomToolCall,
|
||||
OutputItemCustomToolCallOutput,
|
||||
OutputItemFileSearchToolCall,
|
||||
OutputItemFunctionShellCall,
|
||||
OutputItemFunctionShellCallOutput,
|
||||
OutputItemFunctionToolCall,
|
||||
OutputItemImageGenToolCall,
|
||||
OutputItemLocalShellToolCall,
|
||||
OutputItemLocalShellToolCallOutput,
|
||||
OutputItemMcpApprovalRequest,
|
||||
OutputItemMcpApprovalResponseResource,
|
||||
OutputItemMcpToolCall,
|
||||
OutputItemMessage,
|
||||
OutputItemOutputMessage,
|
||||
OutputItemReasoningItem,
|
||||
OutputItemWebSearchToolCall,
|
||||
OutputMessageContent,
|
||||
OutputMessageContentOutputTextContent,
|
||||
OutputMessageContentRefusalContent,
|
||||
ResponseStreamEvent,
|
||||
StructuredOutputsOutputItem,
|
||||
SummaryTextContent,
|
||||
TextContent,
|
||||
)
|
||||
from azure.ai.agentserver.responses.streaming._builders import (
|
||||
OutputItemFunctionCallBuilder,
|
||||
OutputItemMcpCallBuilder,
|
||||
@@ -114,22 +44,45 @@ from azure.ai.agentserver.responses.streaming._builders import (
|
||||
from mcp import McpError
|
||||
from typing_extensions import Any
|
||||
|
||||
from ._shared import (
|
||||
ApprovalStorage,
|
||||
_arguments_to_str, # pyright: ignore[reportPrivateUsage]
|
||||
_convert_message_content, # pyright: ignore[reportPrivateUsage]
|
||||
_convert_output_message_content, # pyright: ignore[reportPrivateUsage]
|
||||
_item_to_message, # pyright: ignore[reportPrivateUsage]
|
||||
_items_to_messages, # pyright: ignore[reportPrivateUsage]
|
||||
_output_item_to_message, # pyright: ignore[reportPrivateUsage]
|
||||
_output_items_to_messages, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
# Re-export the conversion helpers under their historical names so existing
|
||||
# tests (which import them from this module) keep working — the canonical
|
||||
# definitions now live in :mod:`._shared`.
|
||||
__all__ = (
|
||||
"ApprovalStorage",
|
||||
"_arguments_to_str",
|
||||
"_convert_message_content",
|
||||
"_convert_output_message_content",
|
||||
"_item_to_message",
|
||||
"_items_to_messages",
|
||||
"_output_item_to_message",
|
||||
"_output_items_to_messages",
|
||||
)
|
||||
|
||||
# Local aliases for the agent-server SDK types this module touches at the
|
||||
# Python type-annotation layer. Using ``models.X`` everywhere would work but
|
||||
# would noisily clutter type-only positions where the alias adds no value.
|
||||
CreateResponse = models.CreateResponse
|
||||
ResponseStreamEvent = models.ResponseStreamEvent
|
||||
FunctionShellAction = models.FunctionShellAction
|
||||
FunctionShellCallOutputContent = models.FunctionShellCallOutputContent
|
||||
FunctionShellCallOutputExitOutcome = models.FunctionShellCallOutputExitOutcome
|
||||
LocalEnvironmentResource = models.LocalEnvironmentResource
|
||||
OAuthConsentRequestOutputItem = models.OAuthConsentRequestOutputItem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# region Approval Storage
|
||||
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."""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryFunctionApprovalStorage:
|
||||
"""An in-memory storage for function approval requests."""
|
||||
|
||||
@@ -515,7 +468,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
by the hosting infrastructure or files will be preserved upon deactivation.
|
||||
"""
|
||||
input_items = await context.get_input_items()
|
||||
input_messages = await _items_to_messages(input_items)
|
||||
input_messages = await _items_to_messages(input_items, approval_storage=self._approval_storage)
|
||||
is_streaming_request = request.stream is not None and request.stream is True
|
||||
|
||||
_, are_options_set = _to_chat_options(request)
|
||||
@@ -563,9 +516,9 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
# conversation_id when set). When conversation_id is set, this
|
||||
# matches restore_storage; when only previous_response_id was
|
||||
# supplied, restore_storage points at the *prior* response's
|
||||
# directory and write_storage points at the *current* response's.
|
||||
# directory and checkpoint_storage points at the *current* response's.
|
||||
write_context_id = context.conversation_id or context.response_id
|
||||
write_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, write_context_id)
|
||||
checkpoint_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, write_context_id)
|
||||
|
||||
# Multi-turn pattern: when we have a prior checkpoint, restore it
|
||||
# first (drive the workflow back to idle with prior state intact),
|
||||
@@ -584,6 +537,8 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
# items (carried as FunctionResult/FunctionApprovalResponse content)
|
||||
# that fulfill them via :meth:`WorkflowAgent._process_pending_requests`.
|
||||
if latest_checkpoint_id is not None:
|
||||
if restore_storage is None: # pragma: no cover - defensive
|
||||
raise RuntimeError("Checkpoint restore storage is not configured.")
|
||||
if is_streaming_request:
|
||||
async for _ in self._agent.run(
|
||||
stream=True,
|
||||
@@ -605,19 +560,19 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
yield response_event_stream.emit_in_progress()
|
||||
|
||||
if not is_streaming_request:
|
||||
# Run the agent in non-streaming mode with the new user input.
|
||||
response = await self._agent.run(
|
||||
input_messages,
|
||||
stream=False,
|
||||
checkpoint_storage=write_storage,
|
||||
)
|
||||
# Run the agent in non-streaming mode
|
||||
response = await self._agent.run(input_messages, stream=False, checkpoint_storage=checkpoint_storage)
|
||||
|
||||
for message in response.messages:
|
||||
for content in message.contents:
|
||||
async for item in _to_outputs(response_event_stream, content):
|
||||
async for item in _to_outputs(
|
||||
response_event_stream,
|
||||
content,
|
||||
approval_storage=self._approval_storage,
|
||||
):
|
||||
yield item
|
||||
|
||||
await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name)
|
||||
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
|
||||
yield response_event_stream.emit_completed()
|
||||
return
|
||||
|
||||
@@ -625,17 +580,17 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
# lazily created on matching content, closed when a different type arrives.
|
||||
tracker = _OutputItemTracker(response_event_stream)
|
||||
|
||||
# Run the workflow agent in streaming mode with the new user input.
|
||||
async for update in self._agent.run(
|
||||
input_messages,
|
||||
stream=True,
|
||||
checkpoint_storage=write_storage,
|
||||
):
|
||||
# Run the workflow agent in streaming mode
|
||||
async for update in self._agent.run(input_messages, stream=True, checkpoint_storage=checkpoint_storage):
|
||||
for content in update.contents:
|
||||
for event in tracker.handle(content):
|
||||
yield event
|
||||
if tracker.needs_async:
|
||||
async for item in _to_outputs(response_event_stream, content):
|
||||
async for item in _to_outputs(
|
||||
response_event_stream,
|
||||
content,
|
||||
approval_storage=self._approval_storage,
|
||||
):
|
||||
yield item
|
||||
tracker.needs_async = False
|
||||
|
||||
@@ -643,7 +598,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
for event in tracker.close():
|
||||
yield event
|
||||
|
||||
await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name)
|
||||
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
|
||||
yield response_event_stream.emit_completed()
|
||||
|
||||
@staticmethod
|
||||
@@ -846,681 +801,6 @@ def _to_chat_options(request: CreateResponse) -> tuple[ChatOptions, bool]:
|
||||
# endregion
|
||||
|
||||
|
||||
# region Input Message Conversion
|
||||
|
||||
|
||||
async def _items_to_messages(
|
||||
input_items: Sequence[Item], *, approval_storage: ApprovalStorage | None = None
|
||||
) -> list[Message]:
|
||||
"""Converts a sequence of input items to a list of Messages, one per item.
|
||||
|
||||
Args:
|
||||
input_items: The input items to convert.
|
||||
approval_storage: An optional ApprovalStorage instance used to look up
|
||||
approval requests when converting MCP approval response items.
|
||||
|
||||
Returns:
|
||||
A list of Messages, one per supported input item.
|
||||
"""
|
||||
messages: list[Message] = []
|
||||
for item in input_items:
|
||||
messages.append(await _item_to_message(item, approval_storage=approval_storage))
|
||||
return messages
|
||||
|
||||
|
||||
async def _item_to_message(item: Item, *, approval_storage: ApprovalStorage | None = None) -> Message:
|
||||
"""Converts an Item to a Message.
|
||||
|
||||
Args:
|
||||
item: The Item to convert.
|
||||
approval_storage: An optional ApprovalStorage instance used to look up
|
||||
approval requests when converting MCP approval response items.
|
||||
|
||||
Returns:
|
||||
The converted Message.
|
||||
|
||||
Raises:
|
||||
ValueError: If the Item type is not supported.
|
||||
"""
|
||||
if item.type == "message":
|
||||
msg = cast(ItemMessage, item)
|
||||
if isinstance(msg.content, str):
|
||||
return Message(role=msg.role, contents=[Content.from_text(msg.content)])
|
||||
return Message(role=msg.role, contents=[_convert_message_content(part) for part in msg.content])
|
||||
|
||||
if item.type == "output_message":
|
||||
output_msg = cast(ItemOutputMessage, item)
|
||||
return Message(
|
||||
role=output_msg.role, contents=[_convert_output_message_content(part) for part in output_msg.content]
|
||||
)
|
||||
|
||||
if item.type == "function_call":
|
||||
fc = cast(ItemFunctionToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(fc.call_id, fc.name, arguments=fc.arguments)],
|
||||
)
|
||||
|
||||
if item.type == "function_call_output":
|
||||
fco = cast(FunctionCallOutputItemParam, item)
|
||||
output = fco.output if isinstance(fco.output, str) else str(fco.output)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(fco.call_id, result=output)],
|
||||
)
|
||||
|
||||
if item.type == "reasoning":
|
||||
reasoning = cast(ItemReasoningItem, item)
|
||||
reason_contents: list[Content] = []
|
||||
if reasoning.summary:
|
||||
for summary in reasoning.summary:
|
||||
reason_contents.append(Content.from_text(summary.text))
|
||||
return Message(role="assistant", contents=reason_contents)
|
||||
|
||||
if item.type == "mcp_call":
|
||||
mcp = cast(ItemMcpToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_call(
|
||||
mcp.id,
|
||||
mcp.name,
|
||||
server_name=mcp.server_label,
|
||||
arguments=mcp.arguments,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "mcp_approval_request":
|
||||
mcp_req = cast(ItemMcpApprovalRequest, item)
|
||||
if approval_storage is not None:
|
||||
function_approval_request_content = await approval_storage.load_approval_request(mcp_req.id)
|
||||
else:
|
||||
raise ValueError("ApprovalStorage is required to load approval request.")
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[function_approval_request_content],
|
||||
)
|
||||
|
||||
if item.type == "mcp_approval_response":
|
||||
mcp_resp = cast(MCPApprovalResponse, item)
|
||||
if approval_storage is not None:
|
||||
function_approval_request_content = await approval_storage.load_approval_request(
|
||||
mcp_resp.approval_request_id
|
||||
)
|
||||
else:
|
||||
raise ValueError("ApprovalStorage is required to load approval request.")
|
||||
return Message(
|
||||
role="user",
|
||||
contents=[function_approval_request_content.to_function_approval_response(mcp_resp.approve)],
|
||||
)
|
||||
|
||||
if item.type == "code_interpreter_call":
|
||||
ci = cast(ItemCodeInterpreterToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_code_interpreter_tool_call(call_id=ci.id)],
|
||||
)
|
||||
|
||||
if item.type == "image_generation_call":
|
||||
ig = cast(ItemImageGenToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_image_generation_tool_call(image_id=ig.id)],
|
||||
)
|
||||
|
||||
if item.type == "shell_call":
|
||||
sc = cast(FunctionShellCallItemParam, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_shell_tool_call(
|
||||
call_id=sc.call_id,
|
||||
commands=sc.action.commands,
|
||||
status=str(sc.status),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "shell_call_output":
|
||||
sco = cast(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 Message(
|
||||
role="tool",
|
||||
contents=[
|
||||
Content.from_shell_tool_result(
|
||||
call_id=sco.call_id,
|
||||
outputs=outputs,
|
||||
max_output_length=sco.max_output_length,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "local_shell_call":
|
||||
lsc = cast(ItemLocalShellToolCall, item)
|
||||
commands = lsc.action.command if hasattr(lsc.action, "command") and lsc.action.command else []
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_shell_tool_call(
|
||||
call_id=lsc.call_id,
|
||||
commands=commands,
|
||||
status=str(lsc.status),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "local_shell_call_output":
|
||||
lsco = cast(ItemLocalShellToolCallOutput, item)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[
|
||||
Content.from_shell_tool_result(
|
||||
call_id=lsco.id,
|
||||
outputs=[Content.from_shell_command_output(stdout=lsco.output)],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "file_search_call":
|
||||
fs = cast(ItemFileSearchToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
fs.id,
|
||||
"file_search",
|
||||
arguments=json.dumps({"queries": fs.queries}),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "web_search_call":
|
||||
ws = cast(ItemWebSearchToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(ws.id, "web_search")],
|
||||
)
|
||||
|
||||
if item.type == "computer_call":
|
||||
cc = cast(ItemComputerToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
cc.call_id,
|
||||
"computer_use",
|
||||
arguments=str(cc.action),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "computer_call_output":
|
||||
cco = cast(ComputerCallOutputItemParam, item)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(cco.call_id, result=str(cco.output))],
|
||||
)
|
||||
|
||||
if item.type == "custom_tool_call":
|
||||
ct = cast(ItemCustomToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(ct.call_id, ct.name, arguments=ct.input)],
|
||||
)
|
||||
|
||||
if item.type == "custom_tool_call_output":
|
||||
cto = cast(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 Message(
|
||||
role="tool",
|
||||
contents=[Content.from_mcp_server_tool_result(call_id=cto.call_id, output=output)],
|
||||
)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(cto.call_id, result=output)],
|
||||
)
|
||||
|
||||
if item.type == "apply_patch_call":
|
||||
ap = cast(ApplyPatchToolCallItemParam, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
ap.call_id,
|
||||
"apply_patch",
|
||||
arguments=str(ap.operation),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "apply_patch_call_output":
|
||||
apo = cast(ApplyPatchToolCallOutputItemParam, item)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(apo.call_id, result=apo.output or "")],
|
||||
)
|
||||
|
||||
raise ValueError(f"Unsupported Item type: {item.type}")
|
||||
|
||||
|
||||
async def _output_items_to_messages(
|
||||
history: Sequence[OutputItem],
|
||||
*,
|
||||
approval_storage: ApprovalStorage | None = None,
|
||||
) -> list[Message]:
|
||||
"""Converts a sequence of OutputItem objects to a list of Message objects.
|
||||
|
||||
Args:
|
||||
history (Sequence[OutputItem]): The sequence of OutputItem objects to convert.
|
||||
approval_storage (ApprovalStorage | None, optional): The approval storage to use for
|
||||
resolving MCP approval requests. Defaults to None.
|
||||
|
||||
Returns:
|
||||
list[Message]: The list of Message objects.
|
||||
"""
|
||||
messages: list[Message] = []
|
||||
for item in history:
|
||||
messages.append(await _output_item_to_message(item, approval_storage=approval_storage))
|
||||
return messages
|
||||
|
||||
|
||||
async def _output_item_to_message(item: OutputItem, *, approval_storage: ApprovalStorage | None = None) -> Message:
|
||||
"""Converts an OutputItem to a Message.
|
||||
|
||||
Args:
|
||||
item (OutputItem): The OutputItem to convert.
|
||||
approval_storage (ApprovalStorage | None, optional): The approval storage to use for
|
||||
resolving MCP approval requests. Defaults to None.
|
||||
|
||||
Returns:
|
||||
Message: The converted Message.
|
||||
|
||||
Raises:
|
||||
ValueError: If the OutputItem type is not supported.
|
||||
"""
|
||||
if item.type == "output_message":
|
||||
output_msg = cast(OutputItemOutputMessage, item)
|
||||
return Message(
|
||||
role=output_msg.role, contents=[_convert_output_message_content(part) for part in output_msg.content]
|
||||
)
|
||||
|
||||
if item.type == "message":
|
||||
msg = cast(OutputItemMessage, item)
|
||||
return Message(role=msg.role, contents=[_convert_message_content(part) for part in msg.content])
|
||||
|
||||
if item.type == "function_call":
|
||||
fc = cast(OutputItemFunctionToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(fc.call_id, fc.name, arguments=fc.arguments)],
|
||||
)
|
||||
|
||||
if item.type == "function_call_output":
|
||||
fco = cast(FunctionCallOutputItemParam, item)
|
||||
output = fco.output if isinstance(fco.output, str) else str(fco.output)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(fco.call_id, result=output)],
|
||||
)
|
||||
|
||||
if item.type == "reasoning":
|
||||
reasoning = cast(OutputItemReasoningItem, item)
|
||||
contents: list[Content] = []
|
||||
if reasoning.summary:
|
||||
for summary in reasoning.summary:
|
||||
contents.append(Content.from_text(summary.text))
|
||||
return Message(role="assistant", contents=contents)
|
||||
|
||||
if item.type == "mcp_call":
|
||||
mcp = cast(OutputItemMcpToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_call(
|
||||
mcp.id,
|
||||
mcp.name,
|
||||
server_name=mcp.server_label,
|
||||
arguments=mcp.arguments,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "mcp_approval_request":
|
||||
mcp_req = cast(OutputItemMcpApprovalRequest, item)
|
||||
if approval_storage is not None:
|
||||
function_approval_request_content = await approval_storage.load_approval_request(mcp_req.id)
|
||||
else:
|
||||
raise ValueError("ApprovalStorage is required to load approval request.")
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[function_approval_request_content],
|
||||
)
|
||||
|
||||
if item.type == "mcp_approval_response":
|
||||
mcp_resp = cast(OutputItemMcpApprovalResponseResource, item)
|
||||
if approval_storage is not None:
|
||||
function_approval_request_content = await approval_storage.load_approval_request(
|
||||
mcp_resp.approval_request_id
|
||||
)
|
||||
else:
|
||||
raise ValueError("ApprovalStorage is required to load approval request.")
|
||||
|
||||
return Message(
|
||||
role="user",
|
||||
contents=[function_approval_request_content.to_function_approval_response(mcp_resp.approve)],
|
||||
)
|
||||
|
||||
if item.type == "code_interpreter_call":
|
||||
ci = cast(OutputItemCodeInterpreterToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_code_interpreter_tool_call(call_id=ci.id)],
|
||||
)
|
||||
|
||||
if item.type == "image_generation_call":
|
||||
ig = cast(OutputItemImageGenToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_image_generation_tool_call(image_id=ig.id)],
|
||||
)
|
||||
|
||||
if item.type == "shell_call":
|
||||
sc = cast(OutputItemFunctionShellCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_shell_tool_call(
|
||||
call_id=sc.call_id,
|
||||
commands=sc.action.commands,
|
||||
status=str(sc.status),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "shell_call_output":
|
||||
sco = cast(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 Message(
|
||||
role="tool",
|
||||
contents=[
|
||||
Content.from_shell_tool_result(
|
||||
call_id=sco.call_id,
|
||||
outputs=outputs,
|
||||
max_output_length=sco.max_output_length,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "local_shell_call":
|
||||
lsc = cast(OutputItemLocalShellToolCall, item)
|
||||
commands = lsc.action.command if hasattr(lsc.action, "command") and lsc.action.command else []
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_shell_tool_call(
|
||||
call_id=lsc.call_id,
|
||||
commands=commands,
|
||||
status=str(lsc.status),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "local_shell_call_output":
|
||||
lsco = cast(OutputItemLocalShellToolCallOutput, item)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[
|
||||
Content.from_shell_tool_result(
|
||||
call_id=lsco.id,
|
||||
outputs=[Content.from_shell_command_output(stdout=lsco.output)],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "file_search_call":
|
||||
fs = cast(OutputItemFileSearchToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
fs.id,
|
||||
"file_search",
|
||||
arguments=json.dumps({"queries": fs.queries}),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "web_search_call":
|
||||
ws = cast(OutputItemWebSearchToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(ws.id, "web_search")],
|
||||
)
|
||||
|
||||
if item.type == "computer_call":
|
||||
cc = cast(OutputItemComputerToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
cc.call_id,
|
||||
"computer_use",
|
||||
arguments=str(cc.action),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "computer_call_output":
|
||||
cco = cast(OutputItemComputerToolCallOutputResource, item)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(cco.call_id, result=str(cco.output))],
|
||||
)
|
||||
|
||||
if item.type == "custom_tool_call":
|
||||
ct = cast(OutputItemCustomToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(ct.call_id, ct.name, arguments=ct.input)],
|
||||
)
|
||||
|
||||
if item.type == "custom_tool_call_output":
|
||||
cto = cast(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 Message(
|
||||
role="tool",
|
||||
contents=[Content.from_mcp_server_tool_result(call_id=cto.call_id, output=output)],
|
||||
)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(cto.call_id, result=output)],
|
||||
)
|
||||
|
||||
if item.type == "apply_patch_call":
|
||||
ap = cast(OutputItemApplyPatchToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
ap.call_id,
|
||||
"apply_patch",
|
||||
arguments=str(ap.operation),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "apply_patch_call_output":
|
||||
apo = cast(OutputItemApplyPatchToolCallOutput, item)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(apo.call_id, result=apo.output or "")],
|
||||
)
|
||||
|
||||
if item.type == "oauth_consent_request":
|
||||
oauth = cast(OAuthConsentRequestOutputItem, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_oauth_consent_request(oauth.consent_link)],
|
||||
)
|
||||
|
||||
if item.type == "structured_outputs":
|
||||
so = cast(StructuredOutputsOutputItem, item)
|
||||
text = json.dumps(so.output) if not isinstance(so.output, str) else so.output
|
||||
return Message(role="assistant", contents=[Content.from_text(text)])
|
||||
|
||||
raise ValueError(f"Unsupported OutputItem type: {item.type}")
|
||||
|
||||
|
||||
def _convert_output_message_content(content: OutputMessageContent) -> Content:
|
||||
"""Converts an OutputMessageContent to a Content object.
|
||||
|
||||
Args:
|
||||
content (OutputMessageContent): The OutputMessageContent to convert.
|
||||
|
||||
Returns:
|
||||
Content: The converted Content object.
|
||||
|
||||
Raises:
|
||||
ValueError: If the OutputMessageContent type is not supported.
|
||||
"""
|
||||
if content.type == "output_text":
|
||||
text_content = cast(OutputMessageContentOutputTextContent, content)
|
||||
return Content.from_text(text_content.text)
|
||||
if content.type == "refusal":
|
||||
refusal_content = cast(OutputMessageContentRefusalContent, content)
|
||||
return Content.from_text(refusal_content.refusal)
|
||||
|
||||
raise ValueError(f"Unsupported OutputMessageContent type: {content.type}")
|
||||
|
||||
|
||||
def _convert_file_data(data_uri: str, filename: str | None = None) -> Content:
|
||||
"""Convert a file_data data URI to a Content object.
|
||||
|
||||
For text/* MIME types, decodes the base64 content and returns it as text.
|
||||
For other types, returns a URI-based Content with the filename preserved.
|
||||
"""
|
||||
# Parse data URI: data:<media_type>;base64,<data>
|
||||
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: MessageContent) -> Content:
|
||||
"""Converts a MessageContent to a Content object.
|
||||
|
||||
Args:
|
||||
content (MessageContent): The MessageContent to convert.
|
||||
|
||||
Returns:
|
||||
Content: The converted Content object.
|
||||
|
||||
Raises:
|
||||
ValueError: If the MessageContent type is not supported.
|
||||
"""
|
||||
if content.type == "input_text":
|
||||
input_text = cast(MessageContentInputTextContent, content)
|
||||
return Content.from_text(input_text.text)
|
||||
if content.type == "output_text":
|
||||
output_text = cast(MessageContentOutputTextContent, content)
|
||||
return Content.from_text(output_text.text)
|
||||
if content.type == "text":
|
||||
text = cast(TextContent, content)
|
||||
return Content.from_text(text.text)
|
||||
if content.type == "summary_text":
|
||||
summary = cast(SummaryTextContent, content)
|
||||
return Content.from_text(summary.text)
|
||||
if content.type == "refusal":
|
||||
refusal = cast(MessageContentRefusalContent, content)
|
||||
return Content.from_text(refusal.refusal)
|
||||
if content.type == "reasoning_text":
|
||||
reasoning = cast(MessageContentReasoningTextContent, content)
|
||||
return Content.from_text_reasoning(text=reasoning.text)
|
||||
if content.type == "input_image":
|
||||
image = cast(MessageContentInputImageContent, content)
|
||||
if image.image_url:
|
||||
if image.image_url.startswith("data:"):
|
||||
return Content.from_uri(image.image_url)
|
||||
return Content.from_uri(image.image_url, media_type="image/*")
|
||||
if image.file_id:
|
||||
return Content.from_hosted_file(image.file_id)
|
||||
if content.type == "input_file":
|
||||
file = cast(MessageContentInputFileContent, content)
|
||||
if file.file_url:
|
||||
return Content.from_uri(file.file_url)
|
||||
if file.file_id:
|
||||
return Content.from_hosted_file(file.file_id, name=file.filename)
|
||||
if file.file_data:
|
||||
return _convert_file_data(file.file_data, file.filename)
|
||||
if content.type == "computer_screenshot":
|
||||
screenshot = cast(ComputerScreenshotContent, content)
|
||||
return Content.from_uri(screenshot.image_url)
|
||||
|
||||
raise ValueError(f"Unsupported MessageContent type: {content.type}")
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Output Item Conversion
|
||||
|
||||
|
||||
def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str:
|
||||
"""Convert arguments to a JSON string.
|
||||
|
||||
Args:
|
||||
arguments: The arguments to convert, can be a string, mapping, or None.
|
||||
|
||||
Returns:
|
||||
The arguments as a JSON string.
|
||||
"""
|
||||
if arguments is None:
|
||||
return ""
|
||||
if isinstance(arguments, str):
|
||||
return arguments
|
||||
return json.dumps(arguments)
|
||||
|
||||
|
||||
async def _to_outputs(
|
||||
stream: ResponseEventStream,
|
||||
content: Content,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260519"
|
||||
version = "1.0.0a260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"azure-ai-agentserver-core>=2.0.0b3,<3",
|
||||
"azure-ai-agentserver-responses>=1.0.0b5,<2",
|
||||
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2892,6 +2892,8 @@ class TestCheckpointContextPathValidation:
|
||||
f"before={before} after={after}"
|
||||
)
|
||||
assert list(root.iterdir()) == [], f"Checkpoint directory created inside root for {context_field}={bad_id!r}"
|
||||
|
||||
|
||||
# region Agent lifecycle (lazy entry & OAuth consent surfacing)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-openai>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-openai>=1.6.0,<2",
|
||||
"foundry-local-sdk>=0.5.1,<0.5.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Google Gemini integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260519"
|
||||
version = "1.0.0a260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2.0",
|
||||
"agent-framework-core>=1.6.0,<2.0",
|
||||
"google-genai>=1.65.0,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"github-copilot-sdk>=1.0.0b2,<=1.0.0b2; python_version >= '3.11'",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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/).
|
||||
@@ -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__",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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<media_type>[^;]+);base64,(?P<data>[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()
|
||||
@@ -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 = []
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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? See the companion
|
||||
> [`agent-framework-hosting-teams`](../hosting-teams) package, which is
|
||||
> built on `microsoft-teams-apps` and exposes Teams-specific affordances
|
||||
> on top of (still) Azure Bot Service.
|
||||
|
||||
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="<entra app id>",
|
||||
client_secret="<entra 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.
|
||||
+7
@@ -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"]
|
||||
+931
@@ -0,0 +1,931 @@
|
||||
# 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:<conversation_id>`` 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="<app id>",
|
||||
tenant_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=<bytes>`` 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.
|
||||
``"<at>Personal Assistant</at> /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 Teams channel.
|
||||
|
||||
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:
|
||||
chunk = getattr(update, "text", None)
|
||||
if chunk:
|
||||
accumulated += chunk
|
||||
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:
|
||||
chunk = getattr(update, "text", None)
|
||||
if chunk:
|
||||
accumulated += chunk
|
||||
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"]
|
||||
@@ -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"
|
||||
@@ -0,0 +1,775 @@
|
||||
# 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_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": "<p>hello there</p>"},
|
||||
],
|
||||
})
|
||||
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": "<at>Personal Assistant</at> /todos",
|
||||
"recipient": {"id": "bot-1"},
|
||||
"entities": [
|
||||
{"type": "mention", "text": "<at>Personal Assistant</at>", "mentioned": {"id": "bot-1"}},
|
||||
],
|
||||
}
|
||||
assert _command_text(activity) == "/todos"
|
||||
|
||||
def test_strips_bot_mention_without_space(self) -> None:
|
||||
activity = {
|
||||
"text": "<at>Bot</at>/help",
|
||||
"recipient": {"id": "bot-1"},
|
||||
"entities": [{"type": "mention", "text": "<at>Bot</at>", "mentioned": {"id": "bot-1"}}],
|
||||
}
|
||||
assert _command_text(activity) == "/help"
|
||||
|
||||
def test_keeps_other_user_mention(self) -> None:
|
||||
activity = {
|
||||
"text": "/whoami <at>Someone</at>",
|
||||
"recipient": {"id": "bot-1"},
|
||||
"entities": [{"type": "mention", "text": "<at>Someone</at>", "mentioned": {"id": "user-9"}}],
|
||||
}
|
||||
# Another user's mention must not be stripped.
|
||||
assert _command_text(activity) == "/whoami <at>Someone</at>"
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
@@ -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
|
||||
|
||||
@@ -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="<discord application id>",
|
||||
public_key="<discord public key>",
|
||||
bot_token="<discord bot token>",
|
||||
guild_id="<guild id for fast dev command registration>",
|
||||
)
|
||||
],
|
||||
)
|
||||
host.serve()
|
||||
```
|
||||
|
||||
Configure the Discord Developer Portal interaction endpoint as:
|
||||
|
||||
```text
|
||||
https://<your-host>/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:<text>` 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:<guild-or-dm>:<channel_id>:<user_id>`,
|
||||
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.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
```
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/).
|
||||
@@ -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__",
|
||||
]
|
||||
@@ -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<media_type>[^;]+);base64,(?P<data>[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)
|
||||
@@ -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 = []
|
||||
@@ -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]
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user