diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 280134da049..b614d196a46 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -342,6 +342,9 @@
+
+
+
diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs
index b51739eca55..14ad46af5a8 100644
--- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs
+++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs
@@ -15,17 +15,27 @@ namespace SampleApp;
///
internal sealed partial class WebBrowsingTool : AIFunction
{
- private static readonly HttpClient s_httpClient = new();
+ private const int MaxRedirects = 10;
private readonly AIFunction _inner;
private readonly WebBrowsingToolOptions _options;
+ private readonly Func> _resolveHostAddressesAsync;
///
/// Initializes a new instance of the class.
///
/// Options controlling which URLs are permitted. By default, no hosts are accessible.
public WebBrowsingTool(WebBrowsingToolOptions options)
+ : this(options, Dns.GetHostAddressesAsync)
+ {
+ }
+
+ internal WebBrowsingTool(
+ WebBrowsingToolOptions options,
+ Func> resolveHostAddressesAsync)
{
this._options = options ?? throw new ArgumentNullException(nameof(options));
+ this._resolveHostAddressesAsync = resolveHostAddressesAsync ??
+ throw new ArgumentNullException(nameof(resolveHostAddressesAsync));
this._inner = AIFunctionFactory.Create(this.DownloadUriAsync);
}
@@ -45,7 +55,7 @@ public WebBrowsingTool(WebBrowsingToolOptions options)
this._inner.InvokeAsync(arguments, cancellationToken);
[Description("Fetch the html from the given url as markdown")]
- private async Task DownloadUriAsync(
+ internal async Task DownloadUriAsync(
[Description("The URL to download")] string uri,
CancellationToken cancellationToken = default)
{
@@ -59,17 +69,58 @@ private async Task DownloadUriAsync(
return $"Error: Only HTTP and HTTPS URLs are supported. Got: '{parsedUri.Scheme}'.";
}
- // Check access policy.
- string? accessError = await this.CheckAccessAsync(parsedUri, cancellationToken);
- if (accessError is not null)
+ AccessCheckResult access = await this.CheckAccessAsync(parsedUri, cancellationToken);
+ if (access.Error is not null)
{
- return accessError;
+ return access.Error;
}
try
{
- string html = await s_httpClient.GetStringAsync(parsedUri, cancellationToken);
- return HtmlToMarkdownConverter.Convert(html);
+ // Follow redirects manually, re-checking the access policy on every hop so a permitted
+ // URL cannot redirect into a blocked (private/metadata/localhost) target.
+ Uri currentUri = parsedUri;
+ for (int hop = 0; ; hop++)
+ {
+ if (hop > MaxRedirects)
+ {
+ return $"Error downloading {uri}: too many redirects.";
+ }
+
+ using HttpClient httpClient = CreateHttpClient(currentUri, access.Addresses);
+ using var response = await httpClient.GetAsync(
+ currentUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
+
+ if (response.StatusCode is HttpStatusCode.Moved or HttpStatusCode.Found
+ or HttpStatusCode.SeeOther or HttpStatusCode.TemporaryRedirect
+ or HttpStatusCode.PermanentRedirect)
+ {
+ Uri? location = response.Headers.Location;
+ if (location is null)
+ {
+ return $"Error downloading {uri}: redirect with no location.";
+ }
+
+ // Resolve relative redirects against the current URL, then re-check access.
+ currentUri = new Uri(currentUri, location);
+ if (currentUri.Scheme is not "http" and not "https")
+ {
+ return $"Error: redirect to unsupported scheme '{currentUri.Scheme}'.";
+ }
+
+ access = await this.CheckAccessAsync(currentUri, cancellationToken);
+ if (access.Error is not null)
+ {
+ return access.Error;
+ }
+
+ continue;
+ }
+
+ response.EnsureSuccessStatusCode();
+ string html = await response.Content.ReadAsStringAsync(cancellationToken);
+ return HtmlToMarkdownConverter.Convert(html);
+ }
}
catch (HttpRequestException ex)
{
@@ -79,11 +130,14 @@ private async Task DownloadUriAsync(
///
/// Checks whether the given URI is permitted by the configured access policy.
- /// Returns null if allowed, or an error message string if blocked.
+ /// Returns the addresses that the HTTP connection may use, or an error message if blocked.
///
- private async Task CheckAccessAsync(Uri uri, CancellationToken cancellationToken)
+ private async Task CheckAccessAsync(
+ Uri uri,
+ CancellationToken cancellationToken)
{
string host = uri.Host;
+ bool isAllowedHost = false;
// 1. Check AllowedHosts.
if (this._options.AllowedHosts is { Count: > 0 } allowedHosts)
@@ -92,59 +146,145 @@ private async Task DownloadUriAsync(
{
if (HostMatchesPattern(host, pattern))
{
- return null; // Allowed by explicit host list.
+ isAllowedHost = true;
+ break;
}
}
}
// 2. Short-circuit when the policy is guaranteed to block.
- if (!this._options.AllowPublicNetworks &&
+ if (!isAllowedHost &&
+ !this._options.AllowPublicNetworks &&
!this._options.AllowPrivateNetworks &&
!this._options.AllowAllHosts)
{
- return $"Error: Access to '{host}' is blocked by the current access policy. Configure WebBrowsingToolOptions to allow access.";
+ return AccessCheckResult.Blocked(
+ $"Error: Access to '{host}' is blocked by the current access policy. Configure WebBrowsingToolOptions to allow access.");
}
- // 3. Resolve DNS to determine if the host is public or private.
+ // 3. Resolve exactly once. The resulting addresses are also used for the socket connection.
IPAddress[] addresses;
try
{
- addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
+ addresses = IPAddress.TryParse(uri.IdnHost, out IPAddress? literalAddress)
+ ? [literalAddress]
+ : await this._resolveHostAddressesAsync(uri.IdnHost, cancellationToken);
}
catch (SocketException)
{
- return $"Error: Could not resolve host '{host}'.";
+ return AccessCheckResult.Blocked($"Error: Could not resolve host '{host}'.");
}
if (addresses.Length == 0)
{
- return $"Error: Could not resolve host '{host}'.";
+ return AccessCheckResult.Blocked($"Error: Could not resolve host '{host}'.");
}
- bool isPrivate = Array.Exists(addresses, IsPrivateAddress);
+ IPAddress[] distinctAddresses = addresses.Distinct().ToArray();
+ if (isAllowedHost || this._options.AllowAllHosts)
+ {
+ return AccessCheckResult.Allowed(distinctAddresses);
+ }
- // 4. If public and AllowPublicNetworks is true → allow.
- if (!isPrivate && this._options.AllowPublicNetworks)
+ // Permit only addresses in enabled network classes. Mixed DNS answers cannot cause the
+ // connection callback to reach an address class that the policy did not authorize.
+ IPAddress[] allowedAddresses = distinctAddresses
+ .Where(address =>
+ IsPrivateAddress(address)
+ ? this._options.AllowPrivateNetworks
+ : this._options.AllowPublicNetworks)
+ .ToArray();
+ if (allowedAddresses.Length > 0)
{
- return null;
+ return AccessCheckResult.Allowed(allowedAddresses);
}
- // 5. If private and AllowPrivateNetworks is true → allow.
- if (isPrivate && this._options.AllowPrivateNetworks)
+ bool hasPrivateAddress = Array.Exists(distinctAddresses, IsPrivateAddress);
+ bool hasPublicAddress = Array.Exists(distinctAddresses, address => !IsPrivateAddress(address));
+ string networkType = (hasPrivateAddress, hasPublicAddress) switch
+ {
+ (true, true) => "private/internal and public network",
+ (true, false) => "private/internal network",
+ _ => "public network",
+ };
+ return AccessCheckResult.Blocked(
+ $"Error: Access to '{host}' is blocked. The host resolves to a {networkType} address and the current access policy does not permit this. " +
+ "Configure WebBrowsingToolOptions to allow access.");
+ }
+
+ private static HttpClient CreateHttpClient(Uri uri, IPAddress[] addresses)
+ {
+ // Redirects are followed by DownloadUriAsync so each hop is re-checked. A proxy is
+ // disabled because the connection callback would otherwise see the proxy endpoint and
+ // could no longer pin the socket to the addresses approved by the access policy.
+ var handler = new SocketsHttpHandler
+ {
+ AllowAutoRedirect = false,
+ UseProxy = false,
+ ConnectCallback = (context, cancellationToken) =>
+ ConnectAsync(context, uri.IdnHost, uri.Port, addresses, cancellationToken),
+ };
+ return new HttpClient(handler);
+ }
+
+ private static async ValueTask ConnectAsync(
+ SocketsHttpConnectionContext context,
+ string expectedHost,
+ int expectedPort,
+ IPAddress[] addresses,
+ CancellationToken cancellationToken)
+ {
+ if (!string.Equals(
+ context.DnsEndPoint.Host,
+ expectedHost,
+ StringComparison.OrdinalIgnoreCase) ||
+ context.DnsEndPoint.Port != expectedPort)
{
- return null;
+ throw new HttpRequestException(
+ $"Unexpected HTTP connection destination '{context.DnsEndPoint.Host}:{context.DnsEndPoint.Port}'.");
}
- // 6. If AllowAllHosts is true → allow.
- if (this._options.AllowAllHosts)
+ var failures = new List(addresses.Length);
+ foreach (IPAddress address in addresses)
{
- return null;
+ Socket? socket = null;
+ bool streamOwnsSocket = false;
+
+ try
+ {
+ socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
+ {
+ NoDelay = true,
+ };
+ await socket.ConnectAsync(
+ new IPEndPoint(address, expectedPort),
+ cancellationToken);
+ streamOwnsSocket = true;
+ return new NetworkStream(socket, ownsSocket: true);
+ }
+ catch (SocketException ex) when (!cancellationToken.IsCancellationRequested)
+ {
+ failures.Add(ex);
+ }
+ finally
+ {
+ if (!streamOwnsSocket)
+ {
+ socket?.Dispose();
+ }
+ }
}
- // 7. Block.
- string networkType = isPrivate ? "private/internal network" : "public network";
- return $"Error: Access to '{host}' is blocked. The host resolves to a {networkType} address and the current access policy does not permit this. " +
- "Configure WebBrowsingToolOptions to allow access.";
+ throw new HttpRequestException(
+ $"Could not connect to any policy-approved address for '{expectedHost}:{expectedPort}'.",
+ new AggregateException(failures));
+ }
+
+ private readonly record struct AccessCheckResult(string? Error, IPAddress[] Addresses)
+ {
+ public static AccessCheckResult Allowed(IPAddress[] addresses) => new(null, addresses);
+
+ public static AccessCheckResult Blocked(string error) => new(error, []);
}
///
@@ -177,6 +317,11 @@ private static bool IsPrivateAddress(IPAddress address)
address = address.MapToIPv4();
}
+ if (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any))
+ {
+ return true;
+ }
+
if (IPAddress.IsLoopback(address))
{
return true;
diff --git a/dotnet/samples/02-agents/Harness/README.md b/dotnet/samples/02-agents/Harness/README.md
index 9417ba42952..51c03e47fb6 100644
--- a/dotnet/samples/02-agents/Harness/README.md
+++ b/dotnet/samples/02-agents/Harness/README.md
@@ -22,6 +22,14 @@ Samples accompanying the [*Build your own agent harness or claw with Microsoft A
| [Claw_Step03_ScalingCapabilities](./BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/README.md) | Post 3 — scaling the claw with skills (plus optional Foundry skills), a confined shell, CodeAct, and background agents |
| [Claw_Step04_ProductionReady](./BuildYourOwnClaw/Claw_Step04_ProductionReady/README.md) | Post 4 — production-ready shared claw library with observability, opt-in Purview governance, Foundry hosted deployment, and evals |
+## Foundry hosted Harness sample
+
+The Research sample hosts a `HarnessAgent` as a Foundry hosted agent using source ZIP deployment with the default `AllowStoredOutputEnabled = false`, so the model service never stores the conversation.
+
+| Sample | Description |
+| --- | --- |
+| [Hosted-Harness-Research](../../04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/README.md) | Hosts the research harness with web search, a browsing tool, planning, a todo loop, and session-scoped file memory |
+
## Security Considerations
Several harness providers extend the agent's trust boundary to external systems the developer
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/.agentignore b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/.agentignore
new file mode 100644
index 00000000000..cf4a635260a
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/.agentignore
@@ -0,0 +1,20 @@
+# Controls which files are excluded from the source-deploy ZIP (.gitignore syntax).
+azure.yaml
+.agentignore
+
+.env
+.env.*
+.azure/
+.git/
+
+bin/
+obj/
+out/
+*.user
+*.suo
+.vs/
+
+agent-files/
+.checkpoints/
+
+# Include local-feed/ and nuget.config so remote restore uses the prepared framework packages.
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/.env.example
new file mode 100644
index 00000000000..f2ab64ba92e
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/.env.example
@@ -0,0 +1,4 @@
+FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/
+AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5.4-mini
+AZURE_TOKEN_CREDENTIALS=dev
+ASPNETCORE_URLS=http://localhost:8088
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/.gitignore b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/.gitignore
new file mode 100644
index 00000000000..1f5eea24dcf
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/.gitignore
@@ -0,0 +1,4 @@
+.env
+out/
+agent-files/
+.checkpoints/
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/HostedHarnessResearch.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/HostedHarnessResearch.csproj
new file mode 100644
index 00000000000..c3350d650e5
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/HostedHarnessResearch.csproj
@@ -0,0 +1,49 @@
+
+
+
+ false
+
+
+
+
+
+ net10.0
+
+ enable
+ enable
+ $(NoWarn);MAAI001;OPENAI001
+ 1.20.0-preview.260831.1
+
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/Program.cs
new file mode 100644
index 00000000000..f7fd02845ac
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/Program.cs
@@ -0,0 +1,75 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// A hosted research harness: planning, browsing, file memory, and an execute-mode todo loop.
+// The browsing tool is shared with the existing console research sample.
+
+using System.ClientModel.Primitives;
+using Azure.AI.Projects;
+using Azure.Identity;
+using DotNetEnv;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Foundry.Hosting;
+using Microsoft.Extensions.AI;
+using SampleApp;
+
+Env.NoClobber().TraversePath().Load();
+
+string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
+ ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
+string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME")
+ ?? throw new InvalidOperationException("AZURE_AI_MODEL_DEPLOYMENT_NAME is not set.");
+
+// Foundry mounts the home directory per session; the application directory is read-only there.
+bool isHosted = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("FOUNDRY_HOSTING_ENVIRONMENT"));
+if (isHosted)
+{
+ Directory.SetCurrentDirectory(System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile));
+}
+
+string fileMemoryDir = isHosted
+ ? Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), "agent-files")
+ : Path.Combine(AppContext.BaseDirectory, "agent-files");
+
+const int MaxContextWindowTokens = 1_050_000;
+const int MaxOutputTokens = 128_000;
+
+IChatClient modelClient = new AIProjectClient(
+ new Uri(endpoint),
+ new DefaultAzureCredential(),
+ new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) })
+ .GetProjectOpenAIClient()
+ .GetProjectResponsesClientForModel(deploymentName)
+ .AsIChatClient();
+
+IChatClient chatClient = new StatelessWebSearchChatClient(modelClient);
+
+AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
+{
+ Name = "hosted-harness-research",
+ Description = "Plans and performs research using web search and browsing.",
+ MaxContextWindowTokens = MaxContextWindowTokens,
+ MaxOutputTokens = MaxOutputTokens,
+ FileMemoryStore = new FileSystemAgentFileStore(fileMemoryDir),
+ LoopEvaluators = [new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = ["execute"] })],
+ LoopAgentOptions = new LoopAgentOptions { MaxIterations = 10 },
+ ChatOptions = new ChatOptions
+ {
+ ModelId = deploymentName,
+ Instructions =
+ """
+ You are a research assistant. Plan complex research using the todo and mode tools.
+ Verify important claims with web search and browsing, cross-check sources, and cite
+ the sources you used. Save the final report to file memory so it survives compaction.
+ """,
+ Tools = [new WebBrowsingTool(new WebBrowsingToolOptions { AllowPublicNetworks = true })],
+ MaxOutputTokens = MaxOutputTokens,
+ Reasoning = new() { Effort = ReasoningEffort.Medium },
+ },
+});
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddFoundryResponses(agent);
+
+var app = builder.Build();
+app.MapFoundryResponses();
+app.Run();
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/README.md
new file mode 100644
index 00000000000..258b5ceec20
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/README.md
@@ -0,0 +1,80 @@
+# Hosted Harness Research
+
+This sample hosts a research `HarnessAgent` with web search, a public-network browsing tool, planning, a bounded todo loop, and session-scoped file memory. It links the browsing tool from `dotnet/samples/02-agents/Harness/Harness_Step01_Research` instead of maintaining a second implementation.
+
+The project references the framework **source in this repository** so it exercises the local Foundry hosting fix. `AddFoundryResponses(agent)` keeps the default `AllowStoredOutputEnabled = false`. The model must not store a second copy of the response; the harness's local history marker is not a service conversation ID.
+
+Deployment uses a **source ZIP**, with no Dockerfile or container registry. The preparation step copies the linked browsing tool into a standalone source folder. Foundry restores the packages and publishes the .NET 10 application from that folder.
+
+## Prerequisites
+
+- .NET 10 SDK.
+- An existing Foundry project and model deployment.
+- Azure CLI authenticated with `az login`.
+- Azure Developer CLI (`azd`) with the AI agents extension: `azd extension install azure.ai.agents`.
+- PowerShell 7 for the local framework packaging helper.
+
+## Run locally
+
+1. Set `FOUNDRY_PROJECT_ENDPOINT` and `AZURE_AI_MODEL_DEPLOYMENT_NAME` for an existing Foundry model deployment, then sign in with `az login`. Copy `.env.example` to `.env` if you prefer a local file.
+2. From this directory, run `dotnet run --tl:off`. The host serves `POST http://localhost:8088/responses` when `ASPNETCORE_URLS=http://localhost:8088`.
+3. Send a research question with model `hosted-harness-research`. Send a second request with the same conversation ID to verify that local history continues without enabling agent-side storage. The platform `store` setting is independent of the model client's `store=false` setting.
+
+Browsing allows public networks only. The browsing tool checks every redirect against the same policy and connects only to the addresses it approved, so a public page cannot send it to a private or metadata address. File memory goes under `agent-files` locally and the session's writable home directory when hosted.
+
+Hosted file memory relies on Foundry's session sandbox: by default each caller gets their own session with a private `$HOME` (see [Isolate hosted agent sessions per user](https://learn.microsoft.com/azure/foundry/agents/how-to/isolate-sessions-per-user)). If you [place several users in one session](https://learn.microsoft.com/azure/foundry/agents/how-to/multiplex-session-users), partition the file store per user yourself.
+
+## Deploy to Foundry (source ZIP)
+
+Start in this sample directory. Prepare the source in a working directory **outside the repository** so the upload includes the shared browsing tool without copying its implementation into the checked-in sample:
+
+```powershell
+$repo = (Resolve-Path '..\..\..\..\..\..').Path
+$work = Join-Path $env:TEMP 'hosted-harness-research-work'
+$source = Join-Path $work 'source'
+
+New-Item -ItemType Directory -Path $work -Force | Out-Null
+dotnet msbuild HostedHarnessResearch.csproj -target:PrepareSourceDeployment `
+ "-property:SourceDeploymentDirectory=$source"
+
+Set-Location $work
+azd auth login
+azd ai agent init -m (Join-Path $source 'azure.yaml') `
+ -p '' -d ''
+```
+
+`PrepareSourceDeployment` copies the project, local C# files, deployment configuration, and linked browsing files. It does not copy `.env`, local sessions, or build output. The copied project has one target framework and explicit package versions, so it can build without the repository's shared project configuration.
+
+`azd ai agent init` adopts the prepared `azure.yaml` and creates `hosted-harness-research` under `$work`. Passing `-p` selects an existing project rather than provisioning a new one.
+
+### Include the local framework fix
+
+**Do not skip this step when testing this branch.** Published packages may not contain the change that allows the harness to keep local chat history with model storage disabled.
+
+The existing contributor helper packs the local framework, including `Microsoft.Agents.AI.Harness`, into the scaffolded folder. It creates `local-feed/` and `nuget.config` and updates `AgentFrameworkVersion`. Both the feed and configuration travel inside the ZIP.
+
+```powershell
+& (Join-Path $repo 'dotnet\samples\04-hosting\FoundryHostedAgents\scripts\Add-LocalFrameworkFeed.ps1') `
+ -Path (Join-Path $work 'hosted-harness-research')
+
+Set-Location (Join-Path $work 'hosted-harness-research')
+dotnet build -c Debug --tl:off
+azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME ''
+azd provision
+azd deploy
+azd ai agent invoke 'Name an official weather-data source.' `
+ --new-session --new-conversation -o raw
+```
+
+Foundry runs `dotnet restore` and `dotnet publish` during deployment because `azure.yaml` sets `dependencyResolution: remote_build`. `.agentignore` excludes secrets, build output, and local session files. No Dockerfile or registry connection is needed.
+
+For Linux or macOS, use the same `dotnet msbuild` preparation command with an absolute destination path and the sibling [`add-local-framework-feed.sh`](../../scripts/add-local-framework-feed.sh) helper after initialization. See [`Hosted-ChatClientAgent`](../Hosted-ChatClientAgent/README.md#deploy-your-local-framework-changes-contributors) for the package helper details.
+
+For later changes, prepare a fresh source folder, copy its `.cs` and `.csproj` files into the scaffolded agent directory, rerun the package helper, and run `azd deploy` from that directory. Keep the scaffolded `azure.yaml` and `.azure` environment rather than replacing them with the template. Check the raw terminal event for `response.completed` or `response.failed`; a zero CLI exit code does not prove the turn succeeded.
+
+**Stateless web-search history:** The OpenAI chat adapter returns hosted search results with a raw `WebSearchCallResponseItem`. After another local function call, replaying that raw item to the Foundry model with `store=false` caused HTTP 400 `invalid_payload`. `StatelessWebSearchChatClient` omits only the raw search result from the *next model request*, leaving the original session history, assistant text, local function calls, and citations intact. The model does not receive that raw search metadata again, so later turns must rely on the cited assistant text for source details.
+
+## Related resources
+
+- [Research console sample](../../../../02-agents/Harness/Harness_Step01_Research/README.md).
+- [Official source deployment guide](https://learn.microsoft.com/azure/foundry/agents/how-to/deploy-hosted-agent-code).
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/StatelessWebSearchChatClient.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/StatelessWebSearchChatClient.cs
new file mode 100644
index 00000000000..1574e2867db
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/StatelessWebSearchChatClient.cs
@@ -0,0 +1,48 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Extensions.AI;
+using OpenAI.Responses;
+
+namespace SampleApp;
+
+internal sealed class StatelessWebSearchChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient)
+{
+ public override Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default) =>
+ base.GetResponseAsync(FilterSearchResults(messages), options, cancellationToken);
+
+ public override IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default) =>
+ base.GetStreamingResponseAsync(FilterSearchResults(messages), options, cancellationToken);
+
+ private static List FilterSearchResults(IEnumerable messages)
+ {
+ List result = [];
+ foreach (ChatMessage message in messages)
+ {
+ if (message.Role != ChatRole.Assistant ||
+ !message.Contents.Any(static content => content is WebSearchToolResultContent { RawRepresentation: WebSearchCallResponseItem }))
+ {
+ result.Add(message);
+ continue;
+ }
+
+ // In this sample, Foundry returned invalid_payload when the OpenAI adapter sent
+ // this raw web_search_call again after a local tool call with store=false.
+ // The public OpenAI API accepted that pattern. Copy the message so only the next
+ // model request omits the raw result; keep the original session history, cited
+ // text, and local tool calls unchanged.
+ ChatMessage filtered = message.Clone();
+ filtered.Contents = message.Contents
+ .Where(static content => content is not WebSearchToolResultContent { RawRepresentation: WebSearchCallResponseItem })
+ .ToList();
+ result.Add(filtered);
+ }
+
+ return result;
+ }
+}
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/azure.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/azure.yaml
new file mode 100644
index 00000000000..e5a1a79cf50
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/azure.yaml
@@ -0,0 +1,30 @@
+# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
+
+name: hosted-harness-research
+services:
+ ai-project:
+ host: azure.ai.project
+ hosted-harness-research:
+ project: .
+ host: azure.ai.agent
+ language: csharp
+ uses:
+ - ai-project
+ codeConfiguration:
+ dependencyResolution: remote_build
+ entryPoint: HostedHarnessResearch.dll
+ runtime: dotnet_10
+ env:
+ ASPNETCORE_URLS: http://+:8088
+ AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
+ container:
+ resources:
+ cpu: "0.5"
+ memory: 1Gi
+ description: |
+ A research harness with web search, browsing, planning, a todo loop, and session-scoped file memory.
+ kind: hosted
+ name: hosted-harness-research
+ protocols:
+ - protocol: responses
+ version: 2.0.0
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 b/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1
index 75ba7f25ca4..c61884be751 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1
@@ -56,6 +56,7 @@ $frameworkProjects = @(
'Microsoft.Agents.AI'
'Microsoft.Agents.AI.Workflows'
'Microsoft.Agents.AI.Hosting'
+ 'Microsoft.Agents.AI.Harness'
'Microsoft.Agents.AI.LocalCodeAct'
'Microsoft.Agents.AI.Mcp'
'Microsoft.Agents.AI.Foundry'
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/SourceDeployment.targets b/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/SourceDeployment.targets
new file mode 100644
index 00000000000..811afffa8b7
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/SourceDeployment.targets
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ <_SourceDeploymentDirectory>$([MSBuild]::NormalizeDirectory('$(SourceDeploymentDirectory)'))
+ <_SourceDeploymentProjectDirectory>$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)'))
+
+
+
+
+ <_SourceDeploymentRootFile Include="*.cs;$(MSBuildProjectFile);azure.yaml;.agentignore;.env.example;README.md" />
+
+ <_SourceDeploymentLinkedFile Include="@(Compile->WithMetadataValue('DefiningProjectFullPath', '$(MSBuildProjectFullPath)'))" />
+ <_SourceDeploymentLinkedFile Include="@(Content->WithMetadataValue('DefiningProjectFullPath', '$(MSBuildProjectFullPath)'))" />
+ <_SourceDeploymentLinkedFile Remove="@(_SourceDeploymentLinkedFile)" Condition="'%(_SourceDeploymentLinkedFile.Link)' == ''" />
+
+
+
+
+
+
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/add-local-framework-feed.sh b/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/add-local-framework-feed.sh
index 71c6043c341..3a9ac98a0a3 100755
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/add-local-framework-feed.sh
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/add-local-framework-feed.sh
@@ -44,6 +44,7 @@ framework_projects=(
Microsoft.Agents.AI
Microsoft.Agents.AI.Workflows
Microsoft.Agents.AI.Hosting
+ Microsoft.Agents.AI.Harness
Microsoft.Agents.AI.LocalCodeAct
Microsoft.Agents.AI.Mcp
Microsoft.Agents.AI.Foundry
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs
index 1c66e592f5c..32324b05b1d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs
@@ -689,11 +689,15 @@ await sessionStore.SaveSessionAsync(
return stream.EmitInProgress();
}
- // Check whenever the agent is storing messages when it should not.
+ // A per-service-call history provider uses a local marker in ConversationId to keep
+ // function invocation working; only a real service conversation indicates stored output.
bool CheckNotAllowedStoreUsage() =>
- // For IChatClients implementations when the backend is set to not store (store = false) the returned responseMessage.ConversationId comes null.
- // If for any reason this property is set it means that the storage setting was enabled when it shouldn't.
- !allowStoredOutputEnabled && session is ChatClientAgentSession { ConversationId: not null };
+ !allowStoredOutputEnabled
+ && session is ChatClientAgentSession { ConversationId: not null } chatSession
+ && !string.Equals(
+ chatSession.ConversationId,
+ PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId,
+ StringComparison.Ordinal);
}
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryResponsesOptions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryResponsesOptions.cs
index 984a624c945..2d400224ea4 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryResponsesOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryResponsesOptions.cs
@@ -29,6 +29,10 @@ public sealed class FoundryResponsesOptions
/// setting exactly as the container configured it, in which case hosting neither changes it nor
/// checks it.
///
+ ///
+ /// A local conversation marker used by the framework to persist chat history in the agent
+ /// session does not count as service-stored output and does not require this option.
+ ///
///
///
/// Default is .
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 2e93db80801..bdf92960bde 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
@@ -15,7 +15,7 @@
true
true
true
- $(NoWarn);OPENAI001;MEAI001;MAAI001;NU1903
+ $(NoWarn);OPENAI001;MEAI001;MAAI001
false
@@ -41,6 +41,7 @@
+
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentSession.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentSession.cs
index 102aee83e68..df9648ca783 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentSession.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentSession.cs
@@ -48,6 +48,11 @@ internal ChatClientAgentSession()
/// agent service managed chat history, and the default behavior of a service is
/// to fork the chat history with each iteration.
///
+ ///
+ /// Per-service-call chat history persistence uses an internal marker here to keep the
+ /// function-invocation loop in sync with locally managed history. That marker is not
+ /// an identifier for a conversation stored by the underlying service.
+ ///
///
[JsonPropertyName("conversationId")]
public string? ConversationId
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs
index c2087b2d829..94de688243d 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs
@@ -19,6 +19,8 @@ namespace Microsoft.Agents.AI;
/// This decorator is intended to operate between the and the leaf
/// in a pipeline. It is activated when
/// is .
+/// For custom pipelines, use
+/// to add this decorator.
///
///
/// When active, it handles two complementary scenarios:
@@ -54,7 +56,7 @@ namespace Microsoft.Agents.AI;
/// available or if the agent is not a .
///
///
-internal sealed class PerServiceCallChatHistoryPersistingChatClient : DelegatingChatClient
+public sealed class PerServiceCallChatHistoryPersistingChatClient : DelegatingChatClient
{
///
/// A sentinel value returned on to signal
@@ -72,13 +74,13 @@ internal sealed class PerServiceCallChatHistoryPersistingChatClient : Delegating
/// requests before forwarding to the inner client, so the underlying model never sees it.
///
///
- internal const string LocalHistoryConversationId = "_agent_local_chat_history";
+ public const string LocalHistoryConversationId = "_agent_local_chat_history";
///
/// Initializes a new instance of the class.
///
/// The underlying chat client that will handle the core operations.
- public PerServiceCallChatHistoryPersistingChatClient(IChatClient innerClient)
+ internal PerServiceCallChatHistoryPersistingChatClient(IChatClient innerClient)
: base(innerClient)
{
}
diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt
index 1bd39868305..e881b52a842 100644
--- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt
@@ -1,4 +1,8 @@
#nullable enable
+Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient
+const Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId = "_agent_local_chat_history" -> string!
+override Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+override Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore
[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void
[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore!
diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt
index 1bd39868305..e881b52a842 100644
--- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt
@@ -1,4 +1,8 @@
#nullable enable
+Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient
+const Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId = "_agent_local_chat_history" -> string!
+override Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+override Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore
[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void
[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore!
diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt
index 1bd39868305..e881b52a842 100644
--- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt
@@ -1,4 +1,8 @@
#nullable enable
+Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient
+const Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId = "_agent_local_chat_history" -> string!
+override Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+override Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore
[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void
[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore!
diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt
index 1bd39868305..e881b52a842 100644
--- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt
@@ -1,4 +1,8 @@
#nullable enable
+Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient
+const Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId = "_agent_local_chat_history" -> string!
+override Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+override Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore
[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void
[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore!
diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
index 1bd39868305..e881b52a842 100644
--- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
@@ -1,4 +1,8 @@
#nullable enable
+Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient
+const Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId = "_agent_local_chat_history" -> string!
+override Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+override Microsoft.Agents.AI.PerServiceCallChatHistoryPersistingChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore
[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void
[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore!
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs
index 09d896c543c..631d2b8e6c1 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs
@@ -1086,6 +1086,100 @@ public async Task CreateAsync_AgentWhoseChatClientStoredTheTurn_FailsTheRequestA
Assert.Equal(501, failure.StatusCode);
}
+ [Fact]
+ public async Task CreateAsync_HarnessLocalHistory_CompletesBothTurnsWithoutServiceStorageAsync()
+ {
+ // Arrange
+ var requests = new List<(List Messages, ChatOptions? Options)>();
+ var client = new Mock();
+ client.Setup(c => c.GetStreamingResponseAsync(
+ It.IsAny>(), It.IsAny(), It.IsAny()))
+ .Returns((IEnumerable messages, ChatOptions? options, CancellationToken _) =>
+ {
+ requests.Add((messages.ToList(), options));
+ return ToAsyncEnumerableUpdatesAsync(new ChatResponseUpdate(ChatRole.Assistant, "answer")
+ {
+ MessageId = "resp_msg_1",
+ });
+ });
+
+ var agent = CreateHarnessAgent(client.Object);
+ var store = new InMemoryAgentSessionStore();
+ var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), store);
+
+ // Act
+ var firstEvents = new List();
+ await foreach (var evt in handler.CreateAsync(
+ NewConversationRequest("conv-harness-local", "first question", store: true),
+ NewContextServing("resp_" + new string('a', 46), []),
+ CancellationToken.None))
+ {
+ firstEvents.Add(evt);
+ }
+
+ var storageAgent = new FoundryHostingAgent(
+ agent,
+ FoundryHostingAgent.ResolveSessionStorageIdentity(agent, registrationKey: null, defaultAgent: agent));
+ var storageKey = new AgentSessionStoreKey("conv-harness-local")
+ .WithPartition("user", FakeHostedSessionIsolationKeyProvider.DefaultUserId);
+ var savedSession = Assert.IsType(
+ await store.GetSessionAsync(storageAgent, storageKey, CancellationToken.None));
+
+ var secondEvents = new List();
+ await foreach (var evt in handler.CreateAsync(
+ NewConversationRequest("conv-harness-local", "second question", store: true),
+ NewContextServing("resp_" + new string('b', 46), []),
+ CancellationToken.None))
+ {
+ secondEvents.Add(evt);
+ }
+
+ // Assert: the local marker survives the session round-trip, but is never sent to the model.
+ Assert.Contains(firstEvents, evt => evt is ResponseCompletedEvent);
+ Assert.Contains(secondEvents, evt => evt is ResponseCompletedEvent);
+ Assert.Equal("_agent_local_chat_history", savedSession.ConversationId);
+ Assert.Equal(2, requests.Count);
+ Assert.Single(requests[1].Messages, message => message.Text.Contains("first question", StringComparison.Ordinal));
+ Assert.Single(requests[1].Messages, message => message.Text.Contains("answer", StringComparison.Ordinal));
+ Assert.Null(requests[1].Options?.ConversationId);
+
+ // The default host still disables storage in each underlying service request.
+ foreach (var (_, options) in requests)
+ {
+ Assert.NotNull(options?.RawRepresentationFactory);
+ var raw = Assert.IsType(options.RawRepresentationFactory(client.Object));
+ Assert.False(raw.StoredOutputEnabled);
+ }
+ }
+
+ [Fact]
+ public async Task CreateAsync_HarnessServiceStoredConversationId_StillFailsTheRequestAsync()
+ {
+ // Arrange
+ var client = new Mock();
+ client.Setup(c => c.GetStreamingResponseAsync(
+ It.IsAny>(), It.IsAny(), It.IsAny()))
+ .Returns(() => ToAsyncEnumerableUpdatesAsync(
+ new ChatResponseUpdate(ChatRole.Assistant, "answer")
+ {
+ MessageId = "resp_msg_1",
+ ConversationId = "conv-downstream",
+ }));
+
+ var agent = CreateHarnessAgent(client.Object);
+ var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore());
+
+ // Act
+ var failure = await Assert.ThrowsAsync(() => DrainEventsAsync(handler.CreateAsync(
+ NewConversationRequest("conv-harness-rejected", "first question", store: true),
+ NewContextServing("resp_" + new string('c', 46), []),
+ CancellationToken.None)));
+
+ // Assert: a real service conversation ID remains a hosting misconfiguration.
+ Assert.Equal(HostedStoredOutputCompatibility.MisconfiguredAgentErrorCode, failure.Error.Code);
+ Assert.Equal(HostedStoredOutputCompatibility.MisconfiguredAgentStatusCode, failure.StatusCode);
+ }
+
[Fact]
public async Task CreateAsync_AgentWhoseChatClientStoredTheTurn_NeverReportsTheTurnCompletedAsync()
{
@@ -1485,6 +1579,18 @@ private static IChatClient CreateCapturingChatClient(List captured,
return mock.Object;
}
+ private static HarnessAgent CreateHarnessAgent(IChatClient client) =>
+ new(client, new HarnessAgentOptions
+ {
+ DisableToolAutoApproval = true,
+ DisableOpenTelemetry = true,
+ DisableFileMemory = true,
+ DisableWebSearch = true,
+ DisableTodoProvider = true,
+ DisableAgentModeProvider = true,
+ DisableAgentSkillsProvider = true,
+ });
+
private static async IAsyncEnumerable ToAsyncEnumerableUpdatesAsync(params ChatResponseUpdate[] updates)
{
foreach (var update in updates)
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 41c9906b830..7677f6fa44e 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
@@ -19,6 +19,11 @@
+
+
+
+
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/StatelessWebSearchChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/StatelessWebSearchChatClientTests.cs
new file mode 100644
index 00000000000..e1a6ba77fd8
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/StatelessWebSearchChatClientTests.cs
@@ -0,0 +1,107 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+#if NET10_0
+#pragma warning disable OPENAI001 // Web-search response items are experimental.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Moq;
+using OpenAI.Responses;
+using SampleApp;
+
+namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
+
+public class StatelessWebSearchChatClientTests
+{
+ [Fact]
+ public async Task GetStreamingResponseAsync_RemovesRawSearchResultWithoutMutatingHistoryAsync()
+ {
+ // Arrange
+ var rawSearch = ResponseItem.CreateWebSearchCallItem();
+ var searchResult = new WebSearchToolResultContent("search_1") { RawRepresentation = rawSearch };
+ var searchCall = new WebSearchToolCallContent("search_1");
+ var functionCall = new FunctionCallContent("call_1", "file_memory_write");
+ var assistant = new ChatMessage(ChatRole.Assistant,
+ [
+ new TextContent("Source citation"),
+ searchCall,
+ searchResult,
+ functionCall,
+ ]);
+ var tool = new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call_1", "saved")]);
+ List? forwarded = null;
+ var inner = new Mock();
+ inner.Setup(client => client.GetStreamingResponseAsync(
+ It.IsAny>(), It.IsAny(), It.IsAny()))
+ .Returns((IEnumerable messages, ChatOptions? _, CancellationToken _) =>
+ {
+ forwarded = messages.ToList();
+ return GetUpdatesAsync();
+ });
+
+ // Act
+ var client = new StatelessWebSearchChatClient(inner.Object);
+ await foreach (var _ in client.GetStreamingResponseAsync([assistant, tool], new ChatOptions()))
+ {
+ }
+
+ // Assert: only the raw search item is withheld from the model; stored history and
+ // the following function call and result keep their original identities.
+ Assert.NotNull(forwarded);
+ Assert.Equal(2, forwarded.Count);
+ Assert.NotSame(assistant, forwarded[0]);
+ Assert.NotSame(assistant.Contents, forwarded[0].Contents);
+ Assert.DoesNotContain(forwarded[0].Contents, content => content is WebSearchToolResultContent);
+ Assert.Contains(forwarded[0].Contents, content => ReferenceEquals(content, searchCall));
+ Assert.Contains(forwarded[0].Contents, content => ReferenceEquals(content, functionCall));
+ Assert.Same(tool, forwarded[1]);
+ Assert.Same(searchResult, assistant.Contents[2]);
+ Assert.Same(rawSearch, searchResult.RawRepresentation);
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_RemovesRawSearchResultButKeepsUnrelatedContentsAsync()
+ {
+ // Arrange
+ var searchResult = new WebSearchToolResultContent("search_1")
+ {
+ RawRepresentation = ResponseItem.CreateWebSearchCallItem(),
+ };
+ var otherSearchResult = new WebSearchToolResultContent("search_2");
+ var assistant = new ChatMessage(ChatRole.Assistant,
+ [
+ new TextContent("Summary"),
+ searchResult,
+ otherSearchResult,
+ ]);
+ List? forwarded = null;
+ var inner = new Mock();
+ inner.Setup(client => client.GetResponseAsync(
+ It.IsAny>(), It.IsAny(), It.IsAny()))
+ .Returns((IEnumerable messages, ChatOptions? _, CancellationToken _) =>
+ {
+ forwarded = messages.ToList();
+ return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")));
+ });
+
+ // Act
+ var client = new StatelessWebSearchChatClient(inner.Object);
+ _ = await client.GetResponseAsync([assistant], new ChatOptions());
+
+ // Assert
+ Assert.NotNull(forwarded);
+ Assert.DoesNotContain(forwarded[0].Contents, content => ReferenceEquals(content, searchResult));
+ Assert.Contains(forwarded[0].Contents, content => ReferenceEquals(content, otherSearchResult));
+ Assert.Contains(assistant.Contents, content => ReferenceEquals(content, searchResult));
+ }
+
+ private static async IAsyncEnumerable GetUpdatesAsync()
+ {
+ yield return new ChatResponseUpdate(ChatRole.Assistant, "done");
+ await Task.CompletedTask;
+ }
+}
+#endif
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/WebBrowsingToolTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/WebBrowsingToolTests.cs
new file mode 100644
index 00000000000..375fd163893
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/WebBrowsingToolTests.cs
@@ -0,0 +1,223 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+#if NET10_0
+
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using SampleApp;
+
+namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
+
+///
+/// Covers the browsing tool that the hosted research sample links from the Harness research sample.
+///
+public sealed class WebBrowsingToolTests
+{
+ [Fact]
+ public async Task DownloadUriAsync_UsesThePolicyValidatedAddressAsync()
+ {
+ // Arrange
+ await using var server = new OneShotHttpServer("pinned");
+ int resolutionCount = 0;
+ var tool = new WebBrowsingTool(
+ new WebBrowsingToolOptions { AllowedHosts = ["rebind.invalid"] },
+ (host, _) =>
+ {
+ Assert.Equal("rebind.invalid", host);
+ resolutionCount++;
+ return Task.FromResult(new[] { IPAddress.Loopback });
+ });
+
+ // Act
+ string result = await tool.DownloadUriAsync(
+ $"http://rebind.invalid:{server.Port}/",
+ TestContext.Current.CancellationToken);
+
+ // Assert: the connection reuses the checked address instead of resolving the host again.
+ Assert.Equal("pinned", result);
+ Assert.Equal(1, resolutionCount);
+ }
+
+ [Fact]
+ public async Task DownloadUriAsync_PreservesTheOriginalHostHeaderAsync()
+ {
+ // Arrange
+ await using var server = new OneShotHttpServer("host");
+ var tool = CreateTool(
+ ["original-host.invalid"],
+ (_, _) => Task.FromResult(new[] { IPAddress.Loopback }));
+
+ // Act
+ await tool.DownloadUriAsync(
+ $"http://original-host.invalid:{server.Port}/resource",
+ TestContext.Current.CancellationToken);
+
+ // Assert
+ string requestHeaders = await server.RequestHeaders;
+ Assert.Contains(
+ $"Host: original-host.invalid:{server.Port}",
+ requestHeaders,
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task DownloadUriAsync_TriesEveryPolicyValidatedAddressAsync()
+ {
+ // Arrange: the first approved address refuses the connection.
+ await using var server = new OneShotHttpServer("second");
+ var tool = CreateTool(
+ ["multiple.invalid"],
+ (_, _) => Task.FromResult(new[] { IPAddress.Parse("127.0.0.2"), IPAddress.Loopback }));
+
+ // Act
+ string result = await tool.DownloadUriAsync(
+ $"http://multiple.invalid:{server.Port}/",
+ TestContext.Current.CancellationToken);
+
+ // Assert
+ Assert.Equal("second", result);
+ }
+
+ [Fact]
+ public async Task DownloadUriAsync_RevalidatesRedirectDestinationsAsync()
+ {
+ // Arrange: an allowed host redirects to a host that resolves to a private address.
+ await using var server = new OneShotHttpServer(
+ "redirect",
+ $"http://blocked.invalid:{GetUnusedPort()}/private");
+ var resolvedHosts = new List();
+ var tool = new WebBrowsingTool(
+ new WebBrowsingToolOptions
+ {
+ AllowedHosts = ["allowed.invalid"],
+ AllowPublicNetworks = true,
+ },
+ (host, _) =>
+ {
+ resolvedHosts.Add(host);
+ return Task.FromResult(new[] { IPAddress.Loopback });
+ });
+
+ // Act
+ string result = await tool.DownloadUriAsync(
+ $"http://allowed.invalid:{server.Port}/",
+ TestContext.Current.CancellationToken);
+
+ // Assert
+ Assert.Contains(
+ "resolves to a private/internal network address",
+ result,
+ StringComparison.OrdinalIgnoreCase);
+ Assert.Equal(["allowed.invalid", "blocked.invalid"], resolvedHosts);
+ }
+
+ [Fact]
+ public async Task DownloadUriAsync_BlocksTheUnspecifiedAddressAsNonPublicAsync()
+ {
+ // Arrange
+ await using var server = new OneShotHttpServer("local");
+ var tool = new WebBrowsingTool(
+ new WebBrowsingToolOptions { AllowPublicNetworks = true },
+ (_, _) => throw new InvalidOperationException("IP literals must not use DNS."));
+
+ // Act
+ string result = await tool.DownloadUriAsync(
+ $"http://0.0.0.0:{server.Port}/",
+ TestContext.Current.CancellationToken);
+
+ // Assert
+ Assert.Contains(
+ "resolves to a private/internal network address",
+ result,
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static WebBrowsingTool CreateTool(
+ IReadOnlyList allowedHosts,
+ Func> resolver) =>
+ new(new WebBrowsingToolOptions { AllowedHosts = allowedHosts }, resolver);
+
+ private static int GetUnusedPort()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ int port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ ///
+ /// Serves one HTTP response on the loopback interface and records the request headers.
+ ///
+ private sealed class OneShotHttpServer : IAsyncDisposable
+ {
+ private readonly TcpListener _listener;
+ private readonly CancellationTokenSource _cancellationTokenSource = new();
+ private readonly Task _serverTask;
+ private readonly TaskCompletionSource _requestHeaders =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public OneShotHttpServer(string responseBody, string? redirectLocation = null)
+ {
+ this._listener = new TcpListener(IPAddress.Loopback, 0);
+ this._listener.Start();
+ this.Port = ((IPEndPoint)this._listener.LocalEndpoint).Port;
+ this._serverTask = this.ServeAsync(responseBody, redirectLocation);
+ }
+
+ public int Port { get; }
+
+ public Task RequestHeaders => this._requestHeaders.Task;
+
+ public async ValueTask DisposeAsync()
+ {
+ await this._cancellationTokenSource.CancelAsync();
+ this._listener.Dispose();
+
+ try
+ {
+ await this._serverTask;
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (SocketException) when (this._cancellationTokenSource.IsCancellationRequested)
+ {
+ }
+
+ this._cancellationTokenSource.Dispose();
+ }
+
+ private async Task ServeAsync(string responseBody, string? redirectLocation)
+ {
+ using TcpClient client = await this._listener.AcceptTcpClientAsync(this._cancellationTokenSource.Token);
+ await using NetworkStream stream = client.GetStream();
+
+ var request = new StringBuilder();
+ byte[] buffer = new byte[1024];
+ while (!request.ToString().Contains("\r\n\r\n", StringComparison.Ordinal))
+ {
+ int bytesRead = await stream.ReadAsync(buffer, this._cancellationTokenSource.Token);
+ if (bytesRead == 0)
+ {
+ break;
+ }
+
+ request.Append(Encoding.ASCII.GetString(buffer, 0, bytesRead));
+ }
+
+ this._requestHeaders.TrySetResult(request.ToString());
+
+ string response = redirectLocation is null
+ ? $"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {Encoding.UTF8.GetByteCount(responseBody)}\r\nConnection: close\r\n\r\n{responseBody}"
+ : $"HTTP/1.1 302 Found\r\nLocation: {redirectLocation}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
+ await stream.WriteAsync(Encoding.UTF8.GetBytes(response), this._cancellationTokenSource.Token);
+ }
+ }
+}
+#endif
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/PerServiceCallChatHistoryPersistingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/PerServiceCallChatHistoryPersistingChatClientTests.cs
index a24b8453e1a..4d890e49978 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/PerServiceCallChatHistoryPersistingChatClientTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/PerServiceCallChatHistoryPersistingChatClientTests.cs
@@ -20,6 +20,24 @@ namespace Microsoft.Agents.AI.UnitTests;
///
public class PerServiceCallChatHistoryPersistingChatClientTests
{
+ [Fact]
+ public void PublicContract_ExposesLocalHistoryMarkerWithoutPublicConstructors()
+ {
+ // Arrange
+ Type clientType = typeof(PerServiceCallChatHistoryPersistingChatClient);
+
+ // Act
+ var constructors = clientType.GetConstructors();
+ var marker = clientType.GetField(nameof(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId));
+
+ // Assert
+ Assert.True(clientType.IsPublic);
+ Assert.Empty(constructors);
+ Assert.NotNull(marker);
+ Assert.True(marker.IsLiteral);
+ Assert.Equal("_agent_local_chat_history", marker.GetRawConstantValue());
+ }
+
///
/// Verifies that by default (RequirePerServiceCallChatHistoryPersistence is false),
/// the ChatHistoryProvider receives messages after a successful non-streaming call.