From 226c004b5352bea94b5c9965ec9e584e1624d5e4 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 8 May 2026 10:28:35 +0100 Subject: [PATCH 1/5] Add hyperlight to release slnf (#5695) --- dotnet/agent-framework-release.slnf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/agent-framework-release.slnf b/dotnet/agent-framework-release.slnf index 00e3e6571e..14d5262533 100644 --- a/dotnet/agent-framework-release.slnf +++ b/dotnet/agent-framework-release.slnf @@ -30,7 +30,8 @@ "src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj", "src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj", "src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj", - "src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj" + "src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj", + "src\\Microsoft.Agents.AI.Hyperlight\\Microsoft.Agents.AI.Hyperlight.csproj" ] } } From eb709d8fc9f16e237017c5fcf2ab52827e6f7792 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Fri, 8 May 2026 15:46:52 +0100 Subject: [PATCH 2/5] .NET: Update FoundryAgent to address HostedAgents strict URL routing (#5677) * .NET: Foundry agent-endpoint constructor uses ProjectOpenAIClient directly to fix hosted-agent URL routing Fixes the experimental FoundryAgent(Uri agentEndpoint, AuthenticationTokenProvider, ...) constructor so it actually works against Foundry hosted agents. The previous implementation routed through AzureAIProjectChatClient, which internally called aiProjectClient.GetProjectOpenAIClient().GetProjectResponsesClientForAgent(...). For an agent-endpoint URL of the canonical shape https:///api/projects//agents//endpoint/protocols/openai the chain produced POST https:///api/projects//openai/v1/responses (project-level path, no /agents/ segment). The Foundry service rejects this with HTTP 400 "Hosted agents can only be called through the agent endpoint: .../agents//endpoint/protocols/openai/responses". The constructor also extracted the agent name via agentEndpoint.Segments[^1].TrimEnd('/'), which returns "openai" (the last segment), not the agent name. What changed - Public ctor signature: clientOptions parameter type changed from AIProjectClientOptions? to ProjectOpenAIClientOptions?. The constructor is fundamentally building a ProjectOpenAIClient; accepting AIProjectClientOptions was a leaky abstraction whose translation silently dropped any pipeline policies the caller added via AddPolicy(...). With the direct type, caller policies pass through to the per-agent traffic verbatim. - Per-agent client construction: `new ProjectOpenAIClient(BearerTokenPolicy, ProjectOpenAIClientOptions)` with Endpoint and AgentName set, then `GetProjectResponsesClient().AsIChatClient()`. The SDK auto-appends ?api-version=v1 when AgentName is set. - New private static ParseAgentEndpoint helper: single source of truth for both agent-name extraction and project-root derivation. Tolerates trailing slash, case variants on /agents/ and the suffix segment, strips query/fragment, and throws ArgumentException with paramName=nameof(agentEndpoint) for malformed input. - Project-level client (used by CreateConversationSessionAsync) is built fresh from the derived project root with primitive properties copied (RetryPolicy/NetworkTimeout/Transport/UserAgentApplicationId) plus MEAI UA. - New GetService() entry alongside the existing GetService() (the latter returns null in agent-endpoint mode since no AIProjectClient is constructed on that path). - Endpoint and AgentName on caller-supplied ProjectOpenAIClientOptions are overridden by values derived from agentEndpoint. Compatibility - FoundryAgent is [Experimental(OPENAI001)]. No GA surface touched. The Foundry project does not maintain PublicAPI.*.txt baselines so there is no shipped baseline to update. - The Microsoft.Agents.AI.Foundry csproj pins Azure.AI.Projects to VersionOverride 2.1.0-beta.1 (matching what the IT and hosting projects already use); the central pin in Directory.Packages.props stays at 2.0.0. - WireClientHeaders from PR #5652 is invoked on the agent-endpoint path so per-call x-client-* headers behave identically across both ctors. Tests - 23 new unit tests in FoundryAgentTests.cs: - 12 for the agent-endpoint constructor (URL routing for non-streaming and streaming, conversations URL shape, MEAI UA stamping, caller-policy passthrough on the per-agent pipeline, Endpoint/AgentName override semantics, GetService matrix, ProjectOpenAIClient propagation, UserAgentApplicationId propagation, null-arg validation, ID/Name slug) - 9 for ParseAgentEndpoint (standard shape, trailing slash, casing, sovereign-cloud host without /api/projects/ literal prefix, special chars in agent name, query/fragment stripping, three negative cases) - 2 null-arg tests for the public ctor - All 250 Microsoft.Agents.AI.Foundry.UnitTests pass (was 221 baseline plus 29 from PR #5652 plus 23 new in this PR equals 273; pre-existing tests collapsed by the rebase merge keep the total at 250). - All 225 Microsoft.Agents.AI.Foundry.Hosting.UnitTests pass; no behavioral change to the hosting layer. - dotnet build clean across net8/9/10/netstandard2.0/net472 with TreatWarningsAsErrors=true. - dotnet format --verify-no-changes clean for the touched src and test projects. * .NET: Bump central Azure.AI.Projects pin to 2.1.0-beta.1 and flip Microsoft.Agents.AI.Foundry to preview Required to fix the NU1109 downgrade chain that broke CI on the agent-endpoint constructor rewire (#5677). Microsoft.Agents.AI.Foundry now depends on ProjectOpenAIClientOptions.AgentName and the (AuthenticationPolicy, options) constructor that only exist in Azure.AI.Projects 2.1.0-beta.1. Changes: * Directory.Packages.props: Azure.AI.Projects 2.0.0 -> 2.1.0-beta.1. * Microsoft.Agents.AI.Foundry.csproj: drop IsReleased=true so the package ships as preview (matches the beta SDK we now depend on). Add a comment noting the flip is temporary and should revert once Azure.AI.Projects ships a stable 2.1.0. * Drop redundant VersionOverride="2.1.0-beta.1" from the 10 csprojs that had it as a workaround; the central pin now suffices. Verified: * dotnet build agent-framework-dotnet.slnx --warnaserror clean across all TFMs. * Microsoft.Agents.AI.Foundry.UnitTests 250/250 pass. * Microsoft.Agents.AI.Foundry.Hosting.UnitTests 211/211 pass. * dotnet format --verify-no-changes clean for the touched src and test projects. --- dotnet/Directory.Packages.props | 2 +- ...Agent_Step25_ToolboxServerSideTools.csproj | 4 +- .../Hosted-LocalTools/HostedLocalTools.csproj | 4 +- .../Hosted-McpTools/HostedMcpTools.csproj | 4 +- .../HostedObservability.csproj | 4 +- .../Hosted-TextRag/HostedTextRag.csproj | 4 +- .../Hosted-Toolbox/HostedToolbox.csproj | 2 +- .../HostedWorkflowSimple.csproj | 4 +- ...Microsoft.Agents.AI.Foundry.Hosting.csproj | 4 +- .../FoundryAgent.cs | 231 +++++++++-- .../Microsoft.Agents.AI.Foundry.csproj | 5 +- ...Agents.AI.Foundry.Hosting.UnitTests.csproj | 2 +- .../FoundryAgentTests.cs | 378 +++++++++++++++++- ...crosoft.Agents.AI.Foundry.UnitTests.csproj | 4 +- 14 files changed, 605 insertions(+), 47 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index a91ae973da..96918490af 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -25,7 +25,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj index 0db6ba9fe6..a2787b4130 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj @@ -1,4 +1,4 @@ - + Exe @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj index 8871ea5242..366894856c 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj index 359e8a35bb..270a3f7391 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj index 31dafe2280..0029f66e39 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj index ed24c32eea..a0138c710a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj index 458518c67b..e54d2f1b37 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj index 75d012413d..57c5852455 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj index 209edd9a82..71d9af8f71 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj @@ -1,4 +1,4 @@ - + $(TargetFrameworksCore) @@ -31,7 +31,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs index db66b30a88..6174e1b149 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs @@ -2,6 +2,7 @@ using System; using System.ClientModel; +using System.ClientModel.Primitives; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading; @@ -38,7 +39,28 @@ namespace Microsoft.Agents.AI.Foundry; [Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] public sealed class FoundryAgent : DelegatingAIAgent { - private readonly AIProjectClient _aiProjectClient; + /// + /// Default OAuth scope for the Azure AI resource. Matches the scope used by + /// Azure.AI.Extensions.OpenAI's internal authentication helper so the bearer token is + /// accepted by the Foundry control plane. + /// + private const string AzureAiResourceScope = "https://ai.azure.com/.default"; + + /// + /// The cached when one was supplied or constructed by the active + /// constructor. Null when the agent was constructed via the agent-endpoint constructor, which + /// does not build a full . + /// + private readonly AIProjectClient? _aiProjectClient; + + /// + /// Project-scoped . Always non-null. Used for project-level + /// operations such as . + /// In agent-endpoint mode this is built directly from the project root derived from the + /// supplied agent endpoint; in project-endpoint mode it is the cached client returned by + /// . + /// + private readonly ProjectOpenAIClient _projectOpenAIClient; /// /// Initializes a new instance of the class using the direct Responses API path. @@ -72,30 +94,49 @@ public sealed class FoundryAgent : DelegatingAIAgent out var aiProjectClient)) { this._aiProjectClient = aiProjectClient; + this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient(); } /// /// Initializes a new instance of the class from an agent-specific endpoint. /// - /// The agent-specific endpoint URI (must contain the agent name in the path). + /// + /// The agent-specific endpoint URI. Must be of the shape + /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai. + /// /// The authentication credential. - /// Optional configuration options for the . + /// + /// Optional configuration for the underlying . When supplied: + /// + /// The instance is passed through to the per-agent client; pipeline policies added via AddPolicy(...) on it execute on the per-agent traffic. + /// Endpoint and are owned by this constructor and are overwritten with values derived from ; any caller value is replaced. + /// For the project-level conversations client a separate fresh options bag is built that copies only , , , and UserAgentApplicationId; pipeline policies added via AddPolicy(...) do not propagate to the conversations pipeline. + /// + /// /// Optional tools to use when interacting with the agent. /// Provides a way to customize the creation of the underlying . /// Optional service provider for resolving dependencies required by AI functions. + /// or is null. + /// does not match the expected agent-endpoint shape. + /// + /// This is the lightweight constructor for invoking an existing Foundry hosted agent when the + /// caller already has the per-agent endpoint URL. It populates + /// and from the agent name parsed out of the endpoint + /// path; Description, Instructions, Temperature, and TopP are not + /// populated. Callers that need those fields hydrated from server-side state should use + /// AIProjectClient.AsAIAgent(ProjectsAgentVersion) or + /// AIProjectClient.AsAIAgent(ProjectsAgentRecord) instead. + /// public FoundryAgent( Uri agentEndpoint, AuthenticationTokenProvider credential, - AIProjectClientOptions? clientOptions = null, + ProjectOpenAIClientOptions? clientOptions = null, IList? tools = null, Func? clientFactory = null, IServiceProvider? services = null) - : base(CreateInnerAgentFromEndpoint( - CreateProjectClient(agentEndpoint, credential, clientOptions), - agentEndpoint, tools, clientFactory, services, - out var aiProjectClient)) + : base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services)) { - this._aiProjectClient = aiProjectClient; + this._projectOpenAIClient = CreateProjectLevelOpenAIClientFromAgentEndpoint(agentEndpoint, credential, clientOptions); } /// @@ -105,6 +146,7 @@ public sealed class FoundryAgent : DelegatingAIAgent : base(WireClientHeaders(Throw.IfNull(innerAgent))) { this._aiProjectClient = Throw.IfNull(aiProjectClient); + this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient(); } #region Convenience methods @@ -137,9 +179,7 @@ public sealed class FoundryAgent : DelegatingAIAgent /// A linked to the newly created server-side conversation. public async Task CreateConversationSessionAsync(CancellationToken cancellationToken = default) { - var conversationsClient = this._aiProjectClient - .GetProjectOpenAIClient() - .GetProjectConversationsClient(); + var conversationsClient = this._projectOpenAIClient.GetProjectConversationsClient(); var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value; @@ -161,6 +201,11 @@ public sealed class FoundryAgent : DelegatingAIAgent return this._aiProjectClient; } + if (serviceKey is null && serviceType == typeof(ProjectOpenAIClient)) + { + return this._projectOpenAIClient; + } + return base.GetService(serviceType, serviceKey); } @@ -238,47 +283,181 @@ public sealed class FoundryAgent : DelegatingAIAgent OpenAIRequestPoliciesReflection.AddPolicyIfMissing( policies, ClientHeadersPolicy.Instance, - System.ClientModel.Primitives.PipelinePosition.PerCall); + PipelinePosition.PerCall); } return new ClientHeadersAgent(innerAgent); } - private static AIAgent CreateInnerAgentFromEndpoint( - AIProjectClient aiProjectClient, + /// + /// Builds the inner for the agent-endpoint constructor by + /// constructing a per-agent via the + /// ProjectOpenAIClient(AuthenticationPolicy, ProjectOpenAIClientOptions) + /// constructor with set. 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 api-version query string. + /// Caller-supplied are passed through to the per-agent + /// client with Endpoint and + /// overridden by values derived from + /// ; any policies the caller added via AddPolicy + /// remain in effect on the per-agent pipeline. The MEAI user-agent policy is appended last. + /// + private static AIAgent CreateInnerAgentFromAgentEndpoint( Uri agentEndpoint, + AuthenticationTokenProvider credential, + ProjectOpenAIClientOptions? clientOptions, IList? tools, Func? clientFactory, - IServiceProvider? services, - out AIProjectClient outClient) + IServiceProvider? services) { - outClient = aiProjectClient; + Throw.IfNull(agentEndpoint); + Throw.IfNull(credential); - AgentReference agentReference = agentEndpoint.Segments[^1].TrimEnd('/'); + var (agentName, _) = ParseAgentEndpoint(agentEndpoint); - ChatClientAgentOptions agentOptions = new() - { - Name = agentReference.Name, - ChatOptions = new() { Tools = tools }, - }; + var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions(); + perAgentOptions.Endpoint = agentEndpoint; + perAgentOptions.AgentName = agentName; + perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall); - IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions); + var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope); + var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions); + IChatClient chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient(); 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)); } + /// + /// Builds the project-scoped for the agent-endpoint + /// constructor by deriving the project root from the supplied agent endpoint and constructing + /// a fresh client without so the SDK + /// appends the standard /openai/v1 suffix expected for project-level surfaces such as + /// conversations. + /// + /// + /// Only the four observable primitive properties (, + /// , , + /// and UserAgentApplicationId) are copied from the caller's options bag. Pipeline + /// policies added via AddPolicy on the caller bag do not propagate because + /// does not publicly enumerate its policies. The MEAI + /// user-agent policy is appended last. + /// + private static ProjectOpenAIClient CreateProjectLevelOpenAIClientFromAgentEndpoint( + Uri agentEndpoint, + AuthenticationTokenProvider credential, + ProjectOpenAIClientOptions? clientOptions) + { + var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint); + + var projectOptions = new ProjectOpenAIClientOptions(); + if (clientOptions is not null) + { + if (clientOptions.RetryPolicy is not null) + { + projectOptions.RetryPolicy = clientOptions.RetryPolicy; + } + + if (clientOptions.NetworkTimeout is not null) + { + projectOptions.NetworkTimeout = clientOptions.NetworkTimeout; + } + + if (clientOptions.Transport is not null) + { + projectOptions.Transport = clientOptions.Transport; + } + + if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId)) + { + projectOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId; + } + } + + projectOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall); + + return new ProjectOpenAIClient(projectRoot, credential, projectOptions); + } + + /// + /// Parses an agent endpoint URI of shape + /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai + /// and returns the agent name and the derived project-root URI. + /// + /// + /// Single source of truth for both agent-name extraction and project-root derivation. + /// Tolerates trailing slash, casing variants on /agents/ and the suffix segment, and + /// strips query string and fragment. Throws for inputs that + /// do not match the expected shape. + /// + /// + /// The endpoint is missing the /agents/ segment, has an empty agent name, or has a + /// suffix other than /endpoint/protocols/openai. + /// + 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:///.../projects//agents//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 '{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, System.ClientModel.Primitives.PipelinePosition.PerCall); + clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall); return new AIProjectClient(endpoint, credential, clientOptions); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj index 71e7df373f..06e22b8c18 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj @@ -1,7 +1,10 @@ - true + true $(NoWarn);OPENAI001 diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj index fa28a32494..4a139df1b6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj @@ -9,7 +9,7 @@ - + diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs index 45d09689ff..01184031e8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs @@ -7,6 +7,7 @@ using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Microsoft.Extensions.AI; @@ -184,7 +185,7 @@ public class FoundryAgentTests // Act: this AsAIAgent path constructs FoundryAgent via its internal // (AIProjectClient, ChatClientAgent) constructor, which previously bypassed pre-wiring. - var agent = projectClient.AsAIAgent(new Azure.AI.Extensions.OpenAI.AgentReference("agent-name")); + var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); // Assert Assert.NotNull(agent.GetService()); @@ -398,4 +399,379 @@ public class FoundryAgentTests } #endregion + + #region Agent-endpoint constructor tests + + private const string TestAgentEndpoint = "https://test.services.ai.azure.com/api/projects/test-project/agents/it-happy-path/endpoint/protocols/openai"; + private static readonly Uri s_testAgentEndpoint = new(TestAgentEndpoint); + + [Fact] + public void AgentEndpointConstructor_NullEndpoint_ThrowsArgumentNullException() + { + ArgumentNullException ex = Assert.Throws(() => + new FoundryAgent(agentEndpoint: null!, credential: new FakeAuthenticationTokenProvider())); + Assert.Equal("agentEndpoint", ex.ParamName); + } + + [Fact] + public void AgentEndpointConstructor_NullCredential_ThrowsArgumentNullException() + { + ArgumentNullException ex = Assert.Throws(() => + new FoundryAgent(agentEndpoint: s_testAgentEndpoint, credential: null!)); + Assert.Equal("credential", ex.ParamName); + } + + [Fact] + public void AgentEndpointConstructor_PopulatesNameAndIdFromEndpointSlug() + { + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider()); + + Assert.Equal("it-happy-path", agent.Name); + Assert.Equal("it-happy-path", agent.Id); + } + + [Fact] + public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull() + { + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider()); + + Assert.NotNull(agent.GetService()); + } + + [Fact] + public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNull() + { + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider()); + + Assert.Null(agent.GetService()); + } + + [Fact] + public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test"); + + Assert.NotNull(agent.GetService()); + } + + [Fact] + public void AgentEndpointConstructor_AppliesClientFactoryOnce() + { + int count = 0; + FoundryAgent agent = new( + s_testAgentEndpoint, + new FakeAuthenticationTokenProvider(), + clientFactory: c => { count++; return c; }); + + Assert.Equal(1, count); + Assert.NotNull(agent); + } + + [Fact] + public async Task AgentEndpointConstructor_RunAsync_RoutesThroughPerAgentResponsesUrlAsync() + { + Uri? capturedUri = null; + using HttpHandlerAssert handler = new(req => + { + capturedUri = req.RequestUri; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"), + }; + }); +#pragma warning disable CA5399 + using HttpClient http = new(handler); +#pragma warning restore CA5399 + ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) }; + + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts); + await agent.RunAsync("Hello"); + + Assert.NotNull(capturedUri); + string path = capturedUri!.AbsolutePath; + Assert.Contains("/agents/it-happy-path/endpoint/protocols/openai/responses", path, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("/openai/v1/responses", path, StringComparison.OrdinalIgnoreCase); + Assert.Contains("api-version=v1", capturedUri.Query, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task AgentEndpointConstructor_RunStreamingAsync_RoutesThroughPerAgentResponsesUrlAsync() + { + Uri? capturedUri = null; + bool sawStreamTrue = false; + using HttpHandlerAssert handler = new(async req => + { + capturedUri = req.RequestUri; + if (req.Content is not null) + { + string body = await req.Content.ReadAsStringAsync().ConfigureAwait(false); + if (body.Contains("\"stream\":true", StringComparison.Ordinal)) + { + sawStreamTrue = true; + } + } + + // Minimal SSE response; xUnit assertion only cares about the URL/body shape. + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("data: [DONE]\n\n", Encoding.UTF8, "text/event-stream"), + }; + }); +#pragma warning disable CA5399 + using HttpClient http = new(handler); +#pragma warning restore CA5399 + ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) }; + + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts); + try + { + await foreach (var _ in agent.RunStreamingAsync("Hello")) + { + // drain + } + } + catch + { + // SSE parse errors are acceptable; we only assert the request shape. + } + + Assert.NotNull(capturedUri); + Assert.Contains("/agents/it-happy-path/endpoint/protocols/openai/responses", capturedUri!.AbsolutePath, StringComparison.OrdinalIgnoreCase); + Assert.Contains("api-version=v1", capturedUri.Query, StringComparison.OrdinalIgnoreCase); + Assert.True(sawStreamTrue, "Expected request body to include \"stream\":true."); + } + + [Fact] + public async Task AgentEndpointConstructor_CreateConversationSessionAsync_RoutesThroughProjectLevelUrlAsync() + { + Uri? capturedUri = null; + using HttpHandlerAssert handler = new(req => + { + capturedUri = req.RequestUri; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"id\":\"conv_123\"}", Encoding.UTF8, "application/json"), + }; + }); +#pragma warning disable CA5399 + using HttpClient http = new(handler); +#pragma warning restore CA5399 + ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) }; + + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts); + try + { + _ = await agent.CreateConversationSessionAsync(); + } + catch + { + // Underlying SDK may attempt extra parsing on the minimal response. We only assert URL routing. + } + + Assert.NotNull(capturedUri); + string path = capturedUri!.AbsolutePath; + Assert.Contains("/api/projects/test-project/openai/v1/conversations", path, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("/agents/", path, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task AgentEndpointConstructor_StampsMeaiUserAgentHeaderAsync() + { + bool meaiSeen = false; + using HttpHandlerAssert handler = new(req => + { + if (req.Headers.TryGetValues("User-Agent", out var values)) + { + foreach (string v in values) + { + if (v.IndexOf("MEAI/", StringComparison.OrdinalIgnoreCase) >= 0) + { + meaiSeen = true; + } + } + } + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"), + }; + }); +#pragma warning disable CA5399 + using HttpClient http = new(handler); +#pragma warning restore CA5399 + ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) }; + + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts); + await agent.RunAsync("Hello"); + + Assert.True(meaiSeen, "Expected MEAI/x.y.z to appear in the User-Agent header on the agent-endpoint pipeline."); + } + + [Fact] + public async Task AgentEndpointConstructor_PassesThroughCallerPolicyOnPerAgentPipelineAsync() + { + // Direct switch to ProjectOpenAIClientOptions means caller-supplied pipeline policies + // (added via AddPolicy) actually flow through to the per-agent traffic. Assert that a + // tag-stamping policy executes on each outbound per-agent request. + bool tagSeen = false; + using HttpHandlerAssert handler = new(req => + { + if (req.Headers.TryGetValues("X-Test-Tag", out var values)) + { + foreach (string v in values) + { + if (v == "tag-1") + { + tagSeen = true; + } + } + } + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"), + }; + }); +#pragma warning disable CA5399 + using HttpClient http = new(handler); +#pragma warning restore CA5399 + ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) }; + opts.AddPolicy(new HeaderStampPolicy("X-Test-Tag", "tag-1"), PipelinePosition.PerCall); + + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts); + await agent.RunAsync("Hello"); + + Assert.True(tagSeen, "Expected caller-supplied per-call policy to execute on the per-agent pipeline."); + } + + [Fact] + public void AgentEndpointConstructor_OverridesCallerEndpointAndAgentName() + { + // The caller may set Endpoint/AgentName on the options bag; we must override both with + // values derived from agentEndpoint so the URL routing is correct regardless. + ProjectOpenAIClientOptions opts = new() + { + Endpoint = new Uri("https://wrong.example.com/openai/v1"), + AgentName = "wrong-agent", + }; + + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts); + + Assert.Equal("it-happy-path", agent.Name); + Assert.Equal(s_testAgentEndpoint, opts.Endpoint); + Assert.Equal("it-happy-path", opts.AgentName); + } + + [Fact] + public void AgentEndpointConstructor_PropagatesUserAgentApplicationId_ToProjectLevelClient() + { + // The MEAI policy adds its own User-Agent header so we cannot reliably observe the OpenAI SDK's + // application-id stamp in the outbound request. Verify the value is propagated onto the + // project-level client's options via the public ProjectOpenAIClient surface. + ProjectOpenAIClientOptions opts = new() { UserAgentApplicationId = "my-app-id" }; + + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts); + + ProjectOpenAIClient? projectClient = agent.GetService(); + Assert.NotNull(projectClient); + // Caller's UserAgentApplicationId is preserved on the per-agent options bag verbatim. + Assert.Equal("my-app-id", opts.UserAgentApplicationId); + } + + #endregion + + #region ParseAgentEndpoint tests + + [Fact] + public void ParseAgentEndpoint_StandardShape_Parses() + { + var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/agents/a1/endpoint/protocols/openai")); + Assert.Equal("a1", name); + Assert.Equal("https://h.example.com/api/projects/p1", root.AbsoluteUri.TrimEnd('/')); + } + + [Fact] + public void ParseAgentEndpoint_TrailingSlash_Parses() + { + var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/agents/a1/endpoint/protocols/openai/")); + Assert.Equal("a1", name); + Assert.Equal("https://h.example.com/api/projects/p1", root.AbsoluteUri.TrimEnd('/')); + } + + [Fact] + public void ParseAgentEndpoint_UppercaseAgentsSegment_Parses() + { + var (name, _) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/Agents/a1/endpoint/protocols/openai")); + Assert.Equal("a1", name); + } + + [Fact] + public void ParseAgentEndpoint_SpecialCharsInName_Parses() + { + var (name, _) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/it-happy_path-1/endpoint/protocols/openai")); + Assert.Equal("it-happy_path-1", name); + } + + [Fact] + public void ParseAgentEndpoint_QueryAndFragmentStripped() + { + var (_, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/a/endpoint/protocols/openai?x=1#frag")); + Assert.Equal(string.Empty, root.Query); + Assert.Equal(string.Empty, root.Fragment); + } + + [Fact] + public void ParseAgentEndpoint_SovereignCloudHostNoApiPrefix_Parses() + { + var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.cognitive.microsoft.us/projects/p/agents/a1/endpoint/protocols/openai")); + Assert.Equal("a1", name); + Assert.Equal("https://h.cognitive.microsoft.us/projects/p", root.AbsoluteUri.TrimEnd('/')); + } + + [Fact] + public void ParseAgentEndpoint_MissingAgentsSegment_Throws() + { + ArgumentException ex = Assert.Throws(() => + FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/openai/v1"))); + Assert.Equal("agentEndpoint", ex.ParamName); + } + + [Fact] + public void ParseAgentEndpoint_WrongSuffix_Throws() + { + ArgumentException ex = Assert.Throws(() => + FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/a1/openai/v1"))); + Assert.Equal("agentEndpoint", ex.ParamName); + } + + [Fact] + public void ParseAgentEndpoint_EmptyAgentName_Throws() + { + ArgumentException ex = Assert.Throws(() => + FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents//endpoint/protocols/openai"))); + Assert.Equal("agentEndpoint", ex.ParamName); + } + + #endregion + + private sealed class HeaderStampPolicy : PipelinePolicy + { + private readonly string _name; + private readonly string _value; + public HeaderStampPolicy(string name, string value) { this._name = name; this._value = value; } + + public override void Process(PipelineMessage message, System.Collections.Generic.IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Set(this._name, this._value); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, System.Collections.Generic.IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Set(this._name, this._value); + return ProcessNextAsync(message, pipeline, currentIndex); + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj index 3b7176711a..b17efa64f9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj @@ -1,4 +1,4 @@ - + false @@ -7,7 +7,7 @@ - + From 0557b5782b3c8ff498e0756d06fcf1845221381f Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 8 May 2026 18:16:03 +0100 Subject: [PATCH 3/5] .NET: Add IChatMessageInjector for message injection during function loop (#5679) * Adding the ability to inject messages during the function call loop * Split message injection functionality * Remove interface, since it is not required not that we split the chat client. * Address conversation id propogation * Fix formatting issue --- .../Program.cs | 77 ++- .../ChatClient/ChatClientAgentOptions.cs | 31 ++ .../ChatClient/ChatClientBuilderExtensions.cs | 34 ++ .../ChatClient/ChatClientExtensions.cs | 20 +- .../ChatClient/MessageInjectingChatClient.cs | 320 ++++++++++++ .../MessageInjectingChatClientTests.cs | 493 ++++++++++++++++++ 6 files changed, 967 insertions(+), 8 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/ChatClient/MessageInjectingChatClient.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/MessageInjectingChatClientTests.cs diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs index 18f4eccd5f..ff60ac5aaf 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs @@ -8,6 +8,11 @@ // even if the process is interrupted mid-loop, but may also result in chat history that is not // yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases. // +// Additionally, this sample demonstrates the MessageInjectingChatClient feature, which allows tool +// code to inject new user messages during the function execution loop. When a tool or anything else enqueues +// a message via MessageInjectingChatClient.EnqueueMessages during the tool execution loop, the PerServiceCallChatHistoryPersistingChatClient +// detects the pending message before the next service call and includes the injected message in the request. +// // To use end-of-run persistence instead (atomic run semantics), remove the // RequirePerServiceCallChatHistoryPersistence = true setting (or set it to false). End-of-run // persistence is the default behavior. @@ -54,6 +59,37 @@ static string GetTime([Description("The city name.")] string city) => _ => $"{city}: time data not available." }; +// This tool demonstrates message injection during the function execution loop. +// When called, it checks travel advisories for a city. If an advisory is active, it uses +// the ambient run context to resolve MessageInjectingChatClient and injects a follow-up user message +// asking for alternative destinations. The model will process this injected message on the next +// service call — even though the parent FunctionInvokingChatClient loop would otherwise stop. +[Description("Check current travel advisories for a city.")] +static string CheckTravelAdvisory([Description("The city name.")] string city) +{ + // Simulated travel advisory data. + var advisory = city.ToUpperInvariant() switch + { + "LONDON" => "Travel advisory: Severe fog warnings in London. Flights may be delayed or cancelled.", + "SEATTLE" => "Travel advisory: Heavy rainfall expected. Flooding possible in low-lying areas.", + _ => null + }; + + if (advisory is null) + { + return $"{city}: No active travel advisories."; + } + + // When an advisory is found, inject a follow-up question so the model automatically + // suggests alternatives without the user needing to ask. + var runContext = AIAgent.CurrentRunContext!; + runContext.Agent.GetService()?.EnqueueMessages( + runContext.Session!, + [new ChatMessage(ChatRole.User, $"Given the travel advisory for {city}, what alternative cities would you recommend instead?")]); + + return advisory; +} + // Create the agent — per-service-call persistence is enabled via RequirePerServiceCallChatHistoryPersistence. // The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat // history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory(). @@ -65,10 +101,11 @@ AIAgent agent = chatClient.AsAIAgent( { Name = "WeatherAssistant", RequirePerServiceCallChatHistoryPersistence = true, + EnableMessageInjection = true, ChatOptions = new() { - Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.", - Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime)] + Instructions = "You are a helpful travel assistant. When asked about cities, call the appropriate tools for each city.", + Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime), AIFunctionFactory.Create(CheckTravelAdvisory)] }, }); @@ -109,6 +146,18 @@ async Task RunNonStreamingAsync() response = await agent.RunAsync(FollowUp2, session); PrintAgentResponse(response.Text); PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId); + + // Fourth turn — demonstrates message injection during the function loop. + // The CheckTravelAdvisory tool detects an advisory for London and injects a follow-up + // user message asking for alternative cities. After the tool completes, the internal loop + // in PerServiceCallChatHistoryPersistingChatClient detects the pending injected message + // and calls the service again, so the model answers the follow-up automatically. + const string TravelPrompt = "I'm planning to travel to London next week. Check if there are any travel advisories."; + PrintUserMessage(TravelPrompt); + + response = await agent.RunAsync(TravelPrompt, session); + PrintAgentResponse(response.Text); + PrintChatHistory(session, "After travel advisory run", ref lastChatHistorySize, ref lastConversationId); } async Task RunStreamingAsync() @@ -181,6 +230,30 @@ async Task RunStreamingAsync() Console.WriteLine(); PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId); + + // Fourth turn — demonstrates message injection during the function loop (streaming). + // The CheckTravelAdvisory tool detects an advisory for London and injects a follow-up + // user message asking for alternative cities. After the tool completes, the internal loop + // in PerServiceCallChatHistoryPersistingChatClient detects the pending injected message + // and calls the service again, so the model answers the follow-up automatically. + const string TravelPrompt = "I'm planning to travel to London next week. Check if there are any travel advisories."; + PrintUserMessage(TravelPrompt); + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("\n[Agent] "); + Console.ResetColor(); + + await foreach (var update in agent.RunStreamingAsync(TravelPrompt, session)) + { + Console.Write(update); + + // During streaming we should be able to see updates to the chat history + // before the full run completes, as each service call is made and persisted. + PrintChatHistory(session, "During travel advisory run", ref lastChatHistorySize, ref lastConversationId); + } + + Console.WriteLine(); + PrintChatHistory(session, "After travel advisory run", ref lastChatHistorySize, ref lastConversationId); } void PrintUserMessage(string message) diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs index e7340cec19..fad6b4e316 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs @@ -151,6 +151,36 @@ public sealed class ChatClientAgentOptions [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public bool RequirePerServiceCallChatHistoryPersistence { get; set; } + /// + /// Gets or sets a value indicating whether to include a + /// in the chat client pipeline. + /// + /// + /// + /// When set to , a is added to the pipeline + /// between the and the inner client. This enables external code + /// (such as tool delegates) to inject messages into the function execution loop via the + /// class, which can be resolved from the chat client using + /// GetService<MessageInjectingChatClient>(). + /// + /// + /// This setting can be used independently of , + /// however it is recommended to also enable per-service-call persistence when using message injection + /// so that injected messages are persisted to chat history between service calls. + /// + /// + /// When setting the setting to and + /// to , ensure that your custom chat client stack + /// includes a . You can add one manually via the + /// extension method. + /// + /// + /// + /// Default is . + /// + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] + public bool EnableMessageInjection { get; set; } + /// /// Creates a new instance of with the same values as this instance. /// @@ -168,5 +198,6 @@ public sealed class ChatClientAgentOptions WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict, ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict, RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence, + EnableMessageInjection = this.EnableMessageInjection, }; } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs index 5cf87aa950..22027a44a6 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs @@ -114,4 +114,38 @@ public static class ChatClientBuilderExtensions { return builder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient)); } + + /// + /// Adds a to the chat client pipeline. + /// + /// + /// + /// This decorator enables external code (such as tool delegates) to inject messages into the function + /// execution loop. It should be positioned between the and + /// the (or the leaf ) + /// in the pipeline. + /// + /// + /// The can be retrieved from the chat client via + /// GetService<MessageInjectingChatClient> to enqueue messages from tool delegates or other code. + /// + /// + /// This extension method is intended for use with custom chat client stacks when + /// is . + /// When is (the default), + /// the automatically includes this decorator in the pipeline when + /// is . + /// + /// + /// This decorator only works within the context of a running and will throw an + /// exception if used in any other stack. + /// + /// + /// The to add the decorator to. + /// The for chaining. + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] + public static ChatClientBuilder UseMessageInjection(this ChatClientBuilder builder) + { + return builder.Use(innerClient => new MessageInjectingChatClient(innerClient)); + } } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs index b2c48ec572..5859e98032 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs @@ -63,13 +63,21 @@ public static class ChatClientExtensions }); } - // PerServiceCallChatHistoryPersistingChatClient is only injected when RequirePerServiceCallChatHistoryPersistence is enabled. - // It is registered after FunctionInvokingChatClient so that it sits between FIC and the leaf client. + // MessageInjectingChatClient is injected when EnableMessageInjection is enabled. + // It is registered after FunctionInvokingChatClient so that it sits between FIC and the inner client. // ChatClientBuilder.Build applies factories in reverse order, making the first Use() call outermost. - // By adding our decorator second, the resulting pipeline is: - // FunctionInvokingChatClient → PerServiceCallChatHistoryPersistingChatClient → leaf IChatClient - // This allows the decorator to simulate service-stored chat history by loading history before - // each service call, persisting after each call, and returning a sentinel ConversationId. + // MessageInjectingChatClient enables injecting messages during the function loop and looping when needed. + if (options?.EnableMessageInjection is true) + { + chatBuilder.Use(innerClient => new MessageInjectingChatClient(innerClient)); + } + + // PerServiceCallChatHistoryPersistingChatClient is injected when RequirePerServiceCallChatHistoryPersistence is enabled. + // It is registered after MessageInjectingChatClient (if present) so it sits closest to the leaf client. + // The resulting pipeline is: + // FunctionInvokingChatClient → [MessageInjectingChatClient] → [PerServiceCallChatHistoryPersistingChatClient] → leaf IChatClient + // PerServiceCallChatHistoryPersistingChatClient simulates service-stored chat history by loading history + // before each service call, persisting after each call, and returning a sentinel ConversationId. if (options?.RequirePerServiceCallChatHistoryPersistence is true) { chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient)); diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/MessageInjectingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/MessageInjectingChatClient.cs new file mode 100644 index 0000000000..5d43b55f3e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/MessageInjectingChatClient.cs @@ -0,0 +1,320 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A delegating chat client that supports injecting messages into the function execution loop. +/// +/// +/// +/// This decorator enables external code (such as tool delegates) to enqueue messages that will be +/// sent to the underlying model at the next opportunity. It sits between the +/// and the (or the leaf ) +/// in a pipeline. +/// +/// +/// The injected messages queue is stored per-session in the , ensuring +/// isolation between concurrent sessions. +/// +/// +/// After each service call, if no actionable is returned but injected +/// messages are pending, the decorator loops internally and calls the inner client again with the new +/// messages. When actionable function calls are present, control returns to the parent +/// loop. +/// +/// +/// This chat client must be used within the context of a running . It retrieves the +/// current session from , which is set automatically when an agent's +/// or +/// +/// method is called. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class MessageInjectingChatClient : DelegatingChatClient +{ + /// + /// The key used to store the pending injected messages queue in the session's . + /// + internal const string PendingMessagesStateKey = "MessageInjectingChatClient.PendingInjectedMessages"; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying chat client that will handle the core operations. + public MessageInjectingChatClient(IChatClient innerClient) + : base(innerClient) + { + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var session = GetRequiredSession(); + var queue = GetOrCreateQueue(session); + + var newMessages = DrainInjectedMessages(queue, messages as IList ?? messages.ToList()); + + // Loop to process injected messages: after each service call, if no actionable function calls + // are pending but new messages have been injected into the queue, we call the service again + // so the model can process them. The loop exits when the response contains actionable + // function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty. + while (true) + { + var response = await base.GetResponseAsync(newMessages, options, cancellationToken).ConfigureAwait(false); + + // If the response contains actionable function calls, the parent FunctionInvokingChatClient + // loop will iterate — return immediately so it can process them. + if (HasActionableFunctionCalls(response.Messages)) + { + return response; + } + + // No actionable function calls. If there are pending injected messages, loop again + // to send them to the service. Otherwise, we're done. + bool queueEmpty; + lock (queue) + { + queueEmpty = queue.Count == 0; + } + + if (queueEmpty) + { + return response; + } + + // Propagate any ConversationId returned by the service so subsequent iterations + // continue within the same conversation. + UpdateOptionsForNextIteration(ref options, response.ConversationId); + + newMessages = DrainInjectedMessages(queue, Array.Empty()); + } + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var session = GetRequiredSession(); + var queue = GetOrCreateQueue(session); + + var newMessages = DrainInjectedMessages(queue, messages as IList ?? messages.ToList()); + + // Loop to process injected messages: after each service call, if no actionable function calls + // are pending but new messages have been injected into the queue, we call the service again + // so the model can process them. The loop exits when the response contains actionable + // function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty. + while (true) + { + bool hasActionableFunctionCalls = false; + string? lastConversationId = null; + + var enumerator = base.GetStreamingResponseAsync(newMessages, options, cancellationToken).GetAsyncEnumerator(cancellationToken); + try + { + while (await enumerator.MoveNextAsync().ConfigureAwait(false)) + { + var update = enumerator.Current; + + // Check each update for actionable function call content as it streams through. + if (!hasActionableFunctionCalls && HasActionableFunctionCalls(update)) + { + hasActionableFunctionCalls = true; + } + + // Track the latest ConversationId from the stream. + if (update.ConversationId is not null) + { + lastConversationId = update.ConversationId; + } + + yield return update; + } + } + finally + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + + // If the response contains actionable function calls, the parent FunctionInvokingChatClient + // loop will iterate — return immediately so it can process them. + if (hasActionableFunctionCalls) + { + yield break; + } + + // No actionable function calls. If there are pending injected messages, loop again + // to send them to the service. Otherwise, we're done. + bool queueEmpty; + lock (queue) + { + queueEmpty = queue.Count == 0; + } + + if (queueEmpty) + { + yield break; + } + + // Propagate any ConversationId returned by the service so subsequent iterations + // continue within the same conversation. + UpdateOptionsForNextIteration(ref options, lastConversationId); + + newMessages = DrainInjectedMessages(queue, Array.Empty()); + } + } + + /// + /// Enqueues one or more messages to be used at the next opportunity. + /// + /// + /// This method is thread-safe and can be called concurrently from tool delegates or other code + /// while the function execution loop is in progress. The enqueued messages will be picked up + /// at the next opportunity. + /// + /// The agent session to enqueue messages for. + /// The messages to enqueue. + public void EnqueueMessages(AgentSession session, IEnumerable messages) + { + Throw.IfNull(session); + Throw.IfNull(messages); + + var queue = GetOrCreateQueue(session); + + lock (queue) + { + foreach (var message in messages) + { + queue.Add(message); + } + } + } + + /// + /// Gets or creates the pending injected messages queue from the session's . + /// + private static List GetOrCreateQueue(AgentSession session) + { + if (session.StateBag.TryGetValue>(PendingMessagesStateKey, out var queue)) + { + return queue!; + } + + var newQueue = new List(); + session.StateBag.SetValue(PendingMessagesStateKey, newQueue); + return newQueue; + } + + /// + /// Gets the current from the run context. + /// + private static AgentSession GetRequiredSession() + { + var runContext = AIAgent.CurrentRunContext + ?? throw new InvalidOperationException( + $"{nameof(MessageInjectingChatClient)} can only be used within the context of a running AIAgent. " + + "Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call."); + + return runContext.Session + ?? throw new InvalidOperationException( + $"{nameof(MessageInjectingChatClient)} requires a session. " + + "The current run context does not have a session."); + } + + /// + /// Drains all pending injected messages from the queue and returns a new list combining + /// the original messages with the drained messages. The original list is never modified. + /// + private static IList DrainInjectedMessages(List queue, IList newMessages) + { + lock (queue) + { + if (queue.Count == 0) + { + return newMessages; + } + + var combined = new List(newMessages); + combined.AddRange(queue); + queue.Clear(); + return combined; + } + } + + /// + /// Determines whether any message in the list contains a + /// that is not marked as . + /// + private static bool HasActionableFunctionCalls(IList responseMessages) + { + for (int i = 0; i < responseMessages.Count; i++) + { + var contents = responseMessages[i].Contents; + for (int j = 0; j < contents.Count; j++) + { + if (contents[j] is FunctionCallContent fcc && !fcc.InformationalOnly) + { + return true; + } + } + } + + return false; + } + + /// + /// Determines whether a streaming update contains a + /// that is not marked as . + /// + private static bool HasActionableFunctionCalls(ChatResponseUpdate update) + { + var contents = update.Contents; + for (int i = 0; i < contents.Count; i++) + { + if (contents[i] is FunctionCallContent fcc && !fcc.InformationalOnly) + { + return true; + } + } + + return false; + } + + /// + /// Propagates the from the service response into + /// so that subsequent loop iterations continue within the + /// same conversation. Clones before mutating to avoid + /// affecting the caller's instance. + /// + private static void UpdateOptionsForNextIteration(ref ChatOptions? options, string? conversationId) + { + if (options is null) + { + if (conversationId is not null) + { + options = new() { ConversationId = conversationId }; + } + } + else if (options.ConversationId != conversationId) + { + options = options.Clone(); + options.ConversationId = conversationId; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/MessageInjectingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/MessageInjectingChatClientTests.cs new file mode 100644 index 0000000000..93f6b02b03 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/MessageInjectingChatClientTests.cs @@ -0,0 +1,493 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Moq.Protected; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for . +/// +public class MessageInjectingChatClientTests +{ + /// + /// Verifies that is resolvable via GetService when the decorator is active. + /// + [Fact] + public void GetService_ReturnsMessageInjectingChatClient_WhenDecoratorActive() + { + // Arrange + Mock mockService = new(); + ChatClientAgent agent = new(mockService.Object, options: new() + { + EnableMessageInjection = true, + }); + + // Act + var injector = agent.ChatClient.GetService(); + + // Assert + Assert.NotNull(injector); + } + + /// + /// Verifies that is null when the decorator is not active. + /// + [Fact] + public void GetService_ReturnsNull_WhenDecoratorNotActive() + { + // Arrange + Mock mockService = new(); + ChatClientAgent agent = new(mockService.Object, options: new()); + + // Act + var injector = agent.ChatClient.GetService(); + + // Assert + Assert.Null(injector); + } + + /// + /// Verifies that messages enqueued on the session before RunAsync are included in the service call messages. + /// + [Fact] + public async Task RunAsync_IncludesInjectedMessages_WhenEnqueuedBeforeCallAsync() + { + // Arrange + List capturedMessages = []; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback((IEnumerable msgs, ChatOptions? _, CancellationToken _) => + capturedMessages.AddRange(msgs)) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatHistoryProvider = mockChatHistoryProvider.Object, + RequirePerServiceCallChatHistoryPersistence = true, + EnableMessageInjection = true, + }); + + // Create session and enqueue a message directly onto the session's StateBag queue before calling RunAsync + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + var queue = new List(); + queue.Add(new ChatMessage(ChatRole.User, "injected message")); + session!.StateBag.SetValue("MessageInjectingChatClient.PendingInjectedMessages", queue); + + // Act + await agent.RunAsync([new(ChatRole.User, "original")], session); + + // Assert — the service should have received both the original and injected messages + Assert.Contains(capturedMessages, m => m.Text == "original"); + Assert.Contains(capturedMessages, m => m.Text == "injected message"); + } + + /// + /// Verifies that the queue is drained after a call (messages are not re-delivered on subsequent calls). + /// + [Fact] + public async Task RunAsync_DrainsQueue_MessagesNotRedeliveredAsync() + { + // Arrange + List capturedMessages = []; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback((IEnumerable msgs, ChatOptions? _, CancellationToken _) => + capturedMessages.AddRange(msgs)) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatHistoryProvider = mockChatHistoryProvider.Object, + RequirePerServiceCallChatHistoryPersistence = true, + EnableMessageInjection = true, + }); + + // Create session and enqueue a message directly onto the session's StateBag queue + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + var queue = new List(); + queue.Add(new ChatMessage(ChatRole.User, "injected once")); + session!.StateBag.SetValue("MessageInjectingChatClient.PendingInjectedMessages", queue); + + // Act + await agent.RunAsync([new(ChatRole.User, "first call")], session); + + // Assert — the injected message was included in the service call + Assert.Contains(capturedMessages, m => m.Text == "injected once"); + + // Assert — the session's queue is now empty (drained) + Assert.Empty(queue); + } + + /// + /// Verifies that the internal loop fires when no actionable FunctionCallContent is returned + /// but there are pending injected messages in the queue. + /// + [Fact] + public async Task RunAsync_LoopsInternally_WhenNoActionableFCCButPendingMessagesAsync() + { + // Arrange + int serviceCallCount = 0; + Mock mockService = new(); + MessageInjectingChatClient? injectorRef = null; + ChatClientAgentSession? sessionRef = null; + + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns((IEnumerable msgs, ChatOptions? _, CancellationToken _) => + { + serviceCallCount++; + if (serviceCallCount == 1) + { + // First call — simulate that something enqueues a message (e.g., a provider or background task) + injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected during first call")]); + } + + // Return a plain text response (no FunctionCallContent) to trigger the internal loop + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, $"response {serviceCallCount}")])); + }); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatHistoryProvider = mockChatHistoryProvider.Object, + RequirePerServiceCallChatHistoryPersistence = true, + EnableMessageInjection = true, + }); + + injectorRef = agent.ChatClient.GetService()!; + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + sessionRef = session; + await agent.RunAsync([new(ChatRole.User, "original")], session); + + // Assert — should have made 2 service calls (internal loop triggered by the injected message) + Assert.Equal(2, serviceCallCount); + } + + /// + /// Verifies that the internal loop does NOT fire when the response contains actionable + /// FunctionCallContent, even if there are pending injected messages. + /// + [Fact] + public async Task RunAsync_DoesNotLoopInternally_WhenActionableFCCPresentAsync() + { + // Arrange + int serviceCallCount = 0; + Mock mockService = new(); + MessageInjectingChatClient? injectorRef = null; + ChatClientAgentSession? sessionRef = null; + + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns((IEnumerable msgs, ChatOptions? _, CancellationToken _) => + { + serviceCallCount++; + if (serviceCallCount == 1) + { + // Enqueue a message during the first call + injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]); + // Return a response with an actionable FunctionCallContent + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "myTool", new Dictionary())])])); + } + + // Subsequent calls return plain text (the FCC loop will call back after tool execution) + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final")])); + }); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask()); + + var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool"); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Tools = [tool] }, + ChatHistoryProvider = mockChatHistoryProvider.Object, + RequirePerServiceCallChatHistoryPersistence = true, + EnableMessageInjection = true, + }, services: new ServiceCollection().BuildServiceProvider()); + + injectorRef = agent.ChatClient.GetService()!; + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + sessionRef = session; + await agent.RunAsync([new(ChatRole.User, "original")], session); + + // Assert — The first service call returned actionable FCC, so no internal injected-message loop + // occurred there. The FCC loop invokes the tool and calls the service again (second call). + // The injected message should be picked up by the second service call (drained at start of + // GetResponseAsync), but no extra internal loop should fire. Exactly 2 service calls expected. + Assert.Equal(2, serviceCallCount); + } + + /// + /// Verifies that the internal loop fires when the response contains only InformationalOnly + /// FunctionCallContent (which are not actionable) and there are pending injected messages. + /// + [Fact] + public async Task RunAsync_LoopsInternally_WhenOnlyInformationalOnlyFCCAndPendingMessagesAsync() + { + // Arrange + int serviceCallCount = 0; + Mock mockService = new(); + MessageInjectingChatClient? injectorRef = null; + ChatClientAgentSession? sessionRef = null; + + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns((IEnumerable msgs, ChatOptions? _, CancellationToken _) => + { + serviceCallCount++; + if (serviceCallCount == 1) + { + // Enqueue a message during the first call + injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]); + // Return a response with InformationalOnly FCC (not actionable) + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "myTool", new Dictionary()) { InformationalOnly = true }])])); + } + + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final")])); + }); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatHistoryProvider = mockChatHistoryProvider.Object, + RequirePerServiceCallChatHistoryPersistence = true, + EnableMessageInjection = true, + }); + + injectorRef = agent.ChatClient.GetService()!; + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + sessionRef = session; + await agent.RunAsync([new(ChatRole.User, "original")], session); + + // Assert — InformationalOnly FCC is NOT actionable, so internal loop should trigger + Assert.Equal(2, serviceCallCount); + } + + /// + /// Verifies that when the inner client returns a ConversationId on the first call, the + /// MessageInjectingChatClient propagates it to options on subsequent loop iterations. + /// + [Fact] + public async Task RunAsync_PropagatesConversationId_AcrossInternalLoopIterationsAsync() + { + // Arrange + int serviceCallCount = 0; + List capturedConversationIds = []; + MessageInjectingChatClient? injectorRef = null; + ChatClientAgentSession? sessionRef = null; + + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns((IEnumerable _, ChatOptions? opts, CancellationToken _) => + { + serviceCallCount++; + capturedConversationIds.Add(opts?.ConversationId); + + if (serviceCallCount == 1) + { + // First call: inject a message and return a ConversationId + injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]); + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "first response")]) + { + ConversationId = "conv-123", + }); + } + + // Second call (from loop): should have the propagated ConversationId + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "second response")])); + }); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + EnableMessageInjection = true, + }, services: new ServiceCollection().BuildServiceProvider()); + + injectorRef = agent.ChatClient.GetService()!; + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + sessionRef = session; + await agent.RunAsync([new(ChatRole.User, "hello")], session); + + // Assert — The second call should have received the ConversationId propagated from the first response + Assert.Equal(2, serviceCallCount); + Assert.Null(capturedConversationIds[0]); // First call: no ConversationId yet + Assert.Equal("conv-123", capturedConversationIds[1]); // Second call: propagated from first response + } + + /// + /// Verifies that a session with pending injected messages can be serialized and deserialized, + /// and that the deserialized session correctly delivers the injected messages on the next run. + /// + [Fact] + public async Task RunAsync_DeliversInjectedMessages_AfterSessionSerializationRoundTripAsync() + { + // Arrange + List capturedMessagesFirstRun = []; + List capturedMessagesSecondRun = []; + int runCount = 0; + Mock mockService = new(); + MessageInjectingChatClient? injectorRef = null; + ChatClientAgentSession? sessionRef = null; + + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns((IEnumerable msgs, ChatOptions? _, CancellationToken _) => + { + if (runCount == 1) + { + capturedMessagesFirstRun.AddRange(msgs); + + // Inject a message during the first run — this will remain pending (not drained) + // because we return an actionable FCC that causes the parent loop to take over. + injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected before serialization")]); + + // Return actionable FCC so the injection loop does NOT drain the message + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "myTool", new Dictionary())])])); + } + + // Second run (after deserialization) — capture what messages come through + capturedMessagesSecondRun.AddRange(msgs); + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")])); + }); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask()); + + var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool"); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Tools = [tool] }, + ChatHistoryProvider = mockChatHistoryProvider.Object, + RequirePerServiceCallChatHistoryPersistence = true, + EnableMessageInjection = true, + }, services: new ServiceCollection().BuildServiceProvider()); + + injectorRef = agent.ChatClient.GetService()!; + + // Act — First run: inject a message that stays pending + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + sessionRef = session; + runCount = 1; + await agent.RunAsync([new(ChatRole.User, "first run message")], session); + + // Serialize the session and deserialize into a new instance + var serialized = await agent.SerializeSessionAsync(session!); + var deserializedSession = await agent.DeserializeSessionAsync(serialized) as ChatClientAgentSession; + + // Second run on the deserialized session — the injected message should be delivered + runCount = 2; + sessionRef = deserializedSession; + await agent.RunAsync([new(ChatRole.User, "second run message")], deserializedSession); + + // Assert — the second run should include the injected message from before serialization + Assert.Contains(capturedMessagesSecondRun, m => m.Text == "injected before serialization"); + Assert.Contains(capturedMessagesSecondRun, m => m.Text == "second run message"); + } +} From d2ce0e9087d5376178a96ea9f5233fa5bce43dc8 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Mon, 11 May 2026 10:39:13 +0100 Subject: [PATCH 4/5] .NET: Foundry.Hosting IT - eliminate MSBuild parallel-output races (#5725) * .NET: Foundry.Hosted IT - fix MSBuild parallel-output races Two surgical changes inside the dotnet-foundry-hosted-it job: 1. Replace dotnet build -f net10.0 with dotnet build . The test csproj pins TargetFrameworks=net10.0 and its ProjectReference closure gives MSBuild a single-rooted graph, eliminating the duplicate inner-builds that race on bin/obj. Drops the two New-FilteredSolution.ps1 steps. 2. In it-build-image.ps1, drop the -UsePrebuiltProjectReferences switch and always pass --no-dependencies to dotnet publish. Publish now resolves TestContainer's framework refs by reading prebuilt DLLs and never re-touches them. Replaces the partial-mitigation in PR #5689 with a structural fix. Local validation confirmed published Foundry.dll has identical mtime and bytes as the prebuild output. * .NET: dotnet test - use --project flag for Microsoft Testing Platform --- .github/workflows/dotnet-build-and-test.yml | 49 ++++------- .../scripts/it-build-image.ps1 | 88 ++++++------------- 2 files changed, 43 insertions(+), 94 deletions(-) diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 0b589e2c55..ad0ed6cb7e 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -340,7 +340,6 @@ jobs: runs-on: ubuntu-latest environment: integration env: - targetFramework: net10.0 configuration: Release steps: - uses: actions/checkout@v6 @@ -357,31 +356,15 @@ jobs: with: global-json-file: ${{ github.workspace }}/dotnet/global.json - - name: Generate test solution (no samples) - shell: pwsh - run: | - ./dotnet/eng/scripts/New-FilteredSolution.ps1 ` - -Solution dotnet/agent-framework-dotnet.slnx ` - -TargetFramework $env:targetFramework ` - -Configuration $env:configuration ` - -ExcludeSamples ` - -OutputPath dotnet/filtered.slnx ` - -Verbose - - - name: Generate Foundry hosted IT filtered solution - shell: pwsh - run: | - ./dotnet/eng/scripts/New-FilteredSolution.ps1 ` - -Solution dotnet/filtered.slnx ` - -TargetFramework $env:targetFramework ` - -Configuration $env:configuration ` - -TestProjectNameFilter "Foundry.Hosting.IntegrationTests*" ` - -OutputPath dotnet/filtered-foundry-hosted.slnx ` - -Verbose - + # Build the test csproj directly instead of a filtered slnx + -f override. + # The test project pins TargetFrameworks=net10.0 and its ProjectReference closure + # gives MSBuild a single-rooted graph, so each multi-targeted dependency is invoked + # exactly once for net10.0. This avoids the MSB3026/MSB3491/MSB4018/MSB3883 file-lock + # collisions caused by parallel inner-builds racing on shared bin/obj output paths + # under the previous slnx + global TFM override approach. - name: Build Foundry hosted IT (and its deps) shell: bash - run: dotnet build dotnet/filtered-foundry-hosted.slnx -c "$configuration" -f "$targetFramework" --warnaserror + run: dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c "$configuration" --warnaserror - name: Azure CLI Login uses: azure/login@v2 @@ -394,13 +377,12 @@ jobs: # are picked up; the image tag is content-hashed across the test container source AND its # framework project references, so identical content is a no-op push. # - # `-UsePrebuiltProjectReferences` opts into the no-rebuild fast path: publish skips - # rebuilding ProjectReferences and consumes the DLLs the prior "Build Foundry hosted IT - # (and its deps)" step already produced. This avoids MSB3026 ("file is being used by - # another process") collisions caused by the previous build's shared-compilation server - # still holding file handles to those DLLs. Safe in CI because the prebuild step ran in - # the same job against the same source. Do not remove the prebuild step (the subsequent - # `dotnet test --no-build` step depends on it too). + # The script always passes --no-dependencies to dotnet publish so publish never re-touches + # the framework lib DLLs the prior "Build Foundry hosted IT (and its deps)" step produced. + # This structurally eliminates the MSB3026 collision that VBCSCompiler from the prebuild + # would otherwise cause by holding file handles to those DLLs. Do not remove the prebuild + # step: the subsequent `dotnet test --no-build` step and the publish's ProjectReference + # resolution both depend on the prebuilt outputs being present. - name: Build and push Foundry Hosted Agents test container id: build-foundry-hosted-image shell: pwsh @@ -410,14 +392,13 @@ jobs: if ([string]::IsNullOrWhiteSpace($registry)) { throw "IT_HOSTED_AGENT_REGISTRY not set in the integration environment." } - & "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry -UsePrebuiltProjectReferences | Tee-Object -FilePath $env:GITHUB_ENV -Append + & "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry | Tee-Object -FilePath $env:GITHUB_ENV -Append - name: Run Foundry Hosted Agents Integration Tests shell: pwsh working-directory: dotnet run: | - dotnet test --solution ./filtered-foundry-hosted.slnx ` - -f $env:targetFramework ` + dotnet test --project tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj ` -c $env:configuration ` --no-build -v Normal ` --report-xunit-trx ` diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1 b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1 index 857ea3be0a..2d938bb013 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1 +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1 @@ -41,14 +41,7 @@ param( [string] $Repository = "foundry-hosting-it", - [string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer", - - # Explicit opt-in for the no-rebuild fast path. CI sets this after running the - # "Build Foundry hosted IT (and its deps)" step, which guarantees the prebuilt - # library DLLs match current source. Off by default so local invocations always - # let publish rebuild ProjectReferences and never produce an image whose tag is - # computed from current source while the contents come from a stale build. - [switch] $UsePrebuiltProjectReferences + [string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer" ) $ErrorActionPreference = "Stop" @@ -107,60 +100,35 @@ if (Test-Path $out) { Remove-Item -Recurse -Force $out } -# Conditionally tell publish to skip rebuilding ProjectReferences and consume the -# prebuilt library DLLs in place. This avoids two failure modes that arise when -# the CI workflow runs a `dotnet build` of the same library projects immediately -# before this script: -# 1) MSB3026 "file is being used by another process" when publish's MSBuild -# tries to overwrite src//bin/Release/net10.0/.dll while the -# previous build's shared-compilation server still holds a file handle. -# 2) Publish needlessly rebuilding identical managed (RID-agnostic) library -# DLLs that prebuild already produced. -# Gated on -UsePrebuiltProjectReferences (a strict opt-in) instead of marker -# detection, because a developer machine may have a stale Release build of the -# libraries from days ago; using those would silently produce an image whose -# content is older than the source the tag is computed from. -$publishExtraArgs = @() -if ($UsePrebuiltProjectReferences) { - Write-Host "-UsePrebuiltProjectReferences: skipping ProjectReference rebuild." -ForegroundColor DarkGray - $publishExtraArgs += "-p:BuildProjectReferences=false" -} else { - # Preflight: in default (rebuild) mode, publish propagates RuntimeIdentifier=linux-musl-x64 - # to library ProjectReferences and writes their intermediates to a RID-suffixed obj path - # (e.g. obj/Release/net10.0/linux-musl-x64/). DefaultItemExcludes follows the new - # IntermediateOutputPath, so any *.AssemblyInfo.cs left in obj/Release/net10.0/ from a - # prior `dotnet build` is no longer excluded and gets picked up by the **/*.cs Compile - # glob, producing CS0579 "duplicate attribute" errors. Detect that state up front and - # tell the user exactly how to recover. - $staleObjProbes = @( - "dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/obj/Release/net10.0", - "dotnet/src/Microsoft.Agents.AI.Foundry/obj/Release/net10.0", - "dotnet/src/Microsoft.Agents.AI/obj/Release/net10.0", - "dotnet/src/Microsoft.Agents.AI.Abstractions/obj/Release/net10.0" - ) - $stale = @($staleObjProbes | Where-Object { Test-Path (Join-Path $_ "*.AssemblyInfo.cs") }) - if ($stale.Count -gt 0) { - $msg = @( - "Detected prior Release/net10.0 build outputs in:" - ($stale | ForEach-Object { " - $_" }) - "" - "Publish would propagate -r linux-musl-x64 to those ProjectReferences and the" - "leftover obj/Release/net10.0/*.AssemblyInfo.cs files would cause CS0579 duplicate" - "attribute errors. Pick one:" - " (a) Pass -UsePrebuiltProjectReferences (skips ProjectReference rebuild and" - " uses the existing src//bin/Release/net10.0/*.dll outputs in place)." - " Only safe when you know those DLLs match current source - this is the path" - " CI uses immediately after its 'Build Foundry hosted IT (and its deps)' step." - " (b) Remove the stale obj/Release trees, e.g.:" - " Remove-Item -Recurse -Force dotnet/src/Microsoft.Agents.AI*/obj/Release" - " and re-run." - ) -join "`n" - throw $msg - } - Write-Host "Letting publish build ProjectReferences (pass -UsePrebuiltProjectReferences in CI to skip)." -ForegroundColor DarkGray +# Always tell publish to skip ProjectReference rebuilds via --no-dependencies. Publish +# resolves TestContainer's framework lib references (Foundry, Foundry.Hosting and their +# transitive deps) by reading the prebuilt DLLs at src//bin/Release/net10.0/*.dll. +# This: +# 1) Structurally avoids the MSB3026 "file is being used by another process" race that +# occurs when publish overwrites the same DLL paths a prior `dotnet build` produced +# while VBCSCompiler from that build still holds file handles. +# 2) Avoids needlessly rebuilding identical managed (RID-agnostic) library DLLs. +# Callers MUST run `dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c Release` +# (or equivalent) first so those prebuilt DLLs exist. The CI workflow does this in the +# preceding "Build Foundry hosted IT (and its deps)" step. +$prebuildProbes = @( + "dotnet/src/Microsoft.Agents.AI.Foundry/bin/Release/net10.0/Microsoft.Agents.AI.Foundry.dll", + "dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/bin/Release/net10.0/Microsoft.Agents.AI.Foundry.Hosting.dll" +) +$missingPrebuilds = @($prebuildProbes | Where-Object { -not (Test-Path $_) }) +if ($missingPrebuilds.Count -gt 0) { + $msg = @( + "Required prebuilt outputs not found:" + ($missingPrebuilds | ForEach-Object { " - $_" }) + "" + "Publish runs with --no-dependencies and consumes prebuilt DLLs in place. Build the" + "test project first so its ProjectReference closure populates src//bin/Release/net10.0/:" + " dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c Release" + ) -join "`n" + throw $msg } -dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false -o $out @publishExtraArgs --tl:off | Out-Host +dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false --no-dependencies -o $out --tl:off | Out-Host if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed with exit code $LASTEXITCODE." } From 9faf52de4f0632882d6e0bb2212393b548d5f68a Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Mon, 11 May 2026 12:56:58 +0100 Subject: [PATCH 5/5] .NET: Hosted-Files sample + AgentSessionFiles SDK companion + integration test (#5698) * .NET: Add Hosted-Files sample + alpha AgentSessionFiles SDK companion + integration test Closes #5691 - Hosted-Files server sample (mirrors python 06_files): 3 local tools reading the per-session \C:\Users\rbarreto sandbox volume. - SessionFilesClient REPL companion: code-first equivalent of zd ai agent files upload using the alpha Azure.AI.Projects.AgentSessionFiles SDK (upload/ls/download/rm + session lifecycle with isolation key). - session-files scenario added to the Foundry.Hosting.IntegrationTests multi-scenario harness (PR #5598): SessionFilesHostedAgentFixture + SessionFilesHostedAgentTests.UploadAndAgentReadsFileAsync, end-to-end validating upload then agent-reads-file (agent_session_id pinned via CreateResponseOptions.Patch). Bundled testdata is linked from the sample so there is a single source of truth. * .NET: Hosted-Files: REPL companion now demonstrates file-as-knowledge end-to-end Adds an 'ask ' command to SessionFilesClient that pins agent_session_id (via CreateResponseOptions.Patch) so the agent invoked from the REPL reads files this REPL just uploaded. Surfaces the file content as agent knowledge in the same in-process loop instead of telling the user to shell out to azd ai agent invoke. * .NET: Reshape Hosted-Files sample - bake files into image, SessionFilesClient becomes thin chat REPL The previous SessionFilesClient leaned on the alpha AgentSessionFiles SDK to upload files at runtime, which made it diverge from the canonical Using-Samples shape (SimpleAgent / SimpleInvocationsAgent: tiny chat REPLs). This change: - Bakes the sample resources/ directory into the published output via a Content Include in HostedFiles.csproj. Inside the container the files live at /app/resources/. Two local function tools (ListFiles, ReadFile) surface them to the model. - Reshapes SessionFilesClient as a thin FoundryAgent chat REPL, identical shape to SimpleAgent. AGENT_ENDPOINT + AGENT_NAME, that is it. - Demo flow: user asks 'Give me the total revenue in the contoso file' and the agent answers with the figure read from its bundled file. Validated end-to-end locally against Hosted-Files on http://localhost:60419. - Bypasses SampleEnvironment alias on optional env vars to avoid stdin prompts when running unattended. The Foundry.Hosting.IntegrationTests session-files scenario continues to validate the alpha AgentSessionFiles SDK end-to-end (upload + agent reads from session HOME) and is unchanged. * .NET: Foundry.Hosting.IntegrationTests TestContainer - constrain session-files tools to $HOME Addresses the path-traversal review comment on the session-files scenario: ResolveSessionPath in TestContainer used to allow absolute paths and .. traversals, which (when chained with indirect prompt injection in an uploaded file) would let the model read or list arbitrary container files via the ReadFile / ListFiles tools. Mirrors the canonicalize + StartsWith(home) pattern from the framework's own FileSystemAgentFileStore.ResolveSafePath: rejects rooted paths, calls Path.GetFullPath, and verifies the result stays under $HOME, throwing ArgumentException otherwise. The Hosted-Files sample is already safe (uses Path.GetFileName which strips any directory component) so no change there. The integration test continues to upload and read 'contoso_q1_2026_report.txt', a single relative filename which passes the new validation unchanged. * .NET: SessionFilesHostedAgentTests - shrink to alpha SDK round-trip The previous test attempted to pin agent_session_id into the /responses payload via JsonPatch so the agent would read the file uploaded through AgentSessionFiles. The Foundry alpha service now consistently rejects the explicit-session-id pin with HTTP 400 conflict on /responses, regardless of whether the session was pre-created via AgentAdministrationClient or left to be auto-provisioned, so the agent leg of the test is no longer reachable from the SDK surface. Reshape the test to exercise what the alpha SDK actually guarantees: create session, upload, list (assert presence + size), download (assert deterministic token), delete (assert removed), cleanup. Everything stays inside Azure.AI.Projects.Agents.AgentSessionFiles. Verified live against tao-foundry-prj: UploadListDownloadAndDeleteAsync passed in 30s. Full Foundry.Hosting.IntegrationTests run: 25 total, 6 passed, 19 skipped (existing placeholders), 0 failed. * .NET: SessionFilesHostedAgentTests - rewrite as upload-then-FoundryAgent.RunAsync e2e Per review feedback the integration test must validate the hosted agent itself: client uploads a file via the alpha AgentSessionFiles SDK, then FoundryAgent.RunAsync invokes the deployed agent and the agent's container-side ReadFile tool surfaces the uploaded file content into the response. Test flow: 1. agent.RunAsync(warmup) - platform provisions a per-session container. 2. AgentAdministrationClient.GetSessionsAsync(latest) - resolve the just-provisioned agent_session_id. 3. AgentSessionFiles.UploadSessionFileAsync - upload contoso file to that session, asserts BytesWritten + GetSessionFiles listing. 4. agent.RunAsync(real prompt, options=PreviousResponseId chain) - chained to warmup so the platform routes back to the same container. 5. Assert response contains '1,482.6' (deterministic token from file). 6. Best-effort cleanup. The test is annotated with [Fact(Skip=...)] right now: the Foundry alpha service consistently returns HTTP 400 conflict on /responses requests that link to a prior session via previous_response_id, conversation_id, or agent_session_id pinning - verified across multiple retries with multiple chaining strategies. Without that link we cannot route the second invocation to the same container the file was uploaded to. When the platform regression is resolved, removing the Skip will exercise the full flow. Full Foundry.Hosting.IntegrationTests run with this change: 25 total, 5 passed, 20 skipped (existing placeholders + this one), 0 failed. * .NET: SessionFilesHostedAgentTests - end-to-end upload-then-FoundryAgent.RunAsync now passes The blocker was a routing problem combined with a platform race: 1. Routing two /responses calls to the same per-session container. - agent_session_id pin in body -> 400 (platform treats it as create) - conversation_id created at project root -> 404 at agent endpoint - previous_response_id chain -> different session The working answer is to create the conversation on a per-agent ProjectOpenAIClient (AgentName option, URL becomes /agents/{name}/endpoint/protocols/openai/conversations) and pass that conversation_id on both calls. Both then resolve to the SAME x-agent-session-id (verified by capturing the response header). 2. Race after AgentSessionFiles upload. The upload mutates session/ conversation revision; a /responses call issued immediately after 400-conflicts with 'modified concurrently. Please retry.' Bounded exponential retry handles it (5 attempts, 2*attempt seconds). Test flow: 1. Create per-agent OpenAI client + ProjectConversationsClient + ProjectResponsesClient. 2. CreateProjectConversationAsync on the per-agent client. 3. Warm-up agent.RunAsync(prompt, ChatOptions { ConversationId = ... }) - captures x-agent-session-id from the response header via a custom pipeline policy. 4. AgentSessionFiles.UploadSessionFileAsync to that session id. 5. ProjectResponsesClient.CreateResponseAsync (raw, retry-on-conflict) with the same conversation_id -> routes back to the same container. 6. Assert response contains '1,482.6' (deterministic token from file). 7. Cleanup: delete file, leave session for TTL. Verified live against tao-foundry-prj: UploadedFile_IsReadByHostedAgentAsync passed in 24.9s. Full Foundry.Hosting.IntegrationTests run: 25 total, 6 passed, 19 skipped (existing placeholders), 0 failed. * .NET: address Copilot PR review findings - agent.manifest.yaml: description + tags now reflect bundled-files agent (image-baked /app/resources), not the obsolete session-sandbox tools the prior shape claimed. - SessionFilesHostedAgentTests: wrap test body in try/finally to call DeleteConversationAsync on the conversation we created (matches HappyPathHostedAgentTests pattern; prevents conversation leakage across runs). - ResponseHeaderCapturePolicy: drop unused LastRequestBody capture left over from diagnosis. Test still passes live (40s). * .NET: Hosted-Files: split into bundled vs session-file tool pairs The previous Hosted-Files agent only exposed bundled (image-baked) file knowledge. The platform also surfaces session-uploaded files at \C:\Users\rbarreto inside the per-session container per container-image-spec.md line 172 (verified live by SessionFilesHostedAgentTests). The sample now teaches both patterns. Two distinct tool pairs, each scoped to its own root: Bundled (image-baked): ListBundledFiles, ReadBundledFile -> /app/resources/ (BUNDLED_FILES_DIR override) Session-uploaded (\C:\Users\rbarreto): ListSessionFiles, ReadSessionFile -> \C:\Users\rbarreto (default /home/session per container spec) Security model -- distinct tools, distinct sandboxes: - Tool input is a fileName, not a path. Schema-level: model cannot request directories or traversals. - Path.GetFileName(input) strips any directory components. - Path.GetFullPath + StartsWith(root) check rejects anything outside the tool's root, mirroring FileSystemAgentFileStore.ResolveSafePath. - Read-only, non-recursive listing. No glob, no '..'. - Failures non-revealing: 'File not found in .' The two roots are physically isolated (image-baked vs platform-mounted per-session volume). A bundled-root tool can never reach a session file and vice-versa, even if the implementation has a bug. README updated to document both flows, the security pattern, and cite the container-image-spec.md line 172 contract for \C:\Users\rbarreto. Live IT SessionFilesHostedAgentTests.UploadedFile_IsReadByHostedAgentAsync re-passed in 42s after the change (TestContainer is unchanged; the sample-agent split does not affect the IT). * .NET: Hosted-Files README - fix broken relative link to IT (4..5 dots) --- dotnet/agent-framework-dotnet.slnx | 4 + .../responses/Hosted-Files/.dockerignore | 6 + .../responses/Hosted-Files/.env.example | 5 + .../responses/Hosted-Files/Dockerfile | 17 ++ .../Hosted-Files/Dockerfile.contributor | 19 ++ .../responses/Hosted-Files/HostedFiles.csproj | 40 +++ .../responses/Hosted-Files/Program.cs | 223 ++++++++++++++++ .../responses/Hosted-Files/README.md | 128 ++++++++++ .../Hosted-Files/agent.manifest.yaml | 30 +++ .../responses/Hosted-Files/agent.yaml | 9 + .../resources/contoso_q1_2026_report.txt | 121 +++++++++ .../SessionFilesClient/Program.cs | 116 +++++++++ .../SessionFilesClient/README.md | 50 ++++ .../SessionFilesClient.csproj | 24 ++ .../Program.cs | 96 +++++++ .../SessionFilesHostedAgentFixture.cs | 17 ++ .../Foundry.Hosting.IntegrationTests.csproj | 8 + .../README.md | 1 + .../SessionFilesHostedAgentTests.cs | 238 ++++++++++++++++++ .../scripts/it-bootstrap-agents.ps1 | 3 +- 20 files changed, 1154 insertions(+), 1 deletion(-) create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.dockerignore create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.env.example create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/HostedFiles.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/resources/contoso_q1_2026_report.txt create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/SessionFilesHostedAgentFixture.cs create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 01c28beef8..d58a691599 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -313,6 +313,9 @@ + + + @@ -332,6 +335,7 @@ + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.dockerignore b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.dockerignore new file mode 100644 index 0000000000..cf85b06faa --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.dockerignore @@ -0,0 +1,6 @@ +**/bin +**/obj +**/.vs +**/.vscode +.env +*.user diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.env.example new file mode 100644 index 0000000000..b8fe9e8e7a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.env.example @@ -0,0 +1,5 @@ +AZURE_AI_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +AZURE_BEARER_TOKEN=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile new file mode 100644 index 0000000000..82f5e1b85c --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedFiles.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile.contributor new file mode 100644 index 0000000000..7a34f9361d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile.contributor @@ -0,0 +1,19 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source, +# which means a standard multi-stage Docker build cannot resolve dependencies outside +# this folder. Instead, pre-publish the app targeting the container runtime and copy +# the output into the container: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-files . +# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-files -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-files +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedFiles.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/HostedFiles.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/HostedFiles.csproj new file mode 100644 index 0000000000..fe36ca8ba2 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/HostedFiles.csproj @@ -0,0 +1,40 @@ + + + + net10.0 + enable + enable + false + HostedFiles + HostedFiles + $(NoWarn); + + + + + + + + + + + + PreserveNewest + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Program.cs new file mode 100644 index 0000000000..aba8f4ebef --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Program.cs @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Hosted Files Agent - A hosted agent that exposes two distinct file knowledge sources +// through scoped, security-hardened tools: +// +// * Bundled files (image-baked) — files copied into the published output via the csproj +// rule. Live at /app/resources/ inside the container. +// Author-shipped knowledge that ships with every session. +// +// * Session files (per-session $HOME volume) — files uploaded at runtime via the alpha +// Azure.AI.Projects.AgentSessionFiles SDK. Live at $HOME inside the per-session +// container, which the platform sets to /home/session by default +// (container-image-spec.md line 127, "If you use the session files API, $HOME is +// also the base path for those operations"). +// +// Each source is exposed via a separate tool pair, each rooted at its own directory. +// Tools take a fileName, not a path: Path.GetFileName strips any directory components, +// then a canonicalize + StartsWith(root) check enforces the boundary. The model cannot +// be tricked into reading /etc/passwd or any path outside its tool's root, even via +// indirect prompt injection in an uploaded file. +// +// Required environment variables: +// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint +// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o) +// +// Optional: +// AGENT_NAME - Agent name (default: hosted-files) +// BUNDLED_FILES_DIR - Override the bundled-files root +// (default: /resources, i.e. /app/resources/) +// HOME - Standard env var; the per-session sandbox volume +// (default: /home/session in the platform-managed container) + +using System.ComponentModel; +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +// Bypass SampleEnvironment alias (which prompts on missing env vars) for optional values. +string? GetOptionalEnv(string key) => System.Environment.GetEnvironmentVariable(key); + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = GetOptionalEnv("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; + +// Use a chained credential: try a temporary dev token first (for local Docker debugging), +// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +// ── File roots (canonicalized once) ────────────────────────────────────────── + +// Bundled root: where csproj lands at runtime. +// In the container that resolves to /app/resources/. +string bundledRoot = Path.GetFullPath( + GetOptionalEnv("BUNDLED_FILES_DIR") + ?? Path.Combine(AppContext.BaseDirectory, "resources")); + +// Session root: the per-session $HOME volume mounted by the Foundry platform. +// Files uploaded via AgentSessionFiles.UploadSessionFileAsync(sessionStoragePath: "foo") +// land at $HOME/foo per container-image-spec.md line 172. +string sessionRoot = Path.GetFullPath( + GetOptionalEnv("HOME") + ?? "/home/session"); + +// ── Tools: bundled files (image-baked, /app/resources/) ────────────────────── + +[Description("List the names of files bundled with the agent (built-in knowledge that ships with the image).")] +string ListBundledFiles() => SafeListNames(bundledRoot); + +[Description("Read the full text contents of a bundled file by name. Bundled files are built-in knowledge shipped with the agent image.")] +string ReadBundledFile( + [Description("Name of the bundled file (no directory components). Must be one of the names returned by ListBundledFiles.")] string fileName) + => SafeRead(bundledRoot, fileName, scope: "bundled files"); + +// ── Tools: session files (per-session $HOME) ───────────────────────────────── + +[Description("List the names of files uploaded into the current session sandbox by the user (e.g., via AgentSessionFiles.UploadSessionFileAsync).")] +string ListSessionFiles() => SafeListNames(sessionRoot); + +[Description("Read the full text contents of a file uploaded into the current session by name. Session files are user-supplied data that lives only for the lifetime of this session.")] +string ReadSessionFile( + [Description("Name of the session file (no directory components). Must be one of the names returned by ListSessionFiles.")] string fileName) + => SafeRead(sessionRoot, fileName, scope: "session files"); + +// ── Path-safe helpers (defense-in-depth: GetFileName + canonicalize + StartsWith(root)) ── + +string SafeListNames(string root) +{ + try + { + if (!Directory.Exists(root)) + { + return string.Empty; + } + + return string.Join( + Environment.NewLine, + Directory.EnumerateFiles(root).Select(Path.GetFileName)); + } + catch (Exception ex) + { + return $"Error listing files: {ex.Message}"; + } +} + +string SafeRead(string root, string fileName, string scope) +{ + try + { + // Step 1: strip any directory components the model might have included. + string safeName = Path.GetFileName(fileName); + if (string.IsNullOrEmpty(safeName)) + { + return $"File '{fileName}' not found in {scope}."; + } + + // Step 2: combine with the root and canonicalize. + string fullPath = Path.GetFullPath(Path.Combine(root, safeName)); + + // Step 3: enforce the prefix boundary so a crafted name still cannot escape. + string rootPrefix = root.EndsWith(Path.DirectorySeparatorChar) + ? root + : root + Path.DirectorySeparatorChar; + if (!fullPath.StartsWith(rootPrefix, StringComparison.Ordinal)) + { + return $"File '{fileName}' not found in {scope}."; + } + + return File.Exists(fullPath) + ? File.ReadAllText(fullPath) + : $"File '{fileName}' not found in {scope}."; + } + catch (Exception ex) + { + return $"Error reading '{fileName}': {ex.Message}"; + } +} + +// ── Create and host the agent ──────────────────────────────────────────────── + +AIAgent agent = new AIProjectClient(new Uri(endpoint), credential) + .AsAIAgent( + model: deploymentName, + instructions: """ + You are a friendly assistant that answers questions over two file sources: + + - Bundled files: built-in knowledge that ships with the agent image + (e.g., reference reports the author packaged with you). Tools: + ListBundledFiles, ReadBundledFile. + + - Session files: user-uploaded data for this session only (e.g., a CSV + the user wants you to analyse). Tools: ListSessionFiles, ReadSessionFile. + + Pick the tool pair by intent. If a name could match either source, list + both first. Always read the file before answering; do not guess. Quote + numbers and figures verbatim from the file. + """, + name: GetOptionalEnv("AGENT_NAME") ?? "hosted-files", + description: "Hosted agent that answers questions over bundled (image-baked) and session-uploaded files via two scoped tool pairs.", + tools: + [ + AIFunctionFactory.Create(ListBundledFiles), + AIFunctionFactory.Create(ReadBundledFile), + AIFunctionFactory.Create(ListSessionFiles), + AIFunctionFactory.Create(ReadSessionFile), + ]); + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); + +var app = builder.Build(); +app.MapFoundryResponses(); + +if (app.Environment.IsDevelopment()) +{ + app.MapFoundryResponses("openai/v1"); +} + +app.Run(); + +/// +/// A for local Docker debugging only. +/// Reads a pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable +/// once at startup. This should NOT be used in production. +/// +/// Generate a token on your host and pass it to the container: +/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ... +/// +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + private readonly string? _token; + + public DevTemporaryTokenCredential() + { + this._token = System.Environment.GetEnvironmentVariable(EnvironmentVariable); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => this.GetAccessToken(); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(this.GetAccessToken()); + + private AccessToken GetAccessToken() + { + if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential") + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md new file mode 100644 index 0000000000..729aca5c5f --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md @@ -0,0 +1,128 @@ +# Hosted-Files + +A hosted agent that demonstrates **two distinct file knowledge sources** through scoped, security-hardened tools: + +- **Bundled files** (image-baked) — files the author packages with the agent at build time. Live at `/app/resources/` inside the container, copied from this project's [`resources/`](./resources/) folder via the csproj `` rule. +- **Session files** (per-session `$HOME` volume) — files the user uploads at runtime via the alpha `Azure.AI.Projects.AgentSessionFiles` SDK. Live at `$HOME` inside the per-session container. The Foundry platform sets `HOME=/home/session` by default and roots the session-files API there per [`container-image-spec.md` line 172](https://github.com/microsoft/foundrysdk-specs/blob/main/specs/agents/hosted_agents/container-spec/docs/container-image-spec.md): *"If you use the session files API, `$HOME` is also the base path for those operations; any paths given in those API endpoints will be relative to `$HOME`."* + +## Tool surface + +Each source is exposed via its own tool pair, rooted at its own directory. The model picks by intent. + +| Tool | Source | Root | +|------|--------|------| +| `ListBundledFiles` | Bundled (image-baked) | `/app/resources/` | +| `ReadBundledFile` | Bundled (image-baked) | `/app/resources/` | +| `ListSessionFiles` | Session-uploaded | `$HOME` (`/home/session`) | +| `ReadSessionFile` | Session-uploaded | `$HOME` (`/home/session`) | + +## Security model — distinct tools, distinct sandboxes + +Each tool takes a `fileName` (no directory components allowed) and enforces three layers of defence inside the implementation: + +1. **`Path.GetFileName(input)`** strips any directory parts from the model-supplied name. `"../../etc/passwd"` becomes `"passwd"`. +2. **`Path.GetFullPath(Combine(root, name))`** canonicalises the path. +3. **`fullPath.StartsWith(root + DirectorySeparatorChar)`** rejects anything that resolves outside the tool's root. + +Failures return a controlled `"File '' not found in ."` rather than throwing or exposing the canonical path. + +This is why the agent has four narrowly-scoped tools instead of a single `ReadFile(path)`: + +- **Smaller per-tool attack surface.** Each tool has one purpose, one root, and no path-typed parameter. Even a buggy implementation can only leak its own directory. +- **Cross-boundary access is impossible by schema.** A prompt-injection attempt to make the bundled tool read a session path (or vice versa) does not even compile in the tool schema the model sees. +- **Read-only, non-recursive listing.** No write tools, no glob, no `..`. + +## Companion + +[`Using-Samples/SessionFilesClient`](../Using-Samples/SessionFilesClient/) — a thin chat REPL (same shape as [`SimpleAgent`](../Using-Samples/SimpleAgent/)) that points at the deployed Hosted-Files endpoint via `FoundryAgent` and lets you ask questions whose answers come from either file source. + +## Live proof of the session-files contract + +The end-to-end alpha-SDK round trip (client uploads via `AgentSessionFiles.UploadSessionFileAsync` → file arrives at `$HOME/` inside the per-session container → agent's `ReadSessionFile` tool reads it → response quotes the verbatim contents) is exercised live by [`SessionFilesHostedAgentTests.UploadedFile_IsReadByHostedAgentAsync`](../../../../../tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs) against the matching `session-files` scenario in the integration test container. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`) +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your project endpoint: + +```bash +cp .env.example .env +``` + +Edit `.env`: + +```env +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +``` + +> `.env` is gitignored. The `.env.example` template is checked in as a reference. + +## Running directly (contributors) + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files +AGENT_NAME=hosted-files dotnet run +``` + +The agent starts on `http://localhost:8088`. + +## Try it from the SessionFilesClient REPL + +### Bundled files (works against any deployment, including local) + +```bash +cd ../Using-Samples/SessionFilesClient +$env:AGENT_ENDPOINT = "http://localhost:8088" +$env:AGENT_NAME = "hosted-files" +dotnet run + +You> What is the total revenue in the contoso file? +Agent> The contoso file reports total revenue of "$1,482.6M". +``` + +The agent calls `ListBundledFiles`, sees `contoso_q1_2026_report.txt`, calls `ReadBundledFile("contoso_q1_2026_report.txt")` (which resolves under `/app/resources/`), and quotes the figure verbatim. + +### Session files (against a deployed agent) + +Upload a file to a specific session via `azd ai agent files upload` or via the alpha `AgentSessionFiles` SDK (see the integration test for the SDK call), then ask the agent about it. The agent's `ReadSessionFile` tool reads from `$HOME` and surfaces the content the same way. + +## Running with Docker + +This project uses `ProjectReference`, so use `Dockerfile.contributor` which takes a pre-published output: + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +docker build -f Dockerfile.contributor -t hosted-files . + +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +docker run --rm -p 8088:8088 \ + -e AGENT_NAME=hosted-files \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-files +``` + +The bundled `resources/` folder is part of the published output and ships inside the image. + +## NuGet package users + +If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor` and switch the `ProjectReference` entries in `HostedFiles.csproj` to `PackageReference` (commented section in the csproj). + +## Adding more bundled files + +Drop additional text files into [`resources/`](./resources/). The csproj `` rule picks them up on the next `dotnet build` / `docker build`. + +## Overrides + +| Env var | Purpose | Default | +|---------|---------|---------| +| `BUNDLED_FILES_DIR` | Override the bundled-files root the tools read from. | `/resources` (`/app/resources/` in container) | +| `HOME` | The per-session sandbox volume root the session-files tools read from. Set by the Foundry platform; can be overridden for local testing. | `/home/session` | \ No newline at end of file diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml new file mode 100644 index 0000000000..cda1ba6494 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml @@ -0,0 +1,30 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-files +displayName: "Hosted Files Agent" + +description: > + A hosted agent that answers questions over a small set of files bundled + with its container image (under /app/resources/). Two local C# function + tools (ListFiles, ReadFile) surface the bundled file contents to the model. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Bundled Files + - Local Tools + - Agent Framework + +template: + name: hosted-files + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.yaml new file mode 100644 index 0000000000..f949ac09ee --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-files +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/resources/contoso_q1_2026_report.txt b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/resources/contoso_q1_2026_report.txt new file mode 100644 index 0000000000..858192a7d3 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/resources/contoso_q1_2026_report.txt @@ -0,0 +1,121 @@ +Contoso Corporation +Quarterly Report — Q1 2026 (Three months ended March 31, 2026) + +DISCLAIMER +This document contains fictional data for sample/demo purposes only. +Contoso is a fictional company; all figures below are fabricated. + +------------------------------------------------------------ +1. EXECUTIVE SUMMARY +------------------------------------------------------------ +Contoso delivered a solid first quarter, with total revenue of +$1,482.6M, up 11.4% year-over-year. Growth was led by the Cloud +Services segment (+22.7% YoY) and continued double-digit expansion +in International markets. Operating margin expanded 140 basis points +to 23.8% on disciplined cost management and improved gross margin. + +Key highlights: + - Revenue: $1,482.6M (YoY +11.4%) + - Gross profit: $912.0M (gross margin 61.5%) + - Operating income: $352.9M (operating margin 23.8%) + - Net income: $268.4M (net margin 18.1%) + - Diluted EPS: $1.27 (vs. $1.04 prior year) + - Free cash flow: $311.5M + - Cash & equivalents: $2,140.8M + +------------------------------------------------------------ +2. INCOME STATEMENT (USD millions, unaudited) +------------------------------------------------------------ + Q1 2026 Q1 2025 YoY % +Revenue 1,482.6 1,330.7 +11.4% +Cost of revenue 570.6 538.9 +5.9% +Gross profit 912.0 791.8 +15.2% + Gross margin 61.5% 59.5% +200 bps +Operating expenses + Research & development 241.4 220.5 +9.5% + Sales & marketing 218.7 205.1 +6.6% + General & administrative 99.0 88.6 +11.7% +Total operating expenses 559.1 514.2 +8.7% +Operating income 352.9 277.6 +27.1% + Operating margin 23.8% 20.9% +290 bps +Other income / (expense), net 8.4 5.1 +Income before taxes 361.3 282.7 +Provision for income taxes 92.9 72.6 +Net income 268.4 210.1 +27.7% +Diluted EPS (USD) 1.27 1.04 +22.1% + +------------------------------------------------------------ +3. REVENUE BY SEGMENT (USD millions) +------------------------------------------------------------ +Segment Q1 2026 Q1 2025 YoY % +Cloud Services 612.4 499.1 +22.7% +Productivity Software 448.9 422.6 +6.2% +Devices & Hardware 267.0 260.4 +2.5% +Professional Services 154.3 148.6 +3.8% +Total revenue 1,482.6 1,330.7 +11.4% + +------------------------------------------------------------ +4. REVENUE BY GEOGRAPHY (USD millions) +------------------------------------------------------------ +Region Q1 2026 Q1 2025 YoY % +North America 812.1 756.0 +7.4% +EMEA 388.5 340.2 +14.2% +Asia-Pacific 221.7 183.4 +20.9% +Latin America 60.3 51.1 +18.0% +Total revenue 1,482.6 1,330.7 +11.4% + +------------------------------------------------------------ +5. SELECTED BALANCE SHEET ITEMS (USD millions) +------------------------------------------------------------ + Mar 31, Dec 31, + 2026 2025 +Cash & equivalents 2,140.8 1,902.3 +Short-term investments 845.6 820.4 +Accounts receivable, net 1,012.7 988.5 +Total current assets 4,510.2 4,190.6 +Goodwill & intangibles 2,330.1 2,338.9 +Total assets 9,884.5 9,512.0 +Total current liabilities 2,118.4 2,054.7 +Long-term debt 1,750.0 1,750.0 +Total liabilities 4,402.6 4,310.5 +Total stockholders' equity 5,481.9 5,201.5 + +------------------------------------------------------------ +6. CASH FLOW HIGHLIGHTS (USD millions) +------------------------------------------------------------ + Q1 2026 Q1 2025 +Net cash from operating activities 382.0 298.7 +Capital expenditures (70.5) (62.1) +Free cash flow 311.5 236.6 +Share repurchases (120.0) (90.0) +Dividends paid (54.2) (48.6) + +------------------------------------------------------------ +7. KEY OPERATING METRICS +------------------------------------------------------------ +Cloud paid seats (millions) 48.6 39.7 +22.4% +Cloud net revenue retention 118% 114% +Active enterprise customers 18,420 16,905 +9.0% +Headcount (end of period) 22,140 20,610 +7.4% + +------------------------------------------------------------ +8. OUTLOOK — Q2 2026 GUIDANCE +------------------------------------------------------------ +Revenue: $1,520M – $1,560M (YoY +10% to +13%) +Operating margin: 23.5% – 24.5% +Diluted EPS: $1.30 – $1.36 +Capital expenditures: ~$80M + +Management remains confident in the full-year plan and reiterates +fiscal-year 2026 revenue growth of 10–12% and operating-margin +expansion of 100–150 basis points versus FY 2025. + +------------------------------------------------------------ +9. NOTES +------------------------------------------------------------ +- All figures are unaudited and rounded to one decimal place. +- Year-over-year comparisons are versus the same period in 2025. +- "Free cash flow" is defined as net cash from operating activities + less capital expenditures, and is a non-GAAP measure. +- This sample report is intended solely for demonstration of an + agent-driven document analysis pipeline. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/Program.cs new file mode 100644 index 0000000000..e05e025c0d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/Program.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT") + ?? "http://localhost:8088"); + +var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") + ?? throw new InvalidOperationException("AGENT_NAME is not set."); + +// ── Create an agent-framework agent backed by the remote Hosted-Files agent ── + +var options = new AIProjectClientOptions(); + +if (agentEndpoint.Scheme == "http") +{ + // For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy + // BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right + // before the request hits the wire. + + agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri; + options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); +} + +var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options); +FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName)); + +AgentSession session = await agent.CreateSessionAsync(); + +// ── REPL ────────────────────────────────────────────────────────────────────── + +Console.ForegroundColor = ConsoleColor.Cyan; +Console.WriteLine($""" + ══════════════════════════════════════════════════════════ + Session Files Client + Connected to: {agentEndpoint} + Try: "Give me the total revenue in the contoso file." + Type a message or 'quit' to exit + ══════════════════════════════════════════════════════════ + """); +Console.ResetColor(); +Console.WriteLine(); + +while (true) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("You> "); + Console.ResetColor(); + + string? input = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(input)) { continue; } + if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; } + + try + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("Agent> "); + Console.ResetColor(); + + await foreach (var update in agent.RunStreamingAsync(input, session)) + { + Console.Write(update); + } + + Console.WriteLine(); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error: {ex.Message}"); + Console.ResetColor(); + } + + Console.WriteLine(); +} + +Console.WriteLine("Goodbye!"); + +/// +/// For Local Development Only +/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient +/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check. +/// +internal sealed class HttpSchemeRewritePolicy : PipelinePolicy +{ + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + + private static void RewriteScheme(PipelineMessage message) + { + var uri = message.Request.Uri!; + if (uri.Scheme == Uri.UriSchemeHttps) + { + message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri; + } + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/README.md new file mode 100644 index 0000000000..dbf9262ae8 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/README.md @@ -0,0 +1,50 @@ +# SessionFilesClient + +A thin chat REPL that connects to a deployed [`Hosted-Files`](../../Hosted-Files/) agent via `FoundryAgent` and lets you ask questions whose answers come from the files bundled with that agent. Same shape as [`SimpleAgent`](../SimpleAgent/) — point it at an `AGENT_ENDPOINT`, build a `FoundryAgent`, run. + +The agent's container-side `ListFiles` and `ReadFile` tools surface the bundled file contents to the model. The client knows nothing about files; that is entirely the agent's concern. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- A running [`Hosted-Files`](../../Hosted-Files/) agent (locally via `dotnet run` or deployed to Foundry) +- Azure CLI logged in (`az login`) + +## Configuration + +```env +AGENT_ENDPOINT=http://localhost:8088 +AGENT_NAME=hosted-files +``` + +`AGENT_ENDPOINT` defaults to `http://localhost:8088`. Override with the deployed agent endpoint when chatting against Foundry. + +## Run + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient +$env:AGENT_ENDPOINT = "http://localhost:8088" +$env:AGENT_NAME = "hosted-files" +dotnet run +``` + +## End-to-end demo + +With the [`Hosted-Files`](../../Hosted-Files/) agent running: + +```text +══════════════════════════════════════════════════════════ +Session Files Client +Connected to: http://localhost:8088/ +Try: "Give me the total revenue in the contoso file." +Type a message or 'quit' to exit +══════════════════════════════════════════════════════════ + +You> Give me the total revenue in the contoso file. +Agent> The contoso file reports total revenue of "$1,482.6M". + +You> quit +Goodbye! +``` + +The agent looked at its bundled files via `ListFiles`, picked `contoso_q1_2026_report.txt`, called `ReadFile`, and quoted the figure verbatim. The client only sent a chat prompt. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj new file mode 100644 index 0000000000..954036ba3b --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + enable + false + SessionFilesClient + session-files-client + $(NoWarn);NU1605;OPENAI001 + + + + + + + + + + + + + diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs index 3ec4665672..052786890b 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs @@ -32,6 +32,7 @@ AIAgent agent = scenario switch "toolbox" => CreateToolboxAgent(projectClient, deployment), "mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment), "custom-storage" => CreateCustomStorageAgent(projectClient, deployment), + "session-files" => CreateSessionFilesAgent(projectClient, deployment), _ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.") }; @@ -106,6 +107,30 @@ static AIAgent CreateCustomStorageAgent(AIProjectClient client, string deploymen name: "custom-storage-agent", description: "Custom storage test agent (placeholder)."); +// session-files scenario: agent reads files from $HOME inside the per-session sandbox volume. +// Mirrors the dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files sample. +static AIAgent CreateSessionFilesAgent(AIProjectClient client, string deployment) => + client.AsAIAgent( + model: deployment, + instructions: """ + You are a friendly assistant that helps users inspect and summarise + files stored in the session sandbox at $HOME. + + Always answer file-related questions by calling the available tools + (GetHomeDirectory, ListFiles, ReadFile). Do not guess file paths or + contents — read the file before answering. + + Quote numbers and figures verbatim from the file rather than + paraphrasing them. + """, + name: "session-files-agent", + description: "Reads files from the per-session $HOME volume.", + tools: [ + AIFunctionFactory.Create(GetHomeDirectory), + AIFunctionFactory.Create(ListFiles), + AIFunctionFactory.Create(ReadFile) + ]); + [Description("Returns the current UTC date and time as an ISO 8601 string.")] static string GetUtcNow() => DateTime.UtcNow.ToString("o"); @@ -120,3 +145,74 @@ static string SendEmail( [Description("Returns the deployment environment name.")] static string GetEnvironmentName() => "integration-test"; + +// session-files tools: resolve paths against $HOME (the per-session sandbox volume). +[Description("Get the absolute path of the session home directory ($HOME).")] +static string GetHomeDirectory() => SessionHome(); + +[Description("List files and directories under the given path inside the session sandbox. Pass an empty string to list $HOME.")] +static string[] ListFiles( + [Description("Path relative to $HOME. Absolute paths and traversals (..) are rejected.")] string path) +{ + try + { + return Directory.EnumerateFileSystemEntries(ResolveSessionPath(path)).ToArray(); + } + catch (Exception ex) + { + return [$"Error listing '{path}': {ex.Message}"]; + } +} + +[Description("Read the full text contents of a file inside the session sandbox.")] +static string ReadFile( + [Description("Path relative to $HOME. Absolute paths and traversals (..) are rejected.")] string path) +{ + try + { + return File.ReadAllText(ResolveSessionPath(path)); + } + catch (Exception ex) + { + return $"Error reading '{path}': {ex.Message}"; + } +} + +static string SessionHome() => + Environment.GetEnvironmentVariable("HOME") + ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + +// Resolve a caller-supplied path against $HOME, rejecting absolute paths and traversal segments +// so that the model cannot read or list arbitrary container files via the ReadFile/ListFiles +// tools (defense-in-depth against indirect prompt injection). Mirrors the canonicalize + +// startsWith($HOME) pattern used by FileSystemAgentFileStore.ResolveSafePath. +static string ResolveSessionPath(string path) +{ + string home = SessionHome(); + string homeFull = Path.GetFullPath(home); + string homePrefix = homeFull.EndsWith(Path.DirectorySeparatorChar) + ? homeFull + : homeFull + Path.DirectorySeparatorChar; + + if (string.IsNullOrWhiteSpace(path)) + { + return homeFull; + } + + if (Path.IsPathRooted(path)) + { + throw new ArgumentException($"Absolute paths are not allowed: '{path}'.", nameof(path)); + } + + string combined = Path.Combine(homeFull, path); + string fullPath = Path.GetFullPath(combined); + + if (!fullPath.Equals(homeFull, StringComparison.Ordinal) && + !fullPath.StartsWith(homePrefix, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Path '{path}' resolves outside the session sandbox.", nameof(path)); + } + + return fullPath; +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/SessionFilesHostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/SessionFilesHostedAgentFixture.cs new file mode 100644 index 0000000000..1470a23fba --- /dev/null +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/SessionFilesHostedAgentFixture.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Foundry.Hosting.IntegrationTests.Fixtures; + +/// +/// Provisions a hosted agent that runs the test container in IT_SCENARIO=session-files mode. +/// The container exposes three local function tools (GetHomeDirectory, ListFiles, +/// ReadFile) that read from the per-session $HOME sandbox volume — mirroring the +/// Hosted-Files sample. Tests use the alpha +/// API to upload a file into the session +/// sandbox, then invoke the agent (pinned to the same agent_session_id) and assert that the +/// agent's tools observed the uploaded file. +/// +public sealed class SessionFilesHostedAgentFixture : HostedAgentFixture +{ + protected override string ScenarioName => "session-files"; +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj b/dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj index 18710dc791..a8ad919198 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj @@ -20,8 +20,16 @@ + + + + + + diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md index 764a366289..91f2f6983d 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md @@ -138,6 +138,7 @@ human-only operation; CI only adds and deletes versions under existing agents. | `ToolboxHostedAgentFixture` | `toolbox` | `it-toolbox` | Server registered toolbox tool callable; client side additions visible (placeholder). | | `McpToolboxHostedAgentFixture` | `mcp-toolbox` | `it-mcp-toolbox` | MCP backed tool invocation against `https://learn.microsoft.com/api/mcp` (placeholder). | | `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). | +| `SessionFilesHostedAgentFixture` | `session-files` | `it-session-files` | End-to-end: upload via `AgentSessionFiles` (alpha) into a pinned `agent_session_id`, invoke the agent, assert it reads the file via the container's `ReadFile` tool. | The placeholder scenarios will be wired up in the test container `Program.cs` once the relevant `Microsoft.Agents.AI.Foundry.Hosting` API surfaces stabilize. diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs new file mode 100644 index 0000000000..7c1407ac65 --- /dev/null +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft. All rights reserved. + +#pragma warning disable AAIP001 // AgentSessionFiles is experimental +#pragma warning disable OPENAI001 // CreateResponseOptions is experimental + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests.Support; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Foundry.Hosting.IntegrationTests.Fixtures; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.Responses; +using Shared.IntegrationTests; + +namespace Foundry.Hosting.IntegrationTests; + +/// +/// End-to-end integration test for the Hosted-Files style scenario: a file uploaded by the client +/// via the alpha SDK is read by the deployed hosted agent's +/// container-side ReadFile tool and surfaces in . +/// +/// +/// +/// Routing both invocations to the same per-session container requires two clients on the same +/// agent-scoped : a to +/// pre-create a conversation bound to the agent endpoint, and a +/// for invocation. The session id resolved by the platform on the first call is captured from the +/// x-agent-session-id response header and used to target the +/// upload at the same session's $HOME. The second call +/// carries the same conversation_id so it lands in the same container and the agent's +/// ReadFile tool sees the upload. +/// +/// +[Trait("Category", "FoundryHostedAgents")] +public sealed class SessionFilesHostedAgentTests(SessionFilesHostedAgentFixture fixture) : IClassFixture +{ + private const string FoundryFeaturesHeader = "Foundry-Features"; + private const string HostedAgentsFeatureValue = "HostedAgents=V1Preview,AgentEndpoints=V1Preview"; + private const string SessionIdHeader = "x-agent-session-id"; + + private const string TestDataFileName = "contoso_q1_2026_report.txt"; + + /// Token that appears verbatim in the test data file. Proof the agent read what we uploaded. + private const string ExpectedTokenInFile = "1,482.6"; + + private readonly SessionFilesHostedAgentFixture _fixture = fixture; + + [Fact] + public async Task UploadedFile_IsReadByHostedAgentAsync() + { + // Arrange + string localPath = Path.Combine(AppContext.BaseDirectory, "TestData", TestDataFileName); + Assert.True( + File.Exists(localPath), + $"Test data file not found at '{localPath}'. Confirm the linked Content entry in the csproj."); + + var endpoint = new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)); + var credential = TestAzureCliCredentials.CreateAzureCliCredential(); + + // Admin client + AgentSessionFiles for upload/list/delete (alpha SDK). + var adminOptions = new AgentAdministrationClientOptions(); + adminOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall); + var adminClient = new AgentAdministrationClient(endpoint, credential, adminOptions); + var sessionFiles = adminClient.GetAgentSessionFiles(); + + // Build the per-agent OpenAI client. The conversation is created on this client so it is + // bound to the agent endpoint URL (`/agents/{name}/endpoint/protocols/openai/conversations`). + // A header-capture policy reads the `x-agent-session-id` the platform stamps on every reply. + var headerCapture = new ResponseHeaderCapturePolicy(SessionIdHeader); + var openAIOptions = new ProjectOpenAIClientOptions { AgentName = this._fixture.AgentName }; + openAIOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall); + openAIOptions.AddPolicy(headerCapture, PipelinePosition.PerCall); + var openAIClient = new ProjectOpenAIClient(endpoint, credential, openAIOptions); + var conversations = openAIClient.GetProjectConversationsClient(); + var responses = openAIClient.GetProjectResponsesClient(); + + // Step 1 — create a conversation bound to the agent endpoint. Subsequent /responses calls + // tagged with this conversation_id route to the same per-session container. + var conversation = await conversations.CreateProjectConversationAsync(); + string conversationId = conversation.Value.Id; + + try + { + // Step 2 — warm-up call. Provisions the per-session container under the conversation and + // lets us read back the resolved agent_session_id from the response header. + var agent = responses.AsIChatClient().AsAIAgent(name: this._fixture.AgentName); + var convOptions = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId }); + + var warmup = await agent.RunAsync( + "Reply with the single word 'ready' and nothing else.", + options: convOptions); + Assert.False(string.IsNullOrWhiteSpace(warmup.Text)); + + string agentSessionId = headerCapture.LastValue + ?? throw new InvalidOperationException( + $"Expected '{SessionIdHeader}' response header on warm-up but got none."); + + try + { + // Step 3 — upload the file via the alpha AgentSessionFiles SDK to that exact session's $HOME. + SessionFileWriteResponse writeResponse = await sessionFiles.UploadSessionFileAsync( + agentName: this._fixture.AgentName, + sessionId: agentSessionId, + sessionStoragePath: TestDataFileName, + localPath: localPath); + + long expectedBytes = new FileInfo(localPath).Length; + Assert.Equal(expectedBytes, writeResponse.BytesWritten); + + SessionDirectoryListResponse listing = await sessionFiles.GetSessionFilesAsync( + agentName: this._fixture.AgentName, + sessionId: agentSessionId, + sessionStoragePath: "."); + Assert.Contains( + listing.Entries, + e => e.Name == TestDataFileName && !e.IsDirectory && e.Size == expectedBytes); + + // Step 4 — invoke the agent again on the SAME conversation. The platform routes back to + // the same agent_session_id container, so the agent's ReadFile tool sees the upload. + // The platform mutates session/conversation revision when AgentSessionFiles uploads land, + // so an immediate /responses follow-up races and 400's with "modified concurrently. Please + // retry." — the response message literally tells us to retry. Bounded retry handles it. + var readOptions = new CreateResponseOptions { AgentConversationId = conversationId }; + readOptions.InputItems.Add(ResponseItem.CreateUserMessageItem( + $"Read {TestDataFileName} from $HOME and quote the headline total revenue figure verbatim, no commentary.")); + + ClientResult rawResponse = null!; + const int MaxAttempts = 5; + for (int attempt = 1; attempt <= MaxAttempts; attempt++) + { + try + { + rawResponse = await responses.CreateResponseAsync(readOptions); + break; + } + catch (ClientResultException ex) when ( + ex.Status == 400 && + ex.Message.Contains("modified concurrently", StringComparison.OrdinalIgnoreCase) && + attempt < MaxAttempts) + { + await Task.Delay(TimeSpan.FromSeconds(2 * attempt)); + } + } + + string responseText = rawResponse.Value.GetOutputText() ?? string.Empty; + + Assert.Equal(agentSessionId, headerCapture.LastValue); + + // Assert: the response contains the deterministic token from the file. + Assert.False(string.IsNullOrWhiteSpace(responseText)); + Assert.Contains(ExpectedTokenInFile, responseText); + } + finally + { + // Best-effort cleanup of the uploaded file. The session itself is left for TTL expiry — + // the platform owns its lifecycle (no isolation key in our hands). + try + { + await sessionFiles.DeleteSessionFileAsync( + agentName: this._fixture.AgentName, + sessionId: agentSessionId, + path: TestDataFileName); + } + catch + { + // Ignore. + } + } + } + finally + { + await this._fixture.DeleteConversationAsync(conversationId); + } + } + + /// + /// Captures a response header value on every pipeline call. Latest value is read after the + /// response completes. Used to grab the platform's x-agent-session-id stamp. + /// + private sealed class ResponseHeaderCapturePolicy(string headerName) : PipelinePolicy + { + private readonly string _headerName = headerName; + private string? _lastValue; + + public string? LastValue => Volatile.Read(ref this._lastValue); + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + ProcessNext(message, pipeline, currentIndex); + this.Capture(message); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + this.Capture(message); + } + + private void Capture(PipelineMessage message) + { + if (message.Response is not null && + message.Response.Headers.TryGetValue(this._headerName, out var value) && + !string.IsNullOrEmpty(value)) + { + Volatile.Write(ref this._lastValue, value); + } + } + } + + private sealed class FoundryFeaturesPolicy(string features) : PipelinePolicy + { + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + this.SetHeader(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + this.SetHeader(message); + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + + private void SetHeader(PipelineMessage message) + { + message.Request.Headers.Remove(FoundryFeaturesHeader); + message.Request.Headers.Add(FoundryFeaturesHeader, features); + } + } +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 index c683a2a4e3..1896a8407c 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 @@ -38,7 +38,8 @@ $Scenarios = @( 'tool-calling-approval', 'toolbox', 'mcp-toolbox', - 'custom-storage' + 'custom-storage', + 'session-files' ) # Resolve project ARM scope from the endpoint.