mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Promote FoundryChatClient to public, add file/vector-store helpers and ToPromptAgentAsync converter (#5940)
* Consolidate Foundry chat client decorators into FoundryChatClient
- Replace AzureAIProjectChatClient and AzureAIProjectResponsesChatClient with a single internal sealed FoundryChatClient that covers three modes (pure responses, server-side agent reference, hosted agent endpoint).
- Rename AzureAIProjectChatClientExtensions to AIProjectClientExtensions to reflect that it extends AIProjectClient.
- All four AsAIAgent extension overloads and both FoundryAgent constructors now construct FoundryChatClient internally so the microsoft.foundry telemetry tag is uniform across paths.
- Introduce AgentFrameworkUserAgentPolicy that stamps agent-framework-dotnet/{version} on outbound requests, mirroring the Python agent-framework-python/{version} contract.
- Delete the Foundry-local MeaiUserAgentPolicy duplicate; rely on MEAI 10.5.1 to stamp MEAI/{version} automatically.
- HostedAgentUserAgentPolicy keeps the combined foundry-hosting/agent-framework-dotnet/{version} segment (Python parity) and upgrades the bare segment in place to avoid duplication.
- Tests reorganized: FoundryChatClientTests, AIProjectClientExtensionsTests, AgentFrameworkUserAgentPolicyTests, MeaiAutoUserAgentVerificationTests, plus in-place upgrade unit tests in HostedOutboundUserAgentTests.
* Promote FoundryChatClient to public; add file/vector-store helpers and ToPromptAgentAsync converter
- Promote FoundryChatClient from internal sealed to public sealed for Python parity, so .NET developers can hold and pass a FoundryChatClient directly the way Python developers do.
- Mode 3 (hosted agent endpoint) now materializes an AIProjectClient from the parsed project root, making GetService<AIProjectClient>() non-null across all three construction modes. This eliminates the per-mode asymmetry that previously hid project-level helpers from agents constructed via an agent endpoint URL.
- Add four new instance methods on FoundryChatClient mirroring Python's spec: UploadFileAsync, DeleteFileAsync, CreateVectorStoreAsync (bundles upload + create + wait), DeleteVectorStoreAsync. Single overload each, path-only inputs to start; additional overloads can be added later without breaking callers. All are Experimental, consistent with the rest of the Foundry package.
- Add ToPromptAgentAsync extension methods on ChatClientAgent and FoundryAgent for the agent-to-prompt-agent converter described in the Foundry spec. Mode 1 (responses API) synthesizes a DeclarativeAgentDefinition from the agent's ChatOptions; mode 2 (server-side agent reference, version, or record) returns the cached or freshly fetched Definition; mode 3 throws InvalidOperationException because no local definition exists to convert.
- Strict AITool to ResponseTool mapping for mode 1: AIFunction becomes CreateFunctionTool with the function's JSON schema; AITool instances that wrap a ResponseTool unwrap via GetService(typeof(ResponseTool)); anything else throws InvalidOperationException naming the offending tool type. Matches the Python spec's unsupported-tools-raise-ValueError contract.
- New unit tests: FoundryChatClientVectorStoreTests (22 tests covering all four helpers across the three FoundryChatClient construction modes plus validation and cancellation), FoundryPromptAgentConverterTests (16 tests covering both extension entry points across mode 1 synthesis, mode 2 cached and fetched paths, all failure modes, and a Python-parity guard asserting both extensions produce equivalent definitions for equivalent inputs), plus four new tests in FoundryChatClientTests for the mode 3 AIProjectClient materialization.
* Stop building duplicate ProjectOpenAIClient in FoundryAgent agent-endpoint ctor
After Plan #2's mode-3 AIProjectClient materialization, the inner FoundryChatClient already exposes a project-level AIProjectClient (via GetService) that internally provides the project-level ProjectOpenAIClient via GetProjectOpenAIClient(). FoundryAgent's agent-endpoint constructor was still independently constructing a second project-level ProjectOpenAIClient via the now-redundant CreateProjectLevelOpenAIClientFromAgentEndpoint helper — two handles to the same logical resource.
Refactor: the agent-endpoint constructor now reads the inner FoundryChatClient's materialized AIProjectClient via base.GetService(typeof(AIProjectClient)) and derives the project-level ProjectOpenAIClient from it. The dead helper on both FoundryAgent (private static wrapper) and FoundryChatClient (the actual implementation) is removed. The user-supplied per-agent ClientPipelineOptions primitives (Transport, RetryPolicy, NetworkTimeout, UserAgentApplicationId) are propagated into the materialized AIProjectClientOptions so test-injected transports and explicit retry / timeout / user-agent settings reach the project-level pipeline — preserving the behavior the dead helper used to provide.
Updated AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNull to its now-correct counterpart AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNonNull, since after Plan #2 the agent-endpoint ctor surfaces a non-null AIProjectClient (per user direction in Plan #2 Q2).
* Strip duplicated AIProjectClient/ProjectOpenAIClient state from FoundryAgent
Both _aiProjectClient and _projectOpenAIClient fields on FoundryAgent were redundant:
- _aiProjectClient: FoundryAgent's GetService<AIProjectClient> override returned this field, but DelegatingAIAgent.GetService → ChatClientAgent.GetService → FoundryChatClient.GetService<AIProjectClient> already returns the same instance through the delegating chain. Field + override are pure duplication.
- _projectOpenAIClient: only used by FoundryAgent's own GetService<ProjectOpenAIClient> override and by CreateConversationSessionAsync. Per user direction, ProjectOpenAIClient is no longer exposed via GetService on either FoundryChatClient or FoundryAgent — callers retrieve it from the AIProjectClient themselves (aiProjectClient.GetProjectOpenAIClient()) the same way the framework does internally. This eliminates the mode-3 asymmetry where the chat client's stored ProjectOpenAIClient was per-agent (URL /agents/{name}/endpoint/protocols/openai) while the agent's was project-level.
Refactor:
- Delete both fields on FoundryAgent and the GetService override.
- Delete the ProjectOpenAIClient branch from FoundryChatClient.GetService.
- CreateConversationSessionAsync now resolves AIProjectClient at call time via this.GetService<AIProjectClient>() and derives the conversations client from it.
- Update FoundryChatClient tests that asserted on GetService<ProjectOpenAIClient> to assert Null (deliberate removal).
- Update FoundryAgent tests AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull and ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull to ...ReturnsNull, and rewrite AgentEndpointConstructor_PropagatesUserAgentApplicationId_ToProjectLevelClient to look up AIProjectClient instead.
No production code (only tests) referenced GetService<ProjectOpenAIClient>, so this is a safe surface reduction. Net: 30 insertions, 61 deletions; FoundryAgent shrinks to a pure delegator with only the two convenience methods (CreateSessionAsync, CreateConversationSessionAsync) on top of the delegating chain.
* Rename FoundryChatClient.HostedAgentName to AgentName and populate it for mode 2
The previous name implied a mode 3 only property tied to the hosted-agent endpoint URL. Today only hosted endpoints surface this name, but conceptually an agent name exists for every server-side agent the client talks to. Renaming to AgentName makes the property general-purpose and ready for future modes where the same chat client may target other server-side agent shapes that are not necessarily 'hosted'.
Mode 2 (server-side agent reference) now mirrors AgentReference.Name into AgentName so callers have a uniform handle regardless of construction mode:
* Mode 1 (pure responses): AgentName is null. There is no agent.
* Mode 2 (AgentReference): AgentName == AgentReference.Name.
* Mode 3 (agent endpoint URL): AgentName is parsed from the URL segment as before.
Converter discriminator update: FoundryPromptAgentConverter previously used 'HostedAgentName is not null' to detect mode 3 and reject it. Now that mode 2 also populates AgentName, the mode 3 guard moves to the end of the resolution chain and uses the unambiguous 'AgentName is set AND no AgentReference exists' test. The user-visible error message and behavior are preserved.
Dead-state cleanup spotted during format verify:
* IDE0052 surfaced that FoundryChatClient._projectOpenAIClient is never read since the prior refactor stopped exposing ProjectOpenAIClient via GetService and rewired CreateConversationSessionAsync to resolve the AIProjectClient through the delegating chain. The field is deleted and its three ctor assignments removed.
* HostedAgentEndpointInner.PerAgentClient only existed to plumb the per-agent ProjectOpenAIClient into that now-deleted field, so the property and its ctor parameter are removed. The local 'perAgentClient' variable inside BuildHostedAgentEndpointInner is still needed to derive the inner IChatClient, but no longer escapes the helper.
Tests:
* Mode1_PureResponses_ReturnsNullForAgentSpecificServices now also asserts AgentName is null.
* New Mode2_AgentReference_PopulatesAgentNameFromAgentReference asserts the mode 2 mirror.
* Mode3_HostedAgentEndpoint_ParsesAgentNameFromUrl renamed assertion target HostedAgentName to AgentName.
Verification: 335/335 net10.0, 273/273 net472 Foundry unit; 229/229 Foundry.Hosting unit; format-verify (WSL2 + Docker mcr.microsoft.com/dotnet/sdk:10.0) clean on Microsoft.Agents.AI.Foundry.
* Adopt canonical mode names: Responses Agent, Prompt Agent, Agent Endpoint
Three FoundryChatClient construction modes now have one canonical noun used everywhere.
* Responses Agent (Mode 1): inline ChatClientAgent, project-level Responses API, no server-side def.
* Prompt Agent (Mode 2): server-side ProjectsAgentDefinition invoked by AgentReference.
* Agent Endpoint (Mode 3): per-agent URL /agents/{name}/endpoint/protocols/openai. Hosted-or-not.
'Hosted' stays the kind of agent (Microsoft.Agents.AI.Foundry.Hosting). Not synonym of Mode 3.
Rings:
1. XML docs + error messages use canonical names. en-GB to en-US: centralises, synthesise.
2. HostedAgentEndpointInner -> AgentEndpointInner, BuildHostedAgentEndpointInner -> BuildAgentEndpointInner.
3. Tests: Mode1_PureResponses_* -> Mode1_ResponsesAgent_*, Mode2_AgentReference_* -> Mode2_PromptAgent_*, Mode3_HostedAgentEndpoint_* -> Mode3_AgentEndpoint_*.
Pure rename. No behavior change. 335/335 net10 + 273/273 net472 unit, format clean.
* Address PR #5940 design feedback (Q-A through Q-F)
Q-A: poll vector store til status leaves InProgress before return. Exp backoff 250ms-2s. Honor cancel.
Q-B: try/catch upload loop. Mid-fail = best-effort DeleteFileAsync on already-uploaded ids. Swallow cleanup errors.
Q-C: pinned AgentReference.Version uses GetAgentVersionAsync. Empty/whitespace/'latest' = GetLatest path.
Q-D: HostedAgentUserAgentPolicy detects existing combined 'foundry-hosting/...' segment. No double prefix.
Q-E: mode-3 vector-store test uses fake transport. No DNS to example.com.
Q-F: no shim. Class always [Experimental] (since 8015e00f5, before dotnet-1.0.0). No compat contract. Callers rename to AIProjectClientExtensions.
Rebase onto origin/main reconciliation: aad20c2b3 added public AsAIAgent(this AIProjectClient, Uri agentEndpoint, ...) extension that calls an internal FoundryAgent(AIProjectClient, Uri, ...) ctor. Reintroduced that ctor + a new FoundryChatClient(AIProjectClient, Uri, ProjectOpenAIClientOptions?) overload that reuses the supplied AIProjectClient's pipeline (via GetProjectResponsesClientForAgentEndpoint) instead of stamping a fresh credential.
Verified: 346/346 net10 + 284/284 net472 Foundry unit, 230/230 Foundry.Hosting unit, format clean.
* Add FoundryAgent helper extensions: UploadFile/DeleteFile/CreateVectorStore/DeleteVectorStore
4 thin forwarders on FoundryAgent that route to the inner FoundryChatClient's helpers via agent.GetService<FoundryChatClient>().X(). Live in existing FoundryAgentExtensions.cs alongside ToPromptAgentAsync.
Throws InvalidOperationException when agent does not expose a FoundryChatClient via GetService (same pattern as ToPromptAgentAsync).
Unit tests: FoundryAgentExtensionsTests covers all 4 forwarders + null-agent ArgumentNullException for each. 8 new tests, 354/354 net10 + 292/292 net472.
Integration tests: parallel FoundryAgentExtensionsTests under Foundry.IntegrationTests mirrors the existing CreateAgent_CreatesAgentWithVectorStoresAsync shape (upload -> create vector store -> FileSearch tool answers question -> cleanup), but routes every helper call through the new FoundryAgent extensions. 4 new IT tests, all verified pass live against the real Foundry project (12-30s each). Skipped by default like the existing vector-store IT.
* Address Sergey's PR review comments
#1 (FoundryAgent.cs:139): drop unused aiProjectClient param from internal FoundryAgent(AIProjectClient, ChatClientAgent) ctor. Was discarded after null-check. Inner FoundryChatClient already surfaces AIProjectClient via GetService. 3 call sites in AIProjectClientExtensions updated.
#2 (FoundryChatClient.cs:376): add pollingTimeout param to CreateVectorStoreAsync. Defaults to 5 min, configurable, Timeout.InfiniteTimeSpan disables. Throws TimeoutException with vector store id and elapsed seconds when bound exceeded. CancellationToken still wins. New unit test PollingTimeout_ThrowsTimeoutExceptionAsync. FoundryAgentExtensions forwarder updated to plumb the new param.
Verified: 355/355 net10 + 293/293 net472 Foundry unit, 230/230 Foundry.Hosting unit, format clean.
This commit is contained in:
committed by
GitHub
Unverified
parent
47f5c3397f
commit
a12cc3878e
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
@@ -9,8 +10,11 @@ using Microsoft.Extensions.AI;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -19,6 +23,12 @@ 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.
|
||||
@@ -30,6 +40,12 @@ 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);
|
||||
@@ -46,13 +62,52 @@ 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
|
||||
|
||||
+8
-11
@@ -23,7 +23,7 @@ namespace Azure.AI.Projects;
|
||||
/// Provides extension methods for <see cref="AIProjectClient"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static partial class AzureAIProjectChatClientExtensions
|
||||
public static partial class AIProjectClientExtensions
|
||||
{
|
||||
/// <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 AzureAIProjectChatClientExtensions
|
||||
clientFactory,
|
||||
services);
|
||||
|
||||
return new FoundryAgent(aiProjectClient, innerAgent);
|
||||
return new FoundryAgent(innerAgent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -132,7 +132,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
!allowDeclarativeMode,
|
||||
services);
|
||||
|
||||
return new FoundryAgent(aiProjectClient, innerAgent);
|
||||
return new FoundryAgent(innerAgent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -165,7 +165,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
!allowDeclarativeMode,
|
||||
services);
|
||||
|
||||
return new FoundryAgent(aiProjectClient, innerAgent);
|
||||
return new FoundryAgent(innerAgent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -246,7 +246,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions);
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -268,10 +268,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
Throw.IfNull(agentOptions.ChatOptions);
|
||||
Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId);
|
||||
|
||||
IChatClient chatClient = aiProjectClient
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(agentOptions.ChatOptions.ModelId);
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -298,7 +295,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions);
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -316,7 +313,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -0,0 +1,88 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// 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
|
||||
@@ -0,0 +1,42 @@
|
||||
// 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,11 +39,6 @@ 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>
|
||||
@@ -73,9 +68,8 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
: base(CreateInnerAgent(
|
||||
CreateProjectClient(projectEndpoint, credential, clientOptions),
|
||||
model, instructions, name, description, tools, clientFactory, loggerFactory, services,
|
||||
out var aiProjectClient))
|
||||
out _))
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -87,9 +81,11 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
/// </param>
|
||||
/// <param name="credential">The authentication credential.</param>
|
||||
/// <param name="clientOptions">
|
||||
/// Optional configuration for the underlying <see cref="ProjectResponsesClient"/>. When supplied:
|
||||
/// Optional configuration for the underlying <see cref="ProjectOpenAIClient"/>. 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>
|
||||
@@ -113,43 +109,37 @@ 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, out var aiProjectClient))
|
||||
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services))
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryAgent"/> class from an agent-specific
|
||||
/// endpoint while reusing an existing <see cref="AIProjectClient"/>.
|
||||
/// 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.
|
||||
/// </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(BuildAgentEndpointInnerAgent(aiProjectClient, agentEndpoint, clientOptions: null, tools, clientFactory, services))
|
||||
: base(CreateInnerAgentFromAgentEndpointReusingProjectClient(aiProjectClient, agentEndpoint, tools, clientFactory, services))
|
||||
{
|
||||
this._aiProjectClient = Throw.IfNull(aiProjectClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal constructor used by <c>AsAIAgent</c> extension methods that already have an <see cref="AIProjectClient"/> and a configured <see cref="ChatClientAgent"/>.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal FoundryAgent(AIProjectClient aiProjectClient, ChatClientAgent innerAgent)
|
||||
internal FoundryAgent(ChatClientAgent innerAgent)
|
||||
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
|
||||
{
|
||||
this._aiProjectClient = Throw.IfNull(aiProjectClient);
|
||||
}
|
||||
|
||||
#region Convenience methods
|
||||
@@ -182,7 +172,13 @@ 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)
|
||||
{
|
||||
var conversationsClient = this._aiProjectClient.ProjectOpenAIClient.GetProjectConversationsClient();
|
||||
// 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 conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
|
||||
|
||||
@@ -196,17 +192,6 @@ 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(
|
||||
@@ -251,7 +236,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
Throw.IfNull(agentOptions.ChatOptions);
|
||||
Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId);
|
||||
|
||||
IChatClient chatClient = new AzureAIProjectResponsesChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId);
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -288,16 +273,10 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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"/>.
|
||||
/// </summary>
|
||||
private static AIAgent CreateInnerAgentFromAgentEndpoint(
|
||||
Uri agentEndpoint,
|
||||
@@ -305,44 +284,14 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
ProjectOpenAIClientOptions? clientOptions,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services,
|
||||
out AIProjectClient outClient)
|
||||
IServiceProvider? services)
|
||||
{
|
||||
Throw.IfNull(agentEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
|
||||
var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
|
||||
outClient = CreateProjectClient(projectRoot, credential, CreateProjectClientOptions(clientOptions));
|
||||
IChatClient chatClient = new FoundryChatClient(agentEndpoint, credential, clientOptions);
|
||||
var agentName = ((FoundryChatClient)chatClient).AgentName!;
|
||||
|
||||
return BuildAgentEndpointInnerAgent(outClient, agentEndpoint, clientOptions, tools, clientFactory, services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 BuildAgentEndpointInnerAgent(
|
||||
AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
ProjectOpenAIClientOptions? clientOptions,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
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);
|
||||
@@ -358,6 +307,46 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: 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.
|
||||
/// </summary>
|
||||
private static AIAgent CreateInnerAgentFromAgentEndpointReusingProjectClient(
|
||||
AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentEndpoint, clientOptions: null);
|
||||
var agentName = ((FoundryChatClient)chatClient).AgentName!;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/// <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>
|
||||
@@ -369,90 +358,12 @@ 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);
|
||||
|
||||
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;
|
||||
return new AIProjectClient(endpoint, credential, clientOptions ?? new AIProjectClientOptions());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// 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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
// 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, 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);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
return await base.GetResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
await foreach (var chunk in base.GetStreamingResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
#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>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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// 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.");
|
||||
}
|
||||
}
|
||||
@@ -1,58 +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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user