mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f8c498d03 |
@@ -602,7 +602,7 @@ jobs:
|
||||
pattern: dotnet-test-results-*
|
||||
path: dotnet-test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/dotnet-integration-report-history.json
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
@@ -619,7 +619,7 @@ jobs:
|
||||
run: cat dotnet-integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/dotnet-integration-report-history.json
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
|
||||
@@ -137,7 +137,6 @@ 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 }}
|
||||
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
working-directory: .
|
||||
- name: Cache Ollama models
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
|
||||
@@ -511,7 +511,7 @@ jobs:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
@@ -528,7 +528,7 @@ jobs:
|
||||
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
|
||||
@@ -295,7 +295,7 @@ jobs:
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
working-directory: .
|
||||
- name: Cache Ollama models
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
|
||||
@@ -692,7 +692,7 @@ jobs:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
@@ -709,7 +709,7 @@ jobs:
|
||||
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
|
||||
@@ -701,7 +701,7 @@ jobs:
|
||||
|
||||
- name: Restore validation history
|
||||
id: cache-restore
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: validation-history/
|
||||
key: validation-history-${{ github.run_id }}
|
||||
@@ -719,7 +719,7 @@ jobs:
|
||||
run: cat trend-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save validation history
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: validation-history/
|
||||
key: validation-history-${{ github.run_id }}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,132 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,320 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -121,7 +121,6 @@
|
||||
<Folder Name="/Samples/02-agents/Harness/">
|
||||
<File Path="samples/02-agents/Harness/README.md" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Shared_Console_OpenAI/Harness_Shared_Console_OpenAI.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Harness_Step02_Research_WithBackgroundAgents.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.6.2</VersionPrefix>
|
||||
<VersionPrefix>1.6.1</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260521</DateSuffix>
|
||||
<DateSuffix>260514</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.2</GitTag>
|
||||
<GitTag>1.6.1</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -192,11 +192,6 @@ public sealed class HarnessAgentRunner : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var observer in this._observers)
|
||||
{
|
||||
await observer.OnResponseUpdateAsync(this._ux, update, this._agent, this._session).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
foreach (var observer in this._observers)
|
||||
|
||||
@@ -24,17 +24,6 @@ public abstract class ConsoleObserver
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called for each <see cref="AgentResponseUpdate"/> in the response stream, regardless of
|
||||
/// whether it contains content. Override to inspect update-level metadata such as
|
||||
/// <see cref="AgentResponseUpdate.RawRepresentation"/> for provider-specific events.
|
||||
/// </summary>
|
||||
/// <param name="ux">The UX state driver, used for rendering output.</param>
|
||||
/// <param name="update">The streaming response update.</param>
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
public virtual Task OnResponseUpdateAsync(IUXStateDriver ux, AgentResponseUpdate update, AIAgent agent, AgentSession session) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Called for each <see cref="AIContent"/> item in the response stream.
|
||||
/// </summary>
|
||||
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenAI" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Harness.Shared.Console.OpenAI;
|
||||
|
||||
/// <summary>
|
||||
/// Detects and displays error/incomplete status from OpenAI Responses API streaming updates.
|
||||
/// Handles <see cref="StreamingResponseFailedUpdate"/> and <see cref="StreamingResponseIncompleteUpdate"/>
|
||||
/// which are not surfaced as <see cref="ErrorContent"/> by the chat client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note: <see cref="StreamingResponseErrorUpdate"/> is already handled by the SDK — it produces
|
||||
/// an <see cref="ErrorContent"/> which is displayed by <see cref="ErrorDisplayObserver"/>.
|
||||
/// This observer covers the cases where the SDK does not produce <see cref="ErrorContent"/>.
|
||||
/// </remarks>
|
||||
public sealed class OpenAIResponsesErrorObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnResponseUpdateAsync(IUXStateDriver ux, AgentResponseUpdate update, AIAgent agent, AgentSession session)
|
||||
{
|
||||
// AgentResponseUpdate.RawRepresentation is the ChatResponseUpdate,
|
||||
// whose RawRepresentation is the underlying StreamingResponseUpdate.
|
||||
object? rawUpdate = (update.RawRepresentation as ChatResponseUpdate)?.RawRepresentation
|
||||
?? update.RawRepresentation;
|
||||
|
||||
switch (rawUpdate)
|
||||
{
|
||||
case StreamingResponseFailedUpdate failedUpdate:
|
||||
// Only display if the response has error details populated.
|
||||
// When error is null, a follow-up StreamingResponseErrorUpdate typically
|
||||
// carries the real error — the SDK surfaces that as ErrorContent,
|
||||
// which is displayed by ErrorDisplayObserver.
|
||||
if (failedUpdate.Response?.Error is { } error)
|
||||
{
|
||||
string errorMessage = error.Message ?? "Unknown error";
|
||||
string? errorCode = error.Code.ToString();
|
||||
string errorText = $"❌ Response failed: {errorMessage}";
|
||||
if (!string.IsNullOrEmpty(errorCode))
|
||||
{
|
||||
errorText += $" (code: {errorCode})";
|
||||
}
|
||||
|
||||
await ux.WriteInfoLineAsync(errorText, ConsoleColor.Red);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case StreamingResponseIncompleteUpdate incompleteUpdate:
|
||||
string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString();
|
||||
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
|
||||
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
-1
@@ -16,7 +16,6 @@
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+3
-2
@@ -3,18 +3,19 @@
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
|
||||
using System.Text;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Harness.Shared.Console.OpenAI;
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// Displays web search activity in the scroll area. Shows search queries,
|
||||
/// page opens, and find-in-page actions as they stream in from the API.
|
||||
/// </summary>
|
||||
public sealed class OpenAIResponsesWebSearchDisplayObserver : ConsoleObserver
|
||||
internal sealed class OpenAIResponsesWebSearchDisplayObserver : ConsoleObserver
|
||||
{
|
||||
private const int MaxQueryDisplayLength = 120;
|
||||
|
||||
@@ -19,7 +19,6 @@ using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.OpenAI;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -108,7 +107,6 @@ await HarnessConsole.RunAgentAsync(
|
||||
{
|
||||
Observers = [
|
||||
new OpenAIResponsesWebSearchDisplayObserver(),
|
||||
new OpenAIResponsesErrorObserver(),
|
||||
.. HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
|
||||
-1
@@ -16,7 +16,6 @@
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+5
-7
@@ -16,7 +16,6 @@ using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.OpenAI;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -103,7 +102,10 @@ AIAgent parentAgent =
|
||||
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||
DisableToolApproval = true, // If enabled, this allows don't-ask-again approval functionality.
|
||||
DisableWebSearch = true,
|
||||
BackgroundAgents = [webSearchAgent],
|
||||
AIContextProviders =
|
||||
[
|
||||
new BackgroundAgentsProvider([webSearchAgent]),
|
||||
],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = parentInstructions,
|
||||
@@ -114,8 +116,4 @@ AIAgent parentAgent =
|
||||
// Run the interactive console session.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
parentAgent,
|
||||
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):",
|
||||
options: new HarnessConsoleOptions
|
||||
{
|
||||
Observers = [new OpenAIResponsesErrorObserver(), .. HarnessConsoleOptions.BuildDefaultObservers()],
|
||||
});
|
||||
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
@@ -10,11 +9,8 @@ using Microsoft.Extensions.AI;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline policy that emits the hosted-agent <c>User-Agent</c> segment
|
||||
/// (<c>"foundry-hosting/agent-framework-dotnet/{version}"</c>), matching Python's hosted
|
||||
/// contract (<c>foundry-hosting/agent-framework-python/{version}</c>, see
|
||||
/// <c>python/packages/core/agent_framework/_telemetry.py</c>: the hosted prefix is joined
|
||||
/// with the base agent-framework segment into a single combined User-Agent value).
|
||||
/// Pipeline policy that appends the hosted-agent <c>User-Agent</c> segment
|
||||
/// (e.g. <c>"foundry-hosting/agent-framework-dotnet/{version}"</c>) to outgoing requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -23,12 +19,6 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
/// is already present in the <c>User-Agent</c> header, the policy does not append it again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When a bare <c>agent-framework-dotnet/{version}</c> segment is already present (stamped by
|
||||
/// the framework-wide <c>AgentFrameworkUserAgentPolicy</c> registered by
|
||||
/// <c>FoundryChatClient</c>), this policy <em>replaces</em> that segment with the combined
|
||||
/// hosted form so the wire never carries both forms simultaneously, preserving Python parity.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This policy is added at hosted-agent resolution time via the MEAI 10.5.1
|
||||
/// <see cref="OpenAIRequestPolicies"/> hook on the agent's underlying chat client. It is only
|
||||
/// registered when an agent is resolved by the Foundry hosting layer.
|
||||
@@ -40,12 +30,6 @@ internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy
|
||||
|
||||
private static readonly string s_supplementValue = CreateSupplementValue();
|
||||
|
||||
/// <summary>Bare segment stamped by <c>AgentFrameworkUserAgentPolicy</c> in the non-hosted scenario; this policy upgrades it in-place when both run.</summary>
|
||||
private const string BareAgentFrameworkPrefix = "agent-framework-dotnet/";
|
||||
|
||||
/// <summary>Combined hosted segment that this policy emits. Recognized in-place so callers whose pipelines already carry a (possibly different-version) combined segment get it replaced rather than double-prefixed (Q-D fix).</summary>
|
||||
private const string CombinedHostedPrefix = "foundry-hosting/agent-framework-dotnet/";
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
@@ -62,52 +46,13 @@ internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing))
|
||||
{
|
||||
// Guard against double-append on retries or when the policy is registered on
|
||||
// multiple pipeline positions.
|
||||
if (existing!.Contains(s_supplementValue))
|
||||
// Guard against double-append on retries or when the policy
|
||||
// is registered on multiple pipeline positions.
|
||||
if (existing.Contains(s_supplementValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Combined-form check first: if the caller's pipeline already has
|
||||
// `foundry-hosting/agent-framework-dotnet/{version}` (with a version that differs
|
||||
// from ours — otherwise the .Contains above would have returned early), replace the
|
||||
// entire combined span in place. Without this, the bare-prefix search below would
|
||||
// match `agent-framework-dotnet/` *inside* the combined segment and produce a
|
||||
// malformed `foundry-hosting/foundry-hosting/agent-framework-dotnet/...` value.
|
||||
var combinedIdx = existing.IndexOf(CombinedHostedPrefix, StringComparison.Ordinal);
|
||||
if (combinedIdx >= 0)
|
||||
{
|
||||
var combinedEnd = existing.IndexOf(' ', combinedIdx);
|
||||
if (combinedEnd < 0)
|
||||
{
|
||||
combinedEnd = existing.Length;
|
||||
}
|
||||
|
||||
var replacedCombined = string.Concat(existing.AsSpan(0, combinedIdx), s_supplementValue.AsSpan(), existing.AsSpan(combinedEnd));
|
||||
message.Request.Headers.Set("User-Agent", replacedCombined);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the bare agent-framework segment is present (stamped by
|
||||
// AgentFrameworkUserAgentPolicy when not hosted), upgrade it in place to the
|
||||
// combined hosted form so the wire never carries both segments simultaneously.
|
||||
// Mirrors Python where get_user_agent() returns a single combined string when the
|
||||
// hosted prefix is registered.
|
||||
var idx = existing.IndexOf(BareAgentFrameworkPrefix, StringComparison.Ordinal);
|
||||
if (idx >= 0)
|
||||
{
|
||||
var end = existing.IndexOf(' ', idx);
|
||||
if (end < 0)
|
||||
{
|
||||
end = existing.Length;
|
||||
}
|
||||
|
||||
var replaced = string.Concat(existing.AsSpan(0, idx), s_supplementValue.AsSpan(), existing.AsSpan(end));
|
||||
message.Request.Headers.Set("User-Agent", replaced);
|
||||
return;
|
||||
}
|
||||
|
||||
message.Request.Headers.Set("User-Agent", $"{existing} {s_supplementValue}");
|
||||
}
|
||||
else
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Framework-wide pipeline policy that appends the <c>agent-framework-dotnet/{version}</c>
|
||||
/// segment to outgoing <c>User-Agent</c> headers, mirroring the
|
||||
/// <c>agent-framework-python/{version}</c> contract used by every Python provider package.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The segment value is computed once from the <c>Microsoft.Agents.AI.Foundry</c> assembly's
|
||||
/// <see cref="AssemblyInformationalVersionAttribute"/>. The policy is idempotent on retries: if
|
||||
/// the segment is already present in the <c>User-Agent</c> header, the policy does not append
|
||||
/// it again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The policy is registered by <c>FoundryChatClient</c> on the underlying chat client's
|
||||
/// <c>OpenAIRequestPolicies</c> hook so every outbound Foundry call carries the segment. The
|
||||
/// policy is currently colocated with the Foundry package; it is expected to migrate to a
|
||||
/// framework-wide location (such as <c>Microsoft.Agents.AI</c>) once another provider package
|
||||
/// adopts the same User-Agent contract.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class AgentFrameworkUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
/// <summary>Gets the singleton policy instance.</summary>
|
||||
public static AgentFrameworkUserAgentPolicy Instance { get; } = new AgentFrameworkUserAgentPolicy();
|
||||
|
||||
private static readonly string s_segmentValue = CreateSegmentValue();
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void AppendHeader(PipelineMessage message)
|
||||
{
|
||||
if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing))
|
||||
{
|
||||
// Guard against double-append on retries or when the policy
|
||||
// is registered on multiple pipeline positions.
|
||||
if (existing!.Contains(s_segmentValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
message.Request.Headers.Set("User-Agent", $"{existing} {s_segmentValue}");
|
||||
}
|
||||
else
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", s_segmentValue);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateSegmentValue()
|
||||
{
|
||||
const string Name = "agent-framework-dotnet";
|
||||
|
||||
if (typeof(AgentFrameworkUserAgentPolicy).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
|
||||
{
|
||||
int pos = version.IndexOf('+');
|
||||
if (pos >= 0)
|
||||
{
|
||||
version = version.Substring(0, pos);
|
||||
}
|
||||
|
||||
if (version.Length > 0)
|
||||
{
|
||||
return $"{Name}/{version}";
|
||||
}
|
||||
}
|
||||
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a chat client implementation that integrates with Azure AI Agents, enabling chat interactions using
|
||||
/// Azure-specific agent capabilities.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
internal sealed class AzureAIProjectChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly ChatClientMetadata? _metadata;
|
||||
private readonly AIProjectClient _agentClient;
|
||||
private readonly ProjectsAgentVersion? _agentVersion;
|
||||
private readonly ProjectsAgentRecord? _agentRecord;
|
||||
private readonly ChatOptions? _chatOptions;
|
||||
private readonly AgentReference _agentReference;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AzureAIProjectChatClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">An instance of <see cref="AIProjectClient"/> to interact with Azure AI Agents services.</param>
|
||||
/// <param name="agentReference">An instance of <see cref="AgentReference"/> representing the specific agent to use.</param>
|
||||
/// <param name="defaultModelId">The default model to use for the agent, if applicable.</param>
|
||||
/// <param name="chatOptions">An instance of <see cref="ChatOptions"/> representing the options on how the agent was predefined.</param>
|
||||
/// <remarks>
|
||||
/// The <see cref="IChatClient"/> provided should be decorated with a <see cref="AzureAIProjectChatClient"/> for proper functionality.
|
||||
/// </remarks>
|
||||
internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, AgentReference agentReference, string? defaultModelId, ChatOptions? chatOptions)
|
||||
: base(Throw.IfNull(aiProjectClient)
|
||||
.GetProjectOpenAIClient()
|
||||
.GetProjectResponsesClientForAgent(agentReference)
|
||||
.AsIChatClient())
|
||||
{
|
||||
this._agentClient = aiProjectClient;
|
||||
this._agentReference = Throw.IfNull(agentReference);
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId);
|
||||
this._chatOptions = chatOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AzureAIProjectChatClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">An instance of <see cref="AIProjectClient"/> to interact with Azure AI Agents services.</param>
|
||||
/// <param name="agentRecord">An instance of <see cref="ProjectsAgentRecord"/> representing the specific agent to use.</param>
|
||||
/// <param name="chatOptions">An instance of <see cref="ChatOptions"/> representing the options on how the agent was predefined.</param>
|
||||
/// <remarks>
|
||||
/// The <see cref="IChatClient"/> provided should be decorated with a <see cref="AzureAIProjectChatClient"/> for proper functionality.
|
||||
/// </remarks>
|
||||
internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, ProjectsAgentRecord agentRecord, ChatOptions? chatOptions)
|
||||
: this(aiProjectClient, Throw.IfNull(agentRecord).GetLatestVersion(), chatOptions)
|
||||
{
|
||||
this._agentRecord = agentRecord;
|
||||
}
|
||||
|
||||
internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, ProjectsAgentVersion agentVersion, ChatOptions? chatOptions)
|
||||
: this(
|
||||
aiProjectClient,
|
||||
CreateAgentReference(Throw.IfNull(agentVersion)),
|
||||
(agentVersion.Definition as DeclarativeAgentDefinition)?.Model,
|
||||
chatOptions)
|
||||
{
|
||||
this._agentVersion = agentVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="AgentReference"/> from an <see cref="ProjectsAgentVersion"/>.
|
||||
/// Uses the agent version's version if available, otherwise defaults to "latest".
|
||||
/// </summary>
|
||||
/// <param name="agentVersion">The agent version to create a reference from.</param>
|
||||
/// <returns>An <see cref="AgentReference"/> for the specified agent version.</returns>
|
||||
private static AgentReference CreateAgentReference(ProjectsAgentVersion agentVersion)
|
||||
{
|
||||
// If the version is null, empty, or whitespace, use "latest" as the default.
|
||||
// This handles cases where hosted agents (like MCP agents) may not have a version assigned.
|
||||
var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version;
|
||||
return new AgentReference(agentVersion.Name, version);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return (serviceKey is null && serviceType == typeof(ChatClientMetadata))
|
||||
? this._metadata
|
||||
: (serviceKey is null && serviceType == typeof(AIProjectClient))
|
||||
? this._agentClient
|
||||
: (serviceKey is null && serviceType == typeof(ProjectsAgentVersion))
|
||||
? this._agentVersion
|
||||
: (serviceKey is null && serviceType == typeof(ProjectsAgentRecord))
|
||||
? this._agentRecord
|
||||
: (serviceKey is null && serviceType == typeof(AgentReference))
|
||||
? this._agentReference
|
||||
: base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var agentOptions = this.GetAgentEnabledChatOptions(options);
|
||||
|
||||
return await base.GetResponseAsync(messages, agentOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var agentOptions = this.GetAgentEnabledChatOptions(options);
|
||||
|
||||
await foreach (var chunk in base.GetStreamingResponseAsync(messages, agentOptions, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
private ChatOptions GetAgentEnabledChatOptions(ChatOptions? options)
|
||||
{
|
||||
// Start with a clone of the base chat options defined for the agent, if any.
|
||||
ChatOptions agentEnabledChatOptions = this._chatOptions?.Clone() ?? new();
|
||||
|
||||
// Ignore per-request all options that can't be overridden.
|
||||
agentEnabledChatOptions.Instructions = null;
|
||||
agentEnabledChatOptions.Tools = null;
|
||||
agentEnabledChatOptions.Temperature = null;
|
||||
agentEnabledChatOptions.TopP = null;
|
||||
agentEnabledChatOptions.PresencePenalty = null;
|
||||
agentEnabledChatOptions.ResponseFormat = null;
|
||||
|
||||
// Use the conversation from the request, or the one defined at the client level.
|
||||
agentEnabledChatOptions.ConversationId = options?.ConversationId ?? this._chatOptions?.ConversationId;
|
||||
|
||||
// Preserve the original RawRepresentationFactory
|
||||
var originalFactory = options?.RawRepresentationFactory;
|
||||
|
||||
agentEnabledChatOptions.RawRepresentationFactory = (client) =>
|
||||
{
|
||||
if (originalFactory?.Invoke(this) is not CreateResponseOptions responseCreationOptions)
|
||||
{
|
||||
responseCreationOptions = new CreateResponseOptions();
|
||||
}
|
||||
|
||||
responseCreationOptions.Agent = this._agentReference;
|
||||
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
responseCreationOptions.Patch.Remove("$.model"u8);
|
||||
#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
|
||||
return responseCreationOptions;
|
||||
};
|
||||
|
||||
return agentEnabledChatOptions;
|
||||
}
|
||||
}
|
||||
+11
-8
@@ -23,7 +23,7 @@ namespace Azure.AI.Projects;
|
||||
/// Provides extension methods for <see cref="AIProjectClient"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static partial class AIProjectClientExtensions
|
||||
public static partial class AzureAIProjectChatClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Uses an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/> and <see cref="AgentReference"/>.
|
||||
@@ -63,7 +63,7 @@ public static partial class AIProjectClientExtensions
|
||||
clientFactory,
|
||||
services);
|
||||
|
||||
return new FoundryAgent(innerAgent);
|
||||
return new FoundryAgent(aiProjectClient, innerAgent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -132,7 +132,7 @@ public static partial class AIProjectClientExtensions
|
||||
!allowDeclarativeMode,
|
||||
services);
|
||||
|
||||
return new FoundryAgent(innerAgent);
|
||||
return new FoundryAgent(aiProjectClient, innerAgent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -165,7 +165,7 @@ public static partial class AIProjectClientExtensions
|
||||
!allowDeclarativeMode,
|
||||
services);
|
||||
|
||||
return new FoundryAgent(innerAgent);
|
||||
return new FoundryAgent(aiProjectClient, innerAgent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -246,7 +246,7 @@ public static partial class AIProjectClientExtensions
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions);
|
||||
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -268,7 +268,10 @@ public static partial class AIProjectClientExtensions
|
||||
Throw.IfNull(agentOptions.ChatOptions);
|
||||
Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId);
|
||||
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId);
|
||||
IChatClient chatClient = aiProjectClient
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(agentOptions.ChatOptions.ModelId);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -295,7 +298,7 @@ public static partial class AIProjectClientExtensions
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions);
|
||||
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -313,7 +316,7 @@ public static partial class AIProjectClientExtensions
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
|
||||
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Azure.AI.Projects;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
internal sealed class AzureAIProjectResponsesChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly ChatClientMetadata _metadata;
|
||||
private readonly AIProjectClient _aiProjectClient;
|
||||
|
||||
internal AzureAIProjectResponsesChatClient(AIProjectClient aiProjectClient, string defaultModelId)
|
||||
: base(Throw.IfNull(aiProjectClient)
|
||||
.GetProjectOpenAIClient()
|
||||
.GetProjectResponsesClientForModel(Throw.IfNullOrWhitespace(defaultModelId))
|
||||
.AsIChatClient())
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId);
|
||||
}
|
||||
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return (serviceKey is null && serviceType == typeof(ChatClientMetadata))
|
||||
? this._metadata
|
||||
: (serviceKey is null && serviceType == typeof(AIProjectClient))
|
||||
? this._aiProjectClient
|
||||
: base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
}
|
||||
#pragma warning restore OPENAI001
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Foundry-specific extensions on <see cref="ChatClientAgent"/>. Mirrors Python's free
|
||||
/// <c>to_prompt_agent(agent)</c> function for agents whose underlying chat client is a
|
||||
/// <see cref="FoundryChatClient"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class ChatClientAgentFoundryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts the supplied agent into a <see cref="ProjectsAgentDefinition"/> ready to publish
|
||||
/// via <c>AgentAdministrationClient.CreateAgentVersionAsync</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only works on agents whose chat client is a <see cref="FoundryChatClient"/> and whose
|
||||
/// construction mode is convertible. The Agent Endpoint construction mode (Mode 3) is not
|
||||
/// convertible because no local definition exists; conversion in that case throws.
|
||||
/// </remarks>
|
||||
/// <param name="agent">The chat client agent to convert.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel an internal server-side fetch when the agent was constructed from a bare <see cref="AgentReference"/>.</param>
|
||||
/// <returns>A <see cref="ProjectsAgentDefinition"/> suitable for publishing.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent's chat client is not a <see cref="FoundryChatClient"/>; the agent was constructed via the Agent Endpoint mode (Mode 3); no model id is set on the agent's <see cref="ChatOptions"/> for the Responses Agent mode (Mode 1); or the agent contains an <see cref="AITool"/> that cannot be converted to a <c>ResponseTool</c>.</exception>
|
||||
public static Task<ProjectsAgentDefinition> ToPromptAgentAsync(this ChatClientAgent agent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(agent);
|
||||
return FoundryPromptAgentConverter.ConvertAsync(agent.ChatClient, agent.GetService<ChatOptions>(), cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,11 @@ namespace Microsoft.Agents.AI.Foundry;
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class FoundryAgent : DelegatingAIAgent
|
||||
{
|
||||
/// <summary>
|
||||
/// The cached <see cref="AIProjectClient"/> supplied to or constructed by the active constructor.
|
||||
/// </summary>
|
||||
private readonly AIProjectClient _aiProjectClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryAgent"/> class using the direct Responses API path.
|
||||
/// </summary>
|
||||
@@ -68,8 +73,9 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
: base(CreateInnerAgent(
|
||||
CreateProjectClient(projectEndpoint, credential, clientOptions),
|
||||
model, instructions, name, description, tools, clientFactory, loggerFactory, services,
|
||||
out _))
|
||||
out var aiProjectClient))
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -81,11 +87,9 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
/// </param>
|
||||
/// <param name="credential">The authentication credential.</param>
|
||||
/// <param name="clientOptions">
|
||||
/// Optional configuration for the underlying <see cref="ProjectOpenAIClient"/>. When supplied:
|
||||
/// Optional configuration for the underlying <see cref="ProjectResponsesClient"/>. When supplied:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The instance is passed through to the per-agent client; pipeline policies added via <c>AddPolicy(...)</c> on it execute on the per-agent traffic.</description></item>
|
||||
/// <item><description><c>Endpoint</c> and <see cref="ProjectOpenAIClientOptions.AgentName"/> are owned by this constructor and are overwritten with values derived from <paramref name="agentEndpoint"/>; any caller value is replaced.</description></item>
|
||||
/// <item><description>For the project-level conversations client a separate fresh options bag is built that copies only <see cref="ClientPipelineOptions.RetryPolicy"/>, <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>, and <c>UserAgentApplicationId</c>; pipeline policies added via <c>AddPolicy(...)</c> do <strong>not</strong> propagate to the conversations pipeline.</description></item>
|
||||
/// </list>
|
||||
/// </param>
|
||||
/// <param name="tools">Optional tools to use when interacting with the agent.</param>
|
||||
@@ -109,37 +113,43 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
IList<AITool>? tools = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null)
|
||||
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services))
|
||||
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services, out var aiProjectClient))
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal constructor used by the <c>AsAIAgent(this AIProjectClient, Uri, ...)</c>
|
||||
/// extension where the caller already has an <see cref="AIProjectClient"/> and the agent
|
||||
/// endpoint URI. Reuses the supplied client's pipeline (no new credential or transport is
|
||||
/// stamped) and surfaces the agent through a <see cref="FoundryChatClient"/> just like the
|
||||
/// public agent-endpoint ctor.
|
||||
/// Initializes a new instance of the <see cref="FoundryAgent"/> class from an agent-specific
|
||||
/// endpoint while reusing an existing <see cref="AIProjectClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">An existing <see cref="AIProjectClient"/> rooted at the same project as <paramref name="agentEndpoint"/>.</param>
|
||||
/// <param name="agentEndpoint">
|
||||
/// The agent-specific endpoint URI. Must be of the shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>.
|
||||
/// </param>
|
||||
/// <param name="tools">Optional tools to use when interacting with the agent.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/>.</param>
|
||||
/// <param name="services">Optional service provider for resolving dependencies required by AI functions.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="aiProjectClient"/> or <paramref name="agentEndpoint"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="agentEndpoint"/> does not match the expected agent-endpoint shape.</exception>
|
||||
internal FoundryAgent(
|
||||
AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
IList<AITool>? tools = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null)
|
||||
: base(CreateInnerAgentFromAgentEndpointReusingProjectClient(aiProjectClient, agentEndpoint, tools, clientFactory, services))
|
||||
: base(BuildAgentEndpointInnerAgent(aiProjectClient, agentEndpoint, clientOptions: null, tools, clientFactory, services))
|
||||
{
|
||||
this._aiProjectClient = Throw.IfNull(aiProjectClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal constructor used by <c>AsAIAgent</c> extension methods that already have a
|
||||
/// configured <see cref="ChatClientAgent"/>. The inner agent already routes through a
|
||||
/// <see cref="FoundryChatClient"/> whose <c>GetService<AIProjectClient>()</c> surfaces
|
||||
/// the project client to downstream callers, so the agent does not also need a private
|
||||
/// <see cref="AIProjectClient"/> reference here.
|
||||
/// Internal constructor used by <c>AsAIAgent</c> extension methods that already have an <see cref="AIProjectClient"/> and a configured <see cref="ChatClientAgent"/>.
|
||||
/// </summary>
|
||||
internal FoundryAgent(ChatClientAgent innerAgent)
|
||||
internal FoundryAgent(AIProjectClient aiProjectClient, ChatClientAgent innerAgent)
|
||||
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
|
||||
{
|
||||
this._aiProjectClient = Throw.IfNull(aiProjectClient);
|
||||
}
|
||||
|
||||
#region Convenience methods
|
||||
@@ -172,13 +182,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
/// <returns>A <see cref="ChatClientAgentSession"/> linked to the newly created server-side conversation.</returns>
|
||||
public async Task<ChatClientAgentSession> CreateConversationSessionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// The inner FoundryChatClient surfaces an AIProjectClient via GetService for all
|
||||
// three construction modes (Plan #2 Agent Endpoint mode materialization). Resolve it through the
|
||||
// delegating chain at call time instead of caching a private reference on this agent.
|
||||
var aiProjectClient = this.GetService<AIProjectClient>()
|
||||
?? throw new InvalidOperationException(
|
||||
"FoundryAgent inner chain does not expose an AIProjectClient; cannot create a project-level conversation session.");
|
||||
var conversationsClient = aiProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient();
|
||||
var conversationsClient = this._aiProjectClient.ProjectOpenAIClient.GetProjectConversationsClient();
|
||||
|
||||
var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
|
||||
|
||||
@@ -192,6 +196,17 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
|
||||
#endregion
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
if (serviceKey is null && serviceType == typeof(AIProjectClient))
|
||||
{
|
||||
return this._aiProjectClient;
|
||||
}
|
||||
|
||||
return base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
#region Private helpers
|
||||
|
||||
private static AIAgent CreateInnerAgent(
|
||||
@@ -236,7 +251,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
Throw.IfNull(agentOptions.ChatOptions);
|
||||
Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId);
|
||||
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId);
|
||||
IChatClient chatClient = new AzureAIProjectResponsesChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -273,10 +288,16 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the inner <see cref="ChatClientAgent"/> for the agent-endpoint constructor. The
|
||||
/// per-agent <see cref="ProjectOpenAIClient"/> shape and URL parsing are owned by
|
||||
/// <see cref="FoundryChatClient"/>; we just construct it in the Agent Endpoint mode (Mode 3)
|
||||
/// and pass the inner chat client through any caller-provided <paramref name="clientFactory"/>.
|
||||
/// Builds the inner <see cref="ChatClientAgent"/> for the agent-endpoint constructor by
|
||||
/// constructing a project-scoped <see cref="ProjectOpenAIClient"/> and using
|
||||
/// <see cref="ProjectOpenAIClient.GetProjectResponsesClientForAgentEndpoint(string, string?, ProjectOpenAIClientOptions?)"/>.
|
||||
/// This routes the outbound URL through the per-agent endpoint shape that the Foundry service
|
||||
/// expects for hosted agents and lets the SDK auto-append the <c>api-version</c> query string.
|
||||
/// Caller-supplied <paramref name="clientOptions"/> are passed through to the per-agent
|
||||
/// client with <c>Endpoint</c> and
|
||||
/// <see cref="ProjectOpenAIClientOptions.AgentName"/> overridden by values derived from
|
||||
/// <paramref name="agentEndpoint"/>; any policies the caller added via <c>AddPolicy</c>
|
||||
/// remain in effect on the per-agent pipeline. The MEAI user-agent policy is appended last.
|
||||
/// </summary>
|
||||
private static AIAgent CreateInnerAgentFromAgentEndpoint(
|
||||
Uri agentEndpoint,
|
||||
@@ -284,37 +305,29 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
ProjectOpenAIClientOptions? clientOptions,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
IServiceProvider? services,
|
||||
out AIProjectClient outClient)
|
||||
{
|
||||
Throw.IfNull(agentEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
|
||||
IChatClient chatClient = new FoundryChatClient(agentEndpoint, credential, clientOptions);
|
||||
var agentName = ((FoundryChatClient)chatClient).AgentName!;
|
||||
var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
|
||||
outClient = CreateProjectClient(projectRoot, credential, CreateProjectClientOptions(clientOptions));
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
chatClient = clientFactory(chatClient);
|
||||
}
|
||||
|
||||
ChatClientAgentOptions agentOptions = new()
|
||||
{
|
||||
Id = agentName,
|
||||
Name = agentName,
|
||||
ChatOptions = new() { Tools = tools },
|
||||
};
|
||||
|
||||
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
|
||||
return BuildAgentEndpointInnerAgent(outClient, agentEndpoint, clientOptions, tools, clientFactory, services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="CreateInnerAgentFromAgentEndpoint"/> that reuses an existing
|
||||
/// <see cref="AIProjectClient"/>'s pipeline instead of stamping a fresh credential. Used by
|
||||
/// the <c>AsAIAgent(AIProjectClient, Uri agentEndpoint, ...)</c> extension overload.
|
||||
/// Builds the inner <see cref="ChatClientAgent"/> for an agent endpoint against a pre-built
|
||||
/// <see cref="AIProjectClient"/>. The caller is responsible for ensuring the supplied client
|
||||
/// is rooted at the same project as <paramref name="agentEndpoint"/>; the agent name is
|
||||
/// parsed from the endpoint URI and passed to
|
||||
/// <see cref="ProjectOpenAIClient.GetProjectResponsesClientForAgentEndpoint(string, string?, ProjectOpenAIClientOptions?)"/>.
|
||||
/// </summary>
|
||||
private static AIAgent CreateInnerAgentFromAgentEndpointReusingProjectClient(
|
||||
private static AIAgent BuildAgentEndpointInnerAgent(
|
||||
AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
ProjectOpenAIClientOptions? clientOptions,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
@@ -322,9 +335,14 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
Throw.IfNull(aiProjectClient);
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentEndpoint, clientOptions: null);
|
||||
var agentName = ((FoundryChatClient)chatClient).AgentName!;
|
||||
var (agentName, _) = ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
|
||||
perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
|
||||
|
||||
IChatClient chatClient = aiProjectClient.ProjectOpenAIClient
|
||||
.GetProjectResponsesClientForAgentEndpoint(agentName, options: perAgentOptions)
|
||||
.AsIChatClient();
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
chatClient = clientFactory(chatClient);
|
||||
@@ -340,13 +358,6 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an agent endpoint URI. Delegates to <see cref="FoundryChatClient.ParseAgentEndpoint(Uri)"/>
|
||||
/// so the chat client and the agent share a single source of truth for the URL shape.
|
||||
/// </summary>
|
||||
internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint)
|
||||
=> FoundryChatClient.ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
/// <summary>
|
||||
/// Parses an agent endpoint URI of shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>
|
||||
@@ -358,12 +369,90 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
/// strips query string and fragment. Throws <see cref="ArgumentException"/> for inputs that
|
||||
/// do not match the expected shape.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// The endpoint is missing the <c>/agents/</c> segment, has an empty agent name, or has a
|
||||
/// suffix other than <c>/endpoint/protocols/openai</c>.
|
||||
/// </exception>
|
||||
internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint)
|
||||
{
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
const string AgentsSegment = "/agents/";
|
||||
const string ExpectedSuffix = "/endpoint/protocols/openai";
|
||||
|
||||
var path = agentEndpoint.AbsolutePath.TrimEnd('/');
|
||||
var idx = path.IndexOf(AgentsSegment, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx < 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Expected an agent endpoint of shape 'https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai' but got '{agentEndpoint}'.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var afterAgents = path.Substring(idx + AgentsSegment.Length);
|
||||
var nextSlash = afterAgents.IndexOf('/');
|
||||
if (nextSlash <= 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Agent endpoint '{agentEndpoint}' is missing the '<agentName>{ExpectedSuffix}' suffix.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var agentName = afterAgents.Substring(0, nextSlash);
|
||||
var suffix = afterAgents.Substring(nextSlash);
|
||||
if (!string.Equals(suffix, ExpectedSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Agent endpoint '{agentEndpoint}' has an unexpected suffix '{suffix}'. Expected '{ExpectedSuffix}'.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var rootPath = path.Substring(0, idx);
|
||||
var projectRoot = new UriBuilder(agentEndpoint)
|
||||
{
|
||||
Path = rootPath,
|
||||
Query = string.Empty,
|
||||
Fragment = string.Empty,
|
||||
}.Uri;
|
||||
|
||||
return (agentName, projectRoot);
|
||||
}
|
||||
|
||||
private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null)
|
||||
{
|
||||
Throw.IfNull(endpoint);
|
||||
Throw.IfNull(credential);
|
||||
|
||||
return new AIProjectClient(endpoint, credential, clientOptions ?? new AIProjectClientOptions());
|
||||
clientOptions ??= new AIProjectClientOptions();
|
||||
clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
|
||||
return new AIProjectClient(endpoint, credential, clientOptions);
|
||||
}
|
||||
|
||||
internal static AIProjectClientOptions? CreateProjectClientOptions(ProjectOpenAIClientOptions? clientOptions)
|
||||
{
|
||||
if (clientOptions is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Copy pipeline behavior the caller configured on the per-agent options bag onto the
|
||||
// project-level options bag so the agent endpoint client honors it. UserAgentApplicationId
|
||||
// is project-level (not derived from the agent endpoint), so it must be carried through too.
|
||||
var projectOptions = new AIProjectClientOptions
|
||||
{
|
||||
Transport = clientOptions.Transport,
|
||||
RetryPolicy = clientOptions.RetryPolicy,
|
||||
NetworkTimeout = clientOptions.NetworkTimeout,
|
||||
MessageLoggingPolicy = clientOptions.MessageLoggingPolicy,
|
||||
UserAgentApplicationId = clientOptions.UserAgentApplicationId,
|
||||
};
|
||||
|
||||
if (clientOptions.ClientLoggingOptions is not null)
|
||||
{
|
||||
projectOptions.ClientLoggingOptions = clientOptions.ClientLoggingOptions;
|
||||
}
|
||||
|
||||
return projectOptions;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Files;
|
||||
using OpenAI.VectorStores;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Foundry-specific extensions on <see cref="FoundryAgent"/>. Hosts the prompt-agent converter
|
||||
/// plus thin forwarders that surface the file and vector-store helpers from the inner
|
||||
/// <see cref="FoundryChatClient"/> at the agent level so callers do not need to drop down to
|
||||
/// <c>agent.GetService<FoundryChatClient>().X()</c> for common workflows.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class FoundryAgentExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts the supplied <see cref="FoundryAgent"/> into a <see cref="ProjectsAgentDefinition"/>
|
||||
/// ready to publish via <c>AgentAdministrationClient.CreateAgentVersionAsync</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Agent Endpoint construction mode (Mode 3) is not convertible because no local
|
||||
/// definition exists; conversion in that case throws <see cref="InvalidOperationException"/>.
|
||||
/// </remarks>
|
||||
/// <param name="agent">The Foundry agent to convert.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel an internal server-side fetch when the agent was constructed from a bare <see cref="AgentReference"/>.</param>
|
||||
/// <returns>A <see cref="ProjectsAgentDefinition"/> suitable for publishing.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent's chat client is not a <see cref="FoundryChatClient"/>; the agent was constructed via the Agent Endpoint mode (Mode 3); no model id is set on the agent's <see cref="ChatOptions"/> for the Responses Agent mode (Mode 1); or the agent contains an <see cref="AITool"/> that cannot be converted to a <c>ResponseTool</c>.</exception>
|
||||
public static Task<ProjectsAgentDefinition> ToPromptAgentAsync(this FoundryAgent agent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(agent);
|
||||
|
||||
var innerChatClient = agent.GetService<IChatClient>()
|
||||
?? throw new InvalidOperationException(
|
||||
"ToPromptAgentAsync could not resolve the inner IChatClient on the FoundryAgent.");
|
||||
var chatOptions = agent.GetService<ChatOptions>();
|
||||
return FoundryPromptAgentConverter.ConvertAsync(innerChatClient, chatOptions, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a file to the project. Thin forwarder to
|
||||
/// <see cref="FoundryChatClient.UploadFileAsync(string, FileUploadPurpose, CancellationToken)"/>
|
||||
/// on the agent's inner <see cref="FoundryChatClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The Foundry agent whose inner chat client owns the upload pipeline.</param>
|
||||
/// <param name="filePath">Path to the file to upload.</param>
|
||||
/// <param name="purpose">The upload purpose (e.g. <see cref="FileUploadPurpose.Assistants"/>).</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the upload.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent does not expose a <see cref="FoundryChatClient"/> via <see cref="AIAgent.GetService{TService}(object?)"/>.</exception>
|
||||
public static Task<OpenAIFile> UploadFileAsync(this FoundryAgent agent, string filePath, FileUploadPurpose purpose, CancellationToken cancellationToken = default)
|
||||
=> RequireFoundryChatClient(agent).UploadFileAsync(filePath, purpose, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a previously uploaded file. Thin forwarder to
|
||||
/// <see cref="FoundryChatClient.DeleteFileAsync(string, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The Foundry agent whose inner chat client owns the file pipeline.</param>
|
||||
/// <param name="fileId">The file id returned by <see cref="UploadFileAsync(FoundryAgent, string, FileUploadPurpose, CancellationToken)"/>.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the delete.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent does not expose a <see cref="FoundryChatClient"/>.</exception>
|
||||
public static Task<FileDeletionResult> DeleteFileAsync(this FoundryAgent agent, string fileId, CancellationToken cancellationToken = default)
|
||||
=> RequireFoundryChatClient(agent).DeleteFileAsync(fileId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the supplied files, creates a vector store containing them, and waits until the
|
||||
/// store leaves the in-progress state. Thin forwarder to
|
||||
/// <see cref="FoundryChatClient.CreateVectorStoreAsync(string, IEnumerable{string}, TimeSpan?, TimeSpan?, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The Foundry agent whose inner chat client owns the file and vector-store pipeline.</param>
|
||||
/// <param name="name">The vector store name.</param>
|
||||
/// <param name="filePaths">Paths to files to upload and attach to the store.</param>
|
||||
/// <param name="expiresAfter">Optional last-active-at expiration window.</param>
|
||||
/// <param name="pollingTimeout">Optional upper bound on the wait for the vector store to leave the in-progress state. Defaults to 5 minutes; pass <see cref="Timeout.InfiniteTimeSpan"/> to disable.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the orchestration.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent does not expose a <see cref="FoundryChatClient"/>.</exception>
|
||||
/// <exception cref="TimeoutException">The vector store did not leave the in-progress state within <paramref name="pollingTimeout"/>.</exception>
|
||||
public static Task<VectorStore> CreateVectorStoreAsync(this FoundryAgent agent, string name, IEnumerable<string> filePaths, TimeSpan? expiresAfter = null, TimeSpan? pollingTimeout = null, CancellationToken cancellationToken = default)
|
||||
=> RequireFoundryChatClient(agent).CreateVectorStoreAsync(name, filePaths, expiresAfter, pollingTimeout, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a vector store. Thin forwarder to
|
||||
/// <see cref="FoundryChatClient.DeleteVectorStoreAsync(string, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The Foundry agent whose inner chat client owns the vector-store pipeline.</param>
|
||||
/// <param name="vectorStoreId">The vector store id.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the delete.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent does not expose a <see cref="FoundryChatClient"/>.</exception>
|
||||
public static Task<VectorStoreDeletionResult> DeleteVectorStoreAsync(this FoundryAgent agent, string vectorStoreId, CancellationToken cancellationToken = default)
|
||||
=> RequireFoundryChatClient(agent).DeleteVectorStoreAsync(vectorStoreId, cancellationToken);
|
||||
|
||||
private static FoundryChatClient RequireFoundryChatClient(FoundryAgent agent)
|
||||
{
|
||||
Throw.IfNull(agent);
|
||||
return agent.GetService<FoundryChatClient>()
|
||||
?? throw new InvalidOperationException(
|
||||
"FoundryAgent does not expose a FoundryChatClient via GetService<FoundryChatClient>(). " +
|
||||
"File and vector-store helpers require the agent's inner chat client to be a FoundryChatClient.");
|
||||
}
|
||||
}
|
||||
@@ -1,703 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Files;
|
||||
using OpenAI.Responses;
|
||||
using OpenAI.VectorStores;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// 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, <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>
|
||||
/// Replaces the previous <c>AzureAIProjectChatClient</c> and <c>AzureAIProjectResponsesChatClient</c>
|
||||
/// decorators. All Foundry entry points (the public <c>FoundryAgent</c> constructors and the
|
||||
/// <c>AIProjectClientExtensions.AsAIAgent</c> overloads) now construct a
|
||||
/// <see cref="FoundryChatClient"/> internally, so telemetry and the agent-framework User-Agent
|
||||
/// segment are uniform across paths.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The three construction modes are:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>Responses Agent</b> (Mode 1): direct Responses API call against a project-level model id; no server-side agent definition exists. Constructed from <c>(AIProjectClient, modelId)</c>.</description></item>
|
||||
/// <item><description><b>Prompt Agent</b> (Mode 2): server-side agent definition (a <see cref="ProjectsAgentDefinition"/>, typically a <see cref="DeclarativeAgentDefinition"/>) invoked by <see cref="AgentReference"/> against the project Responses URL. Constructed from <see cref="AgentReference"/>, <see cref="ProjectsAgentVersion"/>, or <see cref="ProjectsAgentRecord"/>.</description></item>
|
||||
/// <item><description><b>Agent Endpoint</b> (Mode 3): invocation via the per-agent endpoint URL <c>…/projects/{p}/agents/{name}/endpoint/protocols/openai</c>. The agent behind the endpoint can be either a hosted (container-backed) agent or a Prompt Agent. Constructed from <c>(Uri agentEndpoint, credential)</c>.</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Note: "Hosted Agent" refers to a container-based runtime agent (see
|
||||
/// <c>Microsoft.Agents.AI.Foundry.Hosting</c>) and is the <i>kind</i> of agent that may sit
|
||||
/// behind an Agent Endpoint. It is not synonymous with the Agent Endpoint mode itself.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class FoundryChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly ChatClientMetadata _metadata;
|
||||
private readonly AIProjectClient? _aiProjectClient;
|
||||
private readonly AgentReference? _agentReference;
|
||||
private readonly ProjectsAgentVersion? _agentVersion;
|
||||
private readonly ProjectsAgentRecord? _agentRecord;
|
||||
private readonly ChatOptions? _baseChatOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Responses Agent mode (Mode 1): direct Responses API
|
||||
/// call against a project-level model id; no server-side agent definition exists.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The project client.</param>
|
||||
/// <param name="modelId">The model deployment id.</param>
|
||||
internal FoundryChatClient(AIProjectClient aiProjectClient, string modelId)
|
||||
: base(Throw.IfNull(aiProjectClient)
|
||||
.GetProjectOpenAIClient()
|
||||
.GetProjectResponsesClientForModel(Throw.IfNullOrWhitespace(modelId))
|
||||
.AsIChatClient())
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: modelId);
|
||||
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
|
||||
TryRegisterServedModelPolicy(this.InnerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Prompt Agent mode (Mode 2): server-side agent
|
||||
/// definition invoked by <see cref="AgentReference"/>.
|
||||
/// </summary>
|
||||
internal FoundryChatClient(AIProjectClient aiProjectClient, AgentReference agentReference, string? defaultModelId, ChatOptions? baseChatOptions)
|
||||
: base(Throw.IfNull(aiProjectClient)
|
||||
.GetProjectOpenAIClient()
|
||||
.GetProjectResponsesClientForAgent(Throw.IfNull(agentReference))
|
||||
.AsIChatClient())
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
this._agentReference = agentReference;
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId);
|
||||
this._baseChatOptions = baseChatOptions;
|
||||
this.AgentName = agentReference.Name;
|
||||
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
|
||||
TryRegisterServedModelPolicy(this.InnerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Prompt Agent mode (Mode 2, record variant):
|
||||
/// server-side agent definition invoked by record, resolving to the latest version.
|
||||
/// </summary>
|
||||
internal FoundryChatClient(AIProjectClient aiProjectClient, ProjectsAgentRecord agentRecord, ChatOptions? baseChatOptions)
|
||||
: this(aiProjectClient, Throw.IfNull(agentRecord).GetLatestVersion(), baseChatOptions)
|
||||
{
|
||||
this._agentRecord = agentRecord;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Prompt Agent mode (Mode 2, version variant):
|
||||
/// server-side agent definition invoked by a specific version.
|
||||
/// </summary>
|
||||
internal FoundryChatClient(AIProjectClient aiProjectClient, ProjectsAgentVersion agentVersion, ChatOptions? baseChatOptions)
|
||||
: this(
|
||||
aiProjectClient,
|
||||
CreateAgentReference(Throw.IfNull(agentVersion)),
|
||||
(agentVersion.Definition as DeclarativeAgentDefinition)?.Model,
|
||||
baseChatOptions)
|
||||
{
|
||||
this._agentVersion = agentVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Agent Endpoint mode (Mode 3): invocation via the
|
||||
/// per-agent endpoint URL. Parses the URL into its per-agent
|
||||
/// <see cref="ProjectOpenAIClient"/> shape internally and forwards through the resulting
|
||||
/// responses client.
|
||||
/// </summary>
|
||||
/// <param name="agentEndpoint">
|
||||
/// The agent-specific endpoint URI. Must be of the shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>.
|
||||
/// </param>
|
||||
/// <param name="credential">The authentication credential.</param>
|
||||
/// <param name="clientOptions">Optional per-agent client options. <c>Endpoint</c> and <c>AgentName</c> are owned by this ctor and overridden with values derived from <paramref name="agentEndpoint"/>.</param>
|
||||
internal FoundryChatClient(Uri agentEndpoint, AuthenticationTokenProvider credential, ProjectOpenAIClientOptions? clientOptions)
|
||||
: this(BuildAgentEndpointInner(agentEndpoint, credential, clientOptions))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Agent Endpoint mode (Mode 3) by reusing an existing
|
||||
/// <see cref="AIProjectClient"/>'s pipeline. Equivalent to the
|
||||
/// <see cref="FoundryChatClient(Uri, AuthenticationTokenProvider, ProjectOpenAIClientOptions?)"/>
|
||||
/// constructor but skips building a fresh per-agent pipeline: the project-level
|
||||
/// <see cref="ProjectOpenAIClient"/> on <paramref name="aiProjectClient"/> is used directly.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The project client already configured at the project root containing <paramref name="agentEndpoint"/>.</param>
|
||||
/// <param name="agentEndpoint">The per-agent endpoint URI. Same shape constraints as the other agent-endpoint ctor.</param>
|
||||
/// <param name="clientOptions">Optional per-agent client options applied to the per-agent <c>GetProjectResponsesClientForAgentEndpoint</c> call.</param>
|
||||
internal FoundryChatClient(AIProjectClient aiProjectClient, Uri agentEndpoint, ProjectOpenAIClientOptions? clientOptions)
|
||||
: this(BuildAgentEndpointInnerFromProjectClient(aiProjectClient, agentEndpoint, clientOptions))
|
||||
{
|
||||
}
|
||||
|
||||
private FoundryChatClient(AgentEndpointInner inner)
|
||||
: base(inner.ChatClient)
|
||||
{
|
||||
this._aiProjectClient = inner.AIProjectClient;
|
||||
this.AgentName = inner.AgentName;
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry");
|
||||
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
|
||||
TryRegisterServedModelPolicy(this.InnerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent name associated with this chat client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Set in two cases:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// Prompt Agent mode (Mode 2): the value of <see cref="AgentReference.Name"/> supplied at
|
||||
/// construction.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// Agent Endpoint mode (Mode 3): the agent name segment parsed from the supplied agent
|
||||
/// endpoint URI.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Returns <see langword="null"/> for the Responses Agent mode (Mode 1) where no agent name
|
||||
/// exists.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string? AgentName { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return (serviceKey is null && serviceType == typeof(ChatClientMetadata))
|
||||
? this._metadata
|
||||
: (serviceKey is null && serviceType == typeof(AIProjectClient))
|
||||
? this._aiProjectClient
|
||||
: (serviceKey is null && serviceType == typeof(AgentReference))
|
||||
? this._agentReference
|
||||
: (serviceKey is null && serviceType == typeof(ProjectsAgentVersion))
|
||||
? this._agentVersion
|
||||
: (serviceKey is null && serviceType == typeof(ProjectsAgentRecord))
|
||||
? this._agentRecord
|
||||
: base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var effectiveOptions = this._agentReference is not null
|
||||
? this.GetAgentEnabledChatOptions(options)
|
||||
: options;
|
||||
|
||||
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/>
|
||||
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var effectiveOptions = this._agentReference is not null
|
||||
? this.GetAgentEnabledChatOptions(options)
|
||||
: options;
|
||||
|
||||
var box = new StrongBox<string?>(null);
|
||||
var previous = ServedModelScope.Current;
|
||||
ServedModelScope.Current = box;
|
||||
|
||||
try
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#region File and vector-store helpers (mirrors Python's foundry_chat_client surface)
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a single file to the project for the supplied purpose. The upload is performed
|
||||
/// against the project-level <see cref="AIProjectClient"/> reachable via
|
||||
/// <see cref="GetService(Type, object?)"/>, so this method works uniformly across all three
|
||||
/// FoundryChatClient construction modes.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Absolute or relative path to the file to upload. The file must exist.</param>
|
||||
/// <param name="purpose">The file upload purpose (e.g. <see cref="FileUploadPurpose.Assistants"/>).</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the upload.</param>
|
||||
/// <returns>The created <see cref="OpenAIFile"/> as returned by the service.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="filePath"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="FileNotFoundException">The file at <paramref name="filePath"/> does not exist.</exception>
|
||||
public async Task<OpenAIFile> UploadFileAsync(string filePath, FileUploadPurpose purpose, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(filePath);
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"File not found: '{filePath}'.", filePath);
|
||||
}
|
||||
|
||||
var fileClient = this.GetOpenAIFileClient();
|
||||
// Use the Stream overload to honor cancellation; the (string, purpose) overload has no
|
||||
// CancellationToken parameter in the OpenAI SDK.
|
||||
using var stream = File.OpenRead(filePath);
|
||||
var result = await fileClient.UploadFileAsync(stream, Path.GetFileName(filePath), purpose, cancellationToken).ConfigureAwait(false);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
/// <summary>Deletes a file previously uploaded to the project.</summary>
|
||||
/// <param name="fileId">The file id returned by <see cref="UploadFileAsync(string, FileUploadPurpose, CancellationToken)"/>.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the delete.</param>
|
||||
/// <returns>The deletion result.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="fileId"/> is <see langword="null"/> or whitespace.</exception>
|
||||
public async Task<FileDeletionResult> DeleteFileAsync(string fileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNullOrWhitespace(fileId);
|
||||
var fileClient = this.GetOpenAIFileClient();
|
||||
var result = await fileClient.DeleteFileAsync(fileId, cancellationToken).ConfigureAwait(false);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the supplied files, creates a vector store containing them, waits until the
|
||||
/// store finishes ingesting its files (status leaves <see cref="VectorStoreStatus.InProgress"/>),
|
||||
/// and returns the <see cref="VectorStore"/>. Mirrors Python's
|
||||
/// <c>foundry_chat_client.create_vector_store(name, files, expires_after_days)</c>.
|
||||
/// </summary>
|
||||
/// <param name="name">The vector store name.</param>
|
||||
/// <param name="filePaths">Paths to files to upload and attach to the store.</param>
|
||||
/// <param name="expiresAfter">Optional last-active-at expiration window. When supplied, the vector store expires this many days after its last use.</param>
|
||||
/// <param name="pollingTimeout">Optional upper bound on the wait for the vector store to leave <see cref="VectorStoreStatus.InProgress"/>. Defaults to 5 minutes when not supplied; pass <see cref="Timeout.InfiniteTimeSpan"/> to disable. Independent of <paramref name="cancellationToken"/>: cancellation always wins.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the orchestration.</param>
|
||||
/// <returns>The created and fully-ready <see cref="VectorStore"/>. The returned instance reflects the state observed after polling completes; it may be in <see cref="VectorStoreStatus.Completed"/> (typical), <see cref="VectorStoreStatus.Expired"/>, or any other terminal status returned by the service. Only <see cref="VectorStoreStatus.InProgress"/> is polled.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// File-upload semantics are best-effort: when one of the per-file uploads throws, this method
|
||||
/// makes a best-effort attempt to delete the files it has already uploaded so they do not
|
||||
/// accumulate as orphaned resources on the project, then rethrows the original exception. The
|
||||
/// cleanup itself does not throw — its failures are silently ignored because the caller is
|
||||
/// already receiving a more meaningful exception from the original upload failure.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cancellation aborts the polling loop with an <see cref="OperationCanceledException"/>; any
|
||||
/// already-uploaded files and the partially-created vector store remain on the project and are
|
||||
/// the caller's responsibility to clean up. The same applies when the polling timeout elapses
|
||||
/// (a <see cref="TimeoutException"/> is thrown instead).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentException"><paramref name="name"/> is <see langword="null"/> or whitespace, or <paramref name="filePaths"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="TimeoutException">The vector store did not leave <see cref="VectorStoreStatus.InProgress"/> within <paramref name="pollingTimeout"/>.</exception>
|
||||
public async Task<VectorStore> CreateVectorStoreAsync(string name, IEnumerable<string> filePaths, TimeSpan? expiresAfter = null, TimeSpan? pollingTimeout = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
Throw.IfNull(filePaths);
|
||||
|
||||
var fileIds = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var path in filePaths)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var uploaded = await this.UploadFileAsync(path, FileUploadPurpose.Assistants, cancellationToken).ConfigureAwait(false);
|
||||
fileIds.Add(uploaded.Id);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Q-B: best-effort cleanup of files already uploaded before the mid-loop failure so
|
||||
// they do not accumulate as orphaned resources on the project. Swallow cleanup
|
||||
// exceptions — the caller is already going to see the original upload exception, and
|
||||
// there is nothing useful we can do with a secondary delete failure.
|
||||
await this.BestEffortDeleteFilesAsync(fileIds).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
var options = new VectorStoreCreationOptions
|
||||
{
|
||||
Name = name,
|
||||
};
|
||||
foreach (var id in fileIds)
|
||||
{
|
||||
options.FileIds.Add(id);
|
||||
}
|
||||
if (expiresAfter is { } window)
|
||||
{
|
||||
options.ExpirationPolicy = new VectorStoreExpirationPolicy(VectorStoreExpirationAnchor.LastActiveAt, (int)Math.Ceiling(window.TotalDays));
|
||||
}
|
||||
|
||||
var vectorStoreClient = this.GetVectorStoreClient();
|
||||
var createResult = await vectorStoreClient.CreateVectorStoreAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
var created = createResult.Value;
|
||||
|
||||
// Q-A: poll until the vector store leaves the in-progress state. Without this the helper
|
||||
// hands the caller a vector store whose file ingestion may still be running, defeating
|
||||
// the purpose of the one-call wrapper.
|
||||
return await WaitForVectorStoreReadyAsync(vectorStoreClient, created, pollingTimeout ?? s_defaultPollingTimeout, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task BestEffortDeleteFilesAsync(IEnumerable<string> fileIds)
|
||||
{
|
||||
foreach (var id in fileIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Pass CancellationToken.None: cleanup runs in the catch path; the caller's
|
||||
// token may already be cancelled and we still want to do our best to free
|
||||
// orphaned resources before propagating the original exception.
|
||||
await this.DeleteFileAsync(id, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently ignore cleanup failures; see XML doc on CreateVectorStoreAsync.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Upper bound on <see cref="WaitForVectorStoreReadyAsync"/> when the caller does not supply one. Chosen to comfortably cover normal Foundry vector-store ingestion (seconds to a minute for modest file sets) while still surfacing a clear failure if the server is stuck.</summary>
|
||||
private static readonly TimeSpan s_defaultPollingTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
private static async Task<VectorStore> WaitForVectorStoreReadyAsync(VectorStoreClient client, VectorStore initial, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
if (initial.Status != VectorStoreStatus.InProgress)
|
||||
{
|
||||
return initial;
|
||||
}
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var delay = TimeSpan.FromMilliseconds(250);
|
||||
var maxDelay = TimeSpan.FromSeconds(2);
|
||||
var current = initial;
|
||||
while (current.Status == VectorStoreStatus.InProgress)
|
||||
{
|
||||
if (timeout != Timeout.InfiniteTimeSpan && stopwatch.Elapsed >= timeout)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"Vector store '{current.Id}' did not leave the in-progress state within {timeout.TotalSeconds:0.##} seconds.");
|
||||
}
|
||||
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
var refreshed = await client.GetVectorStoreAsync(current.Id, cancellationToken).ConfigureAwait(false);
|
||||
current = refreshed.Value;
|
||||
|
||||
if (delay < maxDelay)
|
||||
{
|
||||
var next = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
|
||||
delay = next < maxDelay ? next : maxDelay;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/// <summary>Deletes a vector store. The associated files (if any) are not deleted by this method; call <see cref="DeleteFileAsync(string, CancellationToken)"/> separately to clean them up.</summary>
|
||||
/// <param name="vectorStoreId">The vector store id.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the delete.</param>
|
||||
/// <returns>The deletion result.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="vectorStoreId"/> is <see langword="null"/> or whitespace.</exception>
|
||||
public async Task<VectorStoreDeletionResult> DeleteVectorStoreAsync(string vectorStoreId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNullOrWhitespace(vectorStoreId);
|
||||
var vectorStoreClient = this.GetVectorStoreClient();
|
||||
var result = await vectorStoreClient.DeleteVectorStoreAsync(vectorStoreId, cancellationToken).ConfigureAwait(false);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
private OpenAIFileClient GetOpenAIFileClient()
|
||||
{
|
||||
var projectClient = this._aiProjectClient
|
||||
?? throw new InvalidOperationException("This FoundryChatClient does not have an AIProjectClient available. File and vector-store helpers require an AIProjectClient.");
|
||||
return projectClient.GetProjectOpenAIClient().GetOpenAIFileClient();
|
||||
}
|
||||
|
||||
private VectorStoreClient GetVectorStoreClient()
|
||||
{
|
||||
var projectClient = this._aiProjectClient
|
||||
?? throw new InvalidOperationException("This FoundryChatClient does not have an AIProjectClient available. File and vector-store helpers require an AIProjectClient.");
|
||||
return projectClient.GetProjectOpenAIClient().GetVectorStoreClient();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Parses an agent endpoint URI of shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>
|
||||
/// and returns the agent name and the derived project-root URI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tolerates trailing slash, casing variants on <c>/agents/</c> and the suffix segment, and
|
||||
/// strips query string and fragment. Throws <see cref="ArgumentException"/> for inputs that
|
||||
/// do not match the expected shape.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// The endpoint is missing the <c>/agents/</c> segment, has an empty agent name, or has a
|
||||
/// suffix other than <c>/endpoint/protocols/openai</c>.
|
||||
/// </exception>
|
||||
internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint)
|
||||
{
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
const string AgentsSegment = "/agents/";
|
||||
const string ExpectedSuffix = "/endpoint/protocols/openai";
|
||||
|
||||
var path = agentEndpoint.AbsolutePath.TrimEnd('/');
|
||||
var idx = path.IndexOf(AgentsSegment, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx < 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Expected an agent endpoint of shape 'https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai' but got '{agentEndpoint}'. " +
|
||||
"If you want to construct a FoundryAgent against a project endpoint, use the (Uri projectEndpoint, AuthenticationTokenProvider credential, string model, string instructions, ...) constructor instead.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var afterAgents = path.Substring(idx + AgentsSegment.Length);
|
||||
var nextSlash = afterAgents.IndexOf('/');
|
||||
if (nextSlash <= 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Agent endpoint '{agentEndpoint}' is missing the '<agentName>{ExpectedSuffix}' suffix.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var agentName = afterAgents.Substring(0, nextSlash);
|
||||
var suffix = afterAgents.Substring(nextSlash);
|
||||
if (!string.Equals(suffix, ExpectedSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Agent endpoint '{agentEndpoint}' has an unexpected suffix '{suffix}'. Expected '{ExpectedSuffix}'.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var rootPath = path.Substring(0, idx);
|
||||
var projectRoot = new UriBuilder(agentEndpoint)
|
||||
{
|
||||
Path = rootPath,
|
||||
Query = string.Empty,
|
||||
Fragment = string.Empty,
|
||||
}.Uri;
|
||||
|
||||
return (agentName, projectRoot);
|
||||
}
|
||||
|
||||
private ChatOptions GetAgentEnabledChatOptions(ChatOptions? options)
|
||||
{
|
||||
// Start with a clone of the base chat options defined for the agent, if any.
|
||||
ChatOptions agentEnabledChatOptions = this._baseChatOptions?.Clone() ?? new();
|
||||
|
||||
// Ignore per-request all options that can't be overridden.
|
||||
agentEnabledChatOptions.Instructions = null;
|
||||
agentEnabledChatOptions.Tools = null;
|
||||
agentEnabledChatOptions.Temperature = null;
|
||||
agentEnabledChatOptions.TopP = null;
|
||||
agentEnabledChatOptions.PresencePenalty = null;
|
||||
agentEnabledChatOptions.ResponseFormat = null;
|
||||
|
||||
// Use the conversation from the request, or the one defined at the client level.
|
||||
agentEnabledChatOptions.ConversationId = options?.ConversationId ?? this._baseChatOptions?.ConversationId;
|
||||
|
||||
// Preserve the original RawRepresentationFactory.
|
||||
var originalFactory = options?.RawRepresentationFactory;
|
||||
|
||||
agentEnabledChatOptions.RawRepresentationFactory = (client) =>
|
||||
{
|
||||
if (originalFactory?.Invoke(this) is not CreateResponseOptions responseCreationOptions)
|
||||
{
|
||||
responseCreationOptions = new CreateResponseOptions();
|
||||
}
|
||||
|
||||
responseCreationOptions.Agent = this._agentReference;
|
||||
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates.
|
||||
responseCreationOptions.Patch.Remove("$.model"u8);
|
||||
#pragma warning restore SCME0001
|
||||
|
||||
return responseCreationOptions;
|
||||
};
|
||||
|
||||
return agentEnabledChatOptions;
|
||||
}
|
||||
|
||||
private static AgentReference CreateAgentReference(ProjectsAgentVersion agentVersion)
|
||||
{
|
||||
// If the version is null, empty, or whitespace, use "latest" as the default. This handles
|
||||
// cases where hosted agents (like MCP agents) may not have a version assigned.
|
||||
var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version;
|
||||
return new AgentReference(agentVersion.Name, version);
|
||||
}
|
||||
|
||||
private static AgentEndpointInner BuildAgentEndpointInner(
|
||||
Uri agentEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
ProjectOpenAIClientOptions? clientOptions)
|
||||
{
|
||||
Throw.IfNull(agentEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
|
||||
var (agentName, projectRoot) = ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
|
||||
perAgentOptions.Endpoint = agentEndpoint;
|
||||
perAgentOptions.AgentName = agentName;
|
||||
|
||||
var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope);
|
||||
var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions);
|
||||
|
||||
var chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient();
|
||||
|
||||
// Materialize a project-level AIProjectClient from the parsed project root so
|
||||
// GetService<AIProjectClient>() returns non-null for all FoundryChatClient
|
||||
// construction modes. Project-level helpers (file upload, vector store create/delete)
|
||||
// depend on this. RBAC for those calls is at the project level; if the supplied
|
||||
// credential lacks project-scope permissions, the SDK surfaces a clean 401/403 at
|
||||
// call time. The four observable primitive ClientPipelineOptions properties are
|
||||
// propagated from the caller's per-agent options bag so test-injected transports and
|
||||
// explicit RetryPolicy / NetworkTimeout / UserAgentApplicationId reach the
|
||||
// project-level pipeline. Pipeline policies added via AddPolicy on the caller bag are
|
||||
// NOT propagated because ClientPipelineOptions does not publicly enumerate policies.
|
||||
var aiProjectClientOptions = new AIProjectClientOptions();
|
||||
if (clientOptions is not null)
|
||||
{
|
||||
if (clientOptions.RetryPolicy is not null)
|
||||
{
|
||||
aiProjectClientOptions.RetryPolicy = clientOptions.RetryPolicy;
|
||||
}
|
||||
if (clientOptions.NetworkTimeout is not null)
|
||||
{
|
||||
aiProjectClientOptions.NetworkTimeout = clientOptions.NetworkTimeout;
|
||||
}
|
||||
if (clientOptions.Transport is not null)
|
||||
{
|
||||
aiProjectClientOptions.Transport = clientOptions.Transport;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId))
|
||||
{
|
||||
aiProjectClientOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId;
|
||||
}
|
||||
}
|
||||
var aiProjectClient = new AIProjectClient(projectRoot, credential, aiProjectClientOptions);
|
||||
|
||||
return new AgentEndpointInner(chatClient, aiProjectClient, agentName);
|
||||
}
|
||||
|
||||
private static AgentEndpointInner BuildAgentEndpointInnerFromProjectClient(
|
||||
AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
ProjectOpenAIClientOptions? clientOptions)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
var (agentName, _) = ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
|
||||
perAgentOptions.Endpoint = agentEndpoint;
|
||||
perAgentOptions.AgentName = agentName;
|
||||
|
||||
var chatClient = aiProjectClient.GetProjectOpenAIClient()
|
||||
.GetProjectResponsesClientForAgentEndpoint(agentName, options: perAgentOptions)
|
||||
.AsIChatClient();
|
||||
|
||||
// Reuse the caller's AIProjectClient verbatim — no new pipeline is materialized.
|
||||
return new AgentEndpointInner(chatClient, aiProjectClient, agentName);
|
||||
}
|
||||
|
||||
/// <summary>Best-effort registration of <see cref="AgentFrameworkUserAgentPolicy"/> via the MEAI <see cref="OpenAIRequestPolicies"/> hook with at-most-once dedup per pipeline.</summary>
|
||||
private static void TryRegisterAgentFrameworkUserAgentPolicy(IChatClient? innerClient)
|
||||
{
|
||||
if (innerClient?.GetService<OpenAIRequestPolicies>() is { } policies)
|
||||
{
|
||||
// OpenAIRequestPoliciesReflection.AddPolicyIfMissing performs a check-then-add against
|
||||
// the private _entries collection on the OpenAIRequestPolicies instance, so the
|
||||
// policy is registered at most once even when many FoundryChatClient instances share
|
||||
// the same underlying chat client.
|
||||
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
|
||||
policies,
|
||||
AgentFrameworkUserAgentPolicy.Instance,
|
||||
PipelinePosition.PerCall);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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";
|
||||
|
||||
private readonly struct AgentEndpointInner
|
||||
{
|
||||
public AgentEndpointInner(IChatClient chatClient, AIProjectClient aiProjectClient, string agentName)
|
||||
{
|
||||
this.ChatClient = chatClient;
|
||||
this.AIProjectClient = aiProjectClient;
|
||||
this.AgentName = agentName;
|
||||
}
|
||||
|
||||
public IChatClient ChatClient { get; }
|
||||
public AIProjectClient AIProjectClient { get; }
|
||||
public string AgentName { get; }
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Shared internal implementation behind the public <c>ToPromptAgentAsync</c> extension methods
|
||||
/// on <see cref="ChatClientAgent"/> and <see cref="FoundryAgent"/>. Converts a Foundry-backed
|
||||
/// agent into a <see cref="ProjectsAgentDefinition"/> ready to publish via
|
||||
/// <see cref="AgentAdministrationClient"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Dispatch by <see cref="FoundryChatClient"/> construction mode (reachable via
|
||||
/// <see cref="IChatClient.GetService(Type, object?)"/>):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>Responses Agent (Mode 1)</b>: synthesize a <see cref="DeclarativeAgentDefinition"/> from the agent's <see cref="ChatOptions"/>.</description></item>
|
||||
/// <item><description><b>Prompt Agent (Mode 2, cached version)</b>: return the cached <see cref="ProjectsAgentVersion.Definition"/>.</description></item>
|
||||
/// <item><description><b>Prompt Agent (Mode 2, AgentReference-only)</b>: fetch the latest version from the service and return its definition.</description></item>
|
||||
/// <item><description><b>Agent Endpoint (Mode 3)</b>: throw — no local definition exists to convert.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
internal static class FoundryPromptAgentConverter
|
||||
{
|
||||
/// <summary>Performs the conversion for an agent whose chat client and chat options are supplied.</summary>
|
||||
/// <param name="chatClient">The chat client extracted from the calling agent (must surface a <see cref="FoundryChatClient"/> via <see cref="IChatClient.GetService(Type, object?)"/>).</param>
|
||||
/// <param name="chatOptions">The agent's chat options (model id, instructions, temperature, top-p, tools). Required for the Responses Agent mode; ignored for the Prompt Agent mode.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel a server-side fetch (Prompt Agent AgentReference path).</param>
|
||||
/// <returns>A <see cref="ProjectsAgentDefinition"/> suitable for <c>AgentAdministrationClient.CreateAgentVersionAsync</c>.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the chat client is not Foundry-backed, the agent was constructed via the Agent Endpoint mode, no model id is set for the Responses Agent mode, or an unsupported <see cref="AITool"/> is encountered.</exception>
|
||||
public static async Task<ProjectsAgentDefinition> ConvertAsync(IChatClient chatClient, ChatOptions? chatOptions, CancellationToken cancellationToken)
|
||||
{
|
||||
Throw.IfNull(chatClient);
|
||||
|
||||
var foundryChatClient = chatClient.GetService<FoundryChatClient>()
|
||||
?? throw new InvalidOperationException(
|
||||
"ToPromptAgentAsync requires a FoundryChatClient-backed agent. " +
|
||||
"The supplied agent's chat client does not expose a FoundryChatClient via GetService<FoundryChatClient>().");
|
||||
|
||||
// Prompt Agent (Mode 2) with a cached server-side version (constructed via ProjectsAgentVersion or ProjectsAgentRecord).
|
||||
if (foundryChatClient.GetService<ProjectsAgentVersion>() is { } cachedVersion)
|
||||
{
|
||||
return cachedVersion.Definition;
|
||||
}
|
||||
|
||||
// Prompt Agent (Mode 2) AgentReference-only: fetch the agent definition from the service.
|
||||
// Honor a pinned AgentReference.Version when present (Q-C fix); fall back to the latest
|
||||
// version only when the reference is unpinned ("", null, or "latest").
|
||||
if (foundryChatClient.GetService<AgentReference>() is { } agentReference)
|
||||
{
|
||||
var aiProjectClient = foundryChatClient.GetService<AIProjectClient>()
|
||||
?? throw new InvalidOperationException(
|
||||
"Cannot fetch the agent version because the FoundryChatClient does not expose an AIProjectClient.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agentReference.Version)
|
||||
&& !string.Equals(agentReference.Version, "latest", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var pinnedVersion = await aiProjectClient.AgentAdministrationClient
|
||||
.GetAgentVersionAsync(agentReference.Name, agentReference.Version, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return pinnedVersion.Value.Definition;
|
||||
}
|
||||
|
||||
var record = await aiProjectClient.AgentAdministrationClient
|
||||
.GetAgentAsync(agentReference.Name, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return record.Value.GetLatestVersion().Definition;
|
||||
}
|
||||
|
||||
// Agent Endpoint (Mode 3): AgentName is set (parsed from URL) but no AgentReference exists
|
||||
// locally. The agent definition lives only on the server and is not retrievable through this
|
||||
// chat client, so conversion is not supported here.
|
||||
if (foundryChatClient.AgentName is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"ToPromptAgentAsync is not supported for agents constructed via the Agent Endpoint mode (Mode 3); " +
|
||||
"no local definition exists to convert.");
|
||||
}
|
||||
|
||||
// Responses Agent (Mode 1): synthesize from ChatOptions.
|
||||
return SynthesizeFromChatOptions(chatOptions);
|
||||
}
|
||||
|
||||
private static DeclarativeAgentDefinition SynthesizeFromChatOptions(ChatOptions? chatOptions)
|
||||
{
|
||||
if (chatOptions is null || string.IsNullOrWhiteSpace(chatOptions.ModelId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"ToPromptAgentAsync requires a model id on the agent's ChatOptions to synthesize a prompt agent definition.");
|
||||
}
|
||||
|
||||
var definition = new DeclarativeAgentDefinition(chatOptions.ModelId!)
|
||||
{
|
||||
Instructions = chatOptions.Instructions,
|
||||
Temperature = chatOptions.Temperature,
|
||||
TopP = chatOptions.TopP,
|
||||
};
|
||||
|
||||
if (chatOptions.Tools is { Count: > 0 } tools)
|
||||
{
|
||||
foreach (var tool in tools)
|
||||
{
|
||||
definition.Tools.Add(ConvertTool(tool));
|
||||
}
|
||||
}
|
||||
|
||||
return definition;
|
||||
}
|
||||
|
||||
private static ResponseTool ConvertTool(AITool tool)
|
||||
{
|
||||
Throw.IfNull(tool);
|
||||
|
||||
if (tool is AIFunction function)
|
||||
{
|
||||
// strictModeEnabled is intentionally true to match the Python spec's
|
||||
// default behavior. JsonSchema on AIFunction is a JsonElement; serialize via its
|
||||
// string form so the payload matches what callers pass elsewhere in this codebase.
|
||||
return ResponseTool.CreateFunctionTool(
|
||||
function.Name,
|
||||
BinaryData.FromString(function.JsonSchema.ToString() ?? "{}"),
|
||||
strictModeEnabled: true,
|
||||
function.Description);
|
||||
}
|
||||
|
||||
if (tool.GetService(typeof(ResponseTool)) is ResponseTool responseTool)
|
||||
{
|
||||
return responseTool;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot convert AITool of type '{tool.GetType().Name}' to a ResponseTool. " +
|
||||
"Only AIFunction and AITool instances that wrap a ResponseTool (such as those produced by FoundryAITool factories) are supported.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
internal static class RequestOptionsExtensions
|
||||
{
|
||||
/// <summary>Gets the singleton <see cref="PipelinePolicy"/> that adds a MEAI user-agent header.</summary>
|
||||
internal static PipelinePolicy UserAgentPolicy => MeaiUserAgentPolicy.Instance;
|
||||
|
||||
/// <summary>Provides a pipeline policy that adds a "MEAI/x.y.z" user-agent header.</summary>
|
||||
private sealed class MeaiUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
public static MeaiUserAgentPolicy Instance { get; } = new MeaiUserAgentPolicy();
|
||||
|
||||
private static readonly string s_userAgentValue = CreateUserAgentValue();
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AddUserAgentHeader(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AddUserAgentHeader(message);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
private static void AddUserAgentHeader(PipelineMessage message) =>
|
||||
message.Request.Headers.Add("User-Agent", s_userAgentValue);
|
||||
|
||||
private static string CreateUserAgentValue()
|
||||
{
|
||||
const string Name = "MEAI";
|
||||
|
||||
if (typeof(MeaiUserAgentPolicy).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
|
||||
{
|
||||
int pos = version.IndexOf('+');
|
||||
if (pos >= 0)
|
||||
{
|
||||
version = version.Substring(0, pos);
|
||||
}
|
||||
|
||||
if (version.Length > 0)
|
||||
{
|
||||
return $"{Name}/{version}";
|
||||
}
|
||||
}
|
||||
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
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;
|
||||
@@ -204,14 +200,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -261,22 +249,6 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
providers.Add(skillsProvider);
|
||||
}
|
||||
|
||||
if (options?.BackgroundAgents is IEnumerable<AIAgent> backgroundAgents)
|
||||
{
|
||||
var materializedAgents = backgroundAgents.ToList();
|
||||
if (materializedAgents.Count > 0)
|
||||
{
|
||||
providers.Add(new BackgroundAgentsProvider(materializedAgents, options.BackgroundAgentsProviderOptions));
|
||||
}
|
||||
}
|
||||
|
||||
#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,9 +2,6 @@
|
||||
|
||||
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;
|
||||
|
||||
@@ -221,49 +218,4 @@ public sealed class HarnessAgentOptions
|
||||
/// This property is ignored when <see cref="DisableOpenTelemetry"/> is <see langword="true"/>.
|
||||
/// </remarks>
|
||||
public string? OpenTelemetrySourceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the collection of background agents available for delegation via <see cref="BackgroundAgentsProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When non-null and non-empty, a <see cref="BackgroundAgentsProvider"/> is automatically included in the
|
||||
/// agent's context providers, enabling the agent to start, monitor, and retrieve results from background tasks.
|
||||
/// When <see langword="null"/> or empty, no <see cref="BackgroundAgentsProvider"/> is configured.
|
||||
/// Each agent in the collection must have a non-empty <see cref="AIAgent.Name"/> and names must be unique
|
||||
/// (case-insensitive). If these requirements are not met, <see cref="BackgroundAgentsProvider"/> will throw
|
||||
/// an <see cref="System.ArgumentException"/> during construction.
|
||||
/// </remarks>
|
||||
public IEnumerable<AIAgent>? BackgroundAgents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets optional configuration for the <see cref="BackgroundAgentsProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use this to customize instructions or agent list formatting for the background agents feature.
|
||||
/// 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,10 +15,6 @@
|
||||
<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="true"/>
|
||||
/// When <paramref name="requireApproval"/> is <see langword="null"/>
|
||||
/// (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,12 +259,14 @@ 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"/> (the default) wraps the function in
|
||||
/// <see cref="ApprovalRequiredAIFunction"/>;
|
||||
/// <see langword="true"/> or <see langword="null"/> (the default)
|
||||
/// wraps the function in <see cref="ApprovalRequiredAIFunction"/>;
|
||||
/// <see langword="false"/> opts out and returns the raw function.
|
||||
/// </param>
|
||||
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
|
||||
public AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool? requireApproval = null)
|
||||
{
|
||||
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 " +
|
||||
@@ -290,7 +292,7 @@ public sealed class DockerShellExecutor : ShellExecutor
|
||||
},
|
||||
new AIFunctionFactoryOptions { Name = name, Description = description });
|
||||
|
||||
return requireApproval ? new ApprovalRequiredAIFunction(fn) : fn;
|
||||
return effectiveRequireApproval ? 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 override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
|
||||
public AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
|
||||
{
|
||||
if (!requireApproval && !this._acknowledgeUnsafe)
|
||||
{
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Tools.Shell;
|
||||
|
||||
@@ -66,20 +65,6 @@ 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();
|
||||
}
|
||||
|
||||
+2
-6
@@ -22,13 +22,9 @@ internal static class AgentProviderExtensions
|
||||
{
|
||||
IAsyncEnumerable<AgentResponseUpdate> agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken);
|
||||
|
||||
// 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.
|
||||
// Enable "autoSend" behavior if this is the workflow conversation.
|
||||
bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId);
|
||||
autoSend |= isWorkflowConversation;
|
||||
|
||||
// Process the agent response updates.
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
|
||||
-7
@@ -71,13 +71,6 @@ 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}]");
|
||||
|
||||
+5
-8
@@ -192,16 +192,13 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
|
||||
|
||||
private bool GetAutoSendValue()
|
||||
{
|
||||
// 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)
|
||||
if (this.AgentOutput?.AutoSend is null)
|
||||
{
|
||||
return this.Evaluator.GetValue(output.AutoSend).Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
EvaluationResult<bool> autoSendResult = this.Evaluator.GetValue(this.AgentOutput.AutoSend);
|
||||
|
||||
return autoSendResult.Value;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-68
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
@@ -104,24 +103,6 @@ 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
|
||||
@@ -260,48 +241,6 @@ 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)
|
||||
@@ -314,16 +253,12 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
|
||||
private bool GetAutoSendValue()
|
||||
{
|
||||
// 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)
|
||||
if (this.Model.Output?.AutoSend is null)
|
||||
{
|
||||
return this.Evaluator.GetValue(output.AutoSend).Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
|
||||
}
|
||||
|
||||
private Dictionary<string, object?>? GetArguments()
|
||||
|
||||
+3
-7
@@ -311,16 +311,12 @@ internal sealed class InvokeMcpToolExecutor(
|
||||
|
||||
private bool GetAutoSendValue()
|
||||
{
|
||||
// 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)
|
||||
if (this.Model.Output?.AutoSend is null)
|
||||
{
|
||||
return this.Evaluator.GetValue(output.AutoSend).Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
|
||||
}
|
||||
|
||||
private string? GetConnectionName()
|
||||
|
||||
+4
-13
@@ -21,21 +21,12 @@ 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, which
|
||||
// is the gated summary surfaced only when includeWorkflowOutputsInResponse = true.
|
||||
AgentResponse response = new([message]);
|
||||
// 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)]);
|
||||
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
using OpenAI.Files;
|
||||
using OpenAI.Responses;
|
||||
using OpenAI.VectorStores;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace Foundry.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the file and vector-store forwarder extensions on
|
||||
/// <see cref="FoundryAgent"/> declared in <see cref="FoundryAgentExtensions"/>. End-to-end
|
||||
/// counterparts of the unit tests in
|
||||
/// <c>FoundryAgentExtensionsTests</c> that exercise the live Foundry project pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="FoundryVersionedAgentCreateTests.CreateAgent_CreatesAgentWithVectorStoresAsync(string)"/>
|
||||
/// in shape (file upload → vector store creation → FileSearchTool answer → cleanup), but routes
|
||||
/// every helper call through the new <see cref="FoundryAgent"/> extensions instead of the raw
|
||||
/// <c>projectOpenAIClient.GetProjectFilesClient()</c> / <c>GetProjectVectorStoresClient()</c>
|
||||
/// path. Skipped by default for the same reasons as the existing vector-store IT (cost and
|
||||
/// runtime); flip Skip to run manually after seeding the right Foundry project.
|
||||
/// </remarks>
|
||||
public class FoundryAgentExtensionsTests
|
||||
{
|
||||
private readonly AIProjectClient _client = new(
|
||||
new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)),
|
||||
TestAzureCliCredentials.CreateAzureCliCredential());
|
||||
|
||||
[Fact(Skip = "For manual testing only")]
|
||||
public async Task UploadFileAsync_ViaAgentExtension_UploadsToProjectAsync()
|
||||
{
|
||||
// Arrange — non-versioned Responses Agent (Mode 1) so we do not have to provision a server-side agent.
|
||||
var agent = this._client.AsAIAgent(
|
||||
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
|
||||
instructions: "Be helpful.");
|
||||
var foundryAgent = this.WrapAsFoundryAgent(agent);
|
||||
|
||||
var filePath = Path.GetTempFileName() + ".txt";
|
||||
File.WriteAllText(filePath, "agent-extensions integration test payload");
|
||||
|
||||
OpenAIFile? uploaded = null;
|
||||
try
|
||||
{
|
||||
// Act.
|
||||
uploaded = await foundryAgent.UploadFileAsync(filePath, FileUploadPurpose.Assistants);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(uploaded);
|
||||
Assert.False(string.IsNullOrEmpty(uploaded.Id));
|
||||
Assert.Equal(Path.GetFileName(filePath), uploaded.Filename);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (uploaded is not null)
|
||||
{
|
||||
await foundryAgent.DeleteFileAsync(uploaded.Id);
|
||||
}
|
||||
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = "For manual testing only")]
|
||||
public async Task DeleteFileAsync_ViaAgentExtension_RemovesUploadedFileAsync()
|
||||
{
|
||||
var agent = this._client.AsAIAgent(
|
||||
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
|
||||
instructions: "Be helpful.");
|
||||
var foundryAgent = this.WrapAsFoundryAgent(agent);
|
||||
|
||||
var filePath = Path.GetTempFileName() + ".txt";
|
||||
File.WriteAllText(filePath, "delete-me payload");
|
||||
|
||||
try
|
||||
{
|
||||
var uploaded = await foundryAgent.UploadFileAsync(filePath, FileUploadPurpose.Assistants);
|
||||
|
||||
// Act.
|
||||
var result = await foundryAgent.DeleteFileAsync(uploaded.Id);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(uploaded.Id, result.FileId);
|
||||
Assert.True(result.Deleted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = "For manual testing only")]
|
||||
public async Task CreateVectorStoreAsync_ViaAgentExtension_BuildsStoreAndAnswersFileSearchQuestionAsync()
|
||||
{
|
||||
// Mirrors CreateAgent_CreatesAgentWithVectorStoresAsync but the upload-then-create-store
|
||||
// sequence routes through the FoundryAgent.CreateVectorStoreAsync extension (single call
|
||||
// that uploads, creates the store, and polls until ready). The resulting vector store id
|
||||
// is then wired to a versioned agent's FileSearch tool and queried for a known value.
|
||||
string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("VectorStoreExtAgent");
|
||||
const string AgentInstructions = """
|
||||
You are a helpful agent that can help fetch data from files you know about.
|
||||
Use the File Search Tool to look up codes for words.
|
||||
Do not answer a question unless you can find the answer using the File Search Tool.
|
||||
""";
|
||||
|
||||
// Non-versioned helper agent that owns the upload pipeline.
|
||||
var helperAgent = this._client.AsAIAgent(
|
||||
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
|
||||
instructions: "Be helpful.");
|
||||
var helperFoundryAgent = this.WrapAsFoundryAgent(helperAgent);
|
||||
|
||||
var searchFilePath = Path.GetTempFileName() + "wordcodelookup.txt";
|
||||
File.WriteAllText(searchFilePath, "The word 'apple' uses the code 442345, while the word 'banana' uses the code 673457.");
|
||||
|
||||
VectorStore? vectorStore = null;
|
||||
FoundryAgent? versionedAgent = null;
|
||||
try
|
||||
{
|
||||
// Act — single agent-level helper call uploads, creates, and waits until ready.
|
||||
vectorStore = await helperFoundryAgent.CreateVectorStoreAsync(
|
||||
"WordCodeLookup_ExtensionVectorStore",
|
||||
new[] { searchFilePath });
|
||||
|
||||
Assert.NotNull(vectorStore);
|
||||
Assert.False(string.IsNullOrEmpty(vectorStore.Id));
|
||||
Assert.NotEqual(VectorStoreStatus.InProgress, vectorStore.Status);
|
||||
|
||||
// Wire the store id into a versioned agent's FileSearch tool to prove it is actually usable.
|
||||
var definition = new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
|
||||
{
|
||||
Instructions = AgentInstructions,
|
||||
Tools = { ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStore.Id]) },
|
||||
};
|
||||
|
||||
var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync(
|
||||
AgentName,
|
||||
new ProjectsAgentVersionCreationOptions(definition));
|
||||
|
||||
versionedAgent = this._client.AsAIAgent(agentVersion);
|
||||
|
||||
// Assert.
|
||||
var result = await versionedAgent.RunAsync("Can you give me the documented code for 'banana'?");
|
||||
Assert.Contains("673457", result.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (versionedAgent is not null)
|
||||
{
|
||||
await this._client.AgentAdministrationClient.DeleteAgentAsync(versionedAgent.Name);
|
||||
}
|
||||
|
||||
// Cleanup the vector store via the new extension too.
|
||||
if (vectorStore is not null)
|
||||
{
|
||||
await helperFoundryAgent.DeleteVectorStoreAsync(vectorStore.Id);
|
||||
}
|
||||
|
||||
File.Delete(searchFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = "For manual testing only")]
|
||||
public async Task DeleteVectorStoreAsync_ViaAgentExtension_RemovesStoreAsync()
|
||||
{
|
||||
var agent = this._client.AsAIAgent(
|
||||
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
|
||||
instructions: "Be helpful.");
|
||||
var foundryAgent = this.WrapAsFoundryAgent(agent);
|
||||
|
||||
var filePath = Path.GetTempFileName() + ".txt";
|
||||
File.WriteAllText(filePath, "delete-store payload");
|
||||
|
||||
VectorStore? vectorStore = null;
|
||||
try
|
||||
{
|
||||
vectorStore = await foundryAgent.CreateVectorStoreAsync(
|
||||
"DeleteVectorStore_ExtensionTest",
|
||||
new[] { filePath });
|
||||
|
||||
// Act.
|
||||
var result = await foundryAgent.DeleteVectorStoreAsync(vectorStore.Id);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(vectorStore.Id, result.VectorStoreId);
|
||||
Assert.True(result.Deleted);
|
||||
vectorStore = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (vectorStore is not null)
|
||||
{
|
||||
await foundryAgent.DeleteVectorStoreAsync(vectorStore.Id);
|
||||
}
|
||||
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the underlying <see cref="FoundryAgent"/> from an <see cref="AIAgent"/> handle
|
||||
/// returned by <c>AIProjectClient.AsAIAgent(model, instructions)</c>. The Mode 1 overload
|
||||
/// returns a <see cref="ChatClientAgent"/>; the extension forwarders we test live on
|
||||
/// <see cref="FoundryAgent"/>, so callers wanting them through this entry point need to
|
||||
/// reach for the FoundryAgent constructor instead. This helper makes the test setup
|
||||
/// consistent across the four IT scenarios.
|
||||
/// </summary>
|
||||
private FoundryAgent WrapAsFoundryAgent(AIAgent agent)
|
||||
{
|
||||
// The Mode 1 AsAIAgent overload returns ChatClientAgent rather than FoundryAgent; use
|
||||
// the FoundryAgent projectEndpoint+model+instructions ctor to get the same underlying
|
||||
// FoundryChatClient surfaced through a FoundryAgent typed handle.
|
||||
_ = agent;
|
||||
return new FoundryAgent(
|
||||
projectEndpoint: new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)),
|
||||
credential: TestAzureCliCredentials.CreateAzureCliCredential(),
|
||||
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
|
||||
instructions: "Be helpful.");
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -11,7 +11,7 @@ namespace Foundry.IntegrationTests;
|
||||
public class FoundryVersionedAgentStructuredOutputRunTests() : StructuredOutputRunTests<FoundryVersionedAgentStructuredOutputFixture<CityInfo>>(() => new FoundryVersionedAgentStructuredOutputFixture<CityInfo>())
|
||||
{
|
||||
private const string NotSupported = "Versioned Foundry agents do not support specifying structured output type at invocation time.";
|
||||
private const string ResponseFormatNotSupported = "FoundryChatClient clears ResponseFormat for versioned agents; structured output must be defined in the server-side agent definition.";
|
||||
private const string ResponseFormatNotSupported = "AzureAIProjectChatClient clears ResponseFormat for versioned agents; structured output must be defined in the server-side agent definition.";
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that response format provided at agent initialization is used when invoking RunAsync.
|
||||
@@ -41,7 +41,7 @@ public class FoundryVersionedAgentStructuredOutputRunTests() : StructuredOutputR
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Versioned Foundry agents do not support specifying the structured output type at invocation time yet.
|
||||
/// The type T provided to RunAsync<T> is ignored by FoundryChatClient and is only used
|
||||
/// The type T provided to RunAsync<T> is ignored by AzureAIProjectChatClient and is only used
|
||||
/// for deserializing the agent response by AgentResponse<T>.Result.
|
||||
/// </remarks>
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay, Skip = ResponseFormatNotSupported)]
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
+4
-191
@@ -64,29 +64,15 @@ public sealed class HostedOutboundUserAgentTests : IAsyncDisposable
|
||||
var inboundBody = await inboundResponse.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert: at least one OUTBOUND request reached the fake transport, AND it carries the
|
||||
// combined hosted segment foundry-hosting/agent-framework-dotnet/{version} on its
|
||||
// User-Agent. This matches Python's contract
|
||||
// (foundry-hosting/agent-framework-python/{version}, see
|
||||
// python/packages/core/agent_framework/_telemetry.py): a single combined segment when
|
||||
// hosted, never two separate ones. The bare agent-framework-dotnet/{version} segment
|
||||
// (from AgentFrameworkUserAgentPolicy in FoundryChatClient) must be upgraded in place
|
||||
// by HostedAgentUserAgentPolicy — never appear duplicated.
|
||||
// foundry-hosting/agent-framework-dotnet/{version} supplement on its User-Agent.
|
||||
// (We don't care about the inbound response shape — only that the agent's call to MEAI
|
||||
// triggered an outbound request whose UA reaches the sandbox boundary correctly.)
|
||||
Assert.True(this._outboundHandler!.Requests.Count > 0,
|
||||
$"Expected at least one outbound request. Inbound status: {(int)inboundResponse.StatusCode}, body: {inboundBody}");
|
||||
var outbound = this._outboundHandler.Requests[0];
|
||||
Assert.StartsWith(TestEndpoint, outbound.Uri);
|
||||
Assert.Contains("MEAI/", outbound.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet/", outbound.UserAgent);
|
||||
|
||||
// The bare agent-framework-dotnet/{v} segment must NOT appear separately when the
|
||||
// combined form is present — Python emits a single combined value when the hosted
|
||||
// prefix is registered, and .NET preserves that contract via the in-place upgrade in
|
||||
// HostedAgentUserAgentPolicy.
|
||||
var combinedIdx = outbound.UserAgent!.IndexOf("foundry-hosting/agent-framework-dotnet/", StringComparison.Ordinal);
|
||||
var beforeCombined = outbound.UserAgent.Substring(0, combinedIdx);
|
||||
var afterCombined = outbound.UserAgent.Substring(combinedIdx + "foundry-hosting/agent-framework-dotnet/".Length);
|
||||
Assert.DoesNotContain("agent-framework-dotnet/", beforeCombined);
|
||||
Assert.DoesNotContain("agent-framework-dotnet/", afterCombined);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", outbound.UserAgent);
|
||||
}
|
||||
|
||||
private async Task StartHostedServerAsync()
|
||||
@@ -211,179 +197,6 @@ public sealed class HostedOutboundUserAgentTests : IAsyncDisposable
|
||||
return array?.Length ?? -1;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Direct unit tests for HostedAgentUserAgentPolicy's in-place upgrade behavior.
|
||||
// These run the policy on a synthetic ClientPipeline (no hosting infrastructure)
|
||||
// so the upgrade logic itself can be asserted in isolation.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task HostedAgentUserAgentPolicy_UpgradesBareAgentFrameworkSegment_InPlaceAsync()
|
||||
{
|
||||
// Arrange: an upstream per-call policy stamps the bare agent-framework-dotnet/{version}
|
||||
// segment (matching what AgentFrameworkUserAgentPolicy would write in non-hosted code).
|
||||
// Then HostedAgentUserAgentPolicy runs and must REPLACE that segment with the combined
|
||||
// foundry-hosting/agent-framework-dotnet/{version} form, not append a duplicate.
|
||||
using var handler = new InspectingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [new SetUserAgentPolicy("agent-framework-dotnet/9.9.9"), HostedAgentUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: combined form is present; bare form is gone (no duplicate agent-framework segment).
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet/", handler.LastUserAgent);
|
||||
var ua = handler.LastUserAgent!;
|
||||
var firstAgentFramework = ua.IndexOf("agent-framework-dotnet/", StringComparison.Ordinal);
|
||||
Assert.True(firstAgentFramework >= 0, "Expected agent-framework-dotnet segment.");
|
||||
var secondAgentFramework = ua.IndexOf("agent-framework-dotnet/", firstAgentFramework + 1, StringComparison.Ordinal);
|
||||
Assert.Equal(-1, secondAgentFramework);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostedAgentUserAgentPolicy_AppendsCombined_WhenNoBareSegmentPresentAsync()
|
||||
{
|
||||
// Arrange: nothing upstream stamps the bare segment. Hosted policy should append the
|
||||
// full combined segment to whatever User-Agent is on the wire.
|
||||
using var handler = new InspectingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [HostedAgentUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet/", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostedAgentUserAgentPolicy_IsIdempotent_WhenCombinedSegmentAlreadyPresentAsync()
|
||||
{
|
||||
// Arrange: upstream pre-populates the combined segment (simulating a retry or duplicate
|
||||
// registration). Hosted policy must not re-append.
|
||||
using var handler = new InspectingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [new SetUserAgentPolicy("foundry-hosting/agent-framework-dotnet/9.9.9"), HostedAgentUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: exactly one occurrence of "foundry-hosting/agent-framework-dotnet/" segment.
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
var first = handler.LastUserAgent!.IndexOf("foundry-hosting/agent-framework-dotnet/", StringComparison.Ordinal);
|
||||
Assert.True(first >= 0);
|
||||
var second = handler.LastUserAgent.IndexOf("foundry-hosting/agent-framework-dotnet/", first + 1, StringComparison.Ordinal);
|
||||
Assert.Equal(-1, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostedAgentUserAgentPolicy_ReplacesDifferentVersionCombinedSegment_InPlaceAsync()
|
||||
{
|
||||
// Q-D regression: when the User-Agent already carries the COMBINED hosted form with a
|
||||
// different version (e.g. an older registration or caller-supplied baseline), the policy
|
||||
// must replace the entire combined span — not just the bare suffix — so we never emit
|
||||
// the malformed `foundry-hosting/foundry-hosting/agent-framework-dotnet/...` shape.
|
||||
using var handler = new InspectingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [new SetUserAgentPolicy("foundry-hosting/agent-framework-dotnet/0.0.1 MEAI/10.5.1"), HostedAgentUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: no doubled foundry-hosting/ prefix.
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.DoesNotContain("foundry-hosting/foundry-hosting/", handler.LastUserAgent, StringComparison.Ordinal);
|
||||
|
||||
// The combined segment must appear exactly once, and the trailing MEAI segment must be
|
||||
// preserved in place (i.e. the policy only rewrote the combined span, not anything after it).
|
||||
var firstCombined = handler.LastUserAgent!.IndexOf("foundry-hosting/agent-framework-dotnet/", StringComparison.Ordinal);
|
||||
Assert.True(firstCombined >= 0);
|
||||
var secondCombined = handler.LastUserAgent.IndexOf("foundry-hosting/agent-framework-dotnet/", firstCombined + 1, StringComparison.Ordinal);
|
||||
Assert.Equal(-1, secondCombined);
|
||||
Assert.Contains(" MEAI/10.5.1", handler.LastUserAgent, StringComparison.Ordinal);
|
||||
|
||||
// And the version that survives must be the runtime supplement value's version, not 0.0.1.
|
||||
Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet/0.0.1", handler.LastUserAgent, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class InspectingHandler : HttpClientHandler
|
||||
{
|
||||
public string? LastUserAgent { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SetUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
private readonly string _value;
|
||||
public SetUserAgentPolicy(string value) => this._value = value;
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", this._value);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", this._value);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
|
||||
-199
@@ -1,199 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the framework-wide <see cref="AgentFrameworkUserAgentPolicy"/>. The policy stamps
|
||||
/// <c>agent-framework-dotnet/{version}</c> onto the outgoing <c>User-Agent</c> header of every
|
||||
/// request made through a Foundry chat client and is registered automatically by
|
||||
/// <c>FoundryChatClient</c> via the MEAI <c>OpenAIRequestPolicies</c> hook.
|
||||
/// </summary>
|
||||
public sealed class AgentFrameworkUserAgentPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AgentFrameworkUserAgentPolicy_AddsAgentFrameworkSegment_ToOutgoingRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [AgentFrameworkUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, handler.Count);
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.Contains("agent-framework-dotnet/", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentFrameworkUserAgentPolicy_DoesNotStampMeaiSegmentAsync()
|
||||
{
|
||||
// Arrange: the AF policy must only contribute the agent-framework-dotnet segment.
|
||||
// The MEAI/{version} segment is contributed by the MEAI-shipped policy at a different
|
||||
// layer; this policy must not duplicate or replace it.
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [AgentFrameworkUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.DoesNotContain("MEAI/", handler.LastUserAgent);
|
||||
Assert.DoesNotContain("foundry-hosting/", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentFrameworkUserAgentPolicy_PreservesExistingUserAgent_WhenAppendingAsync()
|
||||
{
|
||||
// Arrange: a per-call policy upstream that pre-populates the User-Agent header. The AF
|
||||
// policy must read the existing value and append (not overwrite) the agent-framework
|
||||
// segment so both stay reachable on the wire. (The exact separator the HTTP transport
|
||||
// emits between multi-value User-Agent entries is comma per RFC 7230; this test does
|
||||
// not assert on the separator character because that is a transport detail.)
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [new SeedUserAgentPolicy("existing-app/1.0"), AgentFrameworkUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: both segments survive to the wire.
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.Contains("existing-app/1.0", handler.LastUserAgent);
|
||||
Assert.Contains("agent-framework-dotnet/", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentFrameworkUserAgentPolicy_IsIdempotent_DoesNotDoubleStampAsync()
|
||||
{
|
||||
// Arrange: register the same policy twice on the same pipeline. The second application
|
||||
// must detect the segment is already present and not append it again. Guards against
|
||||
// double-stamping on retries or duplicate registration.
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [AgentFrameworkUserAgentPolicy.Instance, AgentFrameworkUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: exactly one occurrence of "agent-framework-dotnet/".
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
var ua = handler.LastUserAgent!;
|
||||
var first = ua.IndexOf("agent-framework-dotnet/", StringComparison.Ordinal);
|
||||
Assert.True(first >= 0, "Expected at least one agent-framework-dotnet segment.");
|
||||
var second = ua.IndexOf("agent-framework-dotnet/", first + 1, StringComparison.Ordinal);
|
||||
Assert.Equal(-1, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentFrameworkUserAgentPolicy_ExposesSingletonInstance()
|
||||
{
|
||||
// Two reads of the static property must return the same instance. The policy is stateless
|
||||
// and shared; allocating a fresh instance per registration site would bloat memory and
|
||||
// defeat the dedup logic in OpenAIRequestPoliciesReflection.AddPolicyIfMissing.
|
||||
var first = AgentFrameworkUserAgentPolicy.Instance;
|
||||
var second = AgentFrameworkUserAgentPolicy.Instance;
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentFrameworkUserAgentPolicy_ValueIncludesAFFoundryAssemblyVersion_ReflectionGuard()
|
||||
{
|
||||
// The policy emits "agent-framework-dotnet/{Microsoft.Agents.AI.Foundry assembly InformationalVersion}".
|
||||
// If the assembly metadata stops being readable, the policy falls back to "agent-framework-dotnet"
|
||||
// without a version, which is a measurable telemetry regression.
|
||||
var attr = typeof(AgentFrameworkUserAgentPolicy).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
||||
Assert.NotNull(attr);
|
||||
Assert.False(string.IsNullOrEmpty(attr!.InformationalVersion));
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
public int Count { get; private set; }
|
||||
public string? LastUserAgent { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.Count++;
|
||||
this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: null;
|
||||
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SeedUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
private readonly string _value;
|
||||
public SeedUserAgentPolicy(string value) => this._value = value;
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", this._value);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", this._value);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-111
@@ -23,9 +23,9 @@ namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
#pragma warning disable CS0618
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AIProjectClientExtensions"/> class.
|
||||
/// Unit tests for the <see cref="AzureAIProjectChatClientExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class AIProjectClientExtensionsTests
|
||||
public sealed class AzureAIProjectChatClientExtensionsTests
|
||||
{
|
||||
#region AsAIAgent(AIProjectClient, model, instructions) Tests
|
||||
|
||||
@@ -71,11 +71,7 @@ public sealed class AIProjectClientExtensionsTests
|
||||
Assert.Equal("test-agent", agent.Name);
|
||||
Assert.Equal("A test agent", agent.Description);
|
||||
Assert.NotNull(agent.GetService<IChatClient>());
|
||||
// After the FoundryChatClient consolidation the inner chat-client now exposes the
|
||||
// AIProjectClient via GetService — Foundry callers can walk to the project client from
|
||||
// the agent without holding their own reference. (Previously this path returned null
|
||||
// because AsAIAgent(model, instructions) skipped the decorator entirely.)
|
||||
Assert.NotNull(agent.GetService<AIProjectClient>());
|
||||
Assert.Null(agent.GetService<AIProjectClient>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -127,10 +123,7 @@ public sealed class AIProjectClientExtensionsTests
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("options-agent", agent.Name);
|
||||
Assert.Equal("Agent from options", agent.Description);
|
||||
// After the FoundryChatClient consolidation the inner chat-client now exposes the
|
||||
// AIProjectClient via GetService — see twin assertion in
|
||||
// AsAIAgent_Rapi_WithModelAndInstructions_CreatesChatClientAgent for the rationale.
|
||||
Assert.NotNull(agent.GetService<AIProjectClient>());
|
||||
Assert.Null(agent.GetService<AIProjectClient>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -192,106 +185,6 @@ public sealed class AIProjectClientExtensionsTests
|
||||
Assert.True(userAgentFound, "MEAI user-agent header was not found in any request");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the non-versioned AsAIAgent overload now wraps with FoundryChatClient
|
||||
/// (regression-prevention for the previously-untagged extension path).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsAIAgent_Rapi_WithModelAndInstructions_ExposesFoundryChatClientAndProviderName()
|
||||
{
|
||||
// Arrange
|
||||
AIProjectClient client = this.CreateTestAgentClient();
|
||||
|
||||
// Act
|
||||
ChatClientAgent agent = client.AsAIAgent("gpt-4o-mini", "You are helpful.");
|
||||
|
||||
// Assert: FoundryChatClient is internal-sealed and reachable via GetService<IChatClient>().
|
||||
var chatClient = agent.GetService<IChatClient>();
|
||||
Assert.NotNull(chatClient);
|
||||
|
||||
// Provider tag is "microsoft.foundry" (previously this path had no Foundry tag at all).
|
||||
var metadata = chatClient!.GetService<ChatClientMetadata>();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("microsoft.foundry", metadata!.ProviderName);
|
||||
Assert.Equal("gpt-4o-mini", metadata.DefaultModelId);
|
||||
|
||||
// Reaching the FoundryChatClient by type (via InternalsVisibleTo).
|
||||
Assert.NotNull(agent.GetService<FoundryChatClient>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the options-based non-versioned AsAIAgent overload now wraps with FoundryChatClient.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsAIAgent_Rapi_WithOptions_ExposesFoundryChatClientAndProviderName()
|
||||
{
|
||||
// Arrange
|
||||
AIProjectClient client = this.CreateTestAgentClient();
|
||||
ChatClientAgentOptions options = new()
|
||||
{
|
||||
Name = "options-agent",
|
||||
ChatOptions = new ChatOptions { ModelId = "gpt-4o-mini", Instructions = "x" },
|
||||
};
|
||||
|
||||
// Act
|
||||
ChatClientAgent agent = client.AsAIAgent(options);
|
||||
|
||||
// Assert
|
||||
var chatClient = agent.GetService<IChatClient>();
|
||||
Assert.NotNull(chatClient);
|
||||
var metadata = chatClient!.GetService<ChatClientMetadata>();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("microsoft.foundry", metadata!.ProviderName);
|
||||
Assert.NotNull(agent.GetService<FoundryChatClient>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the non-versioned AsAIAgent overload stamps the
|
||||
/// agent-framework-dotnet/{version} segment on outbound requests via the new
|
||||
/// AgentFrameworkUserAgentPolicy registered by FoundryChatClient.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AsAIAgent_Rapi_WithModelAndInstructions_StampsAgentFrameworkUserAgentSegmentAsync()
|
||||
{
|
||||
bool afSeen = false;
|
||||
using HttpHandlerAssert httpHandler = new(request =>
|
||||
{
|
||||
if (request.Headers.TryGetValues("User-Agent", out IEnumerable<string>? values))
|
||||
{
|
||||
foreach (string value in values)
|
||||
{
|
||||
if (value.Contains("agent-framework-dotnet/"))
|
||||
{
|
||||
afSeen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json")
|
||||
};
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using HttpClient httpClient = new(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
AIProjectClient aiProjectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
ChatClientAgent agent = aiProjectClient.AsAIAgent("gpt-4o-mini", "You are helpful.");
|
||||
|
||||
// Act
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session);
|
||||
|
||||
// Assert
|
||||
Assert.True(afSeen, "Expected agent-framework-dotnet/{version} segment on outbound requests from AsAIAgent(model, instructions).");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AsAIAgent(AIProjectClient, ProjectsAgentRecord) Tests
|
||||
@@ -0,0 +1,209 @@
|
||||
// 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.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
#pragma warning disable CS0618
|
||||
public class AzureAIProjectChatClientTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that after the first RunAsync, the session's ConversationId is set from the
|
||||
/// response, and subsequent requests include that conversation ID automatically.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatClient_UsesDefaultConversationIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
var responsesRequestCount = 0;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
|
||||
{
|
||||
responsesRequestCount++;
|
||||
|
||||
// Assert: On the second Responses API call, verify the conversation ID
|
||||
// from the first response is automatically included in the request body.
|
||||
if (responsesRequestCount == 2 && request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("resp_0888a", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") };
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session);
|
||||
await agent.RunAsync("Follow up", session);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, responsesRequestCount);
|
||||
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when the chat client doesn't have a default "conv_" conversation id, the chat client still uses the conversation ID in HTTP requests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatClient_UsesPerRequestConversationId_WhenNoDefaultConversationIdIsProvidedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestTriggered = false;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
|
||||
{
|
||||
requestTriggered = true;
|
||||
|
||||
// Assert
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("conv_12345", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") };
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } });
|
||||
|
||||
Assert.True(requestTriggered);
|
||||
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal("conv_12345", chatClientSession.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that even when the chat client has a default conversation id, the chat client will prioritize the per-request conversation id provided in HTTP requests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatClient_UsesPerRequestConversationId_EvenWhenDefaultConversationIdIsProvidedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestTriggered = false;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
|
||||
{
|
||||
requestTriggered = true;
|
||||
|
||||
// Assert
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("conv_12345", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") };
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } });
|
||||
|
||||
Assert.True(requestTriggered);
|
||||
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal("conv_12345", chatClientSession.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when the chat client is provided without a "conv_" prefixed conversation ID, the chat client uses the previous conversation ID in HTTP requests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatClient_UsesPreviousResponseId_WhenConversationIsNotPrefixedAsConvAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestTriggered = false;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
|
||||
{
|
||||
requestTriggered = true;
|
||||
|
||||
// Assert
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("resp_0888a", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") };
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "resp_0888a" } });
|
||||
|
||||
Assert.True(requestTriggered);
|
||||
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId);
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
@@ -1,200 +0,0 @@
|
||||
// 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.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
using OpenAI.Files;
|
||||
|
||||
#pragma warning disable OPENAI001, CS0618
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the file and vector-store forwarder extensions on <see cref="FoundryAgent"/>
|
||||
/// declared in <see cref="FoundryAgentExtensions"/>. The forwarders are thin shims over the
|
||||
/// inner <see cref="FoundryChatClient"/>, so coverage focuses on (a) request shape (the agent
|
||||
/// path reaches the same wire as a direct chat-client call), (b) null/missing-FoundryChatClient
|
||||
/// handling, and (c) returns the same payload the chat client would.
|
||||
/// </summary>
|
||||
public sealed class FoundryAgentExtensionsTests
|
||||
{
|
||||
private static readonly Uri s_testProjectEndpoint = new("https://test.openai.azure.com/");
|
||||
|
||||
[Fact]
|
||||
public async Task UploadFileAsync_Forwards_ToInnerFoundryChatClient_Async()
|
||||
{
|
||||
// Arrange — agent built via the Responses Agent (Mode 1) projectEndpoint+model+instructions
|
||||
// ctor wires a FoundryChatClient inside that the extension can resolve via GetService.
|
||||
var sawPostToFiles = false;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal))
|
||||
{
|
||||
sawPostToFiles = true;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(FakeFileJson("file_via_agent"), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") };
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var agent = new FoundryAgent(
|
||||
projectEndpoint: s_testProjectEndpoint,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "Be helpful.",
|
||||
clientOptions: new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"fae-{Guid.NewGuid():N}.txt");
|
||||
System.IO.File.WriteAllText(path, "hello");
|
||||
|
||||
try
|
||||
{
|
||||
// Act — call the forwarder on the agent.
|
||||
var result = await agent.UploadFileAsync(path, FileUploadPurpose.Assistants);
|
||||
|
||||
// Assert
|
||||
Assert.True(sawPostToFiles, "POST to /files must reach the wire through the agent forwarder.");
|
||||
Assert.Equal("file_via_agent", result.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFileAsync_Forwards_ToInnerFoundryChatClient_Async()
|
||||
{
|
||||
var sawDelete = false;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Delete && req.RequestUri!.AbsolutePath.Contains("/files/", StringComparison.Ordinal))
|
||||
{
|
||||
sawDelete = true;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"id\":\"file_abc\",\"object\":\"file\",\"deleted\":true}", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") };
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var agent = new FoundryAgent(
|
||||
projectEndpoint: s_testProjectEndpoint,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "Be helpful.",
|
||||
clientOptions: new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var result = await agent.DeleteFileAsync("file_abc");
|
||||
|
||||
Assert.True(sawDelete);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_Forwards_ToInnerFoundryChatClient_Async()
|
||||
{
|
||||
var sawVectorStorePost = false;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/vector_stores", StringComparison.Ordinal))
|
||||
{
|
||||
sawVectorStorePost = true;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(FakeVectorStoreJson("vs_via_agent", "kb"), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") };
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var agent = new FoundryAgent(
|
||||
projectEndpoint: s_testProjectEndpoint,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "Be helpful.",
|
||||
clientOptions: new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var store = await agent.CreateVectorStoreAsync("kb", Array.Empty<string>());
|
||||
|
||||
Assert.True(sawVectorStorePost);
|
||||
Assert.Equal("vs_via_agent", store.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVectorStoreAsync_Forwards_ToInnerFoundryChatClient_Async()
|
||||
{
|
||||
var sawDelete = false;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Delete && req.RequestUri!.AbsolutePath.Contains("/vector_stores/", StringComparison.Ordinal))
|
||||
{
|
||||
sawDelete = true;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"id\":\"vs_abc\",\"object\":\"vector_store.deleted\",\"deleted\":true}", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") };
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var agent = new FoundryAgent(
|
||||
projectEndpoint: s_testProjectEndpoint,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "Be helpful.",
|
||||
clientOptions: new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
await agent.DeleteVectorStoreAsync("vs_abc");
|
||||
|
||||
Assert.True(sawDelete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadFileAsync_NullAgent_ThrowsArgumentNullExceptionAsync()
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryAgentExtensions.UploadFileAsync(null!, "x", FileUploadPurpose.Assistants));
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFileAsync_NullAgent_ThrowsArgumentNullExceptionAsync()
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryAgentExtensions.DeleteFileAsync(null!, "file_abc"));
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_NullAgent_ThrowsArgumentNullExceptionAsync()
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryAgentExtensions.CreateVectorStoreAsync(null!, "kb", Array.Empty<string>()));
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVectorStoreAsync_NullAgent_ThrowsArgumentNullExceptionAsync()
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryAgentExtensions.DeleteVectorStoreAsync(null!, "vs_abc"));
|
||||
|
||||
// ----- Helpers -----
|
||||
|
||||
private static string FakeFileJson(string id)
|
||||
=> $"{{\"id\":\"{id}\",\"object\":\"file\",\"bytes\":11,\"created_at\":1700000000,\"filename\":\"x.txt\",\"purpose\":\"assistants\",\"status\":\"processed\"}}";
|
||||
|
||||
private static string FakeVectorStoreJson(string id, string name)
|
||||
=> $"{{\"id\":\"{id}\",\"object\":\"vector_store\",\"created_at\":1700000000,\"name\":\"{name}\",\"usage_bytes\":0,\"file_counts\":{{\"in_progress\":0,\"completed\":0,\"failed\":0,\"cancelled\":0,\"total\":0}},\"status\":\"completed\",\"last_active_at\":1700000000}}";
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
@@ -351,24 +352,18 @@ public class FoundryAgentTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_AgentFrameworkUserAgentHeaderAddedToRequestsAsync()
|
||||
public async Task Constructor_UserAgentHeaderAddedToRequestsAsync()
|
||||
{
|
||||
// After the FoundryChatClient consolidation, every outbound request from a
|
||||
// FoundryAgent-built chat client carries the new agent-framework-dotnet/{version}
|
||||
// segment (stamped by AgentFrameworkUserAgentPolicy registered via the MEAI
|
||||
// OpenAIRequestPolicies hook). The local MEAI/{version} stamp was removed because
|
||||
// MEAI 10.5.1 stamps that itself; this test only verifies the framework-wide segment
|
||||
// that the Foundry package now guarantees.
|
||||
bool agentFrameworkUserAgentFound = false;
|
||||
bool userAgentFound = false;
|
||||
using HttpHandlerAssert httpHandler = new(request =>
|
||||
{
|
||||
if (request.Headers.TryGetValues("User-Agent", out System.Collections.Generic.IEnumerable<string>? values))
|
||||
if (request.Headers.TryGetValues("User-Agent", out IEnumerable<string>? values))
|
||||
{
|
||||
foreach (string value in values)
|
||||
{
|
||||
if (value.Contains("agent-framework-dotnet/"))
|
||||
if (value.StartsWith("MEAI/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
agentFrameworkUserAgentFound = true;
|
||||
userAgentFound = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -401,7 +396,7 @@ public class FoundryAgentTests
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session);
|
||||
|
||||
Assert.True(agentFrameworkUserAgentFound, "Expected agent-framework-dotnet user-agent segment to be present on outbound requests.");
|
||||
Assert.True(userAgentFound, "Expected MEAI user-agent header to be present in requests.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -439,9 +434,6 @@ public class FoundryAgentTests
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull()
|
||||
{
|
||||
// Behavior change: FoundryAgent no longer caches a ProjectOpenAIClient. Callers
|
||||
// retrieve it from the AIProjectClient themselves
|
||||
// (agent.GetService<AIProjectClient>()!.GetProjectOpenAIClient()).
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
|
||||
|
||||
Assert.Null(agent.GetService<ProjectOpenAIClient>());
|
||||
@@ -450,10 +442,6 @@ public class FoundryAgentTests
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNonNull()
|
||||
{
|
||||
// Behavior change: after Plan #2's Agent Endpoint mode (Mode 3) AIProjectClient materialization, the
|
||||
// agent-endpoint constructor now derives a project-level AIProjectClient from the
|
||||
// parsed project root URL and surfaces it via GetService. Previously this returned
|
||||
// null because no AIProjectClient was constructed for hosted-agent-endpoint agents.
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
|
||||
|
||||
Assert.NotNull(agent.GetService<AIProjectClient>());
|
||||
@@ -462,7 +450,6 @@ public class FoundryAgentTests
|
||||
[Fact]
|
||||
public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull()
|
||||
{
|
||||
// See AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull for rationale.
|
||||
FoundryAgent agent = new(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
@@ -624,57 +611,6 @@ public class FoundryAgentTests
|
||||
Assert.True(meaiSeen, "Expected MEAI/x.y.z to appear in the User-Agent header on the agent-endpoint pipeline.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_ExposesFoundryProviderName_OnChatClientMetadata()
|
||||
{
|
||||
// Behavior change: after the FoundryChatClient consolidation, the agent-endpoint path
|
||||
// now wraps with FoundryChatClient in the Agent Endpoint mode (Mode 3) and stamps the microsoft.foundry provider
|
||||
// name. Previously this path used a bare AsIChatClient() with no Foundry-specific
|
||||
// decorator, so the provider name defaulted to whatever MEAI surfaces. This guards the
|
||||
// new behavior.
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
|
||||
|
||||
var metadata = agent.GetService<ChatClientMetadata>();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("microsoft.foundry", metadata!.ProviderName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentEndpointConstructor_StampsAgentFrameworkUserAgentSegmentAsync()
|
||||
{
|
||||
// Behavior change: after the FoundryChatClient consolidation, every outbound request
|
||||
// from the agent-endpoint constructor carries the agent-framework-dotnet/{version}
|
||||
// segment via AgentFrameworkUserAgentPolicy. Previously this path had no
|
||||
// agent-framework branding at all.
|
||||
bool afSeen = false;
|
||||
using HttpHandlerAssert handler = new(req =>
|
||||
{
|
||||
if (req.Headers.TryGetValues("User-Agent", out var values))
|
||||
{
|
||||
foreach (string v in values)
|
||||
{
|
||||
if (v.Contains("agent-framework-dotnet/"))
|
||||
{
|
||||
afSeen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using HttpClient http = new(handler);
|
||||
#pragma warning restore CA5399
|
||||
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
await agent.RunAsync("Hello");
|
||||
|
||||
Assert.True(afSeen, "Expected agent-framework-dotnet/{version} segment on the agent-endpoint outbound User-Agent.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentEndpointConstructor_PassesThroughCallerPolicyOnPerAgentPipelineAsync()
|
||||
{
|
||||
@@ -730,22 +666,82 @@ public class FoundryAgentTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_PropagatesUserAgentApplicationId_ToProjectLevelClient()
|
||||
public void AgentEndpointConstructor_PreservesUserAgentApplicationId()
|
||||
{
|
||||
// The MEAI policy adds its own User-Agent header so we cannot reliably observe the OpenAI SDK's
|
||||
// application-id stamp in the outbound request. Verify the value is propagated onto the
|
||||
// caller's options bag and that the materialized AIProjectClient is reachable so
|
||||
// downstream conversation/file/vector-store operations can pick the application id up.
|
||||
ProjectOpenAIClientOptions opts = new() { UserAgentApplicationId = "my-app-id" };
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
|
||||
AIProjectClient? aiProjectClient = agent.GetService<AIProjectClient>();
|
||||
Assert.NotNull(aiProjectClient);
|
||||
// Caller's UserAgentApplicationId is preserved on the per-agent options bag verbatim.
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("my-app-id", opts.UserAgentApplicationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProjectClientOptions_NullCallerOptions_ReturnsNull()
|
||||
{
|
||||
Assert.Null(FoundryAgent.CreateProjectClientOptions(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProjectClientOptions_CarriesPipelineSettingsAndUserAgent()
|
||||
{
|
||||
// Arrange
|
||||
var transport = new FakePipelineTransport();
|
||||
var retryPolicy = new FakeRetryPolicy();
|
||||
var messageLoggingPolicy = new FakeMessageLoggingPolicy();
|
||||
var clientLoggingOptions = new ClientLoggingOptions { EnableLogging = false };
|
||||
var networkTimeout = TimeSpan.FromSeconds(42);
|
||||
|
||||
ProjectOpenAIClientOptions callerOptions = new()
|
||||
{
|
||||
UserAgentApplicationId = "my-app-id",
|
||||
Transport = transport,
|
||||
RetryPolicy = retryPolicy,
|
||||
MessageLoggingPolicy = messageLoggingPolicy,
|
||||
ClientLoggingOptions = clientLoggingOptions,
|
||||
NetworkTimeout = networkTimeout,
|
||||
};
|
||||
|
||||
// Act
|
||||
AIProjectClientOptions? projectOptions = FoundryAgent.CreateProjectClientOptions(callerOptions);
|
||||
|
||||
// Assert: every settable pipeline behavior the caller configured is forwarded
|
||||
// onto the project-level options bag, not silently dropped.
|
||||
Assert.NotNull(projectOptions);
|
||||
Assert.Equal("my-app-id", projectOptions!.UserAgentApplicationId);
|
||||
Assert.Same(transport, projectOptions.Transport);
|
||||
Assert.Same(retryPolicy, projectOptions.RetryPolicy);
|
||||
Assert.Same(messageLoggingPolicy, projectOptions.MessageLoggingPolicy);
|
||||
Assert.Same(clientLoggingOptions, projectOptions.ClientLoggingOptions);
|
||||
Assert.Equal(networkTimeout, projectOptions.NetworkTimeout);
|
||||
}
|
||||
|
||||
private sealed class FakeRetryPolicy : PipelinePolicy
|
||||
{
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
=> ProcessNext(message, pipeline, currentIndex);
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
=> ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
private sealed class FakeMessageLoggingPolicy : PipelinePolicy
|
||||
{
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
=> ProcessNext(message, pipeline, currentIndex);
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
=> ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
private sealed class FakePipelineTransport : PipelineTransport
|
||||
{
|
||||
protected override PipelineMessage CreateMessageCore() => throw new NotSupportedException();
|
||||
protected override void ProcessCore(PipelineMessage message) => throw new NotSupportedException();
|
||||
protected override ValueTask ProcessCoreAsync(PipelineMessage message) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ParseAgentEndpoint tests
|
||||
@@ -828,13 +824,13 @@ public class FoundryAgentTests
|
||||
private readonly string _value;
|
||||
public HeaderStampPolicy(string name, string value) { this._name = name; this._value = value; }
|
||||
|
||||
public override void Process(PipelineMessage message, System.Collections.Generic.IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set(this._name, this._value);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, System.Collections.Generic.IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set(this._name, this._value);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
|
||||
@@ -1,616 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable OPENAI001, CS0618
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the internal <see cref="FoundryChatClient"/>. Covers the three construction
|
||||
/// modes (Responses Agent, Prompt Agent, Agent Endpoint), the GetService
|
||||
/// returns per mode, the metadata-tagging contract, the agent-framework user-agent registration,
|
||||
/// the Agent Endpoint mode (Mode 3) URL parsing happy and error paths, and end-to-end behavior through the public
|
||||
/// <c>AsAIAgent(AgentReference)</c> extension that constructs a FoundryChatClient internally.
|
||||
/// </summary>
|
||||
public sealed class FoundryChatClientTests
|
||||
{
|
||||
#region the Responses Agent mode (Mode 1): Responses Agent (AIProjectClient + modelId)
|
||||
|
||||
[Fact]
|
||||
public void Mode1_ResponsesAgent_StampsFoundryProviderName()
|
||||
{
|
||||
// Arrange
|
||||
var projectClient = CreateProjectClient();
|
||||
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
// Assert
|
||||
var metadata = chatClient.GetService<ChatClientMetadata>();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("microsoft.foundry", metadata!.ProviderName);
|
||||
Assert.Equal("gpt-4o-mini", metadata.DefaultModelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode1_ResponsesAgent_ExposesAIProjectClient_ViaGetService()
|
||||
{
|
||||
// Arrange
|
||||
var projectClient = CreateProjectClient();
|
||||
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
// Assert
|
||||
Assert.Same(projectClient, chatClient.GetService<AIProjectClient>());
|
||||
// ProjectOpenAIClient is intentionally NOT exposed via GetService — callers retrieve
|
||||
// it from the AIProjectClient themselves (aiProjectClient.GetProjectOpenAIClient()).
|
||||
Assert.Null(chatClient.GetService<ProjectOpenAIClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode1_ResponsesAgent_ReturnsNullForAgentSpecificServices()
|
||||
{
|
||||
// Arrange
|
||||
var projectClient = CreateProjectClient();
|
||||
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
// Assert
|
||||
Assert.Null(chatClient.GetService<AgentReference>());
|
||||
Assert.Null(chatClient.GetService<ProjectsAgentVersion>());
|
||||
Assert.Null(chatClient.GetService<ProjectsAgentRecord>());
|
||||
// No agent name exists in the Responses Agent mode (Mode 1) — only the Prompt Agent mode (Mode 2) (from AgentReference.Name) and the Agent Endpoint mode (Mode 3)
|
||||
// (parsed from URL) populate FoundryChatClient.AgentName.
|
||||
Assert.Null(chatClient.AgentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode1_ResponsesAgent_ThrowsOnNullProjectClient()
|
||||
=> Assert.Throws<ArgumentNullException>(() => new FoundryChatClient(aiProjectClient: null!, "gpt-4o-mini"));
|
||||
|
||||
[Fact]
|
||||
public void Mode1_ResponsesAgent_ThrowsOnEmptyModelId()
|
||||
=> Assert.Throws<ArgumentException>(() => new FoundryChatClient(CreateProjectClient(), modelId: ""));
|
||||
|
||||
#endregion
|
||||
|
||||
#region the Prompt Agent mode (Mode 2): Prompt Agent (direct unit tests)
|
||||
|
||||
[Fact]
|
||||
public void Mode2_PromptAgent_StampsFoundryProviderNameAndDefaultModelId()
|
||||
{
|
||||
// Arrange
|
||||
var projectClient = CreateProjectClient();
|
||||
var agentRef = new AgentReference("agent-name", "1");
|
||||
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(projectClient, agentRef, defaultModelId: "gpt-4o", baseChatOptions: null);
|
||||
|
||||
// Assert
|
||||
var metadata = chatClient.GetService<ChatClientMetadata>();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("microsoft.foundry", metadata!.ProviderName);
|
||||
Assert.Equal("gpt-4o", metadata.DefaultModelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode2_PromptAgent_ExposesAgentReference_ViaGetService()
|
||||
{
|
||||
// Arrange
|
||||
var projectClient = CreateProjectClient();
|
||||
var agentRef = new AgentReference("agent-name", "1");
|
||||
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(projectClient, agentRef, defaultModelId: null, baseChatOptions: null);
|
||||
|
||||
// Assert
|
||||
Assert.Same(agentRef, chatClient.GetService<AgentReference>());
|
||||
Assert.Same(projectClient, chatClient.GetService<AIProjectClient>());
|
||||
// ProjectOpenAIClient is intentionally NOT exposed via GetService — see comment in
|
||||
// Mode1_ResponsesAgent_ExposesAIProjectClient_ViaGetService.
|
||||
Assert.Null(chatClient.GetService<ProjectOpenAIClient>());
|
||||
// Version/Record were not provided via this ctor.
|
||||
Assert.Null(chatClient.GetService<ProjectsAgentVersion>());
|
||||
Assert.Null(chatClient.GetService<ProjectsAgentRecord>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode2_PromptAgent_PopulatesAgentNameFromAgentReference()
|
||||
{
|
||||
// Arrange
|
||||
var projectClient = CreateProjectClient();
|
||||
var agentRef = new AgentReference("my-server-side-agent", "1");
|
||||
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(projectClient, agentRef, defaultModelId: null, baseChatOptions: null);
|
||||
|
||||
// Assert: AgentName is general-purpose across the Prompt Agent (Mode 2) and Agent Endpoint (Mode 3) modes. In the Prompt Agent mode (Mode 2) it mirrors
|
||||
// AgentReference.Name so callers have a uniform handle regardless of construction mode.
|
||||
Assert.Equal("my-server-side-agent", chatClient.AgentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode2_PromptAgent_AllowsNullDefaultModelIdAndBaseChatOptions()
|
||||
{
|
||||
// Arrange
|
||||
var projectClient = CreateProjectClient();
|
||||
var agentRef = new AgentReference("agent-name", "1");
|
||||
|
||||
// Act + Assert: must not throw; defaultModelId and baseChatOptions are optional.
|
||||
var chatClient = new FoundryChatClient(projectClient, agentRef, defaultModelId: null, baseChatOptions: null);
|
||||
Assert.NotNull(chatClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode2_PromptAgent_ThrowsOnNullAgentReference()
|
||||
=> Assert.Throws<ArgumentNullException>(() =>
|
||||
new FoundryChatClient(CreateProjectClient(), agentReference: null!, defaultModelId: null, baseChatOptions: null));
|
||||
|
||||
#endregion
|
||||
|
||||
#region the Prompt Agent mode (Mode 2): Prompt Agent end-to-end round-trip via AsAIAgent(AgentReference) extension
|
||||
|
||||
// The end-to-end tests below exercise the same FoundryChatClient mode-2 behaviors above,
|
||||
// but through the public AsAIAgent(AgentReference) extension that constructs a FoundryChatClient
|
||||
// internally. They focus on the conversation-id handling that only manifests through the
|
||||
// ChatClientAgentSession surface, which requires a fully assembled agent rather than a bare
|
||||
// chat client.
|
||||
|
||||
/// <summary>
|
||||
/// Verify that after the first RunAsync, the session's ConversationId is set from the
|
||||
/// response, and subsequent requests include that conversation ID automatically.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EndToEnd_AgentReference_UsesDefaultConversationIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
var responsesRequestCount = 0;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
|
||||
{
|
||||
responsesRequestCount++;
|
||||
|
||||
// Assert: On the second Responses API call, verify the conversation ID
|
||||
// from the first response is automatically included in the request body.
|
||||
if (responsesRequestCount == 2 && request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("resp_0888a", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") };
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session);
|
||||
await agent.RunAsync("Follow up", session);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, responsesRequestCount);
|
||||
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when the chat client doesn't have a default "conv_" conversation id, the chat client still uses the conversation ID in HTTP requests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EndToEnd_AgentReference_UsesPerRequestConversationId_WhenNoDefaultConversationIdIsProvidedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestTriggered = false;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
|
||||
{
|
||||
requestTriggered = true;
|
||||
|
||||
// Assert
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("conv_12345", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") };
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } });
|
||||
|
||||
Assert.True(requestTriggered);
|
||||
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal("conv_12345", chatClientSession.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that even when the chat client has a default conversation id, the chat client will prioritize the per-request conversation id provided in HTTP requests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EndToEnd_AgentReference_UsesPerRequestConversationId_EvenWhenDefaultConversationIdIsProvidedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestTriggered = false;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
|
||||
{
|
||||
requestTriggered = true;
|
||||
|
||||
// Assert
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("conv_12345", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") };
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } });
|
||||
|
||||
Assert.True(requestTriggered);
|
||||
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal("conv_12345", chatClientSession.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when the chat client is provided without a "conv_" prefixed conversation ID, the chat client uses the previous conversation ID in HTTP requests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EndToEnd_AgentReference_UsesPreviousResponseId_WhenConversationIsNotPrefixedAsConvAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestTriggered = false;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
|
||||
{
|
||||
requestTriggered = true;
|
||||
|
||||
// Assert
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("resp_0888a", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") };
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "resp_0888a" } });
|
||||
|
||||
Assert.True(requestTriggered);
|
||||
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region the Agent Endpoint mode (Mode 3): Agent Endpoint
|
||||
|
||||
[Fact]
|
||||
public void Mode3_AgentEndpoint_ParsesAgentNameFromUrl()
|
||||
{
|
||||
// Arrange + Act
|
||||
var chatClient = new FoundryChatClient(
|
||||
agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"),
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
clientOptions: null);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("myagent", chatClient.AgentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode3_AgentEndpoint_StampsFoundryProviderName()
|
||||
{
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(
|
||||
agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"),
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
clientOptions: null);
|
||||
|
||||
// Assert
|
||||
var metadata = chatClient.GetService<ChatClientMetadata>();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("microsoft.foundry", metadata!.ProviderName);
|
||||
// No model id is knowable from the URL alone.
|
||||
Assert.Null(metadata.DefaultModelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode3_AgentEndpoint_ExposesProjectOpenAIClientAndAIProjectClient()
|
||||
{
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(
|
||||
agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"),
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
clientOptions: null);
|
||||
|
||||
// Assert
|
||||
// ProjectOpenAIClient is intentionally NOT exposed via GetService — callers retrieve
|
||||
// it from the AIProjectClient themselves (aiProjectClient.GetProjectOpenAIClient()).
|
||||
Assert.Null(chatClient.GetService<ProjectOpenAIClient>());
|
||||
// After the materialization change, the Agent Endpoint mode (Mode 3) also exposes a working AIProjectClient
|
||||
// built from the parsed project root. This makes the helper surface symmetric across
|
||||
// all three construction modes.
|
||||
Assert.NotNull(chatClient.GetService<AIProjectClient>());
|
||||
Assert.Null(chatClient.GetService<AgentReference>());
|
||||
Assert.Null(chatClient.GetService<ProjectsAgentVersion>());
|
||||
Assert.Null(chatClient.GetService<ProjectsAgentRecord>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode3_AgentEndpoint_MaterializedAIProjectClient_TargetsParsedProjectRoot()
|
||||
{
|
||||
// The Agent Endpoint mode (Mode 3) ctor must derive the project root from the agent endpoint URL and
|
||||
// construct the AIProjectClient against that root, NOT the agent endpoint itself.
|
||||
var agentEndpoint = new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai");
|
||||
var chatClient = new FoundryChatClient(
|
||||
agentEndpoint: agentEndpoint,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
clientOptions: null);
|
||||
|
||||
var aiProjectClient = chatClient.GetService<AIProjectClient>();
|
||||
Assert.NotNull(aiProjectClient);
|
||||
// AIProjectClient does not expose its endpoint publicly, so we rely on reflection on
|
||||
// the well-known private field. If the SDK field shape changes this guard fails loudly.
|
||||
var field = typeof(AIProjectClient).GetField("_endpoint", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.NotNull(field);
|
||||
var actualEndpoint = (Uri)field!.GetValue(aiProjectClient!)!;
|
||||
Assert.Equal("https://example.com/api/projects/myproj", actualEndpoint.AbsoluteUri.TrimEnd('/'));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode3_AgentEndpoint_MaterializedAIProjectClient_IsReusedAcrossGetServiceCalls()
|
||||
{
|
||||
// Repeated GetService<AIProjectClient>() calls must return the same instance — the
|
||||
// materialized client is cached in the existing _aiProjectClient field, not built on
|
||||
// demand each call.
|
||||
var chatClient = new FoundryChatClient(
|
||||
agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"),
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
clientOptions: null);
|
||||
|
||||
var first = chatClient.GetService<AIProjectClient>();
|
||||
var second = chatClient.GetService<AIProjectClient>();
|
||||
Assert.NotNull(first);
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode1_ResponsesAgent_AIProjectClient_IsTheSuppliedInstance()
|
||||
{
|
||||
// Regression check: the Responses Agent mode (Mode 1) must continue to expose the AIProjectClient the caller
|
||||
// supplied via the constructor, NOT a freshly-materialized one.
|
||||
var supplied = CreateProjectClient();
|
||||
var chatClient = new FoundryChatClient(supplied, "gpt-4o-mini");
|
||||
Assert.Same(supplied, chatClient.GetService<AIProjectClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode2_PromptAgent_AIProjectClient_IsTheSuppliedInstance()
|
||||
{
|
||||
// Regression check: the Prompt Agent mode (Mode 2) must continue to expose the AIProjectClient the caller
|
||||
// supplied via the constructor.
|
||||
var supplied = CreateProjectClient();
|
||||
var agentRef = new AgentReference("agent-name", "1");
|
||||
var chatClient = new FoundryChatClient(supplied, agentRef, defaultModelId: null, baseChatOptions: null);
|
||||
Assert.Same(supplied, chatClient.GetService<AIProjectClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode3_AgentEndpoint_ThrowsOnNullEndpoint()
|
||||
=> Assert.Throws<ArgumentNullException>(() =>
|
||||
new FoundryChatClient(agentEndpoint: null!, credential: new FakeAuthenticationTokenProvider(), clientOptions: null));
|
||||
|
||||
[Fact]
|
||||
public void Mode3_AgentEndpoint_ThrowsOnNullCredential()
|
||||
=> Assert.Throws<ArgumentNullException>(() =>
|
||||
new FoundryChatClient(
|
||||
agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"),
|
||||
credential: null!,
|
||||
clientOptions: null));
|
||||
|
||||
#endregion
|
||||
|
||||
#region ParseAgentEndpoint URL parsing
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_HappyPath_ReturnsAgentNameAndProjectRoot()
|
||||
{
|
||||
// Act
|
||||
var (agentName, projectRoot) = FoundryChatClient.ParseAgentEndpoint(
|
||||
new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("myagent", agentName);
|
||||
Assert.Equal("https://example.com/api/projects/myproj", projectRoot.AbsoluteUri.TrimEnd('/'));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_TolerantOfTrailingSlash()
|
||||
{
|
||||
// Act
|
||||
var (agentName, _) = FoundryChatClient.ParseAgentEndpoint(
|
||||
new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai/"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("myagent", agentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_TolerantOfCaseDifferencesOnAgentsSegment()
|
||||
{
|
||||
// Act
|
||||
var (agentName, _) = FoundryChatClient.ParseAgentEndpoint(
|
||||
new Uri("https://example.com/api/projects/myproj/AGENTS/myagent/endpoint/protocols/openai"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("myagent", agentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_StripsQueryAndFragment()
|
||||
{
|
||||
// Act
|
||||
var (_, projectRoot) = FoundryChatClient.ParseAgentEndpoint(
|
||||
new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai?api-version=v1#frag"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(string.Empty, projectRoot.Query);
|
||||
Assert.Equal(string.Empty, projectRoot.Fragment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_ThrowsOnMissingAgentsSegment()
|
||||
=> Assert.Throws<ArgumentException>(() =>
|
||||
FoundryChatClient.ParseAgentEndpoint(new Uri("https://example.com/api/projects/myproj/anyseg/myagent/endpoint/protocols/openai")));
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_ThrowsOnWrongSuffix()
|
||||
=> Assert.Throws<ArgumentException>(() =>
|
||||
FoundryChatClient.ParseAgentEndpoint(new Uri("https://example.com/api/projects/myproj/agents/myagent/wrong/suffix")));
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_ThrowsOnNullUri()
|
||||
=> Assert.Throws<ArgumentNullException>(() => FoundryChatClient.ParseAgentEndpoint(null!));
|
||||
|
||||
#endregion
|
||||
|
||||
#region AgentFrameworkUserAgentPolicy + ServedModelPolicy registration + dedup
|
||||
|
||||
[Fact]
|
||||
public void Register_AgentFrameworkUserAgentPolicy_OnUnderlyingOpenAIRequestPolicies()
|
||||
{
|
||||
// Arrange + Act: constructing a FoundryChatClient should register the
|
||||
// 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 both policies are present in its entries.
|
||||
var policies = chatClient.GetService<OpenAIRequestPolicies>();
|
||||
Assert.NotNull(policies);
|
||||
Assert.Equal(2, EntriesCount(policies!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_AgentFrameworkUserAgentPolicy_IsDedupedAcrossMultipleClients_OnSharedInner()
|
||||
{
|
||||
// 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 more than 2 entries.
|
||||
var projectClient = CreateProjectClient();
|
||||
var agentVersion = ModelReaderWriter.Read<ProjectsAgentVersion>(
|
||||
BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!;
|
||||
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(projectClient, agentVersion, baseChatOptions: null);
|
||||
|
||||
// Assert: even though the version variant funnels through the AgentReference ctor
|
||||
// via :this(...), each policy is registered exactly once on the inner pipeline.
|
||||
var policies = chatClient.GetService<OpenAIRequestPolicies>();
|
||||
Assert.NotNull(policies);
|
||||
Assert.Equal(2, EntriesCount(policies!));
|
||||
Assert.Same(agentVersion, chatClient.GetService<ProjectsAgentVersion>());
|
||||
Assert.NotNull(chatClient.GetService<AgentReference>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static AIProjectClient CreateProjectClient()
|
||||
=> new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(new HttpClient()) });
|
||||
|
||||
private static int EntriesCount(OpenAIRequestPolicies policies)
|
||||
{
|
||||
var field = typeof(OpenAIRequestPolicies).GetField("_entries", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.NotNull(field);
|
||||
var arr = (Array)field!.GetValue(policies)!;
|
||||
return arr.Length;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
-660
@@ -1,660 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using OpenAI.Files;
|
||||
|
||||
#pragma warning disable OPENAI001, CS0618
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the file and vector-store helper methods on <see cref="FoundryChatClient"/>.
|
||||
/// Covers all four methods across the three FoundryChatClient construction modes plus argument
|
||||
/// validation, cancellation, and request-body shape on the wire.
|
||||
/// </summary>
|
||||
public sealed class FoundryChatClientVectorStoreTests
|
||||
{
|
||||
// ----- Construction helpers shared by every test in this file -----
|
||||
|
||||
private static (FoundryChatClient ChatClient, RequestRecorder Recorder) CreateMode1(string modelId = "gpt-4o-mini", string? responseBody = null)
|
||||
{
|
||||
var recorder = new RequestRecorder(responseBody);
|
||||
#pragma warning disable CA5399
|
||||
var httpClient = new HttpClient(recorder);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
return (new FoundryChatClient(projectClient, modelId), recorder);
|
||||
}
|
||||
|
||||
private static (FoundryChatClient ChatClient, RequestRecorder Recorder) CreateMode2(string? responseBody = null)
|
||||
{
|
||||
var recorder = new RequestRecorder(responseBody);
|
||||
#pragma warning disable CA5399
|
||||
var httpClient = new HttpClient(recorder);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
var agentRef = new AgentReference("agent-name", "1");
|
||||
return (new FoundryChatClient(projectClient, agentRef, defaultModelId: "gpt-4o", baseChatOptions: null), recorder);
|
||||
}
|
||||
|
||||
private static string MakeTempFile(string contents = "hello world")
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"fcc-test-{Guid.NewGuid():N}.txt");
|
||||
File.WriteAllText(path, contents);
|
||||
return path;
|
||||
}
|
||||
|
||||
// ----- UploadFileAsync -----
|
||||
|
||||
[Fact]
|
||||
public async Task UploadFileAsync_Mode1_UploadsViaProjectOpenAIClientAsync()
|
||||
{
|
||||
var (chatClient, recorder) = CreateMode1(responseBody: FakeFileJson("file_abc"));
|
||||
var path = MakeTempFile();
|
||||
try
|
||||
{
|
||||
var result = await chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants);
|
||||
|
||||
Assert.Equal("file_abc", result.Id);
|
||||
Assert.NotEmpty(recorder.Requests);
|
||||
Assert.EndsWith("/files", recorder.Requests[0].PathAndQuery.TrimEnd('/').Split('?')[0]);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadFileAsync_Mode2_UploadsViaProjectOpenAIClientAsync()
|
||||
{
|
||||
var (chatClient, recorder) = CreateMode2(responseBody: FakeFileJson("file_xyz"));
|
||||
var path = MakeTempFile();
|
||||
try
|
||||
{
|
||||
var result = await chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants);
|
||||
Assert.Equal("file_xyz", result.Id);
|
||||
Assert.Contains(recorder.Requests, r => r.PathAndQuery.Contains("/files"));
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadFileAsync_Mode3_UploadsViaMaterializedProjectClientAsync()
|
||||
{
|
||||
// Q-E: Mode 3 (Agent Endpoint) now honors caller-supplied transports via
|
||||
// ProjectOpenAIClientOptions.Transport, so we can use a fake transport here instead of
|
||||
// depending on DNS/network availability against example.com.
|
||||
var sawUpload = false;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal))
|
||||
{
|
||||
sawUpload = true;
|
||||
return MakeJsonResponse(FakeFileJson("file_mode3"));
|
||||
}
|
||||
return MakeJsonResponse("{}");
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var chatClient = new FoundryChatClient(
|
||||
agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"),
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
clientOptions: new ProjectOpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var path = MakeTempFile();
|
||||
try
|
||||
{
|
||||
var result = await chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants, CancellationToken.None);
|
||||
Assert.True(sawUpload);
|
||||
Assert.Equal("file_mode3", result.Id);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadFileAsync_NullFilePath_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
var (chatClient, _) = CreateMode1();
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
chatClient.UploadFileAsync(null!, FileUploadPurpose.Assistants));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadFileAsync_FileNotFound_ThrowsFileNotFoundExceptionAsync()
|
||||
{
|
||||
var (chatClient, _) = CreateMode1();
|
||||
var missing = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(() =>
|
||||
chatClient.UploadFileAsync(missing, FileUploadPurpose.Assistants));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadFileAsync_HonorsCancellationAsync()
|
||||
{
|
||||
// Cancellation propagation through the OpenAI SDK pipeline surfaces different exception
|
||||
// types depending on the framework target (OperationCanceledException on net10.0,
|
||||
// ObjectDisposedException at the transport layer on net472). Asserting on the exact
|
||||
// exception class is brittle; assert only that the call throws when the token is
|
||||
// pre-cancelled.
|
||||
var (chatClient, _) = CreateMode1(responseBody: FakeFileJson("file_abc"));
|
||||
var path = MakeTempFile();
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() =>
|
||||
chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants, cts.Token));
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
// ----- DeleteFileAsync -----
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFileAsync_Mode1_CallsDeleteOnFileClientAsync()
|
||||
{
|
||||
var (chatClient, recorder) = CreateMode1(responseBody: FakeFileDeletedJson("file_abc"));
|
||||
await chatClient.DeleteFileAsync("file_abc");
|
||||
Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/files/file_abc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFileAsync_Mode2_CallsDeleteOnFileClientAsync()
|
||||
{
|
||||
var (chatClient, recorder) = CreateMode2(responseBody: FakeFileDeletedJson("file_xyz"));
|
||||
await chatClient.DeleteFileAsync("file_xyz");
|
||||
Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/files/file_xyz"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFileAsync_NullId_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
var (chatClient, _) = CreateMode1();
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => chatClient.DeleteFileAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFileAsync_EmptyId_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
var (chatClient, _) = CreateMode1();
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => chatClient.DeleteFileAsync(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFileAsync_HonorsCancellationAsync()
|
||||
{
|
||||
// Verify the cancellation token reaches the HTTP pipeline by having the handler
|
||||
// throw OperationCanceledException when the token is cancelled before the request.
|
||||
// This is more robust than asserting on the exact exception the SDK surfaces, which
|
||||
// depends on internal pipeline plumbing.
|
||||
var observedToken = CancellationToken.None;
|
||||
using var handler = new HttpHandlerAssert(async req =>
|
||||
{
|
||||
// We don't have direct access to the SDK's CancellationToken here; instead, sleep
|
||||
// briefly to give the caller's pre-cancellation a chance to be picked up by the
|
||||
// transport. If cancellation reached the pipeline, the await on this handler call
|
||||
// would surface OperationCanceledException; if not, the response is returned.
|
||||
await Task.Delay(50).ConfigureAwait(false);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(FakeFileDeletedJson("file_abc"), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = 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(httpClient) });
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
// Any throw is acceptable evidence that cancellation was honored. The SDK's exact
|
||||
// exception surface for pre-cancelled tokens is an implementation detail of
|
||||
// System.ClientModel's pipeline and may differ between versions.
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => chatClient.DeleteFileAsync("file_abc", cts.Token));
|
||||
}
|
||||
|
||||
// ----- CreateVectorStoreAsync -----
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_UploadsThenCreates_WithFileIds_ReturnsVectorStoreAsync()
|
||||
{
|
||||
// Each file POST returns a distinct file id; the recorder dispatches on URL to differentiate.
|
||||
var fileCount = 0;
|
||||
using var handler = new HttpHandlerAssert(async req =>
|
||||
{
|
||||
var body = req.Content is null ? "" : await req.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
if (req.RequestUri!.AbsolutePath.Contains("/files") && req.Method == HttpMethod.Post)
|
||||
{
|
||||
fileCount++;
|
||||
return MakeJsonResponse(FakeFileJson($"file_{fileCount}"));
|
||||
}
|
||||
if (req.RequestUri.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post)
|
||||
{
|
||||
Assert.Contains("file_1", body);
|
||||
Assert.Contains("file_2", body);
|
||||
Assert.Contains("knowledge-base", body);
|
||||
return MakeJsonResponse(FakeVectorStoreJson("vs_abc", name: "knowledge-base"));
|
||||
}
|
||||
return MakeJsonResponse("{}");
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = 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(httpClient) });
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
var pathA = MakeTempFile("alpha");
|
||||
var pathB = MakeTempFile("beta");
|
||||
try
|
||||
{
|
||||
var store = await chatClient.CreateVectorStoreAsync("knowledge-base", new[] { pathA, pathB });
|
||||
Assert.Equal("vs_abc", store.Id);
|
||||
Assert.Equal(2, fileCount);
|
||||
}
|
||||
finally { File.Delete(pathA); File.Delete(pathB); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_WithExpiresAfter_SerializesLastActiveAtAnchorAsync()
|
||||
{
|
||||
string? vectorStoreBody = null;
|
||||
using var handler = new HttpHandlerAssert(async req =>
|
||||
{
|
||||
if (req.RequestUri!.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post)
|
||||
{
|
||||
vectorStoreBody = req.Content is null ? "" : await req.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
return MakeJsonResponse(FakeVectorStoreJson("vs_abc", name: "x"));
|
||||
}
|
||||
return MakeJsonResponse("{}");
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = 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(httpClient) });
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
await chatClient.CreateVectorStoreAsync("x", Array.Empty<string>(), expiresAfter: TimeSpan.FromDays(7));
|
||||
|
||||
Assert.NotNull(vectorStoreBody);
|
||||
Assert.Contains("\"expires_after\"", vectorStoreBody);
|
||||
Assert.Contains("\"last_active_at\"", vectorStoreBody);
|
||||
Assert.Contains("\"days\":7", vectorStoreBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_WithNullExpiresAfter_OmitsExpirationPolicyAsync()
|
||||
{
|
||||
string? vectorStoreBody = null;
|
||||
using var handler = new HttpHandlerAssert(async req =>
|
||||
{
|
||||
if (req.RequestUri!.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post)
|
||||
{
|
||||
vectorStoreBody = req.Content is null ? "" : await req.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
return MakeJsonResponse(FakeVectorStoreJson("vs_abc", name: "x"));
|
||||
}
|
||||
return MakeJsonResponse("{}");
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = 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(httpClient) });
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
await chatClient.CreateVectorStoreAsync("x", Array.Empty<string>(), expiresAfter: null);
|
||||
|
||||
Assert.NotNull(vectorStoreBody);
|
||||
Assert.DoesNotContain("\"expires_after\"", vectorStoreBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_EmptyFilesList_CreatesEmptyStoreAsync()
|
||||
{
|
||||
var (chatClient, _) = CreateMode1(responseBody: FakeVectorStoreJson("vs_empty", name: "x"));
|
||||
var store = await chatClient.CreateVectorStoreAsync("x", Array.Empty<string>());
|
||||
Assert.Equal("vs_empty", store.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_NullName_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
var (chatClient, _) = CreateMode1();
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() =>
|
||||
chatClient.CreateVectorStoreAsync(null!, Array.Empty<string>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_NullFilePaths_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
var (chatClient, _) = CreateMode1();
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
chatClient.CreateVectorStoreAsync("x", filePaths: null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_HonorsCancellationAsync()
|
||||
{
|
||||
// Same rationale as UploadFileAsync_HonorsCancellationAsync — assert only that any
|
||||
// exception is thrown on a pre-cancelled token.
|
||||
var (chatClient, _) = CreateMode1(responseBody: FakeVectorStoreJson("vs_x", "x"));
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() =>
|
||||
chatClient.CreateVectorStoreAsync("x", Array.Empty<string>(), expiresAfter: null, cancellationToken: cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_PollsUntilStoreLeavesInProgress_Async()
|
||||
{
|
||||
// Q-A regression: when the create response returns status=in_progress, the helper must
|
||||
// poll GET /vector_stores/{id} until status changes before returning. Otherwise the
|
||||
// caller receives a half-built store.
|
||||
var pollCount = 0;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.RequestUri!.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post)
|
||||
{
|
||||
// First response: status=in_progress.
|
||||
return Task.FromResult(MakeJsonResponse(FakeVectorStoreJsonWithStatus("vs_abc", name: "x", status: "in_progress")));
|
||||
}
|
||||
if (req.RequestUri.AbsolutePath.Contains("/vector_stores/vs_abc") && req.Method == HttpMethod.Get)
|
||||
{
|
||||
pollCount++;
|
||||
// Stay in_progress for two polls, then complete on the third.
|
||||
var status = pollCount < 3 ? "in_progress" : "completed";
|
||||
return Task.FromResult(MakeJsonResponse(FakeVectorStoreJsonWithStatus("vs_abc", name: "x", status: status)));
|
||||
}
|
||||
return Task.FromResult(MakeJsonResponse("{}"));
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = 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(httpClient) });
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
var store = await chatClient.CreateVectorStoreAsync("x", Array.Empty<string>());
|
||||
|
||||
Assert.NotEqual(OpenAI.VectorStores.VectorStoreStatus.InProgress, store.Status);
|
||||
Assert.True(pollCount >= 3, $"Expected at least 3 GET polls before status leaves in_progress; saw {pollCount}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_PollingTimeout_ThrowsTimeoutExceptionAsync()
|
||||
{
|
||||
// Sergey #2: caller-supplied (or default) polling timeout must surface as TimeoutException
|
||||
// when the vector store never leaves InProgress. Mock keeps the store stuck and we pass
|
||||
// a tiny timeout; cancellation token stays unused so the only path that ends the loop
|
||||
// is the timeout check.
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.RequestUri!.AbsolutePath.Contains("/vector_stores", StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult(MakeJsonResponse(FakeVectorStoreJsonWithStatus("vs_stuck", name: "x", status: "in_progress")));
|
||||
}
|
||||
return Task.FromResult(MakeJsonResponse("{}"));
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = 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(httpClient) });
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<TimeoutException>(() =>
|
||||
chatClient.CreateVectorStoreAsync("x", Array.Empty<string>(), expiresAfter: null, pollingTimeout: TimeSpan.FromMilliseconds(500)));
|
||||
Assert.Contains("vs_stuck", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("in-progress", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_MidUploadFailure_DeletesAlreadyUploadedFilesAsync()
|
||||
{
|
||||
// Q-B regression: when the upload loop throws partway through (e.g. file 3 of 5 is
|
||||
// missing or the network fails), the helper must DELETE the already-uploaded files so
|
||||
// they do not accumulate as orphaned resources. The exception must still propagate.
|
||||
var uploadCount = 0;
|
||||
var deleted = new List<string>();
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
// DELETE first so we don't match the upload-collection /files path against this.
|
||||
if (req.Method == HttpMethod.Delete)
|
||||
{
|
||||
var segments = req.RequestUri!.AbsolutePath.Split('/');
|
||||
var fileId = segments[segments.Length - 1];
|
||||
deleted.Add(fileId);
|
||||
return MakeJsonResponse(FakeFileDeletedJson(fileId));
|
||||
}
|
||||
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal))
|
||||
{
|
||||
uploadCount++;
|
||||
if (uploadCount == 3)
|
||||
{
|
||||
// 400 is non-retriable; the SDK retry policy ignores it. 5xx would trigger
|
||||
// retries and confuse the assertion on upload count.
|
||||
return new HttpResponseMessage(HttpStatusCode.BadRequest)
|
||||
{
|
||||
Content = new StringContent("{\"error\":{\"code\":\"BadRequest\",\"message\":\"upload-failed-on-3\"}}", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
return MakeJsonResponse(FakeFileJson($"file_{uploadCount}"));
|
||||
}
|
||||
return MakeJsonResponse("{}");
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = 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(httpClient) });
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
var paths = new[] { MakeTempFile("a"), MakeTempFile("b"), MakeTempFile("c"), MakeTempFile("d"), MakeTempFile("e") };
|
||||
try
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => chatClient.CreateVectorStoreAsync("knowledge-base", paths));
|
||||
|
||||
// Three upload attempts: two succeeded, the third threw.
|
||||
Assert.Equal(3, uploadCount);
|
||||
// The two successful uploads must have been deleted as part of best-effort cleanup.
|
||||
Assert.Equal(2, deleted.Count);
|
||||
Assert.Contains("file_1", deleted);
|
||||
Assert.Contains("file_2", deleted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var p in paths)
|
||||
{
|
||||
File.Delete(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_MidUploadFailure_CleanupSwallowsDeleteErrorsAsync()
|
||||
{
|
||||
// Q-B follow-on: if a cleanup DELETE itself fails, the helper must still propagate the
|
||||
// original upload exception — not the cleanup exception. The caller cares about the
|
||||
// upload failure; cleanup is best-effort.
|
||||
var uploadCount = 0;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Delete)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.BadRequest)
|
||||
{
|
||||
Content = new StringContent("{\"error\":{\"code\":\"DeleteFailed\",\"message\":\"cleanup-failed\"}}", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal))
|
||||
{
|
||||
uploadCount++;
|
||||
if (uploadCount == 2)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.BadRequest)
|
||||
{
|
||||
Content = new StringContent("{\"error\":{\"code\":\"BadRequest\",\"message\":\"upload-failed\"}}", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
return MakeJsonResponse(FakeFileJson($"file_{uploadCount}"));
|
||||
}
|
||||
return MakeJsonResponse("{}");
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = 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(httpClient) });
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
var paths = new[] { MakeTempFile("a"), MakeTempFile("b") };
|
||||
try
|
||||
{
|
||||
var ex = await Assert.ThrowsAnyAsync<Exception>(() => chatClient.CreateVectorStoreAsync("kb", paths));
|
||||
|
||||
// The original upload-failure message must surface, not the cleanup-failure message.
|
||||
Assert.DoesNotContain("cleanup-failed", ex.Message ?? "", StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var p in paths)
|
||||
{
|
||||
File.Delete(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- DeleteVectorStoreAsync -----
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVectorStoreAsync_Mode1_CallsDeleteAsync()
|
||||
{
|
||||
var (chatClient, recorder) = CreateMode1(responseBody: FakeVectorStoreDeletedJson("vs_abc"));
|
||||
await chatClient.DeleteVectorStoreAsync("vs_abc");
|
||||
Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/vector_stores/vs_abc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVectorStoreAsync_Mode2_CallsDeleteAsync()
|
||||
{
|
||||
var (chatClient, recorder) = CreateMode2(responseBody: FakeVectorStoreDeletedJson("vs_xyz"));
|
||||
await chatClient.DeleteVectorStoreAsync("vs_xyz");
|
||||
Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/vector_stores/vs_xyz"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVectorStoreAsync_NullId_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
var (chatClient, _) = CreateMode1();
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => chatClient.DeleteVectorStoreAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVectorStoreAsync_HonorsCancellationAsync()
|
||||
{
|
||||
// Same approach as DeleteFileAsync_HonorsCancellationAsync — assert that the call
|
||||
// throws when the token is pre-cancelled, without asserting on the exact exception
|
||||
// surfaced by the SDK pipeline.
|
||||
var (chatClient, _) = CreateMode1(responseBody: FakeVectorStoreDeletedJson("vs_abc"));
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => chatClient.DeleteVectorStoreAsync("vs_abc", cts.Token));
|
||||
}
|
||||
|
||||
// ----- Fixtures and helpers -----
|
||||
|
||||
private static HttpResponseMessage MakeJsonResponse(string json)
|
||||
=> new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
private static string FakeFileJson(string id)
|
||||
=> $"{{\"id\":\"{id}\",\"object\":\"file\",\"bytes\":11,\"created_at\":1700000000,\"filename\":\"x.txt\",\"purpose\":\"assistants\",\"status\":\"processed\"}}";
|
||||
|
||||
private static string FakeFileDeletedJson(string id)
|
||||
=> $"{{\"id\":\"{id}\",\"object\":\"file\",\"deleted\":true}}";
|
||||
|
||||
private static string FakeVectorStoreJson(string id, string name)
|
||||
=> FakeVectorStoreJsonWithStatus(id, name, status: "completed");
|
||||
|
||||
private static string FakeVectorStoreJsonWithStatus(string id, string name, string status)
|
||||
=> $"{{\"id\":\"{id}\",\"object\":\"vector_store\",\"created_at\":1700000000,\"name\":\"{name}\",\"usage_bytes\":0,\"file_counts\":{{\"in_progress\":0,\"completed\":0,\"failed\":0,\"cancelled\":0,\"total\":0}},\"status\":\"{status}\",\"last_active_at\":1700000000}}";
|
||||
|
||||
private static string FakeVectorStoreDeletedJson(string id)
|
||||
=> $"{{\"id\":\"{id}\",\"object\":\"vector_store.deleted\",\"deleted\":true}}";
|
||||
|
||||
private sealed class RequestRecorder : HttpClientHandler
|
||||
{
|
||||
private readonly string _responseBody;
|
||||
public List<RecordedRequest> Requests { get; } = [];
|
||||
|
||||
public RequestRecorder(string? responseBody)
|
||||
{
|
||||
this._responseBody = responseBody ?? "{}";
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.Requests.Add(new RecordedRequest
|
||||
{
|
||||
Method = request.Method.Method,
|
||||
PathAndQuery = request.RequestUri?.PathAndQuery ?? "",
|
||||
#if NET
|
||||
Body = request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false),
|
||||
#else
|
||||
Body = request.Content is null ? "" : await request.Content.ReadAsStringAsync().ConfigureAwait(false),
|
||||
#endif
|
||||
});
|
||||
return MakeJsonResponse(this._responseBody);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordedRequest
|
||||
{
|
||||
public string Method { get; set; } = "";
|
||||
public string PathAndQuery { get; set; } = "";
|
||||
public string Body { get; set; } = "";
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
-433
@@ -1,433 +0,0 @@
|
||||
// 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.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001, CS0618
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the public <c>ToPromptAgentAsync</c> extension methods on
|
||||
/// <see cref="ChatClientAgent"/> and <see cref="FoundryAgent"/>. Both entry points dispatch
|
||||
/// to the same internal converter, so each behavior is asserted through both surfaces.
|
||||
/// </summary>
|
||||
public sealed class FoundryPromptAgentConverterTests
|
||||
{
|
||||
// ----- Failure modes (assert through ChatClientAgent and FoundryAgent extensions) -----
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_ChatClientAgent_NonFoundryChatClient_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
var agent = new ChatClientAgent(new NoOpChatClient());
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => agent.ToPromptAgentAsync());
|
||||
Assert.Contains("FoundryChatClient", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_FoundryChatClientInMode3_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
var foundryAgent = new FoundryAgent(
|
||||
agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"),
|
||||
credential: new FakeAuthenticationTokenProvider());
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => foundryAgent.ToPromptAgentAsync());
|
||||
Assert.Contains("Agent Endpoint mode (Mode 3)", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_MissingModelId_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
var projectClient = CreateProjectClient();
|
||||
// Construct a FoundryChatClient via the Responses Agent mode (Mode 1) then wrap in a ChatClientAgent whose
|
||||
// ChatOptions has no ModelId — synthesis must throw.
|
||||
var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions { ChatOptions = new ChatOptions() });
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => agent.ToPromptAgentAsync());
|
||||
Assert.Contains("model id", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_UnsupportedAITool_ThrowsInvalidOperationExceptionNamingTypeAsync()
|
||||
{
|
||||
var projectClient = CreateProjectClient();
|
||||
var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = "gpt-4o-mini",
|
||||
Tools = new System.Collections.Generic.List<AITool> { new UnsupportedTool() },
|
||||
},
|
||||
});
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => agent.ToPromptAgentAsync());
|
||||
Assert.Contains(nameof(UnsupportedTool), ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_HonorsCancellationAsync()
|
||||
{
|
||||
// Cancellation should bubble up from the AgentReference fetch path. Construct a
|
||||
// FoundryAgent via AsAIAgent(AgentReference) and pass a pre-cancelled token.
|
||||
var (foundryAgent, _) = CreateMode2_PromptAgentOnly("agent-name");
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => foundryAgent.ToPromptAgentAsync(cts.Token));
|
||||
}
|
||||
|
||||
// ----- the Responses Agent mode (Mode 1) (RAPI) synthesis paths -----
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_RoundTripsModelInstructionsTemperatureTopPAsync()
|
||||
{
|
||||
var projectClient = CreateProjectClient();
|
||||
var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = "gpt-4o-mini",
|
||||
Instructions = "Be helpful.",
|
||||
Temperature = 0.5f,
|
||||
TopP = 0.9f,
|
||||
},
|
||||
});
|
||||
|
||||
var def = await agent.ToPromptAgentAsync();
|
||||
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
|
||||
Assert.Equal("gpt-4o-mini", declarative.Model);
|
||||
Assert.Equal("Be helpful.", declarative.Instructions);
|
||||
Assert.Equal(0.5f, declarative.Temperature);
|
||||
Assert.Equal(0.9f, declarative.TopP);
|
||||
Assert.Empty(declarative.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_NoTools_ReturnsDefinitionWithEmptyToolsAsync()
|
||||
{
|
||||
var projectClient = CreateProjectClient();
|
||||
var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions { ModelId = "gpt-4o-mini" },
|
||||
});
|
||||
var def = await agent.ToPromptAgentAsync();
|
||||
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
|
||||
Assert.Empty(declarative.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_AIFunctionTool_ConvertsToFunctionToolAsync()
|
||||
{
|
||||
var projectClient = CreateProjectClient();
|
||||
var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
var function = AIFunctionFactory.Create(() => "ok", "my_function", "A documented function.");
|
||||
var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = "gpt-4o-mini",
|
||||
Tools = new System.Collections.Generic.List<AITool> { function },
|
||||
},
|
||||
});
|
||||
|
||||
var def = await agent.ToPromptAgentAsync();
|
||||
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
|
||||
var fnTool = Assert.Single(declarative.Tools);
|
||||
var ft = Assert.IsType<FunctionTool>(fnTool);
|
||||
Assert.Equal("my_function", ft.FunctionName);
|
||||
Assert.Equal("A documented function.", ft.FunctionDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_FoundryAITool_UnwrapsUnderlyingResponseToolAsync()
|
||||
{
|
||||
var projectClient = CreateProjectClient();
|
||||
var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = "gpt-4o-mini",
|
||||
Tools = new System.Collections.Generic.List<AITool> { FoundryAITool.CreateWebSearchTool() },
|
||||
},
|
||||
});
|
||||
|
||||
var def = await agent.ToPromptAgentAsync();
|
||||
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
|
||||
var tool = Assert.Single(declarative.Tools);
|
||||
// The unwrapped instance must be the concrete WebSearchTool from the OpenAI SDK.
|
||||
Assert.IsType<WebSearchTool>(tool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_MultipleToolsMixed_ConvertsAllInOrderAsync()
|
||||
{
|
||||
var projectClient = CreateProjectClient();
|
||||
var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
var function = AIFunctionFactory.Create(() => "ok", "fn", "");
|
||||
var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = "gpt-4o-mini",
|
||||
Tools = new System.Collections.Generic.List<AITool> { function, FoundryAITool.CreateWebSearchTool() },
|
||||
},
|
||||
});
|
||||
|
||||
var def = await agent.ToPromptAgentAsync();
|
||||
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
|
||||
Assert.Equal(2, declarative.Tools.Count);
|
||||
Assert.IsType<FunctionTool>(declarative.Tools[0]);
|
||||
Assert.IsType<WebSearchTool>(declarative.Tools[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_Mode1_ResultIsDeclarativeAgentDefinitionAsync()
|
||||
{
|
||||
// FoundryAgent constructed via the projectEndpoint+model+instructions ctor (Responses Agent mode, the Responses Agent mode (Mode 1)).
|
||||
var foundryAgent = new FoundryAgent(
|
||||
projectEndpoint: new Uri("https://test.openai.azure.com/"),
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "You are helpful.");
|
||||
|
||||
var def = await foundryAgent.ToPromptAgentAsync();
|
||||
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
|
||||
Assert.Equal("gpt-4o-mini", declarative.Model);
|
||||
Assert.Equal("You are helpful.", declarative.Instructions);
|
||||
}
|
||||
|
||||
// ----- the Prompt Agent mode (Mode 2) paths -----
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_Mode2_AgentVersion_ReturnsCachedDefinitionAsync()
|
||||
{
|
||||
// Construct via ProjectsAgentVersion → the Definition reference must come back unchanged.
|
||||
var version = ModelReaderWriter.Read<ProjectsAgentVersion>(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!;
|
||||
var projectClient = CreateProjectClient();
|
||||
var foundryAgent = projectClient.AsAIAgent(version);
|
||||
|
||||
var def = await foundryAgent.ToPromptAgentAsync();
|
||||
Assert.Same(version.Definition, def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_Mode2_AgentRecord_ReturnsLatestVersionDefinitionAsync()
|
||||
{
|
||||
var record = ModelReaderWriter.Read<ProjectsAgentRecord>(BinaryData.FromString(TestDataUtil.GetAgentResponseJson()))!;
|
||||
var projectClient = CreateProjectClient();
|
||||
var foundryAgent = projectClient.AsAIAgent(record);
|
||||
|
||||
var def = await foundryAgent.ToPromptAgentAsync();
|
||||
Assert.Same(record.GetLatestVersion().Definition, def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_Mode2_PromptAgentOnly_FetchesLatestVersionAsync()
|
||||
{
|
||||
// The handler returns a known agent JSON. The converter must hit GET /agents/{name}
|
||||
// and return that record's latest version definition.
|
||||
var fetched = false;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath.Contains("/agents/agent-name"))
|
||||
{
|
||||
fetched = true;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetAgentResponseJson(agentName: "agent-name"), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") };
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = 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(httpClient) });
|
||||
var foundryAgent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
var def = await foundryAgent.ToPromptAgentAsync();
|
||||
Assert.True(fetched);
|
||||
Assert.NotNull(def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_Mode2_PromptAgentOnly_PinnedVersion_FetchesPinnedVersionAsync()
|
||||
{
|
||||
// Q-C regression: when AgentReference.Version is set, the converter must call
|
||||
// GET /agents/{name}/versions/{version} and return that pinned version's definition,
|
||||
// NOT GET /agents/{name} -> GetLatestVersion() which would silently substitute the
|
||||
// server's latest. We probe both paths from the same handler and assert exactly one was hit.
|
||||
var fetchedLatest = false;
|
||||
var fetchedPinned = false;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
// Pinned-version path: …/agents/{name}/versions/{version}
|
||||
if (req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath.Contains("/agents/agent-name/versions/2", StringComparison.Ordinal))
|
||||
{
|
||||
fetchedPinned = true;
|
||||
var pinnedDef = new DeclarativeAgentDefinition("gpt-pinned") { Instructions = "Pinned-version instructions." };
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(agentName: "agent-name", agentDefinition: pinnedDef), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
// Latest-version path: …/agents/{name}
|
||||
if (req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath.EndsWith("/agents/agent-name", StringComparison.Ordinal))
|
||||
{
|
||||
fetchedLatest = true;
|
||||
var latestDef = new DeclarativeAgentDefinition("gpt-latest") { Instructions = "Latest-version instructions." };
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetAgentResponseJson(agentName: "agent-name", agentDefinition: latestDef), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") };
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = 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(httpClient) });
|
||||
var foundryAgent = projectClient.AsAIAgent(new AgentReference("agent-name", "2"));
|
||||
|
||||
var def = await foundryAgent.ToPromptAgentAsync();
|
||||
|
||||
Assert.True(fetchedPinned, "Pinned-version endpoint (.../agents/agent-name/versions/2) must be called when AgentReference.Version is set.");
|
||||
Assert.False(fetchedLatest, "Latest-version endpoint (.../agents/agent-name) must NOT be called when AgentReference.Version is set.");
|
||||
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
|
||||
Assert.Equal("gpt-pinned", declarative.Model);
|
||||
Assert.Equal("Pinned-version instructions.", declarative.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_Mode2_PromptAgentOnly_UnpinnedVersionKeyword_FetchesLatestAsync()
|
||||
{
|
||||
// Q-C boundary: AgentReference.Version == "latest" must fall back to the GET /agents/{name}
|
||||
// path (the latest-version path), NOT GET /agents/{name}/versions/latest.
|
||||
var fetchedLatest = false;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath.EndsWith("/agents/agent-name", StringComparison.Ordinal))
|
||||
{
|
||||
fetchedLatest = true;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetAgentResponseJson(agentName: "agent-name"), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") };
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = 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(httpClient) });
|
||||
var foundryAgent = projectClient.AsAIAgent(new AgentReference("agent-name", "latest"));
|
||||
|
||||
var def = await foundryAgent.ToPromptAgentAsync();
|
||||
|
||||
Assert.True(fetchedLatest);
|
||||
Assert.NotNull(def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_Mode2_PromptAgentOnly_ServerReturnsError_PropagatesExceptionAsync()
|
||||
{
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("{\"error\":{\"code\":\"NotFound\"}}", Encoding.UTF8, "application/json") });
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = 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(httpClient) });
|
||||
var foundryAgent = projectClient.AsAIAgent(new AgentReference("missing-agent"));
|
||||
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => foundryAgent.ToPromptAgentAsync());
|
||||
}
|
||||
|
||||
// ----- Python-parity guard: both extensions produce equivalent definitions -----
|
||||
|
||||
[Fact]
|
||||
public async Task BothExtensions_ProduceEquivalentDefinitions_ForEquivalentInputsAsync()
|
||||
{
|
||||
// Build two agents that are semantically equivalent: one as a plain ChatClientAgent
|
||||
// via AsAIAgent(model, instructions), and one as a FoundryAgent via the projectEndpoint
|
||||
// ctor. Both flow through the same converter; assert key fields match.
|
||||
var projectClient = CreateProjectClient();
|
||||
ChatClientAgent ccaAgent = projectClient.AsAIAgent("gpt-4o-mini", "Be helpful.");
|
||||
var foundryAgent = new FoundryAgent(
|
||||
projectEndpoint: new Uri("https://test.openai.azure.com/"),
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "Be helpful.");
|
||||
|
||||
var ccaDef = await ccaAgent.ToPromptAgentAsync();
|
||||
var faDef = await foundryAgent.ToPromptAgentAsync();
|
||||
|
||||
var a = Assert.IsType<DeclarativeAgentDefinition>(ccaDef);
|
||||
var b = Assert.IsType<DeclarativeAgentDefinition>(faDef);
|
||||
Assert.Equal(a.Model, b.Model);
|
||||
Assert.Equal(a.Instructions, b.Instructions);
|
||||
Assert.Equal(a.Tools.Count, b.Tools.Count);
|
||||
}
|
||||
|
||||
// ----- Helpers -----
|
||||
|
||||
private static AIProjectClient CreateProjectClient()
|
||||
=> new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(new HttpClient()) });
|
||||
|
||||
private static (FoundryAgent FoundryAgent, AIProjectClient ProjectClient) CreateMode2_PromptAgentOnly(string agentName)
|
||||
{
|
||||
var projectClient = CreateProjectClient();
|
||||
var foundryAgent = projectClient.AsAIAgent(new AgentReference(agentName));
|
||||
return (foundryAgent, projectClient);
|
||||
}
|
||||
|
||||
private sealed class NoOpChatClient : IChatClient
|
||||
{
|
||||
public Task<ChatResponse> GetResponseAsync(System.Collections.Generic.IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ChatResponse());
|
||||
|
||||
public System.Collections.Generic.IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(System.Collections.Generic.IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> EmptyAsyncEnumerableAsync();
|
||||
|
||||
private static async System.Collections.Generic.IAsyncEnumerable<ChatResponseUpdate> EmptyAsyncEnumerableAsync()
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
yield break;
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) => null;
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class UnsupportedTool : AITool
|
||||
{
|
||||
public override string Name => "unsupported";
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// One-shot verification (kept in tree to detect regressions) that MEAI 10.5.1 stamps its own
|
||||
/// <c>MEAI/{version}</c> User-Agent segment automatically when an <see cref="ResponsesClient"/>
|
||||
/// is wrapped via <c>AsIChatClient()</c>. If this test starts failing, the FoundryChatClient
|
||||
/// implementation must re-register the MEAI policy explicitly via OpenAIRequestPolicies because
|
||||
/// the local Foundry copy was deleted under the assumption that MEAI provides it built-in.
|
||||
/// </summary>
|
||||
public sealed class MeaiAutoUserAgentVerificationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MeaiOpenAIResponsesClient_StampsMeaiSegmentAutomatically_WithoutLocalPolicyAsync()
|
||||
{
|
||||
// Arrange: bare OpenAI ResponseClient over a fake HTTP transport, wrapped via MEAI's
|
||||
// AsIChatClient() with no custom OpenAIRequestPolicies registration. If MEAI auto-stamps
|
||||
// its own MEAI/{version} segment, it will appear here.
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var options = new OpenAIClientOptions
|
||||
{
|
||||
Transport = new HttpClientPipelineTransport(httpClient),
|
||||
Endpoint = new Uri("https://example.test/v1"),
|
||||
};
|
||||
|
||||
var responseClient = new ResponsesClient(new ApiKeyCredential("test-key"), options);
|
||||
var chatClient = responseClient.AsIChatClient("gpt-4o-mini");
|
||||
|
||||
// Act: send a request through MEAI's chat client. The fake transport will throw on
|
||||
// response parsing, but we only care about the outbound headers, which are captured
|
||||
// before the response is parsed.
|
||||
try
|
||||
{
|
||||
await chatClient.GetResponseAsync("hi", cancellationToken: CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Expected: the fake response body is not parseable as a Responses API payload.
|
||||
}
|
||||
|
||||
// Assert: at least one outbound request reached the transport, and its User-Agent
|
||||
// contains either "MEAI/" (auto-stamped by MEAI) or no MEAI segment (verification
|
||||
// signal — see test summary).
|
||||
Assert.True(handler.Count > 0, "Expected at least one outbound request from MEAI wrapper.");
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
// INTENT: assert that MEAI auto-stamps. If the assertion fails, see the FoundryChatClient
|
||||
// implementation note about needing to register the MEAI policy explicitly.
|
||||
Assert.Contains("MEAI/", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
public int Count { get; private set; }
|
||||
public string? LastUserAgent { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.Count++;
|
||||
this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: null;
|
||||
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -14,14 +14,11 @@
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Tests requiring net8.0+ (MEAI.Evaluation and some SCM pipeline APIs do not support legacy TFMs) -->
|
||||
<!-- FoundryEval tests require net8.0+ (MEAI.Evaluation does 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,115 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the per-call <c>MeaiUserAgentPolicy</c> exposed via
|
||||
/// <see cref="RequestOptionsExtensions.UserAgentPolicy"/>. The policy is reachable through the
|
||||
/// public <see cref="FoundryAgent"/> constructors (which add it to the internally-built
|
||||
/// <see cref="Azure.AI.Projects.AIProjectClient"/>'s pipeline), so its behavior is part of the
|
||||
/// public API surface.
|
||||
/// </summary>
|
||||
public sealed class RequestOptionsExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MeaiUserAgentPolicy_AddsMeaiSegment_ToOutgoingRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [RequestOptionsExtensions.UserAgentPolicy],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new System.Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, handler.Count);
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.Contains("MEAI/", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MeaiUserAgentPolicy_DoesNotAddFoundryHostingSegmentAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [RequestOptionsExtensions.UserAgentPolicy],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new System.Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: the policy is MEAI-only; the foundry-hosting supplement is added elsewhere
|
||||
// (by the polyfill UserAgentResponsesClient → HostedAgentUserAgentPolicy).
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserAgentPolicy_ExposesSingletonInstance()
|
||||
{
|
||||
// Two reads of the static property must return the same instance — the policy is stateless and shared.
|
||||
var first = RequestOptionsExtensions.UserAgentPolicy;
|
||||
var second = RequestOptionsExtensions.UserAgentPolicy;
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeaiUserAgentPolicy_ValueIncludesAFFoundryAssemblyVersion_ReflectionGuard()
|
||||
{
|
||||
// The policy emits "MEAI/{Microsoft.Agents.AI.Foundry assembly InformationalVersion}".
|
||||
// If the assembly metadata stops being readable, the policy falls back to "MEAI" without a version,
|
||||
// which is a measurable telemetry regression.
|
||||
var attr = typeof(RequestOptionsExtensions).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
||||
Assert.NotNull(attr);
|
||||
Assert.False(string.IsNullOrEmpty(attr!.InformationalVersion));
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
public int Count { get; private set; }
|
||||
public string? LastUserAgent { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.Count++;
|
||||
this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: null;
|
||||
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// 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,9 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Moq;
|
||||
#if NET
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
@@ -40,12 +37,6 @@ public class HarnessAgentOptionsTests
|
||||
Assert.Null(options.FileAccessStore);
|
||||
Assert.Null(options.AgentModeProviderOptions);
|
||||
Assert.Null(options.AgentSkillsSource);
|
||||
Assert.Null(options.BackgroundAgents);
|
||||
Assert.Null(options.BackgroundAgentsProviderOptions);
|
||||
#if NET
|
||||
Assert.Null(options.ShellExecutor);
|
||||
Assert.Null(options.ShellEnvironmentProviderOptions);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -61,12 +52,6 @@ public class HarnessAgentOptionsTests
|
||||
var fileAccessStore = new Mock<AgentFileStore>().Object;
|
||||
var agentModeOptions = new AgentModeProviderOptions();
|
||||
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
|
||||
@@ -92,12 +77,6 @@ public class HarnessAgentOptionsTests
|
||||
AgentSkillsSource = skillsSource,
|
||||
DisableOpenTelemetry = true,
|
||||
OpenTelemetrySourceName = "custom-source",
|
||||
BackgroundAgents = backgroundAgents,
|
||||
BackgroundAgentsProviderOptions = backgroundAgentsOptions,
|
||||
#if NET
|
||||
ShellExecutor = shellExecutor,
|
||||
ShellEnvironmentProviderOptions = shellEnvOptions,
|
||||
#endif
|
||||
};
|
||||
|
||||
// Assert
|
||||
@@ -124,11 +103,5 @@ public class HarnessAgentOptionsTests
|
||||
Assert.Same(skillsSource, options.AgentSkillsSource);
|
||||
Assert.True(options.DisableOpenTelemetry);
|
||||
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,9 +5,6 @@ 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;
|
||||
|
||||
@@ -1200,264 +1197,4 @@ public class HarnessAgentTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Feature: BackgroundAgentsProvider
|
||||
|
||||
/// <summary>
|
||||
/// Verify that BackgroundAgentsProvider is included when BackgroundAgents are specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BackgroundAgentsProvider_IncludedWhenAgentsSpecified()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var bgAgentMock = new Mock<AIAgent>();
|
||||
bgAgentMock.Setup(a => a.Name).Returns("TestBackgroundAgent");
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.BackgroundAgents = [bgAgentMock.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 BackgroundAgentsProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that BackgroundAgentsProvider is not included when BackgroundAgents is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BackgroundAgentsProvider_ExcludedWhenAgentsNull()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.BackgroundAgents = null;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(innerAgent);
|
||||
if (innerAgent!.AIContextProviders != null)
|
||||
{
|
||||
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is BackgroundAgentsProvider);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that BackgroundAgentsProvider is not included when BackgroundAgents is an empty collection.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BackgroundAgentsProvider_ExcludedWhenAgentsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.BackgroundAgents = Array.Empty<AIAgent>();
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(innerAgent);
|
||||
if (innerAgent!.AIContextProviders != null)
|
||||
{
|
||||
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is BackgroundAgentsProvider);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that BackgroundAgentsProviderOptions is passed through when specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BackgroundAgentsProvider_UsesProvidedOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var bgAgentMock = new Mock<AIAgent>();
|
||||
bgAgentMock.Setup(a => a.Name).Returns("TestBackgroundAgent");
|
||||
bgAgentMock.Setup(a => a.Description).Returns("A test background agent");
|
||||
var providerOptions = new BackgroundAgentsProviderOptions
|
||||
{
|
||||
Instructions = "Custom instructions with {background_agents} list.",
|
||||
};
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.BackgroundAgents = [bgAgentMock.Object];
|
||||
options.BackgroundAgentsProviderOptions = providerOptions;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
var bgProvider = innerAgent!.AIContextProviders!.OfType<BackgroundAgentsProvider>().Single();
|
||||
|
||||
#pragma warning disable MAAI001
|
||||
var invokingContext = new AIContextProvider.InvokingContext(
|
||||
new Mock<AIAgent>().Object,
|
||||
new Mock<AgentSession>().Object,
|
||||
new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
|
||||
AIContext result = await bgProvider.InvokingAsync(invokingContext);
|
||||
|
||||
// Assert — custom instructions template is used and agent info is included
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("Custom instructions with", result.Instructions);
|
||||
Assert.Contains("TestBackgroundAgent", result.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that multiple background agents are all passed to the provider.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BackgroundAgentsProvider_IncludesMultipleAgentsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var agent1Mock = new Mock<AIAgent>();
|
||||
agent1Mock.Setup(a => a.Name).Returns("Agent1");
|
||||
agent1Mock.Setup(a => a.Description).Returns("First agent");
|
||||
var agent2Mock = new Mock<AIAgent>();
|
||||
agent2Mock.Setup(a => a.Name).Returns("Agent2");
|
||||
agent2Mock.Setup(a => a.Description).Returns("Second agent");
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.BackgroundAgents = [agent1Mock.Object, agent2Mock.Object];
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
var bgProvider = innerAgent!.AIContextProviders!.OfType<BackgroundAgentsProvider>().Single();
|
||||
|
||||
#pragma warning disable MAAI001
|
||||
var invokingContext = new AIContextProvider.InvokingContext(
|
||||
new Mock<AIAgent>().Object,
|
||||
new Mock<AgentSession>().Object,
|
||||
new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
|
||||
AIContext result = await bgProvider.InvokingAsync(invokingContext);
|
||||
|
||||
// Assert — both agents appear in the provider's instructions
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("Agent1", result.Instructions);
|
||||
Assert.Contains("First agent", result.Instructions);
|
||||
Assert.Contains("Agent2", result.Instructions);
|
||||
Assert.Contains("Second agent", result.Instructions);
|
||||
}
|
||||
|
||||
#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,7 +6,6 @@ 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;
|
||||
|
||||
@@ -227,8 +226,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -283,8 +280,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -377,8 +372,6 @@ 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
@@ -1,139 +0,0 @@
|
||||
// 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,21 +21,6 @@ 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:
|
||||
|
||||
+1
-18
@@ -7,22 +7,6 @@ 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
|
||||
@@ -1104,8 +1088,7 @@ 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.6.0...HEAD
|
||||
[1.6.0]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...python-1.6.0
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...HEAD
|
||||
[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,7 +34,6 @@ 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` |
|
||||
|
||||
@@ -129,7 +129,6 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
self._timeout_config = self._create_timeout_config(timeout)
|
||||
if client is not None:
|
||||
self.client = client
|
||||
self._non_streaming_client: Client | None = None
|
||||
self._close_http_client = True
|
||||
return
|
||||
if agent_card is None:
|
||||
@@ -145,30 +144,17 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
self._http_client = http_client # Store for cleanup
|
||||
self._close_http_client = True
|
||||
|
||||
# Create A2A client using factory
|
||||
config = ClientConfig(
|
||||
httpx_client=http_client,
|
||||
supported_protocol_bindings=["JSONRPC"],
|
||||
)
|
||||
factory = ClientFactory(config)
|
||||
interceptors = [auth_interceptor] if auth_interceptor is not None else None
|
||||
|
||||
# Create streaming client (SSE transport for stream=True)
|
||||
streaming_config = ClientConfig(
|
||||
httpx_client=http_client,
|
||||
streaming=True,
|
||||
supported_protocol_bindings=["JSONRPC"],
|
||||
)
|
||||
# Create non-streaming client (single request/response for stream=False)
|
||||
non_streaming_config = ClientConfig(
|
||||
httpx_client=http_client,
|
||||
streaming=False,
|
||||
supported_protocol_bindings=["JSONRPC"],
|
||||
)
|
||||
streaming_factory = ClientFactory(streaming_config)
|
||||
non_streaming_factory = ClientFactory(non_streaming_config)
|
||||
|
||||
# Attempt transport negotiation with the provided agent card
|
||||
try:
|
||||
self.client = streaming_factory.create(agent_card, interceptors=interceptors) # type: ignore
|
||||
self._non_streaming_client = non_streaming_factory.create(
|
||||
agent_card,
|
||||
interceptors=interceptors, # type: ignore
|
||||
)
|
||||
self.client = factory.create(agent_card, interceptors=interceptors) # type: ignore
|
||||
except Exception as transport_error:
|
||||
# Transport negotiation failed - fall back to minimal agent card with JSONRPC
|
||||
fallback_url = agent_card.supported_interfaces[0].url if agent_card.supported_interfaces else url
|
||||
@@ -180,11 +166,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
) from transport_error
|
||||
fallback_card = minimal_agent_card(fallback_url, ["JSONRPC"])
|
||||
try:
|
||||
self.client = streaming_factory.create(fallback_card, interceptors=interceptors) # type: ignore
|
||||
self._non_streaming_client = non_streaming_factory.create(
|
||||
fallback_card,
|
||||
interceptors=interceptors, # type: ignore
|
||||
)
|
||||
self.client = factory.create(fallback_card, interceptors=interceptors) # type: ignore
|
||||
except Exception as fallback_error:
|
||||
raise RuntimeError(
|
||||
f"A2A transport negotiation failed. "
|
||||
@@ -300,13 +282,6 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
del function_invocation_kwargs, client_kwargs, kwargs
|
||||
normalized_messages = normalize_messages(messages)
|
||||
|
||||
# Use non-streaming transport for non-streaming calls when available.
|
||||
# This sends a single HTTP request/response instead of opening an SSE
|
||||
# connection, matching the protocol's intent for synchronous operations.
|
||||
active_client = (
|
||||
self._non_streaming_client if (not stream and self._non_streaming_client is not None) else self.client
|
||||
)
|
||||
|
||||
if continuation_token is not None:
|
||||
a2a_stream: AsyncIterable[A2AStreamItem] = self.client.subscribe(
|
||||
SubscribeToTaskRequest(id=continuation_token["task_id"])
|
||||
@@ -318,11 +293,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
normalized_messages[-1],
|
||||
context_id=session.service_session_id if session else None,
|
||||
)
|
||||
request = SendMessageRequest(message=a2a_message)
|
||||
if background and not stream:
|
||||
# return_immediately only applies to non-streaming (message/send)
|
||||
request.configuration.return_immediately = True
|
||||
a2a_stream = active_client.send_message(request)
|
||||
a2a_stream = self.client.send_message(SendMessageRequest(message=a2a_message))
|
||||
|
||||
provider_session = session
|
||||
if provider_session is None and self.context_providers:
|
||||
|
||||
@@ -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.0b260521"
|
||||
version = "1.0.0b260519"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"a2a-sdk>=1.0.0,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ class MockA2AClient:
|
||||
self.subscribe_responses: list[StreamResponse] = []
|
||||
self.get_task_response: Task | None = None
|
||||
self.last_message: Any = None
|
||||
self.last_request: Any = None
|
||||
|
||||
def add_message_response(self, message_id: str, text: str, role: str = "agent") -> None:
|
||||
"""Add a mock Message response."""
|
||||
@@ -92,7 +91,6 @@ class MockA2AClient:
|
||||
|
||||
async def send_message(self, request: Any) -> AsyncIterator[StreamResponse]:
|
||||
"""Mock send_message method that yields responses."""
|
||||
self.last_request = request
|
||||
self.last_message = getattr(request, "message", request)
|
||||
self.call_count += 1
|
||||
|
||||
@@ -747,96 +745,6 @@ async def test_working_task_no_token_without_background(a2a_agent: A2AAgent, moc
|
||||
assert response.continuation_token is None
|
||||
|
||||
|
||||
async def test_background_sets_return_immediately_on_request(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that background=True sets return_immediately=True on SendMessageRequest configuration."""
|
||||
mock_a2a_client.add_in_progress_task_response("task-bg", state=TaskState.TASK_STATE_WORKING)
|
||||
|
||||
await a2a_agent.run("Background task", background=True)
|
||||
|
||||
assert mock_a2a_client.last_request.configuration.return_immediately is True
|
||||
|
||||
|
||||
async def test_foreground_does_not_set_return_immediately(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that background=False (default) does not set configuration on SendMessageRequest."""
|
||||
mock_a2a_client.add_task_response("task-fg2", [{"id": "art-1", "content": "Done"}])
|
||||
|
||||
await a2a_agent.run("Foreground task")
|
||||
|
||||
assert mock_a2a_client.last_request.HasField("configuration") is False
|
||||
|
||||
|
||||
async def test_streaming_background_does_not_set_return_immediately(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that background=True with stream=True does not set return_immediately.
|
||||
|
||||
Per A2A spec, return_immediately only applies to non-streaming (message/send).
|
||||
"""
|
||||
mock_a2a_client.add_task_response("task-sb", [{"id": "art-1", "content": "Streaming bg"}])
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Stream background", stream=True, background=True):
|
||||
updates.append(update)
|
||||
|
||||
assert mock_a2a_client.last_request.HasField("configuration") is False
|
||||
|
||||
|
||||
async def test_non_streaming_run_uses_non_streaming_client() -> None:
|
||||
"""Test that stream=False uses the non-streaming client when available."""
|
||||
streaming_client = MockA2AClient()
|
||||
non_streaming_client = MockA2AClient()
|
||||
non_streaming_client.add_task_response("task-ns", [{"id": "art-1", "content": "Non-streaming result"}])
|
||||
|
||||
agent = A2AAgent(name="Test Agent", id="test-ns", client=streaming_client, http_client=None)
|
||||
agent._non_streaming_client = non_streaming_client # type: ignore[assignment]
|
||||
|
||||
response = await agent.run("Hello")
|
||||
|
||||
# Non-streaming client should have been called
|
||||
assert non_streaming_client.call_count == 1
|
||||
assert streaming_client.call_count == 0
|
||||
assert response.messages[0].text == "Non-streaming result"
|
||||
assert non_streaming_client.last_request.HasField("configuration") is False
|
||||
|
||||
|
||||
async def test_streaming_run_uses_streaming_client() -> None:
|
||||
"""Test that stream=True always uses the streaming client."""
|
||||
streaming_client = MockA2AClient()
|
||||
non_streaming_client = MockA2AClient()
|
||||
streaming_client.add_task_response("task-s", [{"id": "art-1", "content": "Streaming result"}])
|
||||
|
||||
agent = A2AAgent(name="Test Agent", id="test-s", client=streaming_client, http_client=None)
|
||||
agent._non_streaming_client = non_streaming_client # type: ignore[assignment]
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("Hello", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
# Streaming client should have been called
|
||||
assert streaming_client.call_count == 1
|
||||
assert non_streaming_client.call_count == 0
|
||||
assert updates[0].contents[0].text == "Streaming result"
|
||||
|
||||
|
||||
async def test_non_streaming_client_fallback_when_not_available(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that stream=False falls back to streaming client when non-streaming client is unavailable."""
|
||||
mock_a2a_client.add_task_response("task-fb", [{"id": "art-1", "content": "Fallback result"}])
|
||||
|
||||
# a2a_agent is created with client= param so _non_streaming_client is None
|
||||
assert a2a_agent._non_streaming_client is None
|
||||
|
||||
response = await a2a_agent.run("Hello")
|
||||
|
||||
assert mock_a2a_client.call_count == 1
|
||||
assert response.messages[0].text == "Fallback result"
|
||||
|
||||
|
||||
async def test_completed_task_has_no_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that a completed task does not set a continuation token."""
|
||||
mock_a2a_client.add_task_response("task-done", [{"id": "art-1", "content": "Result"}])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0rc3"
|
||||
version = "1.0.0rc2"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.5.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.0b260521"
|
||||
version = "1.0.0b260519"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.5.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.0b260521"
|
||||
version = "1.0.0b260519"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.5.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.0a260521"
|
||||
version = "1.0.0a260519"
|
||||
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.6.0,<2",
|
||||
"agent-framework-foundry>=1.6.0,<2",
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-foundry>=1.5.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.0b260521"
|
||||
version = "1.0.0b260519"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.5.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.0b260521"
|
||||
version = "1.0.0b260519"
|
||||
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.6.0,<2",
|
||||
"agent-framework-durabletask>=1.0.0b260521,<2",
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-durabletask>=1.0.0b260519,<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.0b260521"
|
||||
version = "1.0.0b260519"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.5.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.0b260521"
|
||||
version = "1.0.0b260519"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.5.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.0b260521"
|
||||
version = "1.0.0b260519"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.5.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.0b260521"
|
||||
version = "1.0.0b260519"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -49,8 +49,6 @@ class ExperimentalFeature(str, Enum):
|
||||
EVALS = "EVALS"
|
||||
FILE_HISTORY = "FILE_HISTORY"
|
||||
FIDES = "FIDES"
|
||||
FOUNDRY_TOOLS = "FOUNDRY_TOOLS"
|
||||
FOUNDRY_PREVIEW_TOOLS = "FOUNDRY_PREVIEW_TOOLS"
|
||||
FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS"
|
||||
HARNESS = "HARNESS"
|
||||
SKILLS = "SKILLS"
|
||||
|
||||
@@ -2153,14 +2153,15 @@ def _capture_messages(
|
||||
finish_reason: FinishReason | None = None,
|
||||
) -> None:
|
||||
"""Log messages with extra information."""
|
||||
from ._types import normalize_messages
|
||||
from ._types import normalize_messages, prepend_instructions_to_messages
|
||||
|
||||
normalized_messages = normalize_messages(messages)
|
||||
prepped = prepend_instructions_to_messages(normalize_messages(messages), system_instructions)
|
||||
otel_messages: list[dict[str, Any]] = []
|
||||
for index, message in enumerate(normalized_messages):
|
||||
for index, message in enumerate(prepped):
|
||||
# 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={
|
||||
@@ -2169,7 +2170,6 @@ 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.6.0"
|
||||
version = "1.5.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 excludes system instructions
|
||||
# Verify input_messages contains system message
|
||||
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert [msg.get("role") for msg in input_messages] == ["user"]
|
||||
assert any(msg.get("role") == "system" for msg in input_messages)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
@@ -324,40 +324,6 @@ 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(
|
||||
@@ -3015,81 +2981,6 @@ 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):
|
||||
@@ -3213,40 +3104,6 @@ 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.0b260521"
|
||||
version = "1.0.0b260519"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.5.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.0b260521"
|
||||
version = "1.0.0b260519"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.5.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.0b260521"
|
||||
version = "1.0.0b260519"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.5.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",
|
||||
|
||||
@@ -39,70 +39,3 @@ async with Agent(
|
||||
result = await agent.run("What tools are available?")
|
||||
print(result.text)
|
||||
```
|
||||
|
||||
## Hosted tool factories
|
||||
|
||||
`FoundryChatClient` exposes static factory methods that return Foundry SDK tool
|
||||
configurations ready to pass to an `Agent`'s `tools=[...]` argument. These
|
||||
factories don't require a `FoundryChatClient` instance — you can call them
|
||||
statically and reuse the same tool configuration across agents.
|
||||
|
||||
```python
|
||||
from agent_framework import Agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
|
||||
agent = Agent(
|
||||
client=FoundryChatClient(...),
|
||||
instructions="...",
|
||||
tools=[
|
||||
FoundryChatClient.get_web_search_tool(),
|
||||
FoundryChatClient.get_code_interpreter_tool(),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
Generally available factories: `get_code_interpreter_tool`,
|
||||
`get_file_search_tool`, `get_web_search_tool`,
|
||||
`get_image_generation_tool`, `get_mcp_tool`.
|
||||
|
||||
> **Choosing a web grounding tool.** `get_web_search_tool` is the recommended
|
||||
> default — it requires no separate Bing resource and works with Azure OpenAI
|
||||
> models out of the box. Reach for `get_bing_grounding_tool` (experimental,
|
||||
> see below) when you need finer Bing parameters (`count`, `freshness`,
|
||||
> `market`, `set_lang`), are grounding non-OpenAI Foundry models, or are
|
||||
> migrating from Grounding with Bing Search on the classic platform — it
|
||||
> requires a Grounding with Bing Search Azure resource that you manage.
|
||||
> `get_bing_custom_search_tool` (also experimental) is for grounding
|
||||
> restricted to a curated list of domains via a Bing Custom Search instance.
|
||||
> See the
|
||||
> [web grounding overview](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/web-overview)
|
||||
> for the full comparison.
|
||||
|
||||
> **Experimental — `ExperimentalFeature.FOUNDRY_TOOLS`.** The following
|
||||
> factories wrap GA Foundry tool SDK classes but are new wrappers in
|
||||
> `agent-framework-foundry` and may change before the wrappers themselves
|
||||
> reach GA. Calls emit an `ExperimentalWarning` the first time the
|
||||
> `FOUNDRY_TOOLS` feature is exercised in a process (then deduplicated).
|
||||
|
||||
| Factory | Foundry SDK tool |
|
||||
|---------|-----------------|
|
||||
| `get_azure_ai_search_tool(index_connection_id, index_name, ...)` | `AzureAISearchTool` |
|
||||
| `get_bing_grounding_tool(connection_id, ...)` | `BingGroundingTool` |
|
||||
|
||||
> **Experimental — `ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS`.** The
|
||||
> following factories wrap **preview** Foundry tool SDK types — the underlying
|
||||
> Foundry capability itself is in preview and may change or be removed before
|
||||
> reaching GA. Calls emit a separate `ExperimentalWarning` the first time the
|
||||
> `FOUNDRY_PREVIEW_TOOLS` feature is exercised in a process (then
|
||||
> deduplicated). Use `FOUNDRY_TOOLS` for "wrapper is new" and
|
||||
> `FOUNDRY_PREVIEW_TOOLS` for "underlying Foundry feature is preview".
|
||||
|
||||
| Factory | Foundry SDK tool |
|
||||
|---------|-----------------|
|
||||
| `get_sharepoint_tool(connection_id)` | `SharepointPreviewTool` |
|
||||
| `get_fabric_tool(connection_id)` | `MicrosoftFabricPreviewTool` |
|
||||
| `get_memory_search_tool(memory_store_name, scope, ...)` | `MemorySearchPreviewTool` |
|
||||
| `get_computer_use_tool(environment, display_width, display_height)` | `ComputerUsePreviewTool` |
|
||||
| `get_browser_automation_tool(connection_id)` | `BrowserAutomationPreviewTool` |
|
||||
| `get_bing_custom_search_tool(connection_id, instance_name, ...)` | `BingCustomSearchPreviewTool` |
|
||||
| `get_a2a_tool(base_url=..., project_connection_id=..., ...)` | `A2APreviewTool` |
|
||||
|
||||
@@ -16,35 +16,14 @@ from agent_framework import (
|
||||
load_settings,
|
||||
)
|
||||
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
|
||||
from agent_framework._feature_stage import ExperimentalFeature, experimental
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.ai.projects.models import (
|
||||
A2APreviewTool,
|
||||
AISearchIndexResource,
|
||||
AutoCodeInterpreterToolParam,
|
||||
AzureAISearchTool,
|
||||
AzureAISearchToolResource,
|
||||
BingCustomSearchConfiguration,
|
||||
BingCustomSearchPreviewTool,
|
||||
BingCustomSearchToolParameters,
|
||||
BingGroundingSearchConfiguration,
|
||||
BingGroundingSearchToolParameters,
|
||||
BingGroundingTool,
|
||||
BrowserAutomationPreviewTool,
|
||||
BrowserAutomationToolConnectionParameters,
|
||||
BrowserAutomationToolParameters,
|
||||
CodeInterpreterTool,
|
||||
ComputerUsePreviewTool,
|
||||
FabricDataAgentToolParameters,
|
||||
ImageGenTool,
|
||||
MemorySearchPreviewTool,
|
||||
MicrosoftFabricPreviewTool,
|
||||
SharepointGroundingToolParameters,
|
||||
SharepointPreviewTool,
|
||||
ToolProjectConnection,
|
||||
WebSearchApproximateLocation,
|
||||
WebSearchTool,
|
||||
WebSearchToolFilters,
|
||||
@@ -402,44 +381,17 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
custom_search_configuration: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> WebSearchTool:
|
||||
"""Create a Web Search tool configuration for Microsoft Foundry.
|
||||
|
||||
**Choosing a web grounding tool.** Foundry exposes three options that all reach
|
||||
the public web via Bing. Pick the one that matches your scenario:
|
||||
|
||||
* :py:meth:`get_web_search_tool` (this one, GA) — recommended starting point.
|
||||
The Bing resource is managed by Microsoft, no extra Azure setup is required,
|
||||
and only Azure OpenAI models are supported. Parameters are limited to
|
||||
``user_location`` and ``search_context_size``.
|
||||
* :py:meth:`get_bing_grounding_tool` (preview) — use when you need finer Bing parameters (``count``,
|
||||
``freshness``, ``market``, ``set_lang``), want to ground non-OpenAI
|
||||
Foundry models, or are migrating from Grounding with Bing Search on the
|
||||
classic agents platform. You manage the Grounding with Bing Search
|
||||
resource yourself (Contributor/Owner to create the resource, Foundry
|
||||
Project Manager to wire the connection).
|
||||
* :py:meth:`get_bing_custom_search_tool` (preview) — use when you need to
|
||||
restrict grounding to a curated set of domains defined in a Bing Custom
|
||||
Search instance.
|
||||
|
||||
For all three, search data flows outside the Azure compliance boundary. See
|
||||
https://learn.microsoft.com/azure/foundry/agents/how-to/tools/web-overview for
|
||||
the full comparison.
|
||||
"""Create a web search tool configuration for Microsoft Foundry.
|
||||
|
||||
Keyword Args:
|
||||
user_location: Location context with keys like ``"city"``, ``"country"``,
|
||||
``"region"``, ``"timezone"``.
|
||||
search_context_size: Amount of context from search results
|
||||
(``"low"``, ``"medium"``, ``"high"``).
|
||||
allowed_domains: List of domains to restrict search results to. Wrapped
|
||||
into ``WebSearchToolFilters`` and passed as the ``filters`` field on
|
||||
the SDK ``WebSearchTool``.
|
||||
custom_search_configuration: Custom Bing search configuration for
|
||||
domain-restricted scenarios.
|
||||
**kwargs: Additional arguments passed to the SDK ``WebSearchTool``
|
||||
constructor.
|
||||
user_location: Location context with keys like "city", "country", "region", "timezone".
|
||||
search_context_size: Amount of context from search results ("low", "medium", "high").
|
||||
allowed_domains: List of domains to restrict search results to.
|
||||
custom_search_configuration: Custom Bing search configuration.
|
||||
**kwargs: Additional arguments passed to the SDK WebSearchTool constructor.
|
||||
|
||||
Returns:
|
||||
A ``WebSearchTool`` ready to pass to an Agent.
|
||||
A WebSearchTool ready to pass to an Agent.
|
||||
"""
|
||||
ws_kwargs: dict[str, Any] = {**kwargs}
|
||||
if search_context_size:
|
||||
@@ -448,137 +400,15 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
ws_kwargs["filters"] = WebSearchToolFilters(allowed_domains=allowed_domains)
|
||||
if custom_search_configuration:
|
||||
ws_kwargs["custom_search_configuration"] = custom_search_configuration
|
||||
ws_tool = WebSearchTool(**ws_kwargs)
|
||||
if user_location:
|
||||
ws_kwargs["user_location"] = WebSearchApproximateLocation(
|
||||
ws_tool.user_location = WebSearchApproximateLocation(
|
||||
city=user_location.get("city"),
|
||||
country=user_location.get("country"),
|
||||
region=user_location.get("region"),
|
||||
timezone=user_location.get("timezone"),
|
||||
)
|
||||
return WebSearchTool(**ws_kwargs)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS)
|
||||
def get_bing_grounding_tool(
|
||||
*,
|
||||
connection_id: str,
|
||||
market: str | None = None,
|
||||
set_lang: str | None = None,
|
||||
count: int | None = None,
|
||||
freshness: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> BingGroundingTool:
|
||||
"""Create a Grounding with Bing Search tool configuration for Foundry.
|
||||
|
||||
Use this factory when :py:meth:`get_web_search_tool` is too restrictive — for
|
||||
example when you need ``count``/``freshness``/``market``/``set_lang``
|
||||
parameters, want to ground a non-OpenAI Foundry model, or are migrating an
|
||||
agent that already uses Grounding with Bing Search on the classic agents
|
||||
platform. You manage the Grounding with Bing Search Azure resource yourself
|
||||
(Contributor or Owner to create the resource, Foundry Project Manager to
|
||||
create the project connection). Search data flows outside the Azure
|
||||
compliance boundary.
|
||||
|
||||
For domain-restricted grounding to a curated allow-list, use
|
||||
:py:meth:`get_bing_custom_search_tool` instead. For a zero-setup default that
|
||||
works for most agents, see :py:meth:`get_web_search_tool`. The full
|
||||
comparison lives at
|
||||
https://learn.microsoft.com/azure/foundry/agents/how-to/tools/web-overview.
|
||||
|
||||
Keyword Args:
|
||||
connection_id: The Foundry project connection ID for the Grounding with
|
||||
Bing Search resource.
|
||||
market: Optional Bing market identifier (e.g. ``"en-US"``).
|
||||
set_lang: Optional UI language code passed to the Bing API.
|
||||
count: Optional number of search results to return.
|
||||
freshness: Optional time-range filter for search results. See
|
||||
https://learn.microsoft.com/bing/search-apis/bing-web-search/reference/query-parameters
|
||||
for accepted values.
|
||||
**kwargs: Additional arguments forwarded to the SDK
|
||||
``BingGroundingSearchConfiguration``.
|
||||
|
||||
Returns:
|
||||
A ``BingGroundingTool`` ready to pass to an Agent.
|
||||
"""
|
||||
config_kwargs: dict[str, Any] = {
|
||||
**kwargs,
|
||||
"project_connection_id": connection_id,
|
||||
}
|
||||
if market is not None:
|
||||
config_kwargs["market"] = market
|
||||
if set_lang is not None:
|
||||
config_kwargs["set_lang"] = set_lang
|
||||
if count is not None:
|
||||
config_kwargs["count"] = count
|
||||
if freshness is not None:
|
||||
config_kwargs["freshness"] = freshness
|
||||
return BingGroundingTool(
|
||||
bing_grounding=BingGroundingSearchToolParameters(
|
||||
search_configurations=[BingGroundingSearchConfiguration(**config_kwargs)],
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS)
|
||||
def get_bing_custom_search_tool(
|
||||
*,
|
||||
connection_id: str,
|
||||
instance_name: str,
|
||||
market: str | None = None,
|
||||
set_lang: str | None = None,
|
||||
count: int | None = None,
|
||||
freshness: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> BingCustomSearchPreviewTool:
|
||||
"""Create a Grounding with Bing Custom Search tool configuration for Foundry.
|
||||
|
||||
Use this factory (preview) when you need to restrict grounding to a curated
|
||||
list of domains. The allow/block list is defined ahead of time on a Bing
|
||||
Custom Search resource (in the Bing portal) and referenced here by
|
||||
``instance_name``. Like the other Bing-backed tools, search data flows
|
||||
outside the Azure compliance boundary, and you must create the Bing Custom
|
||||
Search resource yourself.
|
||||
|
||||
For unrestricted public-web grounding with no extra Azure setup, prefer
|
||||
:py:meth:`get_web_search_tool`. For unrestricted grounding with finer Bing
|
||||
parameters or non-OpenAI models, prefer :py:meth:`get_bing_grounding_tool`.
|
||||
See
|
||||
https://learn.microsoft.com/azure/foundry/agents/how-to/tools/web-overview
|
||||
for the full comparison.
|
||||
|
||||
Keyword Args:
|
||||
connection_id: The Foundry project connection ID for the Grounding with
|
||||
Bing Custom Search resource.
|
||||
instance_name: The custom configuration instance name defined on the
|
||||
Bing Custom Search resource.
|
||||
market: Optional Bing market identifier (e.g. ``"en-US"``).
|
||||
set_lang: Optional UI language code passed to the Bing API.
|
||||
count: Optional number of search results to return.
|
||||
freshness: Optional time-range filter for search results.
|
||||
**kwargs: Additional arguments forwarded to the SDK
|
||||
``BingCustomSearchConfiguration``.
|
||||
|
||||
Returns:
|
||||
A ``BingCustomSearchPreviewTool`` ready to pass to an Agent.
|
||||
"""
|
||||
config_kwargs: dict[str, Any] = {
|
||||
**kwargs,
|
||||
"project_connection_id": connection_id,
|
||||
"instance_name": instance_name,
|
||||
}
|
||||
if market is not None:
|
||||
config_kwargs["market"] = market
|
||||
if set_lang is not None:
|
||||
config_kwargs["set_lang"] = set_lang
|
||||
if count is not None:
|
||||
config_kwargs["count"] = count
|
||||
if freshness is not None:
|
||||
config_kwargs["freshness"] = freshness
|
||||
return BingCustomSearchPreviewTool(
|
||||
bing_custom_search_preview=BingCustomSearchToolParameters(
|
||||
search_configurations=[BingCustomSearchConfiguration(**config_kwargs)],
|
||||
),
|
||||
)
|
||||
return ws_tool
|
||||
|
||||
@staticmethod
|
||||
def get_image_generation_tool( # type: ignore[override]
|
||||
@@ -683,219 +513,6 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
|
||||
# endregion
|
||||
|
||||
# region Experimental Foundry tool factories (preview SDK types)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS)
|
||||
def get_azure_ai_search_tool(
|
||||
*,
|
||||
index_connection_id: str,
|
||||
index_name: str,
|
||||
query_type: str | None = None,
|
||||
top_k: int | None = None,
|
||||
filter: str | None = None,
|
||||
index_asset_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AzureAISearchTool:
|
||||
"""Create an Azure AI Search tool configuration for Foundry.
|
||||
|
||||
Keyword Args:
|
||||
index_connection_id: The Foundry project connection ID for the Azure AI Search index.
|
||||
index_name: The name of the index to search.
|
||||
query_type: Optional query type (``"simple"``, ``"semantic"``, ``"vector"``,
|
||||
``"vector_simple_hybrid"``, or ``"vector_semantic_hybrid"``).
|
||||
top_k: Optional number of documents to retrieve.
|
||||
filter: Optional OData filter expression.
|
||||
index_asset_id: Optional index asset id for the search resource.
|
||||
**kwargs: Additional arguments forwarded to the SDK ``AISearchIndexResource``.
|
||||
|
||||
Returns:
|
||||
An ``AzureAISearchTool`` ready to pass to an Agent.
|
||||
"""
|
||||
index_kwargs: dict[str, Any] = {
|
||||
**kwargs,
|
||||
"project_connection_id": index_connection_id,
|
||||
"index_name": index_name,
|
||||
}
|
||||
if query_type is not None:
|
||||
index_kwargs["query_type"] = query_type
|
||||
if top_k is not None:
|
||||
index_kwargs["top_k"] = top_k
|
||||
if filter is not None:
|
||||
index_kwargs["filter"] = filter
|
||||
if index_asset_id is not None:
|
||||
index_kwargs["index_asset_id"] = index_asset_id
|
||||
return AzureAISearchTool(
|
||||
azure_ai_search=AzureAISearchToolResource(indexes=[AISearchIndexResource(**index_kwargs)]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS)
|
||||
def get_sharepoint_tool(
|
||||
*,
|
||||
connection_id: str,
|
||||
**kwargs: Any,
|
||||
) -> SharepointPreviewTool:
|
||||
"""Create a SharePoint grounding tool configuration for Foundry.
|
||||
|
||||
Keyword Args:
|
||||
connection_id: The Foundry project connection ID for the SharePoint resource.
|
||||
**kwargs: Additional arguments forwarded to the SDK
|
||||
``SharepointGroundingToolParameters``.
|
||||
|
||||
Returns:
|
||||
A ``SharepointPreviewTool`` ready to pass to an Agent.
|
||||
"""
|
||||
return SharepointPreviewTool(
|
||||
sharepoint_grounding_preview=SharepointGroundingToolParameters(
|
||||
project_connections=[ToolProjectConnection(project_connection_id=connection_id)],
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS)
|
||||
def get_fabric_tool(
|
||||
*,
|
||||
connection_id: str,
|
||||
**kwargs: Any,
|
||||
) -> MicrosoftFabricPreviewTool:
|
||||
"""Create a Microsoft Fabric data agent tool configuration for Foundry.
|
||||
|
||||
Keyword Args:
|
||||
connection_id: The Foundry project connection ID for the Fabric data agent.
|
||||
**kwargs: Additional arguments forwarded to the SDK
|
||||
``FabricDataAgentToolParameters``.
|
||||
|
||||
Returns:
|
||||
A ``MicrosoftFabricPreviewTool`` ready to pass to an Agent.
|
||||
"""
|
||||
return MicrosoftFabricPreviewTool(
|
||||
fabric_dataagent_preview=FabricDataAgentToolParameters(
|
||||
project_connections=[ToolProjectConnection(project_connection_id=connection_id)],
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS)
|
||||
def get_memory_search_tool(
|
||||
*,
|
||||
memory_store_name: str,
|
||||
scope: str,
|
||||
search_options: Any | None = None,
|
||||
update_delay: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> MemorySearchPreviewTool:
|
||||
"""Create a Memory Search tool configuration for Foundry.
|
||||
|
||||
Keyword Args:
|
||||
memory_store_name: The name of the memory store to use.
|
||||
scope: The namespace used to group and isolate memories (e.g. a user ID).
|
||||
Use ``"{{$userId}}"`` to scope memories to the current signed-in user.
|
||||
search_options: Optional ``MemorySearchOptions`` instance.
|
||||
update_delay: Optional seconds to wait before updating memories after inactivity.
|
||||
**kwargs: Additional arguments forwarded to the SDK ``MemorySearchPreviewTool``.
|
||||
|
||||
Returns:
|
||||
A ``MemorySearchPreviewTool`` ready to pass to an Agent.
|
||||
"""
|
||||
params: dict[str, Any] = {
|
||||
**kwargs,
|
||||
"memory_store_name": memory_store_name,
|
||||
"scope": scope,
|
||||
}
|
||||
if search_options is not None:
|
||||
params["search_options"] = search_options
|
||||
if update_delay is not None:
|
||||
params["update_delay"] = update_delay
|
||||
return MemorySearchPreviewTool(**params)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS)
|
||||
def get_computer_use_tool(
|
||||
*,
|
||||
environment: str,
|
||||
display_width: int,
|
||||
display_height: int,
|
||||
**kwargs: Any,
|
||||
) -> ComputerUsePreviewTool:
|
||||
"""Create a Computer Use tool configuration for Foundry.
|
||||
|
||||
Keyword Args:
|
||||
environment: The computer environment to control. One of ``"windows"``,
|
||||
``"mac"``, ``"linux"``, ``"ubuntu"``, or ``"browser"``.
|
||||
display_width: The width of the computer display.
|
||||
display_height: The height of the computer display.
|
||||
**kwargs: Additional arguments forwarded to the SDK ``ComputerUsePreviewTool``.
|
||||
|
||||
Returns:
|
||||
A ``ComputerUsePreviewTool`` ready to pass to an Agent.
|
||||
"""
|
||||
return ComputerUsePreviewTool(
|
||||
environment=environment,
|
||||
display_width=display_width,
|
||||
display_height=display_height,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS)
|
||||
def get_browser_automation_tool(
|
||||
*,
|
||||
connection_id: str,
|
||||
**kwargs: Any,
|
||||
) -> BrowserAutomationPreviewTool:
|
||||
"""Create a Browser Automation tool configuration for Foundry.
|
||||
|
||||
Keyword Args:
|
||||
connection_id: The Foundry project connection ID for the Azure Playwright resource.
|
||||
**kwargs: Additional arguments forwarded to the SDK
|
||||
``BrowserAutomationToolParameters``.
|
||||
|
||||
Returns:
|
||||
A ``BrowserAutomationPreviewTool`` ready to pass to an Agent.
|
||||
"""
|
||||
return BrowserAutomationPreviewTool(
|
||||
browser_automation_preview=BrowserAutomationToolParameters(
|
||||
connection=BrowserAutomationToolConnectionParameters(project_connection_id=connection_id),
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS)
|
||||
def get_a2a_tool(
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
agent_card_path: str | None = None,
|
||||
project_connection_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> A2APreviewTool:
|
||||
"""Create an Agent-to-Agent (A2A) tool configuration for Foundry.
|
||||
|
||||
Keyword Args:
|
||||
base_url: Base URL of the remote A2A agent.
|
||||
agent_card_path: Path to the agent card relative to ``base_url``.
|
||||
Defaults to ``"/.well-known/agent-card.json"`` server-side.
|
||||
project_connection_id: Foundry connection ID for the A2A server. Stores
|
||||
authentication and other connection details.
|
||||
**kwargs: Additional arguments forwarded to the SDK ``A2APreviewTool``.
|
||||
|
||||
Returns:
|
||||
An ``A2APreviewTool`` ready to pass to an Agent.
|
||||
"""
|
||||
params: dict[str, Any] = dict(kwargs)
|
||||
if base_url is not None:
|
||||
params["base_url"] = base_url
|
||||
if agent_card_path is not None:
|
||||
params["agent_card_path"] = agent_card_path
|
||||
if project_connection_id is not None:
|
||||
params["project_connection_id"] = project_connection_id
|
||||
return A2APreviewTool(**params)
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
class FoundryChatClient( # type: ignore[misc]
|
||||
FunctionInvocationLayer[FoundryChatOptionsT],
|
||||
|
||||
@@ -75,15 +75,6 @@ _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",
|
||||
@@ -170,7 +161,6 @@ 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()``.
|
||||
|
||||
@@ -179,8 +169,6 @@ 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:
|
||||
@@ -215,7 +203,7 @@ def _build_testing_criteria(
|
||||
mapping["context"] = "{{item.context}}"
|
||||
if qualified in _GROUND_TRUTH_EVALUATORS:
|
||||
mapping["ground_truth"] = "{{item.ground_truth}}"
|
||||
if include_tool_definitions and qualified in _TOOL_DEFINITION_EVALUATORS:
|
||||
if qualified in _TOOL_EVALUATORS:
|
||||
mapping["tool_definitions"] = "{{item.tool_definitions}}"
|
||||
entry["data_mapping"] = mapping
|
||||
|
||||
@@ -725,7 +713,6 @@ 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.6.0"
|
||||
version = "1.5.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.6.0,<2",
|
||||
"agent-framework-openai>=1.6.0,<2",
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-openai>=1.5.0,<2",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"azure-ai-projects>=2.1.0,<3.0",
|
||||
]
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
@@ -985,25 +984,6 @@ def test_get_web_search_tool_with_location() -> None:
|
||||
assert tool_obj is not None
|
||||
|
||||
|
||||
def test_get_web_search_tool_allowed_domains() -> None:
|
||||
"""allowed_domains is wrapped into the SDK filters field."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
tool_obj = RawFoundryChatClient.get_web_search_tool(allowed_domains=["example.com"])
|
||||
assert tool_obj.filters is not None
|
||||
assert tool_obj.filters.allowed_domains == ["example.com"]
|
||||
|
||||
|
||||
def test_get_web_search_tool_custom_search_configuration() -> None:
|
||||
"""custom_search_configuration is forwarded to the SDK without warning."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
tool_obj = RawFoundryChatClient.get_web_search_tool(
|
||||
custom_search_configuration={"connection_id": "c", "instance_name": "i"},
|
||||
)
|
||||
assert tool_obj.custom_search_configuration == {"connection_id": "c", "instance_name": "i"}
|
||||
|
||||
|
||||
def test_get_image_generation_tool() -> None:
|
||||
"""Test image generation tool creation."""
|
||||
|
||||
@@ -1032,223 +1012,6 @@ def test_get_mcp_tool_with_connection_id() -> None:
|
||||
assert tool_obj is not None
|
||||
|
||||
|
||||
def _skip_if_sdk_class_missing(name: str) -> Any:
|
||||
"""Return the SDK class or skip the test if older azure-ai-projects lacks it."""
|
||||
from azure.ai.projects import models as projects_models
|
||||
|
||||
cls = getattr(projects_models, name, None)
|
||||
if cls is None:
|
||||
pytest.skip(f"azure-ai-projects in this environment does not expose {name!r}.")
|
||||
return cls
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_azure_ai_search_tool() -> None:
|
||||
"""Azure AI Search tool factory builds the nested resource correctly."""
|
||||
azure_ai_search_tool_cls = _skip_if_sdk_class_missing("AzureAISearchTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_azure_ai_search_tool(
|
||||
index_connection_id="conn-1",
|
||||
index_name="my-index",
|
||||
query_type="vector_semantic_hybrid",
|
||||
top_k=5,
|
||||
filter="category eq 'docs'",
|
||||
)
|
||||
assert isinstance(tool_obj, azure_ai_search_tool_cls)
|
||||
indexes = tool_obj.azure_ai_search.indexes
|
||||
assert len(indexes) == 1
|
||||
index = indexes[0]
|
||||
assert index.project_connection_id == "conn-1"
|
||||
assert index.index_name == "my-index"
|
||||
assert index.query_type == "vector_semantic_hybrid"
|
||||
assert index.top_k == 5
|
||||
assert index.filter == "category eq 'docs'"
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_sharepoint_tool() -> None:
|
||||
"""SharePoint tool factory wires the connection through nested params."""
|
||||
sharepoint_tool_cls = _skip_if_sdk_class_missing("SharepointPreviewTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_sharepoint_tool(connection_id="sp-conn")
|
||||
assert isinstance(tool_obj, sharepoint_tool_cls)
|
||||
connections = tool_obj.sharepoint_grounding_preview.project_connections
|
||||
assert connections is not None
|
||||
assert len(connections) == 1
|
||||
assert connections[0].project_connection_id == "sp-conn"
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_fabric_tool() -> None:
|
||||
"""Fabric tool factory wires the connection through nested params."""
|
||||
fabric_tool_cls = _skip_if_sdk_class_missing("MicrosoftFabricPreviewTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_fabric_tool(connection_id="fab-conn")
|
||||
assert isinstance(tool_obj, fabric_tool_cls)
|
||||
connections = tool_obj.fabric_dataagent_preview.project_connections
|
||||
assert connections is not None
|
||||
assert len(connections) == 1
|
||||
assert connections[0].project_connection_id == "fab-conn"
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_memory_search_tool() -> None:
|
||||
"""Memory search tool factory passes core fields through."""
|
||||
memory_tool_cls = _skip_if_sdk_class_missing("MemorySearchPreviewTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_memory_search_tool(
|
||||
memory_store_name="store-1",
|
||||
scope="{{$userId}}",
|
||||
update_delay=600,
|
||||
)
|
||||
assert isinstance(tool_obj, memory_tool_cls)
|
||||
assert tool_obj.memory_store_name == "store-1"
|
||||
assert tool_obj.scope == "{{$userId}}"
|
||||
assert tool_obj.update_delay == 600
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_computer_use_tool() -> None:
|
||||
"""Computer use tool factory passes environment + display dimensions."""
|
||||
computer_use_cls = _skip_if_sdk_class_missing("ComputerUsePreviewTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_computer_use_tool(
|
||||
environment="browser",
|
||||
display_width=1920,
|
||||
display_height=1080,
|
||||
)
|
||||
assert isinstance(tool_obj, computer_use_cls)
|
||||
assert tool_obj.environment == "browser"
|
||||
assert tool_obj.display_width == 1920
|
||||
assert tool_obj.display_height == 1080
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_browser_automation_tool() -> None:
|
||||
"""Browser automation tool factory wraps the connection id in the params type."""
|
||||
browser_tool_cls = _skip_if_sdk_class_missing("BrowserAutomationPreviewTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_browser_automation_tool(connection_id="playwright-conn")
|
||||
assert isinstance(tool_obj, browser_tool_cls)
|
||||
assert tool_obj.browser_automation_preview.connection.project_connection_id == "playwright-conn"
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_bing_custom_search_tool() -> None:
|
||||
"""Bing custom search tool factory builds the nested search configuration."""
|
||||
bing_tool_cls = _skip_if_sdk_class_missing("BingCustomSearchPreviewTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_bing_custom_search_tool(
|
||||
connection_id="bing-conn",
|
||||
instance_name="my-custom-config",
|
||||
market="en-US",
|
||||
count=10,
|
||||
)
|
||||
assert isinstance(tool_obj, bing_tool_cls)
|
||||
configs = tool_obj.bing_custom_search_preview.search_configurations
|
||||
assert len(configs) == 1
|
||||
config = configs[0]
|
||||
assert config.project_connection_id == "bing-conn"
|
||||
assert config.instance_name == "my-custom-config"
|
||||
assert config.market == "en-US"
|
||||
assert config.count == 10
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_bing_grounding_tool() -> None:
|
||||
"""Bing grounding tool factory builds the nested search configuration."""
|
||||
bing_tool_cls = _skip_if_sdk_class_missing("BingGroundingTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_bing_grounding_tool(
|
||||
connection_id="bing-conn",
|
||||
market="en-US",
|
||||
set_lang="en",
|
||||
count=10,
|
||||
freshness="Day",
|
||||
)
|
||||
assert isinstance(tool_obj, bing_tool_cls)
|
||||
configs = tool_obj.bing_grounding.search_configurations
|
||||
assert len(configs) == 1
|
||||
config = configs[0]
|
||||
assert config.project_connection_id == "bing-conn"
|
||||
assert config.market == "en-US"
|
||||
assert config.set_lang == "en"
|
||||
assert config.count == 10
|
||||
assert config.freshness == "Day"
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_a2a_tool() -> None:
|
||||
"""A2A tool factory carries base_url, agent_card_path, and project_connection_id."""
|
||||
a2a_tool_cls = _skip_if_sdk_class_missing("A2APreviewTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_a2a_tool(
|
||||
base_url="https://agent.example.com",
|
||||
agent_card_path="/.well-known/agent-card.json",
|
||||
project_connection_id="a2a-conn",
|
||||
)
|
||||
assert isinstance(tool_obj, a2a_tool_cls)
|
||||
assert tool_obj.base_url == "https://agent.example.com"
|
||||
assert tool_obj.agent_card_path == "/.well-known/agent-card.json"
|
||||
assert tool_obj.project_connection_id == "a2a-conn"
|
||||
|
||||
|
||||
_FOUNDRY_TOOLS_FACTORY_CASES: list[tuple[str, str, dict[str, Any]]] = [
|
||||
("get_azure_ai_search_tool", "AzureAISearchTool", {"index_connection_id": "c", "index_name": "i"}),
|
||||
(
|
||||
"get_bing_grounding_tool",
|
||||
"BingGroundingTool",
|
||||
{"connection_id": "c"},
|
||||
),
|
||||
]
|
||||
|
||||
_FOUNDRY_PREVIEW_TOOLS_FACTORY_CASES: list[tuple[str, str, dict[str, Any]]] = [
|
||||
("get_sharepoint_tool", "SharepointPreviewTool", {"connection_id": "c"}),
|
||||
("get_fabric_tool", "MicrosoftFabricPreviewTool", {"connection_id": "c"}),
|
||||
(
|
||||
"get_memory_search_tool",
|
||||
"MemorySearchPreviewTool",
|
||||
{"memory_store_name": "s", "scope": "u"},
|
||||
),
|
||||
(
|
||||
"get_computer_use_tool",
|
||||
"ComputerUsePreviewTool",
|
||||
{"environment": "browser", "display_width": 1, "display_height": 1},
|
||||
),
|
||||
("get_browser_automation_tool", "BrowserAutomationPreviewTool", {"connection_id": "c"}),
|
||||
(
|
||||
"get_bing_custom_search_tool",
|
||||
"BingCustomSearchPreviewTool",
|
||||
{"connection_id": "c", "instance_name": "i"},
|
||||
),
|
||||
("get_a2a_tool", "A2APreviewTool", {"base_url": "https://a.example.com"}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
@pytest.mark.parametrize("factory_name, sdk_class_name, kwargs", _FOUNDRY_TOOLS_FACTORY_CASES)
|
||||
def test_foundry_tools_factories_are_marked(factory_name: str, sdk_class_name: str, kwargs: dict[str, Any]) -> None:
|
||||
"""Factories wrapping GA Foundry tool SDK classes carry FOUNDRY_TOOLS metadata."""
|
||||
_skip_if_sdk_class_missing(sdk_class_name)
|
||||
factory = getattr(FoundryChatClient, factory_name)
|
||||
assert getattr(factory, "__feature_stage__", None) == "experimental"
|
||||
assert getattr(factory, "__feature_id__", None) == "FOUNDRY_TOOLS"
|
||||
assert factory(**kwargs) is not None
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
@pytest.mark.parametrize("factory_name, sdk_class_name, kwargs", _FOUNDRY_PREVIEW_TOOLS_FACTORY_CASES)
|
||||
def test_foundry_preview_tools_factories_are_marked(
|
||||
factory_name: str, sdk_class_name: str, kwargs: dict[str, Any]
|
||||
) -> None:
|
||||
"""Factories wrapping preview Foundry tool SDK classes carry FOUNDRY_PREVIEW_TOOLS metadata."""
|
||||
_skip_if_sdk_class_missing(sdk_class_name)
|
||||
factory = getattr(FoundryChatClient, factory_name)
|
||||
assert getattr(factory, "__feature_stage__", None) == "experimental"
|
||||
assert getattr(factory, "__feature_id__", None) == "FOUNDRY_PREVIEW_TOOLS"
|
||||
assert factory(**kwargs) is not None
|
||||
|
||||
|
||||
def test_parse_chunk_surfaces_oauth_consent_request() -> None:
|
||||
"""An oauth_consent_request output item surfaces as Content with consent_link."""
|
||||
|
||||
|
||||
@@ -745,12 +745,7 @@ 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,
|
||||
include_tool_definitions=True,
|
||||
)
|
||||
criteria = _build_testing_criteria(["relevance", "tool_call_accuracy"], "gpt-4o", include_data_mapping=True)
|
||||
# relevance: string query/response
|
||||
assert criteria[0]["data_mapping"]["query"] == "{{item.query}}"
|
||||
assert criteria[0]["data_mapping"]["response"] == "{{item.response}}"
|
||||
@@ -767,17 +762,6 @@ 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)
|
||||
@@ -797,12 +781,7 @@ class TestBuildTestingCriteria:
|
||||
"tool_output_utilization",
|
||||
"tool_call_success",
|
||||
]
|
||||
criteria = _build_testing_criteria(
|
||||
tool_evals,
|
||||
"gpt-4o",
|
||||
include_data_mapping=True,
|
||||
include_tool_definitions=True,
|
||||
)
|
||||
criteria = _build_testing_criteria(tool_evals, "gpt-4o", include_data_mapping=True)
|
||||
for c in criteria:
|
||||
assert "tool_definitions" in c["data_mapping"], f"{c['name']} missing tool_definitions"
|
||||
|
||||
|
||||
@@ -2,16 +2,6 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -20,13 +10,4 @@ try:
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = [
|
||||
"FoundryHostedAgentHistoryProvider",
|
||||
"InvocationsHostServer",
|
||||
"ResponsesHostServer",
|
||||
"bind_request_context",
|
||||
"foundry_item_id",
|
||||
"foundry_response_id",
|
||||
"foundry_response_id_factory",
|
||||
"get_current_request_context",
|
||||
]
|
||||
__all__ = ["InvocationsHostServer", "ResponsesHostServer"]
|
||||
|
||||
@@ -1,991 +0,0 @@
|
||||
# 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",
|
||||
]
|
||||
@@ -1,72 +0,0 @@
|
||||
# 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,15 +3,17 @@
|
||||
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
|
||||
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
|
||||
from typing import Protocol, cast
|
||||
|
||||
from agent_framework import (
|
||||
ChatOptions,
|
||||
@@ -19,6 +21,7 @@ from agent_framework import (
|
||||
ContextProvider,
|
||||
FileCheckpointStorage,
|
||||
HistoryProvider,
|
||||
Message,
|
||||
RawAgent,
|
||||
SupportsAgentRun,
|
||||
WorkflowAgent,
|
||||
@@ -29,10 +32,77 @@ 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,
|
||||
@@ -44,45 +114,22 @@ 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."""
|
||||
|
||||
@@ -468,7 +515,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, approval_storage=self._approval_storage)
|
||||
input_messages = await _items_to_messages(input_items)
|
||||
is_streaming_request = request.stream is not None and request.stream is True
|
||||
|
||||
_, are_options_set = _to_chat_options(request)
|
||||
@@ -516,9 +563,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 checkpoint_storage points at the *current* response's.
|
||||
# directory and write_storage points at the *current* response's.
|
||||
write_context_id = context.conversation_id or context.response_id
|
||||
checkpoint_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, write_context_id)
|
||||
write_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),
|
||||
@@ -537,8 +584,6 @@ 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,
|
||||
@@ -560,19 +605,19 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
yield response_event_stream.emit_in_progress()
|
||||
|
||||
if not is_streaming_request:
|
||||
# Run the agent in non-streaming mode
|
||||
response = await self._agent.run(input_messages, stream=False, checkpoint_storage=checkpoint_storage)
|
||||
# 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,
|
||||
)
|
||||
|
||||
for message in response.messages:
|
||||
for content in message.contents:
|
||||
async for item in _to_outputs(
|
||||
response_event_stream,
|
||||
content,
|
||||
approval_storage=self._approval_storage,
|
||||
):
|
||||
async for item in _to_outputs(response_event_stream, content):
|
||||
yield item
|
||||
|
||||
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
|
||||
await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name)
|
||||
yield response_event_stream.emit_completed()
|
||||
return
|
||||
|
||||
@@ -580,17 +625,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
|
||||
async for update in self._agent.run(input_messages, stream=True, checkpoint_storage=checkpoint_storage):
|
||||
# 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,
|
||||
):
|
||||
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,
|
||||
approval_storage=self._approval_storage,
|
||||
):
|
||||
async for item in _to_outputs(response_event_stream, content):
|
||||
yield item
|
||||
tracker.needs_async = False
|
||||
|
||||
@@ -598,7 +643,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
for event in tracker.close():
|
||||
yield event
|
||||
|
||||
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
|
||||
await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name)
|
||||
yield response_event_stream.emit_completed()
|
||||
|
||||
@staticmethod
|
||||
@@ -801,6 +846,681 @@ 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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user