mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ea7b45290 | ||
|
|
894b2c6ce0 | ||
|
|
111648ee5b | ||
|
|
41eac64ee3 | ||
|
|
e642371a20 | ||
|
|
c30f104b20 | ||
|
|
800a58d6c1 | ||
|
|
a60e541c9a | ||
|
|
da308f5f1e | ||
|
|
9b772f3413 | ||
|
|
c885ca3d7a | ||
|
|
0d09d40f0f | ||
|
|
d81a8753d7 | ||
|
|
19b2367366 | ||
|
|
ad95f2f2fa | ||
|
|
97eaef029e | ||
|
|
47fa59f8e9 |
@@ -149,16 +149,14 @@ jobs:
|
||||
--apply-labels
|
||||
|
||||
- name: Stop after spam gate
|
||||
if: ${{ steps.spam.outputs.decision != 'allow' }}
|
||||
if: ${{ steps.spam.outputs.allow_triage != 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
SPAM_DECISION: ${{ steps.spam.outputs.decision }}
|
||||
run: |
|
||||
echo "Stopping: spam gate decided: ${SPAM_DECISION}"
|
||||
echo "Stopping: issue triage preflight did not allow automation."
|
||||
exit 1
|
||||
|
||||
- name: Reproduce reported issue
|
||||
if: ${{ steps.spam.outputs.decision == 'allow' }}
|
||||
if: ${{ steps.spam.outputs.allow_triage == 'true' }}
|
||||
id: repro
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: rogerbarreto
|
||||
date: 2026-05-07
|
||||
deciders: rogerbarreto
|
||||
consulted: []
|
||||
informed: []
|
||||
---
|
||||
|
||||
# Hosted session identity context for Foundry Hosting
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Server-hosted Foundry agents need a way to scope per-user state (most notably `FoundryMemoryProvider` memories) by the end user that initiated the request. The Foundry platform already injects `x-agent-user-isolation-key` and `x-agent-chat-isolation-key` headers on every Responses request, but the agent-framework hosting layer did not surface those values to `AIContextProvider` instances. The provider's `stateInitializer` only received an `AgentSession?` with no identity attached, so per-user scoping was impossible without out-of-band plumbing.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Memory and any future user-private context must be partitioned per end user without per-sample boilerplate.
|
||||
- The identity must be **read-only** from the perspective of `AIContextProvider`s, so a buggy or hostile provider cannot escalate or leak across users.
|
||||
- The persisted session must validate against the live request on every resume to defend against session-id leak and in-process tampering.
|
||||
- The change must work for every existing hosted-agent type (`ChatClientAgent`, `FoundryAgent`, future ones) without per-type refactoring of cast-heavy code paths in `Microsoft.Agents.AI`.
|
||||
- Local Docker debugging must remain possible when the platform headers are absent.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **`HostedSessionContext` stored in `AgentSessionStateBag`, exposed via a public read accessor and an `internal` setter.** Hosting writes once on session creation and validates on every resume.
|
||||
2. **Specialised `HostedAgentSession : AgentSession` wrapper** that carries `UserId`/`ChatId` properties, with `GetService<ChatClientAgentSession>()` as the unwrap escape hatch.
|
||||
3. **New property on `AgentSession` base class** (`HostedSessionContext? HostedContext { get; internal set; }`).
|
||||
4. **AsyncLocal middleware** that reads the headers and stuffs them into a per-request `AsyncLocal<HostedSessionContext>` consumed by the provider.
|
||||
|
||||
For the source of identity:
|
||||
- A. The platform-injected `IsolationContext` exposed by `ResponseContext.Isolation` (typed `UserIsolationKey`/`ChatIsolationKey`).
|
||||
- B. The OpenAI Responses spec's top-level `request.User` field.
|
||||
- C. A custom HTTP header `x-client-user`.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Option 1** was chosen for the storage shape, sourced from **Option A** (`ResponseContext.Isolation`).
|
||||
|
||||
Rationale:
|
||||
|
||||
- **Wrapper rejected (Option 2).** `ChatClientAgentSession` is `sealed` and `ChatClientAgent` rejects any other session type via direct `is not ChatClientAgentSession` checks at multiple call sites. Wrapping would force non-trivial refactors across `Microsoft.Agents.AI` and a corresponding repeat for every other agent type.
|
||||
- **Base-class property rejected (Option 3).** Leaks "hosted" semantics into the universal `AgentSession` abstraction used by Durable, A2A, and CopilotStudio agents that have no notion of a hosted user.
|
||||
- **AsyncLocal rejected (Option 4).** Surfaces the concept only locally, requires every consumer to re-implement the bridge, and cannot be enforced as read-only.
|
||||
- **`request.User` rejected (Option B).** Set by the caller, not the platform. Forging it client-side trivially defeats per-user partitioning.
|
||||
- **`x-client-user` rejected (Option C).** Non-standard, requires custom HTTP plumbing, and duplicates the platform-provided isolation contract.
|
||||
|
||||
Implementation summary in `Microsoft.Agents.AI.Foundry.Hosting`:
|
||||
|
||||
| Type | Visibility | Purpose |
|
||||
|---|---|---|
|
||||
| `HostedSessionContext` | public sealed | Captures `UserId` and `ChatId` (both required, non-whitespace). |
|
||||
| `HostedSessionContextExtensions.GetHostedContext` | public | Read accessor for `AIContextProvider`s. |
|
||||
| `HostedSessionContextExtensions.SetHostedContext` | internal | Writer reserved for the hosting assembly. Backed by `AgentSessionStateBag` under a well-known key for serialisation. |
|
||||
| `HostedSessionIsolationKeyProvider` (abstract) | public | DI-resolvable factory. Async signature: `ValueTask<HostedSessionContext?> GetKeysAsync(ResponseContext, CreateResponse, CancellationToken)`. |
|
||||
| `PlatformHostedSessionIsolationKeyProvider` | internal sealed | Default implementation. Maps `context.Isolation.UserIsolationKey` and `context.Isolation.ChatIsolationKey`. Returns `null` when either is absent. |
|
||||
|
||||
Behaviour added to `AgentFrameworkResponseHandler.CreateAsync`:
|
||||
|
||||
1. Resolve `HostedSessionIsolationKeyProvider` from DI; fall back to `PlatformHostedSessionIsolationKeyProvider`.
|
||||
2. Call `GetKeysAsync(context, request, cancellationToken)`. A `null` result throws `InvalidOperationException` (becomes 500). A null/whitespace `UserId` or `ChatId` is rejected by `HostedSessionContext`'s constructor.
|
||||
3. Branch on the **session's existing context**, not on whether a `conversation_id` was supplied:
|
||||
- **No session (`session is null`):** nothing to stamp; skip.
|
||||
- **Session present but un-stamped (`GetHostedContext() is null`):** treat as fresh. This covers both newly-created sessions and pre-existing sessions whose `conversation_id` was provisioned externally (e.g. via `conversations.CreateProjectConversationAsync()`) before the first hosted-agent request. Stamp the resolved identity now.
|
||||
- **Session present with stamped context:** strict resume. The persisted `UserId` and `ChatId` must equal the resolved values exactly. Mismatch throws `ResponsesApiException` with status 403 and body `Hosted session identity context mismatch`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Per-user memory partitioning works out of the box for any agent that consumes a `Microsoft.Agents.AI.Foundry.FoundryMemoryProvider` configured to read `session.GetHostedContext().UserId`.
|
||||
- Cross-user session-id leak and in-process tampering of the persisted identity both surface as a 403 with a deliberately uninformative body.
|
||||
- The identity is opaque to the framework, matching the platform's semantics. The framework never inspects user identity; the `IsolationContext` keys are pre-partitioned per agent.
|
||||
|
||||
Negative:
|
||||
|
||||
- Every existing hosted sample fails locally without a `HostedSessionIsolationKeyProvider` registered, because the platform headers are absent outside the platform. Mitigated by shipping `Hosted_Shared_Contributor_Setup` with `DevTemporaryLocalSessionIsolationKeyProvider` and `AddDevTemporaryLocalContributorSetup`, and migrating all 9 existing responses samples.
|
||||
- An attacker who can plant an un-stamped session under a victim's `conversation_id` *before* the victim's first hosted-agent request would be stamped with the attacker's identity on that first request. This is not a regression vs. behaviour without this contract, and is mitigated in practice because the `conversation_id` namespace is allocated by the platform per project. Once a session is stamped, the strict equality check fully defends the resume path.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-request `User` field on `CreateResponse` is intentionally not consumed; only the platform `IsolationContext` headers carry trustworthy identity.
|
||||
- Generic (non-Foundry) hosting layers can re-define an equivalent type if needed; nothing in this ADR is moved into `Microsoft.Agents.AI.Hosting` because `Microsoft.Agents.AI.Foundry.Hosting` does not depend on it.
|
||||
- HMAC tamper signatures over the persisted context are not implemented; comparison against `ResponseContext.Isolation` on every request is sufficient because the platform sets those headers at the trust boundary.
|
||||
@@ -327,9 +327,15 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/HostedMemoryAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
|
||||
</Folder>
|
||||
|
||||
+4
@@ -31,6 +31,10 @@ public sealed class ToolCallDisplayObserver : ConsoleObserver
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(this._formatters, functionCall)}...", ConsoleColor.DarkYellow);
|
||||
}
|
||||
else if (content is WebSearchToolCallContent)
|
||||
{
|
||||
// Handled by OpenAIResponsesWebSearchDisplayObserver when present; skip here to avoid duplication.
|
||||
}
|
||||
else if (content is ToolCallContent toolCall)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow);
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
|
||||
using System.Text;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// Displays web search activity in the scroll area. Shows search queries,
|
||||
/// page opens, and find-in-page actions as they stream in from the API.
|
||||
/// </summary>
|
||||
internal sealed class OpenAIResponsesWebSearchDisplayObserver : ConsoleObserver
|
||||
{
|
||||
private const int MaxQueryDisplayLength = 120;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (content is WebSearchToolResultContent resultContent
|
||||
&& resultContent.RawRepresentation is WebSearchCallResponseItem wscri)
|
||||
{
|
||||
await WriteActionAsync(ux, wscri, resultContent.Outputs);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteActionAsync(IUXStateDriver ux, WebSearchCallResponseItem wscri, IList<AIContent>? outputs)
|
||||
{
|
||||
WebSearchAction? action = wscri.Action;
|
||||
if (action is null)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("🌐 Web Search Tool (no action details)", ConsoleColor.DarkCyan);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case WebSearchFindInPageAction findInPage:
|
||||
await WriteFindInPageAsync(ux, findInPage);
|
||||
break;
|
||||
|
||||
case WebSearchOpenPageAction openPage:
|
||||
await WriteOpenPageAsync(ux, openPage);
|
||||
break;
|
||||
|
||||
case WebSearchSearchAction search:
|
||||
await WriteSearchAsync(ux, search, outputs);
|
||||
break;
|
||||
|
||||
default:
|
||||
await ux.WriteInfoLineAsync("🌐 Web Search Tool (unknown action)", ConsoleColor.DarkCyan);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteSearchAsync(IUXStateDriver ux, WebSearchSearchAction search, IList<AIContent>? outputs)
|
||||
{
|
||||
// Read queries directly from the typed action.
|
||||
IList<string> queries = search.Queries;
|
||||
|
||||
if (queries.Count == 0)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("🌐 Web Search Tool: search", ConsoleColor.DarkCyan);
|
||||
return;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("🌐 Web Search Tool: search");
|
||||
|
||||
// Show the search queries.
|
||||
bool hasResults = outputs is { Count: > 0 };
|
||||
for (int i = 0; i < queries.Count; i++)
|
||||
{
|
||||
string connector = (i < queries.Count - 1 || hasResults) ? "├─" : "└─";
|
||||
string query = Truncate(queries[i], MaxQueryDisplayLength);
|
||||
sb.Append($"\n {connector} \"{query}\"");
|
||||
}
|
||||
|
||||
// Show search result sources (URLs + titles) when available.
|
||||
// Sources come from M.E.AI's Outputs when IncludedResponseProperty.WebSearchCallActionSources is set,
|
||||
// or directly from the SDK's WebSearchSearchAction.Sources.
|
||||
if (hasResults)
|
||||
{
|
||||
sb.Append("\n │");
|
||||
for (int i = 0; i < outputs!.Count; i++)
|
||||
{
|
||||
string connector = i < outputs.Count - 1 ? "├─" : "└─";
|
||||
string line = FormatOutput(outputs[i]);
|
||||
sb.Append($"\n {connector} {line}");
|
||||
}
|
||||
}
|
||||
else if (search.Sources is { Count: > 0 } sources)
|
||||
{
|
||||
sb.Append("\n │");
|
||||
for (int i = 0; i < sources.Count; i++)
|
||||
{
|
||||
string connector = i < sources.Count - 1 ? "├─" : "└─";
|
||||
string line = FormatSource(sources[i]);
|
||||
sb.Append($"\n {connector} {line}");
|
||||
}
|
||||
}
|
||||
|
||||
await ux.WriteInfoLineAsync(sb.ToString(), ConsoleColor.DarkCyan);
|
||||
}
|
||||
|
||||
private static async Task WriteOpenPageAsync(IUXStateDriver ux, WebSearchOpenPageAction openPage)
|
||||
{
|
||||
string url = openPage.Uri?.AbsoluteUri ?? "(unknown)";
|
||||
await ux.WriteInfoLineAsync(
|
||||
$"🌐 Web Search Tool: open page\n └─ {url}",
|
||||
ConsoleColor.DarkCyan);
|
||||
}
|
||||
|
||||
private static async Task WriteFindInPageAsync(IUXStateDriver ux, WebSearchFindInPageAction findInPage)
|
||||
{
|
||||
string url = findInPage.Uri?.AbsoluteUri ?? "(unknown)";
|
||||
string pattern = findInPage.Pattern ?? "(unknown)";
|
||||
|
||||
await ux.WriteInfoLineAsync(
|
||||
$"🌐 Web Search Tool: find in page\n ├─ \"{Truncate(pattern, MaxQueryDisplayLength)}\"\n └─ {url}",
|
||||
ConsoleColor.DarkCyan);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a single search result source from the SDK's <see cref="WebSearchActionSource"/> for display.
|
||||
/// </summary>
|
||||
private static string FormatSource(WebSearchActionSource source)
|
||||
{
|
||||
if (source is WebSearchActionUriSource uriSource)
|
||||
{
|
||||
string url = uriSource.Uri?.AbsoluteUri ?? "(unknown)";
|
||||
|
||||
// WebSearchActionUriSource doesn't expose a title property,
|
||||
// but the API may include one in the raw response JSON.
|
||||
string? title = GetTitleFromRawRepresentation(uriSource);
|
||||
|
||||
return title is not null
|
||||
? $"{Truncate(title, MaxQueryDisplayLength)} — {url}"
|
||||
: url;
|
||||
}
|
||||
|
||||
return source.ToString() ?? "(unknown source)";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a single search result output from M.E.AI's <see cref="AIContent"/> for display.
|
||||
/// </summary>
|
||||
private static string FormatOutput(AIContent output)
|
||||
{
|
||||
if (output is UriContent uriContent)
|
||||
{
|
||||
string url = uriContent.Uri?.AbsoluteUri ?? "(unknown)";
|
||||
|
||||
// Try to extract a title from the raw JSON of the source.
|
||||
// The SDK's WebSearchActionUriSource doesn't expose a title property,
|
||||
// but the API may include one in the raw response.
|
||||
string? title = GetTitleFromRawRepresentation(uriContent.RawRepresentation)
|
||||
?? (uriContent.AdditionalProperties?.TryGetValue("title", out var t) is true ? t?.ToString() : null);
|
||||
|
||||
return title is not null
|
||||
? $"{Truncate(title, MaxQueryDisplayLength)} — {url}"
|
||||
: url;
|
||||
}
|
||||
|
||||
return output.ToString() ?? "(unknown output)";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to extract a "title" field from a raw representation object by serializing it to JSON.
|
||||
/// The SDK's <see cref="WebSearchActionUriSource"/> doesn't expose a title property,
|
||||
/// but the API may include one in the raw JSON — this is forward-compatible for when
|
||||
/// the SDK adds title support.
|
||||
/// </summary>
|
||||
private static string? GetTitleFromRawRepresentation(object? rawRepresentation)
|
||||
{
|
||||
if (rawRepresentation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var data = System.ClientModel.Primitives.ModelReaderWriter.Write(rawRepresentation);
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(data);
|
||||
if (doc.RootElement.TryGetProperty("title", out var titleEl)
|
||||
&& titleEl.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
{
|
||||
return titleEl.GetString();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Serialization may not be supported for this object type.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string Truncate(string text, int maxLength)
|
||||
=> text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength - 1), "…");
|
||||
}
|
||||
@@ -163,12 +163,14 @@ await HarnessConsole.RunAgentAsync(
|
||||
userPrompt: "Enter a research topic to get started.",
|
||||
new HarnessConsoleOptions
|
||||
{
|
||||
Observers = HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
executionModeName: "execute",
|
||||
maxContextWindowTokens: MaxContextWindowTokens,
|
||||
maxOutputTokens: MaxOutputTokens,
|
||||
toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()]),
|
||||
Observers = [
|
||||
new OpenAIResponsesWebSearchDisplayObserver(),
|
||||
.. HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
executionModeName: "execute",
|
||||
maxContextWindowTokens: MaxContextWindowTokens,
|
||||
maxOutputTokens: MaxOutputTokens,
|
||||
toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()])],
|
||||
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
|
||||
});
|
||||
|
||||
+1
@@ -18,6 +18,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
+2
-45
@@ -4,6 +4,7 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
@@ -40,6 +41,7 @@ AIAgent agent = new AIProjectClient(projectEndpoint, credential)
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
@@ -51,48 +53,3 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
///
|
||||
/// When debugging and testing a hosted agent in a local Docker container, Azure CLI
|
||||
/// and other interactive credentials are not available. This credential reads a
|
||||
/// pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
|
||||
///
|
||||
/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed.
|
||||
/// In production, the Foundry platform injects a managed identity automatically.
|
||||
///
|
||||
/// 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 ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.GetAccessToken();
|
||||
}
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<AccessToken>(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));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -18,6 +18,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
+2
-45
@@ -5,6 +5,7 @@ using Azure.AI.Projects.Agents;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
@@ -33,6 +34,7 @@ FoundryAgent agent = aiProjectClient.AsAIAgent(agentRecord);
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
@@ -44,48 +46,3 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
///
|
||||
/// When debugging and testing a hosted agent in a local Docker container, Azure CLI
|
||||
/// and other interactive credentials are not available. This credential reads a
|
||||
/// pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
|
||||
///
|
||||
/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed.
|
||||
/// In production, the Foundry platform injects a managed identity automatically.
|
||||
///
|
||||
/// 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 ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.GetAccessToken();
|
||||
}
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<AccessToken>(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));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
+2
-36
@@ -11,6 +11,7 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -112,6 +113,7 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
@@ -126,39 +128,3 @@ app.Run();
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed record Hotel(string Name, int PricePerNight, double Rating, string Location);
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> 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 ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> 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));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -21,6 +21,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
@@ -19,6 +19,7 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -81,6 +82,7 @@ AIAgent agent = new AIProjectClient(projectEndpoint, credential)
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
@@ -92,39 +94,3 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> 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 ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> 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));
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AZURE_AI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-ada-002
|
||||
AZURE_AI_MEMORY_STORE_ID=hosted-memory-sample
|
||||
AGENT_NAME=hosted-memory-agent
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
# When running outside the Foundry platform the platform-injected isolation keys are absent.
|
||||
# These two variables provide fallback values for local Docker debugging only.
|
||||
HOSTED_USER_ISOLATION_KEY=local-dev-user
|
||||
HOSTED_CHAT_ISOLATION_KEY=local-dev-chat
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dockerfile for end-users consuming the Agent Framework via NuGet packages.
|
||||
#
|
||||
# This Dockerfile performs a full `dotnet restore` and `dotnet publish` inside the container,
|
||||
# which only succeeds when the project references its dependencies via PackageReference (see the
|
||||
# commented-out section in HostedMemoryAgent.csproj). Contributors building from the
|
||||
# agent-framework repository source must use Dockerfile.contributor instead because
|
||||
# ProjectReference dependencies live outside this folder and cannot be restored from inside
|
||||
# this build context.
|
||||
#
|
||||
# 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", "HostedMemoryAgent.dll"]
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# 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-memory-agent .
|
||||
# docker run --rm -p 8088:8088 \
|
||||
# -e AGENT_NAME=hosted-memory-agent \
|
||||
# -e HOSTED_USER_ISOLATION_KEY=alice \
|
||||
# -e HOSTED_CHAT_ISOLATION_KEY=alice-chat-1 \
|
||||
# --env-file .env hosted-memory-agent
|
||||
#
|
||||
# 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", "HostedMemoryAgent.dll"]
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedMemoryAgent</RootNamespace>
|
||||
<AssemblyName>HostedMemoryAgent</AssemblyName>
|
||||
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Hosted-MemoryAgent
|
||||
//
|
||||
// Demonstrates how to host an agent that uses FoundryMemoryProvider so that user-private memories
|
||||
// persist across requests and across sessions, scoped per user via the Foundry platform's
|
||||
// isolation key headers.
|
||||
//
|
||||
// Memory scope flows from request -> hosting layer -> session -> provider:
|
||||
// 1. Foundry sets x-agent-user-isolation-key on every inbound request.
|
||||
// 2. AgentFrameworkResponseHandler reads context.Isolation.UserIsolationKey via the registered
|
||||
// HostedSessionIsolationKeyProvider and stores it on the session as a HostedSessionContext.
|
||||
// 3. FoundryMemoryProvider's stateInitializer reads HostedSessionContext.UserId and uses it as
|
||||
// the FoundryMemoryProviderScope, partitioning memories per user.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Load .env file if present (for local development).
|
||||
Env.TraversePath().Load();
|
||||
|
||||
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."));
|
||||
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
|
||||
?? throw new InvalidOperationException("AGENT_NAME is not set.");
|
||||
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
var embeddingDeployment = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002";
|
||||
var memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? "hosted-memory-sample";
|
||||
|
||||
// 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 foundry).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
AIProjectClient projectClient = new(projectEndpoint, credential);
|
||||
|
||||
// FoundryMemoryProvider partitions memories per end user via a built-in HostedFoundryMemoryProviderScopes
|
||||
// helper that reads the platform-injected user isolation key from the HostedSessionContext that the
|
||||
// hosting layer placed on the session.
|
||||
FoundryMemoryProvider memoryProvider = new(
|
||||
projectClient,
|
||||
memoryStoreName,
|
||||
stateInitializer: HostedFoundryMemoryProviderScopes.PerUser());
|
||||
|
||||
// Provision the memory store on startup if it does not already exist. EnsureMemoryStoreCreatedAsync
|
||||
// is idempotent. Doing this once at start avoids per-request latency.
|
||||
await memoryProvider.EnsureMemoryStoreCreatedAsync(deployment, embeddingDeployment, "Memory store for the hosted travel-assistant sample.");
|
||||
|
||||
const string AgentInstructions = """
|
||||
You are a friendly travel assistant. When the user shares trip preferences, destinations,
|
||||
travel companions, or constraints, remember them and use them in later turns. Use known
|
||||
memories about the user when responding, and do not invent details.
|
||||
""";
|
||||
|
||||
ChatClientAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions()
|
||||
{
|
||||
Name = agentName,
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = deployment,
|
||||
Instructions = AgentInstructions
|
||||
},
|
||||
AIContextProviders = [memoryProvider]
|
||||
});
|
||||
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,155 @@
|
||||
# Hosted-MemoryAgent
|
||||
|
||||
A hosted Foundry agent that uses **FoundryMemoryProvider** to remember user-private details across
|
||||
requests and across sessions, scoped per end user via the Foundry platform's isolation keys. The
|
||||
agent plays a friendly travel assistant: tell it about your trip, ask follow-up questions in a new
|
||||
session, and it recalls what it learned about you.
|
||||
|
||||
This sample exists to demonstrate two things together:
|
||||
|
||||
1. How to host an agent that consumes a `Microsoft.Extensions.AI.AIContextProvider` (specifically
|
||||
`FoundryMemoryProvider`) under the Foundry Responses hosting layer.
|
||||
2. How the new `HostedSessionContext` flows from the `Foundry` platform isolation headers
|
||||
(`x-agent-user-isolation-key`, `x-agent-chat-isolation-key`) through the
|
||||
`HostedSessionIsolationKeyProvider` into the provider's `stateInitializer`, so memories are
|
||||
partitioned per user automatically.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure AI Foundry project with at least one chat model deployment and one embedding model deployment
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the template and fill in your values:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Required:
|
||||
|
||||
```env
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<account>.services.ai.azure.com/api/projects/<project>
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AZURE_AI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-ada-002
|
||||
AZURE_AI_MEMORY_STORE_ID=hosted-memory-sample
|
||||
AGENT_NAME=hosted-memory-agent
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
```
|
||||
|
||||
For local container runs only (the platform supplies these in production):
|
||||
|
||||
```env
|
||||
HOSTED_USER_ISOLATION_KEY=alice
|
||||
HOSTED_CHAT_ISOLATION_KEY=alice-chat-1
|
||||
```
|
||||
|
||||
> `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
|
||||
## How memory scoping works
|
||||
|
||||
| Layer | Source of the user identity |
|
||||
|---|---|
|
||||
| Inbound request | The Foundry platform sets `x-agent-user-isolation-key` and `x-agent-chat-isolation-key` headers on every request. |
|
||||
| Hosting layer | `AgentFrameworkResponseHandler` resolves a `HostedSessionIsolationKeyProvider` from DI and calls `GetKeysAsync(context, request, ct)`. The default implementation reads `context.Isolation.UserIsolationKey` and `context.Isolation.ChatIsolationKey`. |
|
||||
| Session | The handler stores the resolved values on the session as a `HostedSessionContext` on the first request, and validates the values on every subsequent request that resumes the same conversation (mismatch returns 403). |
|
||||
| Memory provider | The sample's `stateInitializer` reads `session.GetHostedContext().UserId` and uses it as the `FoundryMemoryProviderScope`. Memories are partitioned per user. |
|
||||
|
||||
When running outside the Foundry platform the headers are absent. The sample registers
|
||||
`DevTemporaryLocalSessionIsolationKeyProvider` (via `AddDevTemporaryLocalContributorSetup`) which
|
||||
falls back to the `HOSTED_USER_ISOLATION_KEY` and `HOSTED_CHAT_ISOLATION_KEY` environment variables,
|
||||
defaulting to a single `local-dev-*` bucket when neither is set.
|
||||
|
||||
> **Production warning.** Never register `DevTemporaryLocalSessionIsolationKeyProvider` in
|
||||
> production. The Foundry platform sets the isolation keys for every inbound request, and
|
||||
> client-supplied environment variables can be forged.
|
||||
|
||||
## Running directly (contributors)
|
||||
|
||||
This project uses `ProjectReference` to build against the local Agent Framework source.
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The agent starts on `http://localhost:8088`.
|
||||
|
||||
### Test it
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "Hi! My name is Taylor and I am planning a hiking trip to Patagonia in November.", "model": "hosted-memory-agent"}'
|
||||
```
|
||||
|
||||
Wait a few seconds for memory extraction, then ask a follow-up using the response id from the
|
||||
previous call as `previous_response_id`:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "What do you already know about my upcoming trip?", "previous_response_id": "<id>", "model": "hosted-memory-agent"}'
|
||||
```
|
||||
|
||||
## Running with Docker
|
||||
|
||||
Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies
|
||||
outside this folder. Use `Dockerfile.contributor` which takes a pre-published output.
|
||||
|
||||
### 1. Publish for the container runtime (Linux Alpine)
|
||||
|
||||
```bash
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
```
|
||||
|
||||
### 2. Build the Docker image
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.contributor -t hosted-memory-agent .
|
||||
```
|
||||
|
||||
### 3. Run the container
|
||||
|
||||
```bash
|
||||
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-memory-agent \
|
||||
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
-e HOSTED_USER_ISOLATION_KEY=alice \
|
||||
-e HOSTED_CHAT_ISOLATION_KEY=alice-chat-1 \
|
||||
--env-file .env \
|
||||
hosted-memory-agent
|
||||
```
|
||||
|
||||
### 4. Smoke test the running container
|
||||
|
||||
A scripted smoke test that exercises memory recall and per-user isolation across two simulated
|
||||
users is provided at `scripts/smoke.ps1`. From the sample folder:
|
||||
|
||||
```powershell
|
||||
pwsh ./scripts/smoke.ps1
|
||||
```
|
||||
|
||||
The script publishes the project, builds the image, runs the container with two distinct
|
||||
`HOSTED_USER_ISOLATION_KEY` values, drives a multi-turn conversation per user, asserts that each
|
||||
user only sees their own memories, and exits non-zero on failure.
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the
|
||||
standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in
|
||||
`HostedMemoryAgent.csproj` for the `PackageReference` alternative.
|
||||
|
||||
## How it differs from sibling samples
|
||||
|
||||
| | Hosted-ChatClientAgent | Hosted-MemoryAgent |
|
||||
|---|---|---|
|
||||
| **Agent definition** | Inline (`AsAIAgent(model, instructions)`) | Inline, plus `AIContextProviders = [memoryProvider]` |
|
||||
| **State** | None beyond the conversation history | Per-user memories persisted in Foundry Memory |
|
||||
| **Identity** | Not used | Required: `HostedSessionContext.UserId` flows into the memory scope |
|
||||
| **Local dev** | `AddDevTemporaryLocalContributorSetup()` keeps requests succeeding when isolation headers are absent | Same; additionally honours `HOSTED_USER_ISOLATION_KEY` to simulate distinct users |
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-memory-agent
|
||||
displayName: "Hosted Memory Agent"
|
||||
|
||||
description: >
|
||||
A travel-assistant hosted agent that uses FoundryMemoryProvider to remember user-private
|
||||
preferences and details across sessions. Memory is scoped per end user via the Foundry
|
||||
platform's isolation key headers.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Streaming
|
||||
- Agent Framework
|
||||
- Memory
|
||||
- Foundry Memory
|
||||
|
||||
template:
|
||||
name: hosted-memory-agent
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
parameters:
|
||||
properties: []
|
||||
resources: []
|
||||
@@ -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-memory-agent
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
#requires -Version 7
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Local smoke test for the Hosted-MemoryAgent sample.
|
||||
.DESCRIPTION
|
||||
Publishes the sample, builds the contributor Docker image, runs the container twice with two
|
||||
distinct HOSTED_USER_ISOLATION_KEY values, drives a multi-turn conversation per user via curl
|
||||
invocations, and asserts that each user only sees their own remembered details.
|
||||
Exits non-zero on failure.
|
||||
|
||||
Prerequisites:
|
||||
- Docker
|
||||
- az login (token is fetched from the host)
|
||||
- .env populated with AZURE_AI_PROJECT_ENDPOINT and model deployments
|
||||
.NOTES
|
||||
This script is for local Docker debugging only. The Foundry platform supplies the isolation
|
||||
keys for every inbound request in production and the dev fallback used here must not be
|
||||
enabled in production deployments.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$Port = 8088,
|
||||
[string]$ImageName = 'hosted-memory-agent-smoke',
|
||||
[int]$RecallDelaySeconds = 25
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location -Path $PSScriptRoot/..
|
||||
|
||||
if (-not (Test-Path .env)) {
|
||||
throw '.env not found. Copy .env.example to .env and fill in AZURE_AI_PROJECT_ENDPOINT.'
|
||||
}
|
||||
|
||||
Write-Host '==> Publishing sample for linux-musl-x64 ...'
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out --tl:off | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' }
|
||||
|
||||
Write-Host '==> Building docker image ...'
|
||||
docker build -f Dockerfile.contributor -t $ImageName . | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw 'docker build failed.' }
|
||||
|
||||
Write-Host '==> Fetching bearer token ...'
|
||||
$bearer = az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv
|
||||
if (-not $bearer) { throw 'Failed to obtain bearer token. Run az login.' }
|
||||
|
||||
function Start-Container([string]$UserKey, [string]$ChatKey, [string]$ContainerName) {
|
||||
docker rm -f $ContainerName 2>$null | Out-Null
|
||||
docker run -d --name $ContainerName -p ${Port}:8088 `
|
||||
-e AGENT_NAME=hosted-memory-agent `
|
||||
-e AZURE_BEARER_TOKEN=$bearer `
|
||||
-e HOSTED_USER_ISOLATION_KEY=$UserKey `
|
||||
-e HOSTED_CHAT_ISOLATION_KEY=$ChatKey `
|
||||
--env-file .env `
|
||||
$ImageName | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw "docker run failed for $ContainerName." }
|
||||
# Wait briefly for the listener to come up.
|
||||
Start-Sleep -Seconds 6
|
||||
}
|
||||
|
||||
function Invoke-Agent([string]$Prompt, [string]$PreviousResponseId = $null) {
|
||||
$body = @{ input = $Prompt; model = 'hosted-memory-agent' }
|
||||
if ($PreviousResponseId) { $body['previous_response_id'] = $PreviousResponseId }
|
||||
$json = $body | ConvertTo-Json -Compress
|
||||
$resp = Invoke-RestMethod -Method Post -Uri "http://localhost:$Port/responses" -ContentType 'application/json' -Body $json
|
||||
return $resp
|
||||
}
|
||||
|
||||
function Assert-Contains([string]$Haystack, [string]$Needle, [string]$Label) {
|
||||
if ($Haystack -notmatch [regex]::Escape($Needle)) {
|
||||
throw "FAILED [$Label]: expected response to contain '$Needle' but got: $Haystack"
|
||||
}
|
||||
Write-Host "PASS [$Label]: response contains '$Needle'."
|
||||
}
|
||||
|
||||
function Assert-NotContains([string]$Haystack, [string]$Needle, [string]$Label) {
|
||||
if ($Haystack -match [regex]::Escape($Needle)) {
|
||||
throw "FAILED [$Label]: response unexpectedly contains '$Needle': $Haystack"
|
||||
}
|
||||
Write-Host "PASS [$Label]: response does not contain '$Needle'."
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host '==> Phase 1: alice teaches the agent her trip details ...'
|
||||
Start-Container -UserKey 'alice' -ChatKey 'alice-chat-1' -ContainerName 'hosted-memory-smoke-alice'
|
||||
$r1 = Invoke-Agent -Prompt 'Hi! My name is Taylor and I am planning a hiking trip to Patagonia in November.'
|
||||
$r2 = Invoke-Agent -Prompt 'I am travelling with my sister and we love finding scenic viewpoints.' -PreviousResponseId $r1.id
|
||||
|
||||
Write-Host "==> Waiting $RecallDelaySeconds s for memory extraction ..."
|
||||
Start-Sleep -Seconds $RecallDelaySeconds
|
||||
|
||||
$r3 = Invoke-Agent -Prompt 'What do you already know about my upcoming trip?' -PreviousResponseId $r2.id
|
||||
$aliceText = ($r3.output | ForEach-Object { $_.content | ForEach-Object { $_.text } }) -join ' '
|
||||
Assert-Contains $aliceText 'Patagonia' 'alice recall: Patagonia'
|
||||
|
||||
docker rm -f hosted-memory-smoke-alice | Out-Null
|
||||
|
||||
Write-Host '==> Phase 2: bob starts a fresh container with a different user isolation key ...'
|
||||
Start-Container -UserKey 'bob' -ChatKey 'bob-chat-1' -ContainerName 'hosted-memory-smoke-bob'
|
||||
$b1 = Invoke-Agent -Prompt 'Hello, what trip am I planning?'
|
||||
$bobText = ($b1.output | ForEach-Object { $_.content | ForEach-Object { $_.text } }) -join ' '
|
||||
Assert-NotContains $bobText 'Patagonia' 'bob isolation: no leak of alice memories'
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '==> All smoke assertions passed.'
|
||||
}
|
||||
finally {
|
||||
docker rm -f hosted-memory-smoke-alice 2>$null | Out-Null
|
||||
docker rm -f hosted-memory-smoke-bob 2>$null | Out-Null
|
||||
}
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
+2
-36
@@ -10,6 +10,7 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -60,6 +61,7 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
@@ -70,39 +72,3 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> 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 ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> 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));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -47,6 +48,7 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
@@ -97,34 +99,3 @@ static Task<IEnumerable<TextSearchProvider.TextSearchResult>> MockSearchAsync(st
|
||||
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
|
||||
/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed.
|
||||
///
|
||||
/// 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 ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(GetAccessToken());
|
||||
|
||||
private static AccessToken GetAccessToken()
|
||||
{
|
||||
var token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
if (string.IsNullOrEmpty(token) || token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
@@ -21,6 +21,7 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
@@ -57,6 +58,7 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register the agent and response handler
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
// Register Foundry Toolbox: connects to the MCP proxy at startup and makes tools available.
|
||||
// The toolset name must match a toolset registered in your Foundry project.
|
||||
@@ -75,39 +77,3 @@ if (app.Environment.IsDevelopment())
|
||||
app.Run();
|
||||
|
||||
// ── DevTemporaryTokenCredential ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> 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 ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> 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.MaxValue);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -24,6 +24,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
|
||||
+1
-28
@@ -17,9 +17,9 @@
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
@@ -192,30 +192,3 @@ static string GetWeather(
|
||||
var condition = conditions[rng.Next(conditions.Length)];
|
||||
return $"Weather in {location}: {temp}C, {condition}. Humidity: {rng.Next(30, 90)}%. Wind: {rng.Next(5, 30)} km/h.";
|
||||
}
|
||||
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> 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));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
+2
-36
@@ -9,6 +9,7 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
@@ -49,6 +50,7 @@ AIAgent agent = new WorkflowBuilder(frenchAgent)
|
||||
// Host the workflow agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
@@ -59,39 +61,3 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> 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 ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> 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));
|
||||
}
|
||||
}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
namespace Hosted_Shared_Contributor_Setup;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="HostedSessionIsolationKeyProvider"/> for local Docker debugging only.
|
||||
///
|
||||
/// When the Foundry platform's <c>x-agent-user-isolation-key</c> and
|
||||
/// <c>x-agent-chat-isolation-key</c> headers are absent (i.e., when the container is running
|
||||
/// outside the Foundry platform), the hosting layer rejects every request with a 500 because the
|
||||
/// default <see cref="HostedSessionIsolationKeyProvider"/> returns null. This provider supplies
|
||||
/// fallback values from the <c>HOSTED_USER_ISOLATION_KEY</c> and <c>HOSTED_CHAT_ISOLATION_KEY</c>
|
||||
/// environment variables, defaulting to the constants below when neither is set.
|
||||
///
|
||||
/// This should NOT be used in production. The Foundry platform sets the isolation keys for every
|
||||
/// inbound request and forging them client-side defeats the per-user partitioning. The dev
|
||||
/// fallback exists solely so a contributor can <c>docker run</c> the sample on their laptop and
|
||||
/// drive a few requests end to end.
|
||||
/// </summary>
|
||||
public sealed class DevTemporaryLocalSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Environment variable that supplies the user isolation key when the platform header is absent.
|
||||
/// </summary>
|
||||
public const string UserIsolationKeyEnvironmentVariable = "HOSTED_USER_ISOLATION_KEY";
|
||||
|
||||
/// <summary>
|
||||
/// Environment variable that supplies the chat isolation key when the platform header is absent.
|
||||
/// </summary>
|
||||
public const string ChatIsolationKeyEnvironmentVariable = "HOSTED_CHAT_ISOLATION_KEY";
|
||||
|
||||
/// <summary>
|
||||
/// Default user isolation key used when neither the platform header nor the environment variable
|
||||
/// supplies a value. All local requests collapse onto this single bucket unless overridden.
|
||||
/// </summary>
|
||||
public const string DefaultLocalUserIsolationKey = "local-dev-user";
|
||||
|
||||
/// <summary>
|
||||
/// Default chat isolation key used when neither the platform header nor the environment variable
|
||||
/// supplies a value.
|
||||
/// </summary>
|
||||
public const string DefaultLocalChatIsolationKey = "local-dev-chat";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<HostedSessionContext?> GetKeysAsync(
|
||||
ResponseContext context,
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userKey = !string.IsNullOrWhiteSpace(context?.Isolation?.UserIsolationKey)
|
||||
? context!.Isolation!.UserIsolationKey
|
||||
: Environment.GetEnvironmentVariable(UserIsolationKeyEnvironmentVariable);
|
||||
if (string.IsNullOrWhiteSpace(userKey))
|
||||
{
|
||||
userKey = DefaultLocalUserIsolationKey;
|
||||
}
|
||||
|
||||
var chatKey = !string.IsNullOrWhiteSpace(context?.Isolation?.ChatIsolationKey)
|
||||
? context!.Isolation!.ChatIsolationKey
|
||||
: Environment.GetEnvironmentVariable(ChatIsolationKeyEnvironmentVariable);
|
||||
if (string.IsNullOrWhiteSpace(chatKey))
|
||||
{
|
||||
chatKey = DefaultLocalChatIsolationKey;
|
||||
}
|
||||
|
||||
return new ValueTask<HostedSessionContext?>(new HostedSessionContext(userKey!, chatKey!));
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
|
||||
namespace Hosted_Shared_Contributor_Setup;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
///
|
||||
/// When debugging and testing a hosted agent in a local Docker container, Azure CLI
|
||||
/// and other interactive credentials are not available. This credential reads a
|
||||
/// pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
|
||||
///
|
||||
/// This should NOT be used in production. Tokens expire (around one hour) and cannot be refreshed.
|
||||
/// In production, the Foundry platform injects a managed identity automatically.
|
||||
///
|
||||
/// 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 ...
|
||||
/// </summary>
|
||||
public sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DevTemporaryTokenCredential"/> class.
|
||||
/// Reads the bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable when present.
|
||||
/// </summary>
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.GetAccessToken();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<AccessToken>(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));
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Hosted_Shared_Contributor_Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Registration helpers for the developer-only utilities shipped in this sample-shared project.
|
||||
/// </summary>
|
||||
public static class HostedContributorSetupExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers developer-only services that allow a hosted Foundry agent to run outside the
|
||||
/// Foundry platform (e.g., inside a Docker container during contributor debugging).
|
||||
///
|
||||
/// <para><b>For local Docker debugging only and should not be used in production.</b></para>
|
||||
///
|
||||
/// Currently this method registers a <see cref="DevTemporaryLocalSessionIsolationKeyProvider"/>
|
||||
/// so that requests succeed when the platform's <c>x-agent-user-isolation-key</c> and
|
||||
/// <c>x-agent-chat-isolation-key</c> headers are absent. In production those headers are
|
||||
/// always present and the default platform isolation key provider (registered automatically by
|
||||
/// the hosting layer) is used instead.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register the developer-only services into.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
||||
public static IServiceCollection AddDevTemporaryLocalContributorSetup(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider, DevTemporaryLocalSessionIsolationKeyProvider>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>Hosted_Shared_Contributor_Setup</RootNamespace>
|
||||
<AssemblyName>Hosted_Shared_Contributor_Setup</AssemblyName>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -458,8 +458,9 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
// This ensures all AGUI events have a valid messageId regardless of agent type.
|
||||
if (string.IsNullOrWhiteSpace(chatResponse.MessageId))
|
||||
{
|
||||
streamingMessageId ??= Guid.NewGuid().ToString("N");
|
||||
chatResponse.MessageId = streamingMessageId;
|
||||
chatResponse.MessageId = ContainsToolResult(chatResponse)
|
||||
? Guid.NewGuid().ToString("N")
|
||||
: (streamingMessageId ??= Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
|
||||
if (chatResponse is { Contents.Count: > 0 } &&
|
||||
@@ -725,4 +726,17 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
_ => JsonSerializer.Serialize(functionResultContent.Result, options.GetTypeInfo(functionResultContent.Result.GetType())),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ContainsToolResult(ChatResponseUpdate chatResponse)
|
||||
{
|
||||
foreach (AIContent content in chatResponse.Contents)
|
||||
{
|
||||
if (content is FunctionResultContent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
private readonly ILogger<AgentFrameworkResponseHandler> _logger;
|
||||
private readonly FoundryToolboxService? _toolboxService;
|
||||
|
||||
/// <summary>
|
||||
/// Cached fallback used when no <see cref="HostedSessionIsolationKeyProvider"/> is registered in DI.
|
||||
/// Avoids a per-request allocation on the request hot path.
|
||||
/// </summary>
|
||||
private static readonly HostedSessionIsolationKeyProvider s_defaultIsolationKeyProvider = new PlatformHostedSessionIsolationKeyProvider();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFrameworkResponseHandler"/> class
|
||||
/// that resolves agents from keyed DI services.
|
||||
@@ -67,6 +73,42 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false)
|
||||
: await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 2.5. Resolve and apply the per-request hosted session identity context.
|
||||
// Fresh sessions are tagged once. Resumed sessions are validated against the live request
|
||||
// to detect cross-user session leaks and in-process tampering of the persisted identity.
|
||||
var isolationKeyProvider = this._serviceProvider.GetService<HostedSessionIsolationKeyProvider>()
|
||||
?? s_defaultIsolationKeyProvider;
|
||||
var resolvedHostedContext = await isolationKeyProvider.GetKeysAsync(context, request, cancellationToken).ConfigureAwait(false);
|
||||
if (resolvedHostedContext is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The registered {nameof(HostedSessionIsolationKeyProvider)} returned null for the current request. " +
|
||||
"Ensure the Foundry platform is providing the x-agent-user-isolation-key and x-agent-chat-isolation-key headers, " +
|
||||
"or register a custom provider that supplies fallback values for local development.");
|
||||
}
|
||||
|
||||
if (session is not null)
|
||||
{
|
||||
var existingHostedContext = session.GetHostedContext();
|
||||
if (existingHostedContext is null)
|
||||
{
|
||||
// Fresh path: the session has no hosted context yet (either freshly created here,
|
||||
// or freshly loaded for a conversation_id that the platform supplied without any
|
||||
// prior hosted-agent request having stamped a context). Stamp it now.
|
||||
session.SetHostedContext(resolvedHostedContext);
|
||||
}
|
||||
else if (!string.Equals(existingHostedContext.UserId, resolvedHostedContext.UserId, StringComparison.Ordinal)
|
||||
|| !string.Equals(existingHostedContext.ChatId, resolvedHostedContext.ChatId, StringComparison.Ordinal))
|
||||
{
|
||||
// Resume path: the persisted identity must match the live request. A mismatch
|
||||
// signals either a cross-user session leak or in-process tampering of the
|
||||
// persisted identity. Reject the request hard.
|
||||
throw new ResponsesApiException(
|
||||
new Error("hosted_session_identity_mismatch", "Hosted session identity context mismatch"),
|
||||
403);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create the SDK event stream builder
|
||||
var stream = new ResponseEventStream(context, request);
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Built-in <see cref="FoundryMemoryProvider"/> <c>stateInitializer</c> factories that derive the
|
||||
/// <see cref="FoundryMemoryProviderScope"/> from the per-session <see cref="HostedSessionContext"/>
|
||||
/// applied by the Foundry hosting layer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pass the result of any of these helpers as the <c>stateInitializer</c> argument when constructing
|
||||
/// <see cref="FoundryMemoryProvider"/>:
|
||||
/// <code>
|
||||
/// new FoundryMemoryProvider(client, "my-store",
|
||||
/// stateInitializer: HostedFoundryMemoryProviderScopes.PerUser());
|
||||
/// </code>
|
||||
/// All helpers throw <see cref="InvalidOperationException"/> when
|
||||
/// <see cref="HostedSessionContextExtensions.GetHostedContext"/> returns <see langword="null"/>.
|
||||
/// That happens when the agent runs outside the Foundry hosting layer (e.g., a console app); in
|
||||
/// that case write a custom <c>stateInitializer</c> instead of using these helpers.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class HostedFoundryMemoryProviderScopes
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a <c>stateInitializer</c> that scopes memories per end user, using
|
||||
/// <see cref="HostedSessionContext.UserId"/> as the partition key.
|
||||
/// </summary>
|
||||
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
|
||||
public static Func<AgentSession?, FoundryMemoryProvider.State> PerUser() =>
|
||||
session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).UserId));
|
||||
|
||||
/// <summary>
|
||||
/// Returns a <c>stateInitializer</c> that scopes memories per conversation, using
|
||||
/// <see cref="HostedSessionContext.ChatId"/> as the partition key. Use this when memories should
|
||||
/// be visible to every participant in a shared conversation (for example, a Teams group chat).
|
||||
/// </summary>
|
||||
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
|
||||
public static Func<AgentSession?, FoundryMemoryProvider.State> PerChat() =>
|
||||
session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).ChatId));
|
||||
|
||||
/// <summary>
|
||||
/// Returns a <c>stateInitializer</c> that scopes memories per (user, chat) pair, using
|
||||
/// <c>"{UserId}:{ChatId}"</c> as the partition key. Use this when memories should be visible
|
||||
/// only to the same user within the same conversation.
|
||||
/// </summary>
|
||||
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
|
||||
public static Func<AgentSession?, FoundryMemoryProvider.State> PerUserAndChat() =>
|
||||
session =>
|
||||
{
|
||||
var ctx = GetRequiredHostedContext(session);
|
||||
return new FoundryMemoryProvider.State(new FoundryMemoryProviderScope($"{ctx.UserId}:{ctx.ChatId}"));
|
||||
};
|
||||
|
||||
private static HostedSessionContext GetRequiredHostedContext(AgentSession? session) =>
|
||||
session?.GetHostedContext()
|
||||
?? throw new InvalidOperationException(
|
||||
$"{nameof(HostedSessionContext)} was not provided by the hosting layer. " +
|
||||
$"The {nameof(HostedFoundryMemoryProviderScopes)} helpers require the agent to be hosted via the Foundry hosting layer. " +
|
||||
"If running outside a hosted Foundry container, supply a custom stateInitializer to FoundryMemoryProvider instead.");
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Azure.AI.Projects;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency-injection helpers that register a <see cref="FoundryMemoryProvider"/> wired with a
|
||||
/// <see cref="HostedFoundryMemoryProviderScopes"/> strategy.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class HostedFoundryMemoryProviderServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a singleton <see cref="FoundryMemoryProvider"/> wired to the supplied
|
||||
/// <see cref="AIProjectClient"/> and the supplied <paramref name="stateInitializer"/>.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="client">The <see cref="AIProjectClient"/> used to talk to Foundry Memory.</param>
|
||||
/// <param name="memoryStoreName">The name of the memory store in Microsoft Foundry.</param>
|
||||
/// <param name="stateInitializer">
|
||||
/// Strategy that selects the per-session <see cref="FoundryMemoryProviderScope"/>. When
|
||||
/// <see langword="null"/>, the extension uses <see cref="HostedFoundryMemoryProviderScopes.PerUser"/>.
|
||||
/// Pass any other helper (or a custom delegate) to override.
|
||||
/// </param>
|
||||
/// <param name="options">Optional <see cref="FoundryMemoryProviderOptions"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
||||
public static IServiceCollection AddHostedFoundryMemoryProvider(
|
||||
this IServiceCollection services,
|
||||
AIProjectClient client,
|
||||
string memoryStoreName,
|
||||
Func<AgentSession?, FoundryMemoryProvider.State>? stateInitializer = null,
|
||||
FoundryMemoryProviderOptions? options = null)
|
||||
{
|
||||
Throw.IfNull(services);
|
||||
Throw.IfNull(client);
|
||||
Throw.IfNullOrWhitespace(memoryStoreName);
|
||||
|
||||
var initializer = stateInitializer ?? HostedFoundryMemoryProviderScopes.PerUser();
|
||||
services.AddSingleton(sp => new FoundryMemoryProvider(
|
||||
client,
|
||||
memoryStoreName,
|
||||
initializer,
|
||||
options,
|
||||
sp.GetService<ILoggerFactory>()));
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a singleton <see cref="FoundryMemoryProvider"/> that resolves its
|
||||
/// <see cref="AIProjectClient"/> from <see cref="IServiceProvider"/> at construction time.
|
||||
/// Use this overload when an <see cref="AIProjectClient"/> is already registered with the
|
||||
/// service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="memoryStoreName">The name of the memory store in Microsoft Foundry.</param>
|
||||
/// <param name="stateInitializer">
|
||||
/// Strategy that selects the per-session <see cref="FoundryMemoryProviderScope"/>. When
|
||||
/// <see langword="null"/>, the extension uses <see cref="HostedFoundryMemoryProviderScopes.PerUser"/>.
|
||||
/// Pass any other helper (or a custom delegate) to override.
|
||||
/// </param>
|
||||
/// <param name="options">Optional <see cref="FoundryMemoryProviderOptions"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
||||
public static IServiceCollection AddHostedFoundryMemoryProvider(
|
||||
this IServiceCollection services,
|
||||
string memoryStoreName,
|
||||
Func<AgentSession?, FoundryMemoryProvider.State>? stateInitializer = null,
|
||||
FoundryMemoryProviderOptions? options = null)
|
||||
{
|
||||
Throw.IfNull(services);
|
||||
Throw.IfNullOrWhitespace(memoryStoreName);
|
||||
|
||||
var initializer = stateInitializer ?? HostedFoundryMemoryProviderScopes.PerUser();
|
||||
services.AddSingleton(sp => new FoundryMemoryProvider(
|
||||
sp.GetRequiredService<AIProjectClient>(),
|
||||
memoryStoreName,
|
||||
initializer,
|
||||
options,
|
||||
sp.GetService<ILoggerFactory>()));
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Captures the per-session identity values produced by a <see cref="HostedSessionIsolationKeyProvider"/>
|
||||
/// when a Foundry hosted agent processes a request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <see cref="UserId"/> partitions data that belongs to the individual who initiated the request
|
||||
/// (e.g., personal memory, per-user preferences). The <see cref="ChatId"/> partitions data that belongs
|
||||
/// to the conversation (e.g., conversation history, turn state). Both values are opaque strings whose
|
||||
/// meaning is determined by the active <see cref="HostedSessionIsolationKeyProvider"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Instances are constructed by the hosting layer from the platform-provided
|
||||
/// <c>IsolationContext</c> headers and stored on the session via
|
||||
/// <see cref="HostedSessionContextExtensions.SetHostedContext"/>. Consumers (typically
|
||||
/// <see cref="AIContextProvider"/> implementations) read the values through
|
||||
/// <see cref="HostedSessionContextExtensions.GetHostedContext"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class HostedSessionContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HostedSessionContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userId">The opaque user identity for this hosted session. Must not be null or whitespace.</param>
|
||||
/// <param name="chatId">The opaque chat (conversation) identity for this hosted session. Must not be null or whitespace.</param>
|
||||
/// <exception cref="System.ArgumentException">Thrown when <paramref name="userId"/> or <paramref name="chatId"/> is null or whitespace.</exception>
|
||||
public HostedSessionContext(string userId, string chatId)
|
||||
{
|
||||
this.UserId = Throw.IfNullOrWhitespace(userId);
|
||||
this.ChatId = Throw.IfNullOrWhitespace(chatId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the opaque user identity for this hosted session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Stable for a given user across sessions. In production this is sourced from the
|
||||
/// <c>x-agent-user-isolation-key</c> platform header.
|
||||
/// </remarks>
|
||||
public string UserId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the opaque chat (conversation) identity for this hosted session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In a 1:1 user-to-agent chat this typically equals <see cref="UserId"/>. In shared-surface
|
||||
/// scenarios (e.g., a Teams group chat) it represents the common partition all participants
|
||||
/// write to. In production this is sourced from the <c>x-agent-chat-isolation-key</c> platform header.
|
||||
/// </remarks>
|
||||
public string ChatId { get; }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for reading and writing the <see cref="HostedSessionContext"/> associated
|
||||
/// with an <see cref="AgentSession"/> in a Foundry hosted agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The hosted session context is written exactly once by the hosting layer when a session is created,
|
||||
/// and is validated against the live request on every subsequent invocation. The <see cref="SetHostedContext"/>
|
||||
/// method is intentionally <see langword="internal"/> so that only the hosting layer can establish the
|
||||
/// identity values; consumers (such as <see cref="AIContextProvider"/> implementations) read the values
|
||||
/// through the public <see cref="GetHostedContext"/> accessor.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class HostedSessionContextExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The well-known <see cref="AgentSessionStateBag"/> key used to store the
|
||||
/// <see cref="HostedSessionContext"/> on a session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exposed as a constant so consumers can correlate persisted state across processes.
|
||||
/// External code must not write to this key directly; use <see cref="SetHostedContext"/> from the
|
||||
/// hosting assembly instead.
|
||||
/// </remarks>
|
||||
public const string StateKey = "Microsoft.Agents.AI.Foundry.Hosting.HostedSessionContext";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="HostedSessionContext"/> previously written by the hosting layer
|
||||
/// for this session, if any.
|
||||
/// </summary>
|
||||
/// <param name="session">The session to read from.</param>
|
||||
/// <returns>
|
||||
/// The <see cref="HostedSessionContext"/> for the session, or <see langword="null"/> when the
|
||||
/// session was not produced by a hosted agent (or the value has not yet been written).
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="session"/> is <see langword="null"/>.</exception>
|
||||
public static HostedSessionContext? GetHostedContext(this AgentSession session)
|
||||
{
|
||||
Throw.IfNull(session);
|
||||
|
||||
return session.StateBag.TryGetValue<HostedSessionContext>(StateKey, out var context, HostedSessionJsonUtilities.DefaultOptions)
|
||||
? context
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the <see cref="HostedSessionContext"/> for this session.
|
||||
/// </summary>
|
||||
/// <param name="session">The session to write to.</param>
|
||||
/// <param name="context">The hosted session context to associate with <paramref name="session"/>.</param>
|
||||
/// <remarks>
|
||||
/// Internal to the hosting assembly. Consumers must not invoke this method directly; the hosting
|
||||
/// layer is the single writer and uses validation against the live request to detect any tampering
|
||||
/// that does occur via lower-level APIs. Throws when a context has already been written for this
|
||||
/// session to enforce the write-once contract.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="session"/> or <paramref name="context"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown when this session already carries a <see cref="HostedSessionContext"/>.</exception>
|
||||
internal static void SetHostedContext(this AgentSession session, HostedSessionContext context)
|
||||
{
|
||||
Throw.IfNull(session);
|
||||
Throw.IfNull(context);
|
||||
|
||||
if (session.StateBag.TryGetValue<HostedSessionContext>(StateKey, out _, HostedSessionJsonUtilities.DefaultOptions))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A {nameof(HostedSessionContext)} has already been written to this session. " +
|
||||
"The hosted session identity is write-once; resumed sessions must validate against the existing context, not overwrite it.");
|
||||
}
|
||||
|
||||
session.StateBag.SetValue(StateKey, context, HostedSessionJsonUtilities.DefaultOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the per-request <see cref="HostedSessionContext"/> for a Foundry hosted agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implementations are invoked once per incoming Responses API request. The returned
|
||||
/// <see cref="HostedSessionContext"/> establishes the identity of a freshly created session and
|
||||
/// is validated against the live request on every subsequent invocation that resumes the same session.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The default implementation registered when no custom <see cref="HostedSessionIsolationKeyProvider"/>
|
||||
/// is present in DI maps the platform-injected <c>x-agent-user-isolation-key</c> and
|
||||
/// <c>x-agent-chat-isolation-key</c> headers via <see cref="ResponseContext.Isolation"/>. Hosting samples and contributor-only environments
|
||||
/// can register an alternate implementation in DI to provide values when the platform headers are absent
|
||||
/// (e.g., during local Docker debugging).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Implementations must return a <see cref="HostedSessionContext"/> whose <see cref="HostedSessionContext.UserId"/>
|
||||
/// and <see cref="HostedSessionContext.ChatId"/> are both non-null and non-whitespace. Returning either as null
|
||||
/// (or throwing from <see cref="GetKeysAsync"/>) is treated as a configuration error and surfaces as a
|
||||
/// 500 from the hosting layer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public abstract class HostedSessionIsolationKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the <see cref="HostedSessionContext"/> for the supplied request.
|
||||
/// </summary>
|
||||
/// <param name="context">The per-request <see cref="ResponseContext"/> from the Azure AI Responses Server SDK.</param>
|
||||
/// <param name="request">The <see cref="CreateResponse"/> describing the incoming request.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="HostedSessionContext"/> with non-null <see cref="HostedSessionContext.UserId"/> and
|
||||
/// <see cref="HostedSessionContext.ChatId"/>, or <see langword="null"/> when the implementation cannot
|
||||
/// produce identity keys for the current request. A <see langword="null"/> result is treated as a
|
||||
/// configuration error by the hosting layer and surfaces as 500.
|
||||
/// </returns>
|
||||
public abstract ValueTask<HostedSessionContext?> GetKeysAsync(
|
||||
ResponseContext context,
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// JSON serialization utilities for hosted session identity types.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
internal static class HostedSessionJsonUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Default JSON serializer options for hosted session state.
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions DefaultOptions { get; } = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
TypeInfoResolver = HostedSessionJsonContext.Default
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON serialization context for hosted session identity types.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
JsonSerializerDefaults.General,
|
||||
UseStringEnumConverter = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
WriteIndented = false)]
|
||||
[JsonSerializable(typeof(HostedSessionContext))]
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
internal partial class HostedSessionJsonContext : JsonSerializerContext;
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="HostedSessionIsolationKeyProvider"/> implementation that maps the platform-injected
|
||||
/// <c>x-agent-user-isolation-key</c> and <c>x-agent-chat-isolation-key</c> headers from
|
||||
/// <see cref="ResponseContext.Isolation"/> into a <see cref="HostedSessionContext"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the implementation used in production Foundry hosted environments. When running locally
|
||||
/// outside the platform, both isolation keys are <see langword="null"/>, which causes
|
||||
/// <see cref="GetKeysAsync"/> to return <see langword="null"/>. The hosting layer treats a null
|
||||
/// result as a configuration error and surfaces it as a 500 from the request. Local development
|
||||
/// should register an alternate <see cref="HostedSessionIsolationKeyProvider"/> implementation
|
||||
/// that provides fallback values for the missing headers.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
internal sealed class PlatformHostedSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<HostedSessionContext?> GetKeysAsync(
|
||||
ResponseContext context,
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userKey = context?.Isolation?.UserIsolationKey;
|
||||
var chatKey = context?.Isolation?.ChatIsolationKey;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userKey) || string.IsNullOrWhiteSpace(chatKey))
|
||||
{
|
||||
return new ValueTask<HostedSessionContext?>((HostedSessionContext?)null);
|
||||
}
|
||||
|
||||
return new ValueTask<HostedSessionContext?>(new HostedSessionContext(userKey!, chatKey!));
|
||||
}
|
||||
}
|
||||
@@ -120,9 +120,24 @@ public static class OpenAIResponseClientExtensions
|
||||
return Throw.IfNull(responseClient)
|
||||
.AsIChatClient(model)
|
||||
.AsBuilder()
|
||||
.ConfigureOptions(x => x.RawRepresentationFactory = _ => includeReasoningEncryptedContent
|
||||
? new CreateResponseOptions() { StoredOutputEnabled = false, IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent } }
|
||||
: new CreateResponseOptions() { StoredOutputEnabled = false })
|
||||
.ConfigureOptions(x =>
|
||||
{
|
||||
var previousFactory = x.RawRepresentationFactory;
|
||||
x.RawRepresentationFactory = state =>
|
||||
{
|
||||
var responseOptions = previousFactory?.Invoke(state) as CreateResponseOptions ?? new CreateResponseOptions();
|
||||
|
||||
responseOptions.StoredOutputEnabled = false;
|
||||
|
||||
if (includeReasoningEncryptedContent &&
|
||||
!responseOptions.IncludedProperties.Contains(IncludedResponseProperty.ReasoningEncryptedContent))
|
||||
{
|
||||
responseOptions.IncludedProperties.Add(IncludedResponseProperty.ReasoningEncryptedContent);
|
||||
}
|
||||
|
||||
return responseOptions;
|
||||
};
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using Azure.Identity;
|
||||
using Azure.Search.Documents;
|
||||
using Azure.Search.Documents.Models;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -34,6 +35,7 @@ AIAgent agent = scenario switch
|
||||
"tool-calling-approval" => CreateToolCallingApprovalAgent(projectClient, deployment),
|
||||
"mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment),
|
||||
"custom-storage" => CreateCustomStorageAgent(projectClient, deployment),
|
||||
"memory" => await CreateMemoryAgentAsync(projectClient, deployment).ConfigureAwait(false),
|
||||
"azure-search-rag" => CreateAzureSearchRagAgent(projectClient, deployment),
|
||||
"session-files" => CreateSessionFilesAgent(projectClient, deployment),
|
||||
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
|
||||
@@ -179,6 +181,34 @@ static AIAgent CreateSessionFilesAgent(AIProjectClient client, string deployment
|
||||
AIFunctionFactory.Create(ReadFile)
|
||||
]);
|
||||
|
||||
// Memory scenario. The agent uses FoundryMemoryProvider scoped per user via the
|
||||
// HostedSessionContext that the hosting layer applies from the platform isolation headers.
|
||||
// In production the platform sets the headers; here we rely on the default
|
||||
// PlatformHostedSessionIsolationKeyProvider that AgentFrameworkResponseHandler resolves.
|
||||
static async Task<AIAgent> CreateMemoryAgentAsync(AIProjectClient client, string deployment)
|
||||
{
|
||||
var embedding = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002";
|
||||
var memoryStoreName = Environment.GetEnvironmentVariable("IT_MEMORY_STORE_ID") ?? "it-memory-store";
|
||||
|
||||
var memoryProvider = new FoundryMemoryProvider(
|
||||
client,
|
||||
memoryStoreName,
|
||||
stateInitializer: HostedFoundryMemoryProviderScopes.PerUser());
|
||||
|
||||
await memoryProvider.EnsureMemoryStoreCreatedAsync(deployment, embedding, "Memory store for hosted-memory IT scenario.").ConfigureAwait(false);
|
||||
|
||||
return client.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "memory-agent",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = deployment,
|
||||
Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details."
|
||||
},
|
||||
AIContextProviders = [memoryProvider]
|
||||
});
|
||||
}
|
||||
|
||||
[Description("Returns the current UTC date and time as an ISO 8601 string.")]
|
||||
static string GetUtcNow() => DateTime.UtcNow.ToString("o");
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=memory</c> mode.
|
||||
/// Used by tests that exercise <see cref="Microsoft.Agents.AI.Foundry.FoundryMemoryProvider"/>
|
||||
/// running inside the Foundry hosted agent. The memory store name is randomised per fixture
|
||||
/// instance so concurrent test runs do not share state.
|
||||
/// </summary>
|
||||
public sealed class MemoryHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "memory";
|
||||
|
||||
/// <summary>
|
||||
/// Memory store name passed to the test container via <c>IT_MEMORY_STORE_ID</c> so that each
|
||||
/// fixture instance gets a fresh, isolated bucket of memories.
|
||||
/// </summary>
|
||||
public string MemoryStoreId { get; } = $"it-memory-{Guid.NewGuid():N}";
|
||||
|
||||
protected override void ConfigureEnvironment(IDictionary<string, string> environment)
|
||||
{
|
||||
environment["IT_MEMORY_STORE_ID"] = this.MemoryStoreId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
#pragma warning disable OPENAI001 // Experimental Responses API surfaces
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the Hosted-MemoryAgent end-to-end against a deployed test container running the
|
||||
/// <c>IT_SCENARIO=memory</c> scenario. Asserts that <see cref="Microsoft.Agents.AI.Foundry.FoundryMemoryProvider"/>
|
||||
/// scoped via <see cref="Microsoft.Agents.AI.Foundry.Hosting.HostedSessionContext"/> recalls user
|
||||
/// preferences across multiple turns of a conversation.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class MemoryHostedAgentTests(MemoryHostedAgentFixture fixture) : IClassFixture<MemoryHostedAgentFixture>
|
||||
{
|
||||
private readonly MemoryHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact]
|
||||
public async Task Memory_RecallsAcrossTurnsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act: teach the agent two pieces of information about the user.
|
||||
var first = await agent.RunAsync("My name is Taylor and I am planning a hiking trip to Patagonia in November.", session);
|
||||
Assert.False(string.IsNullOrWhiteSpace(first.Text));
|
||||
|
||||
var second = await agent.RunAsync("I am travelling with my sister and we love finding scenic viewpoints.", session);
|
||||
Assert.False(string.IsNullOrWhiteSpace(second.Text));
|
||||
|
||||
// FoundryMemoryProvider defaults to UpdateDelay=0 (immediate trigger). Server-side ingestion
|
||||
// typically completes within ~3 seconds; allow a small margin.
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
|
||||
var recall = await agent.RunAsync("What do you already know about my upcoming trip?", session);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Patagonia", recall.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Foundry Memory write propagation is eventually consistent and the in-container WhenUpdatesCompletedAsync flush hook is not callable from the test process; this scenario is exercised manually via the sample's smoke.ps1.")]
|
||||
public async Task Memory_PersistsAcrossSessionsForSameUserAsync()
|
||||
{
|
||||
// Arrange: drive a session that establishes some user-private memory. Foundry Memory
|
||||
// extracts memories more reliably from multi-turn conversations than from a single
|
||||
// imperative utterance, so mirror the sample's two-turn teaching pattern.
|
||||
var agent = this._fixture.Agent;
|
||||
var teachingSession = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("My preferred airline is Iberia and I always fly business class.", teachingSession);
|
||||
await agent.RunAsync("I also prefer aisle seats whenever they are available.", teachingSession);
|
||||
|
||||
// FoundryMemoryProvider defaults to UpdateDelay=0 (immediate trigger). Server-side
|
||||
// ingestion typically completes within ~3 seconds; poll a fresh-session recall a few
|
||||
// times before failing so the test does not flake on cold caches.
|
||||
AgentResponse recall = null!;
|
||||
const int MaxAttempts = 6;
|
||||
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
|
||||
var freshSession = await agent.CreateSessionAsync();
|
||||
recall = await agent.RunAsync("Which airline do I prefer? Reply with just the airline name.", freshSession);
|
||||
|
||||
if (recall.Text.Contains("Iberia", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Iberia", recall.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ $Scenarios = @(
|
||||
'tool-calling-approval',
|
||||
'mcp-toolbox',
|
||||
'custom-storage',
|
||||
'memory',
|
||||
'azure-search-rag',
|
||||
'session-files'
|
||||
)
|
||||
|
||||
@@ -149,6 +149,93 @@ public sealed class AGUIStreamingMessageIdTests
|
||||
"ParentMessageId should have a generated fallback for empty provider MessageId");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tool results are separate tool-role messages, so their fallback IDs must not
|
||||
/// collide with the assistant message that requested the tool call.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ToolResults_NullMessageId_GeneratesDistinctMessageIdAsync()
|
||||
{
|
||||
FunctionCallContent functionCall = new("call_abc123", "GetWeather")
|
||||
{
|
||||
Arguments = new Dictionary<string, object?> { ["location"] = "San Francisco" }
|
||||
};
|
||||
|
||||
List<ChatResponseUpdate> providerUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "Checking the weather"),
|
||||
new ChatResponseUpdate
|
||||
{
|
||||
Role = ChatRole.Assistant,
|
||||
Contents = [functionCall]
|
||||
},
|
||||
new ChatResponseUpdate(ChatRole.Tool, [new FunctionResultContent("call_abc123", "72F and sunny")])
|
||||
];
|
||||
|
||||
List<BaseEvent> aguiEvents = [];
|
||||
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
|
||||
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
aguiEvents.Add(evt);
|
||||
}
|
||||
|
||||
TextMessageStartEvent textStart = Assert.Single(aguiEvents.OfType<TextMessageStartEvent>());
|
||||
ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType<ToolCallStartEvent>());
|
||||
ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType<ToolCallResultEvent>());
|
||||
|
||||
Assert.Equal(textStart.MessageId, toolCallStart.ParentMessageId);
|
||||
Assert.Equal("call_abc123", toolCallResult.ToolCallId);
|
||||
Assert.False(string.IsNullOrEmpty(toolCallResult.MessageId));
|
||||
Assert.NotEqual(textStart.MessageId, toolCallResult.MessageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToolResults_WithTextContent_GeneratesDistinctMessageIdAsync()
|
||||
{
|
||||
FunctionCallContent functionCall = new("call_abc123", "GetWeather")
|
||||
{
|
||||
Arguments = new Dictionary<string, object?> { ["location"] = "San Francisco" }
|
||||
};
|
||||
|
||||
List<ChatResponseUpdate> providerUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "Checking the weather"),
|
||||
new ChatResponseUpdate
|
||||
{
|
||||
Role = ChatRole.Assistant,
|
||||
Contents = [functionCall]
|
||||
},
|
||||
new ChatResponseUpdate
|
||||
{
|
||||
Role = ChatRole.Tool,
|
||||
Contents =
|
||||
[
|
||||
new TextContent("Tool says: "),
|
||||
new FunctionResultContent("call_abc123", "72F and sunny")
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
List<BaseEvent> aguiEvents = [];
|
||||
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
|
||||
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
aguiEvents.Add(evt);
|
||||
}
|
||||
|
||||
TextMessageStartEvent[] textStarts = aguiEvents.OfType<TextMessageStartEvent>().ToArray();
|
||||
TextMessageContentEvent toolText = Assert.Single(
|
||||
aguiEvents.OfType<TextMessageContentEvent>(),
|
||||
content => content.Delta == "Tool says: ");
|
||||
ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType<ToolCallStartEvent>());
|
||||
ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType<ToolCallResultEvent>());
|
||||
|
||||
Assert.Equal(textStarts[0].MessageId, toolCallStart.ParentMessageId);
|
||||
Assert.NotEqual(textStarts[0].MessageId, toolCallResult.MessageId);
|
||||
Assert.Equal(toolCallResult.MessageId, toolText.MessageId);
|
||||
Assert.Equal(textStarts[^1].MessageId, toolCallResult.MessageId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When a provider properly sets MessageId (e.g., OpenAI), the AGUI pipeline
|
||||
/// produces valid events with correct messageId values.
|
||||
|
||||
@@ -24,6 +24,45 @@ public class AgentSessionTests
|
||||
Assert.Equal("value1", session.StateBag.GetValue<string>("key1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateBag_Default_IsEmpty()
|
||||
{
|
||||
// Arrange & Act
|
||||
var session = new TestAgentSession();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, session.StateBag.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateBag_MultipleKeys_StoreAndRetrieveIndependently()
|
||||
{
|
||||
// Arrange
|
||||
var session = new TestAgentSession();
|
||||
|
||||
// Act
|
||||
session.StateBag.SetValue("key1", "value1");
|
||||
session.StateBag.SetValue("key2", "value2");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("value1", session.StateBag.GetValue<string>("key1"));
|
||||
Assert.Equal("value2", session.StateBag.GetValue<string>("key2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateBag_OverwriteValue_ReturnsUpdatedValue()
|
||||
{
|
||||
// Arrange
|
||||
var session = new TestAgentSession();
|
||||
session.StateBag.SetValue("key1", "original");
|
||||
|
||||
// Act
|
||||
session.StateBag.SetValue("key1", "updated");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("updated", session.StateBag.GetValue<string>("key1"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetService Method Tests
|
||||
|
||||
+4
@@ -47,6 +47,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -75,6 +76,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton<AIAgent>("keyed-agent", agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -116,6 +118,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton(preWrapped);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -147,6 +150,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
+17
@@ -29,6 +29,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<ILogger<AgentFrameworkResponseHandler>>(NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -67,6 +68,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton<AIAgent>("my-agent", agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -102,6 +104,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -151,6 +154,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton<AIAgent>("my-agent", agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -188,6 +192,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton<AIAgent>("entity-agent", agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -228,6 +233,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -263,6 +269,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -297,6 +304,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -331,6 +339,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -376,6 +385,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -424,6 +434,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -467,6 +478,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -506,6 +518,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -548,6 +561,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -589,6 +603,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton<AIAgent>("agent-1", agent1);
|
||||
services.AddKeyedSingleton<AIAgent>("agent-2", agent2);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -626,6 +641,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -663,6 +679,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
+2
@@ -144,6 +144,7 @@ public class AgentFrameworkResponseHandlerWorkflowTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton("my-workflow", workflowAgent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -166,6 +167,7 @@ public class AgentFrameworkResponseHandlerWorkflowTests
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton(agent);
|
||||
services.AddSingleton<ILogger<AgentFrameworkResponseHandler>>(NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Test fake that returns a non-null <see cref="HostedSessionContext"/> by default, allowing tests
|
||||
/// that were written before the strict isolation-key contract to keep passing without each test
|
||||
/// having to stub <c>ResponseContext.Isolation</c>. The constructor also accepts <see langword="null"/>
|
||||
/// values so individual tests can exercise the handler's null-key error path.
|
||||
/// </summary>
|
||||
internal sealed class FakeHostedSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider
|
||||
{
|
||||
public const string DefaultUserId = "test-user-isolation";
|
||||
public const string DefaultChatId = "test-chat-isolation";
|
||||
|
||||
private readonly HostedSessionContext? _context;
|
||||
|
||||
public FakeHostedSessionIsolationKeyProvider(string? userId = DefaultUserId, string? chatId = DefaultChatId)
|
||||
{
|
||||
this._context = userId is null || chatId is null
|
||||
? null
|
||||
: new HostedSessionContext(userId, chatId);
|
||||
}
|
||||
|
||||
public override ValueTask<HostedSessionContext?> GetKeysAsync(
|
||||
ResponseContext context,
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken)
|
||||
=> new(this._context);
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HostedFoundryMemoryProviderScopes"/> built-in stateInitializer factories.
|
||||
/// </summary>
|
||||
public class HostedFoundryMemoryProviderScopesTests
|
||||
{
|
||||
private const string TestUserId = "user-isolation-key-1";
|
||||
private const string TestChatId = "chat-isolation-key-1";
|
||||
|
||||
[Fact]
|
||||
public void PerUser_UsesUserIdAsScope()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession(TestUserId, TestChatId);
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUser();
|
||||
|
||||
// Act
|
||||
var state = initializer(session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal(TestUserId, state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerChat_UsesChatIdAsScope()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession(TestUserId, TestChatId);
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerChat();
|
||||
|
||||
// Act
|
||||
var state = initializer(session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal(TestChatId, state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUserAndChat_ComposesUserAndChatWithColon()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession(TestUserId, TestChatId);
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
|
||||
|
||||
// Act
|
||||
var state = initializer(session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal($"{TestUserId}:{TestChatId}", state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUser_NullSession_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUser();
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => initializer(null));
|
||||
Assert.Contains(nameof(HostedSessionContext), ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerChat_NullSession_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerChat();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() => initializer(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUserAndChat_NullSession_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() => initializer(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUser_SessionWithoutHostedContext_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var session = new BareAgentSession();
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUser();
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => initializer(session));
|
||||
Assert.Contains(nameof(HostedFoundryMemoryProviderScopes), ex.Message);
|
||||
}
|
||||
|
||||
private static BareAgentSession CreateTaggedSession(string userId, string chatId)
|
||||
{
|
||||
var session = new BareAgentSession();
|
||||
session.SetHostedContext(new HostedSessionContext(userId, chatId));
|
||||
return session;
|
||||
}
|
||||
|
||||
private sealed class BareAgentSession : AgentSession
|
||||
{
|
||||
public BareAgentSession() : base(new AgentSessionStateBag()) { }
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HostedFoundryMemoryProviderServiceCollectionExtensions"/>.
|
||||
/// </summary>
|
||||
public class HostedFoundryMemoryProviderServiceCollectionExtensionsTests
|
||||
{
|
||||
private const string TestUserId = "ext-user-1";
|
||||
private const string TestChatId = "ext-chat-1";
|
||||
private const string MemoryStoreName = "test-memory-store";
|
||||
|
||||
[Fact]
|
||||
public void AddHostedFoundryMemoryProvider_ExplicitClient_RegistersSingleton()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var client = CreateClient();
|
||||
|
||||
// Act
|
||||
services.AddHostedFoundryMemoryProvider(client, MemoryStoreName);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
var first = sp.GetRequiredService<FoundryMemoryProvider>();
|
||||
var second = sp.GetRequiredService<FoundryMemoryProvider>();
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHostedFoundryMemoryProvider_DiResolvedClient_RegistersSingleton()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(CreateClient());
|
||||
|
||||
// Act
|
||||
services.AddHostedFoundryMemoryProvider(MemoryStoreName);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
var first = sp.GetRequiredService<FoundryMemoryProvider>();
|
||||
var second = sp.GetRequiredService<FoundryMemoryProvider>();
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHostedFoundryMemoryProvider_DiResolvedClient_MissingClient_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Act
|
||||
services.AddHostedFoundryMemoryProvider(MemoryStoreName);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
Assert.Throws<InvalidOperationException>(() => sp.GetRequiredService<FoundryMemoryProvider>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHostedFoundryMemoryProvider_NullStateInitializer_DefaultsToPerUser()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession();
|
||||
|
||||
// Act
|
||||
var services = new ServiceCollection();
|
||||
services.AddHostedFoundryMemoryProvider(CreateClient(), MemoryStoreName);
|
||||
var provider = services.BuildServiceProvider().GetRequiredService<FoundryMemoryProvider>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
var defaultInitializer = HostedFoundryMemoryProviderScopes.PerUser();
|
||||
var state = defaultInitializer(session);
|
||||
Assert.Equal(TestUserId, state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHostedFoundryMemoryProvider_CustomStateInitializer_IsHonored()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession();
|
||||
static FoundryMemoryProvider.State Custom(AgentSession? _)
|
||||
=> new(new FoundryMemoryProviderScope("custom-scope"));
|
||||
|
||||
// Act
|
||||
var services = new ServiceCollection();
|
||||
services.AddHostedFoundryMemoryProvider(CreateClient(), MemoryStoreName, Custom);
|
||||
var provider = services.BuildServiceProvider().GetRequiredService<FoundryMemoryProvider>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
var state = Custom(session);
|
||||
Assert.Equal("custom-scope", state.Scope.Scope);
|
||||
}
|
||||
|
||||
private static AIProjectClient CreateClient()
|
||||
=> new(new Uri("https://example.services.ai.azure.com/api/projects/test"), new DefaultAzureCredential());
|
||||
|
||||
private static BareAgentSession CreateTaggedSession()
|
||||
{
|
||||
var session = new BareAgentSession();
|
||||
session.SetHostedContext(new HostedSessionContext(TestUserId, TestChatId));
|
||||
return session;
|
||||
}
|
||||
|
||||
private sealed class BareAgentSession : AgentSession
|
||||
{
|
||||
public BareAgentSession() : base(new AgentSessionStateBag()) { }
|
||||
}
|
||||
}
|
||||
+1
@@ -101,6 +101,7 @@ public sealed class HostedOutboundUserAgentTests : IAsyncDisposable
|
||||
AIAgent agent = new ChatClientAgent(chatClient);
|
||||
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
builder.Services.AddLogging();
|
||||
|
||||
this._app = builder.Build();
|
||||
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests covering the per-session identity context that <see cref="AgentFrameworkResponseHandler"/>
|
||||
/// applies via the registered <see cref="HostedSessionIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
public class HostedSessionIdentityContextTests
|
||||
{
|
||||
private const string TestUserId = "user-isolation-key-1";
|
||||
private const string TestChatId = "chat-isolation-key-1";
|
||||
|
||||
[Fact]
|
||||
public void HostedSessionContext_RejectsNullOrWhitespaceKeys()
|
||||
{
|
||||
// Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new HostedSessionContext(null!, TestChatId));
|
||||
Assert.Throws<ArgumentNullException>(() => new HostedSessionContext(TestUserId, null!));
|
||||
Assert.Throws<ArgumentException>(() => new HostedSessionContext(string.Empty, TestChatId));
|
||||
Assert.Throws<ArgumentException>(() => new HostedSessionContext(TestUserId, " "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlatformProvider_MapsIsolationContextValuesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PlatformHostedSessionIsolationKeyProvider();
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.Isolation).Returns(new IsolationContext(TestUserId, TestChatId));
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
|
||||
// Act
|
||||
var result = await provider.GetKeysAsync(mockContext.Object, request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(TestUserId, result.UserId);
|
||||
Assert.Equal(TestChatId, result.ChatId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlatformProvider_ReturnsNullWhenIsolationKeysAreEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PlatformHostedSessionIsolationKeyProvider();
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
// CallBase delegates to ResponseContext.Isolation default which is IsolationContext.Empty.
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
|
||||
// Act
|
||||
var result = await provider.GetKeysAsync(mockContext.Object, request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_FreshSession_AppliesContextFromCustomProviderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var capturingAgent = new HostedContextCapturingAgent();
|
||||
var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A");
|
||||
var handler = BuildHandler(capturingAgent, fakeProvider);
|
||||
|
||||
var (request, mockContext) = BuildFreshRequest();
|
||||
|
||||
// Act
|
||||
await DrainAsync(handler.CreateAsync(request, mockContext.Object, CancellationToken.None));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturingAgent.LastSession);
|
||||
var ctx = capturingAgent.LastSession.GetHostedContext();
|
||||
Assert.NotNull(ctx);
|
||||
Assert.Equal("alice", ctx.UserId);
|
||||
Assert.Equal("chat-A", ctx.ChatId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_NullKeysFromProvider_ThrowsInvalidOperationAsync()
|
||||
{
|
||||
// Arrange
|
||||
var capturingAgent = new HostedContextCapturingAgent();
|
||||
var fakeProvider = new FakeHostedSessionIsolationKeyProvider(userId: null, chatId: null);
|
||||
var handler = BuildHandler(capturingAgent, fakeProvider);
|
||||
|
||||
var (request, mockContext) = BuildFreshRequest();
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => DrainAsync(handler.CreateAsync(request, mockContext.Object, CancellationToken.None)));
|
||||
Assert.Contains(nameof(HostedSessionIsolationKeyProvider), ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_ResumeSession_MatchingKeys_PassesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var capturingAgent = new HostedContextCapturingAgent();
|
||||
var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A");
|
||||
var sessionStore = new InMemoryAgentSessionStore();
|
||||
var handler = BuildHandler(capturingAgent, fakeProvider, sessionStore);
|
||||
|
||||
// Step 1: drive a fresh request to populate the session store with a tagged session.
|
||||
var (freshRequest, freshContext) = BuildFreshRequest();
|
||||
await DrainAsync(handler.CreateAsync(freshRequest, freshContext.Object, CancellationToken.None));
|
||||
Assert.NotNull(capturingAgent.LastSession);
|
||||
|
||||
// Step 2: persist the session under a known conversation id (mimics what the handler does
|
||||
// when it has a conversation id; here we plant it directly so we can drive a resume request).
|
||||
const string ConversationId = "resume-chat-id";
|
||||
await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession, CancellationToken.None);
|
||||
|
||||
// Step 3: drive a resume request with the same isolation keys.
|
||||
var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId);
|
||||
capturingAgent.LastSession = null;
|
||||
|
||||
// Act
|
||||
await DrainAsync(handler.CreateAsync(resumeRequest, resumeContext.Object, CancellationToken.None));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturingAgent.LastSession);
|
||||
var ctx = capturingAgent.LastSession.GetHostedContext();
|
||||
Assert.NotNull(ctx);
|
||||
Assert.Equal("alice", ctx.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_ResumeSession_MismatchedUserId_Returns403Async()
|
||||
{
|
||||
// Arrange
|
||||
var capturingAgent = new HostedContextCapturingAgent();
|
||||
var aliceProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A");
|
||||
var sessionStore = new InMemoryAgentSessionStore();
|
||||
var aliceHandler = BuildHandler(capturingAgent, aliceProvider, sessionStore);
|
||||
|
||||
var (freshRequest, freshContext) = BuildFreshRequest();
|
||||
await DrainAsync(aliceHandler.CreateAsync(freshRequest, freshContext.Object, CancellationToken.None));
|
||||
const string ConversationId = "resume-chat-id";
|
||||
await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession!, CancellationToken.None);
|
||||
|
||||
// Bob attempts to resume Alice's conversation.
|
||||
var bobProvider = new FakeHostedSessionIsolationKeyProvider("bob", "chat-A");
|
||||
var bobHandler = BuildHandler(capturingAgent, bobProvider, sessionStore);
|
||||
var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<ResponsesApiException>(() => DrainAsync(bobHandler.CreateAsync(resumeRequest, resumeContext.Object, CancellationToken.None)));
|
||||
Assert.Equal(403, ex.StatusCode);
|
||||
Assert.Equal("Hosted session identity context mismatch", ex.Error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_ResumeSession_MismatchedChatId_Returns403Async()
|
||||
{
|
||||
// Arrange
|
||||
var capturingAgent = new HostedContextCapturingAgent();
|
||||
var chatAProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A");
|
||||
var sessionStore = new InMemoryAgentSessionStore();
|
||||
var chatAHandler = BuildHandler(capturingAgent, chatAProvider, sessionStore);
|
||||
|
||||
var (freshRequest, freshContext) = BuildFreshRequest();
|
||||
await DrainAsync(chatAHandler.CreateAsync(freshRequest, freshContext.Object, CancellationToken.None));
|
||||
const string ConversationId = "resume-chat-id";
|
||||
await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession!, CancellationToken.None);
|
||||
|
||||
var chatBProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-B");
|
||||
var chatBHandler = BuildHandler(capturingAgent, chatBProvider, sessionStore);
|
||||
var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<ResponsesApiException>(() => DrainAsync(chatBHandler.CreateAsync(resumeRequest, resumeContext.Object, CancellationToken.None)));
|
||||
Assert.Equal(403, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_ResumeSession_WithoutPriorContext_StampsAsFreshAsync()
|
||||
{
|
||||
// Arrange: store an untagged session. This case arises in production when the platform
|
||||
// (or the caller) creates a Foundry conversation_id externally, and the very first
|
||||
// hosted-agent request for that conversation hits the handler before any context is
|
||||
// stamped. Such a session is treated as "fresh" rather than "resume" because there is
|
||||
// no prior identity to defend; the stamp made now is what future resumes will validate.
|
||||
var capturingAgent = new HostedContextCapturingAgent();
|
||||
var sessionStore = new InMemoryAgentSessionStore();
|
||||
const string ConversationId = "untagged-chat-id";
|
||||
var untagged = await capturingAgent.CreateSessionAsync(CancellationToken.None);
|
||||
await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, untagged, CancellationToken.None);
|
||||
|
||||
var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A");
|
||||
var handler = BuildHandler(capturingAgent, fakeProvider, sessionStore);
|
||||
var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId);
|
||||
|
||||
// Act
|
||||
await DrainAsync(handler.CreateAsync(resumeRequest, resumeContext.Object, CancellationToken.None));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturingAgent.LastSession);
|
||||
var ctx = capturingAgent.LastSession.GetHostedContext();
|
||||
Assert.NotNull(ctx);
|
||||
Assert.Equal("alice", ctx.UserId);
|
||||
Assert.Equal("chat-A", ctx.ChatId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHostedContext_ReturnsNullWhenAbsent()
|
||||
{
|
||||
// Arrange
|
||||
var session = new HostedContextCapturingSession();
|
||||
|
||||
// Act
|
||||
var ctx = session.GetHostedContext();
|
||||
|
||||
// Assert
|
||||
Assert.Null(ctx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetHostedContext_ThenGet_RoundTrips()
|
||||
{
|
||||
// Arrange
|
||||
var session = new HostedContextCapturingSession();
|
||||
|
||||
// Act
|
||||
session.SetHostedContext(new HostedSessionContext("alice", "chat-A"));
|
||||
var ctx = session.GetHostedContext();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(ctx);
|
||||
Assert.Equal("alice", ctx.UserId);
|
||||
Assert.Equal("chat-A", ctx.ChatId);
|
||||
}
|
||||
|
||||
private static AgentFrameworkResponseHandler BuildHandler(
|
||||
AIAgent agent,
|
||||
HostedSessionIsolationKeyProvider provider,
|
||||
AgentSessionStore? sessionStore = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(sessionStore ?? new InMemoryAgentSessionStore());
|
||||
services.AddSingleton(agent);
|
||||
services.AddSingleton(provider);
|
||||
var sp = services.BuildServiceProvider();
|
||||
return new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
}
|
||||
|
||||
private static (CreateResponse Request, Mock<ResponseContext> Context) BuildFreshRequest()
|
||||
{
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<Item>());
|
||||
return (request, mockContext);
|
||||
}
|
||||
|
||||
private static (CreateResponse Request, Mock<ResponseContext> Context) BuildResumeRequest(string conversationId)
|
||||
{
|
||||
var (request, mockContext) = BuildFreshRequest();
|
||||
request.Conversation = BinaryData.FromString($"\"{conversationId}\"");
|
||||
return (request, mockContext);
|
||||
}
|
||||
|
||||
private static async Task DrainAsync(IAsyncEnumerable<ResponseStreamEvent> stream)
|
||||
{
|
||||
await foreach (var _ in stream)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="AIAgent"/> subclass that captures the session it was invoked with so tests
|
||||
/// can inspect the <see cref="HostedSessionContext"/> applied by the handler.
|
||||
/// </summary>
|
||||
private sealed class HostedContextCapturingAgent : AIAgent
|
||||
{
|
||||
public AgentSession? LastSession { get; set; }
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.LastSession = session;
|
||||
return ToAsyncEnumerableAsync(new AgentResponseUpdate
|
||||
{
|
||||
MessageId = "resp_msg_1",
|
||||
Contents = [new Extensions.AI.TextContent("ok")]
|
||||
});
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(new HostedContextCapturingSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(((HostedContextCapturingSession)session).Serialize());
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(HostedContextCapturingSession.Deserialize(serializedState));
|
||||
|
||||
private static async IAsyncEnumerable<AgentResponseUpdate> ToAsyncEnumerableAsync(params AgentResponseUpdate[] items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal session implementation that round-trips its <see cref="AgentSessionStateBag"/> via JSON.
|
||||
/// </summary>
|
||||
private sealed class HostedContextCapturingSession : AgentSession
|
||||
{
|
||||
public HostedContextCapturingSession()
|
||||
{
|
||||
}
|
||||
|
||||
private HostedContextCapturingSession(AgentSessionStateBag bag)
|
||||
{
|
||||
this.StateBag = bag;
|
||||
}
|
||||
|
||||
public JsonElement Serialize() => this.StateBag.Serialize();
|
||||
|
||||
public static HostedContextCapturingSession Deserialize(JsonElement element)
|
||||
=> new(AgentSessionStateBag.Deserialize(element));
|
||||
}
|
||||
}
|
||||
+78
-1
@@ -129,6 +129,75 @@ public sealed class ProjectResponsesClientExtensionsTests
|
||||
Assert.DoesNotContain(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsIChatClientWithStoredOutputDisabled preserves an existing RawRepresentationFactory
|
||||
/// set on ChatOptions, augmenting it with StoredOutputEnabled and ReasoningEncryptedContent
|
||||
/// rather than replacing it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsIChatClientWithStoredOutputDisabled_PreservesExistingRawRepresentationFactory()
|
||||
{
|
||||
// Arrange
|
||||
var responseClient = CreateTestClient();
|
||||
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled();
|
||||
|
||||
// Simulate a caller setting their own RawRepresentationFactory on ChatOptions
|
||||
// (e.g., to add WebSearchCallActionSources).
|
||||
var options = new ChatOptions
|
||||
{
|
||||
RawRepresentationFactory = _ => new CreateResponseOptions
|
||||
{
|
||||
IncludedProperties = { IncludedResponseProperty.WebSearchCallActionSources },
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(createResponseOptions);
|
||||
Assert.False(createResponseOptions.StoredOutputEnabled);
|
||||
Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
|
||||
Assert.Contains(IncludedResponseProperty.WebSearchCallActionSources, createResponseOptions.IncludedProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsIChatClientWithStoredOutputDisabled does not duplicate ReasoningEncryptedContent
|
||||
/// when the existing factory already includes it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsIChatClientWithStoredOutputDisabled_DoesNotDuplicateReasoningEncryptedContent()
|
||||
{
|
||||
// Arrange
|
||||
var responseClient = CreateTestClient();
|
||||
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled();
|
||||
|
||||
// Simulate a caller that already includes ReasoningEncryptedContent
|
||||
var options = new ChatOptions
|
||||
{
|
||||
RawRepresentationFactory = _ => new CreateResponseOptions
|
||||
{
|
||||
IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent },
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options);
|
||||
|
||||
// Assert - ReasoningEncryptedContent should appear exactly once
|
||||
Assert.NotNull(createResponseOptions);
|
||||
int count = 0;
|
||||
foreach (var prop in createResponseOptions.IncludedProperties)
|
||||
{
|
||||
if (prop == IncludedResponseProperty.ReasoningEncryptedContent)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsIChatClientWithStoredOutputDisabled works with an optional deployment name.
|
||||
/// </summary>
|
||||
@@ -153,6 +222,15 @@ public sealed class ProjectResponsesClientExtensionsTests
|
||||
/// by using reflection to access the configure action and invoking it on a test <see cref="ChatOptions"/>.
|
||||
/// </summary>
|
||||
private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient)
|
||||
{
|
||||
return GetCreateResponseOptionsFromPipeline(chatClient, new ChatOptions());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overload that runs the configure action on caller-supplied <see cref="ChatOptions"/>,
|
||||
/// useful for testing that existing factories are preserved.
|
||||
/// </summary>
|
||||
private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient, ChatOptions options)
|
||||
{
|
||||
var configureField = chatClient.GetType().GetField("_configureOptions", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
Assert.NotNull(configureField);
|
||||
@@ -160,7 +238,6 @@ public sealed class ProjectResponsesClientExtensionsTests
|
||||
var configureAction = configureField.GetValue(chatClient) as Action<ChatOptions>;
|
||||
Assert.NotNull(configureAction);
|
||||
|
||||
var options = new ChatOptions();
|
||||
configureAction(options);
|
||||
|
||||
Assert.NotNull(options.RawRepresentationFactory);
|
||||
|
||||
+78
-1
@@ -370,6 +370,75 @@ public sealed class OpenAIResponseClientExtensionsTests
|
||||
Assert.DoesNotContain(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsIChatClientWithStoredOutputDisabled preserves an existing RawRepresentationFactory
|
||||
/// set on ChatOptions, augmenting it with StoredOutputEnabled and ReasoningEncryptedContent
|
||||
/// rather than replacing it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsIChatClientWithStoredOutputDisabled_PreservesExistingRawRepresentationFactory()
|
||||
{
|
||||
// Arrange
|
||||
var responseClient = new TestOpenAIResponseClient();
|
||||
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled();
|
||||
|
||||
// Simulate a caller setting their own RawRepresentationFactory on ChatOptions
|
||||
// (e.g., to add WebSearchCallActionSources).
|
||||
var options = new ChatOptions
|
||||
{
|
||||
RawRepresentationFactory = _ => new CreateResponseOptions
|
||||
{
|
||||
IncludedProperties = { IncludedResponseProperty.WebSearchCallActionSources },
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(createResponseOptions);
|
||||
Assert.False(createResponseOptions.StoredOutputEnabled);
|
||||
Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
|
||||
Assert.Contains(IncludedResponseProperty.WebSearchCallActionSources, createResponseOptions.IncludedProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsIChatClientWithStoredOutputDisabled does not duplicate ReasoningEncryptedContent
|
||||
/// when the existing factory already includes it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsIChatClientWithStoredOutputDisabled_DoesNotDuplicateReasoningEncryptedContent()
|
||||
{
|
||||
// Arrange
|
||||
var responseClient = new TestOpenAIResponseClient();
|
||||
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled();
|
||||
|
||||
// Simulate a caller that already includes ReasoningEncryptedContent
|
||||
var options = new ChatOptions
|
||||
{
|
||||
RawRepresentationFactory = _ => new CreateResponseOptions
|
||||
{
|
||||
IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent },
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options);
|
||||
|
||||
// Assert - ReasoningEncryptedContent should appear exactly once
|
||||
Assert.NotNull(createResponseOptions);
|
||||
int count = 0;
|
||||
foreach (var prop in createResponseOptions.IncludedProperties)
|
||||
{
|
||||
if (prop == IncludedResponseProperty.ReasoningEncryptedContent)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple test IServiceProvider implementation for testing.
|
||||
/// </summary>
|
||||
@@ -394,6 +463,15 @@ public sealed class OpenAIResponseClientExtensionsTests
|
||||
/// by using reflection to access the configure action and invoking it on a test <see cref="ChatOptions"/>.
|
||||
/// </summary>
|
||||
private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient)
|
||||
{
|
||||
return GetCreateResponseOptionsFromPipeline(chatClient, new ChatOptions());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overload that runs the configure action on caller-supplied <see cref="ChatOptions"/>,
|
||||
/// useful for testing that existing factories are preserved.
|
||||
/// </summary>
|
||||
private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient, ChatOptions options)
|
||||
{
|
||||
// The ConfigureOptionsChatClient stores the configure action in a private field.
|
||||
var configureField = chatClient.GetType().GetField("_configureOptions", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
@@ -402,7 +480,6 @@ public sealed class OpenAIResponseClientExtensionsTests
|
||||
var configureAction = configureField.GetValue(chatClient) as Action<ChatOptions>;
|
||||
Assert.NotNull(configureAction);
|
||||
|
||||
var options = new ChatOptions();
|
||||
configureAction(options);
|
||||
|
||||
Assert.NotNull(options.RawRepresentationFactory);
|
||||
|
||||
+24
-1
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.4.0] - 2026-05-14
|
||||
|
||||
### Added
|
||||
- **agent-framework-core**: Forward MCP tool call metadata ([#5815](https://github.com/microsoft/agent-framework/pull/5815))
|
||||
- **agent-framework-core**: Support `list[str]` arguments for file-based skill scripts ([#5850](https://github.com/microsoft/agent-framework/pull/5850))
|
||||
- **agent-framework-core**: Strip server-issued response item IDs under storage ([#5690](https://github.com/microsoft/agent-framework/pull/5690))
|
||||
- **agent-framework-ag-ui**: Add tool result display channel ([#5762](https://github.com/microsoft/agent-framework/pull/5762))
|
||||
- **agent-framework-ag-ui**: Promote to release candidate stage ([#5844](https://github.com/microsoft/agent-framework/pull/5844))
|
||||
- **agent-framework-devui**: Improvements for DevUI ([#5840](https://github.com/microsoft/agent-framework/pull/5840))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-core**: [BREAKING — experimental skills API] Align file skill folder discovery with agentskills.io spec ([#5807](https://github.com/microsoft/agent-framework/pull/5807))
|
||||
- **agent-framework-core**: [BREAKING — experimental skills API] Extract skill spec metadata into `SkillFrontmatter` ([#5775](https://github.com/microsoft/agent-framework/pull/5775))
|
||||
- **agent-framework-devui**: [BREAKING] Tighten default access controls and CORS posture ([#5740](https://github.com/microsoft/agent-framework/pull/5740))
|
||||
- **agent-framework-a2a**: [BREAKING] Migrate to a2a-sdk v1.0 ([#5752](https://github.com/microsoft/agent-framework/pull/5752))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-a2a**: Fix A2A v1.0 non-streaming response and sample runtime issues ([#5849](https://github.com/microsoft/agent-framework/pull/5849))
|
||||
- **agent-framework-foundry-hosting**: Reject path-traversal context IDs in checkpoint storage ([#5851](https://github.com/microsoft/agent-framework/pull/5851))
|
||||
- **agent-framework-core**: Prevent MCP message_handler deadlock on notification reload ([#4866](https://github.com/microsoft/agent-framework/pull/4866))
|
||||
|
||||
## [1.3.0] - 2026-05-07
|
||||
|
||||
### Added
|
||||
@@ -1050,7 +1071,9 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.2.2...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...HEAD
|
||||
[1.4.0]: https://github.com/microsoft/agent-framework/compare/python-1.3.0...python-1.4.0
|
||||
[1.3.0]: https://github.com/microsoft/agent-framework/compare/python-1.2.2...python-1.3.0
|
||||
[1.2.2]: https://github.com/microsoft/agent-framework/compare/python-1.2.1...python-1.2.2
|
||||
[1.2.1]: https://github.com/microsoft/agent-framework/compare/python-1.2.0...python-1.2.1
|
||||
[1.2.0]: https://github.com/microsoft/agent-framework/compare/python-1.1.1...python-1.2.0
|
||||
|
||||
@@ -157,9 +157,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
self.client = factory.create(agent_card, interceptors=interceptors) # type: ignore
|
||||
except Exception as transport_error:
|
||||
# Transport negotiation failed - fall back to minimal agent card with JSONRPC
|
||||
fallback_url = (
|
||||
agent_card.supported_interfaces[0].url if agent_card.supported_interfaces else url
|
||||
)
|
||||
fallback_url = agent_card.supported_interfaces[0].url if agent_card.supported_interfaces else url
|
||||
if not fallback_url:
|
||||
raise ValueError(
|
||||
"A2A transport negotiation failed and no fallback URL is available. "
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"a2a-sdk>=1.0.0,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"ag-ui-protocol>=0.1.16,<0.2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<0.42.0"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"anthropic>=0.80.0,<0.80.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"azure-search-documents>=11.7.0b2,<11.7.0b3",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Content Understanding integration for Microsoft Agent Frame
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260507"
|
||||
version = "1.0.0a260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-foundry>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"agent-framework-foundry>=1.4.0,<2",
|
||||
"azure-ai-contentunderstanding>=1.0.1,<1.1",
|
||||
"aiohttp>=3.9,<4",
|
||||
"filetype>=1.2,<2",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"azure-cosmos>=4.3.0,<5",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"agent-framework-durabletask",
|
||||
"azure-functions>=1.24.0,<2",
|
||||
"azure-functions-durable>=1.3.1,<2",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"boto3>=1.35.0,<2.0.0",
|
||||
"botocore>=1.35.0,<2.0.0",
|
||||
]
|
||||
|
||||
@@ -21,6 +21,7 @@ from chatkit.types import (
|
||||
HiddenContextItem,
|
||||
ImageAttachment,
|
||||
SDKHiddenContextItem,
|
||||
StructuredInputItem,
|
||||
TaskItem,
|
||||
ThreadItem,
|
||||
UserMessageItem,
|
||||
@@ -527,6 +528,9 @@ class ThreadItemConverter:
|
||||
case GeneratedImageItem():
|
||||
# TODO(evmattso): Implement generated image handling in a future PR
|
||||
return []
|
||||
case StructuredInputItem():
|
||||
# TODO(evmattso): Implement structured input handling in a future PR
|
||||
return []
|
||||
case _:
|
||||
assert_never(item)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"openai-chatkit>=1.4.1,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"claude-agent-sdk>=0.1.36,<0.1.49",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -1513,6 +1513,97 @@ YAML_INDENTED_KV_RE = re.compile(
|
||||
# must not start or end with a hyphen, and must not contain consecutive hyphens.
|
||||
VALID_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$")
|
||||
|
||||
# Block scalar indicator characters recognised by the lightweight YAML parser.
|
||||
_BLOCK_SCALAR_INDICATORS = ("|", ">")
|
||||
|
||||
|
||||
def _parse_yaml_scalar_value(yaml_content: str, kv_match: re.Match[str]) -> str:
|
||||
"""Resolve the scalar value for an unquoted YAML key-value match.
|
||||
|
||||
If the captured value starts with a YAML block scalar indicator (``|`` or
|
||||
``>``), the function reads subsequent indented continuation lines, strips
|
||||
the common leading indentation, and joins them according to the scalar
|
||||
style (literal preserves newlines, folded replaces them with spaces).
|
||||
|
||||
Chomping indicators are respected per YAML 1.2 §8.1.1.2:
|
||||
|
||||
* ``-`` (strip) — final line break and trailing empty lines excluded
|
||||
* ``+`` (keep) — final line break and any trailing empty lines preserved
|
||||
* default (clip) — final line break preserved, trailing empty lines excluded
|
||||
|
||||
For plain (non-block-scalar) values the captured text is returned as-is.
|
||||
Note: explicit indentation indicators (e.g. ``|2``) are not supported;
|
||||
indentation is auto-detected from the common leading whitespace.
|
||||
"""
|
||||
value: str = kv_match.group(3)
|
||||
|
||||
if not value or value[0] not in _BLOCK_SCALAR_INDICATORS:
|
||||
return value
|
||||
|
||||
scalar_style = value[0]
|
||||
keep_trailing_newline = len(value) > 1 and value[1] == "+"
|
||||
strip_trailing_newline = len(value) > 1 and value[1] == "-"
|
||||
|
||||
# Find the start of the next line after this key-value match.
|
||||
next_line_start = yaml_content.find("\n", kv_match.end())
|
||||
if next_line_start < 0:
|
||||
return value
|
||||
next_line_start += 1 # skip the newline character itself
|
||||
|
||||
# Collect indented continuation lines (or blank lines within the block).
|
||||
block_lines: list[str] = []
|
||||
pos = next_line_start
|
||||
while pos < len(yaml_content):
|
||||
line_end = yaml_content.find("\n", pos)
|
||||
if line_end < 0:
|
||||
line = yaml_content[pos:]
|
||||
line_end = len(yaml_content)
|
||||
else:
|
||||
line = yaml_content[pos:line_end]
|
||||
|
||||
if not line or line.isspace():
|
||||
# Blank / whitespace-only lines are part of the block.
|
||||
block_lines.append("")
|
||||
pos = line_end + 1 if line_end < len(yaml_content) else line_end
|
||||
continue
|
||||
|
||||
if line[0] not in (" ", "\t"):
|
||||
# Non-indented, non-blank line — end of the block.
|
||||
break
|
||||
|
||||
block_lines.append(line)
|
||||
pos = line_end + 1 if line_end < len(yaml_content) else line_end
|
||||
|
||||
# Strip trailing blank lines collected from the block.
|
||||
while block_lines and block_lines[-1] == "":
|
||||
block_lines.pop()
|
||||
|
||||
if not block_lines:
|
||||
return ""
|
||||
|
||||
# Determine the common leading indentation across non-empty lines.
|
||||
# Only space/tab characters count as indentation (matches YAML semantics).
|
||||
def _indent_width(s: str) -> int:
|
||||
i = 0
|
||||
while i < len(s) and s[i] in (" ", "\t"):
|
||||
i += 1
|
||||
return i
|
||||
|
||||
common_indent = min(_indent_width(line) for line in block_lines if line)
|
||||
normalized = [line[common_indent:] if line else "" for line in block_lines]
|
||||
|
||||
# Literal preserves newlines; folded joins non-empty lines with spaces.
|
||||
parsed = "\n".join(normalized) if scalar_style == "|" else " ".join(line for line in normalized if line)
|
||||
|
||||
if keep_trailing_newline:
|
||||
return parsed + "\n"
|
||||
if strip_trailing_newline:
|
||||
return parsed
|
||||
# Clip (default): literal gets a trailing newline, folded does not.
|
||||
if scalar_style == "|":
|
||||
return parsed + "\n"
|
||||
return parsed
|
||||
|
||||
|
||||
# Default system prompt template for advertising available skills to the model.
|
||||
# Use {skills} as the placeholder for the generated skills XML list.
|
||||
@@ -2879,7 +2970,9 @@ class FileSkillsSource(SkillsSource):
|
||||
|
||||
for kv_match in YAML_KV_RE.finditer(yaml_content):
|
||||
key = kv_match.group(1)
|
||||
value = kv_match.group(2) if kv_match.group(2) is not None else kv_match.group(3)
|
||||
value = (
|
||||
kv_match.group(2) if kv_match.group(2) is not None else _parse_yaml_scalar_value(yaml_content, kv_match)
|
||||
)
|
||||
|
||||
key_lower = key.lower()
|
||||
if key_lower == "name":
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -319,9 +319,7 @@ class TestDiscoverResourceFiles:
|
||||
refs = skill_dir / "references"
|
||||
refs.mkdir(parents=True)
|
||||
(refs / "doc.md").write_text("content", encoding="utf-8")
|
||||
resources = FileSkillsSource._discover_resource_files(
|
||||
str(skill_dir), directories=("references", "references")
|
||||
)
|
||||
resources = FileSkillsSource._discover_resource_files(str(skill_dir), directories=("references", "references"))
|
||||
assert resources == ["references/doc.md"]
|
||||
|
||||
def test_results_are_sorted(self, tmp_path: Path) -> None:
|
||||
@@ -1675,9 +1673,7 @@ class TestValidateAndNormalizeDirectoryNames:
|
||||
FileSkillsSource._validate_and_normalize_directory_names([" "])
|
||||
|
||||
def test_multiple_directories(self) -> None:
|
||||
result = FileSkillsSource._validate_and_normalize_directory_names(
|
||||
[".", "references", "assets", "scripts"]
|
||||
)
|
||||
result = FileSkillsSource._validate_and_normalize_directory_names([".", "references", "assets", "scripts"])
|
||||
assert result == [".", "references", "assets", "scripts"]
|
||||
|
||||
def test_default_resource_directories(self) -> None:
|
||||
@@ -2163,6 +2159,145 @@ class TestExtractFrontmatterEdgeCases:
|
||||
assert result.description == desc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _extract_frontmatter block scalar parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractFrontmatterBlockScalars:
|
||||
"""Tests for YAML block scalar (| and >) parsing in _extract_frontmatter."""
|
||||
|
||||
def test_literal_block_scalar(self) -> None:
|
||||
content = "---\nname: test-skill\ndescription: |\n Line one\n Line two\n---\nBody."
|
||||
result = FileSkillsSource._extract_frontmatter(content, "test.md")
|
||||
assert result is not None
|
||||
assert result.description == "Line one\nLine two\n"
|
||||
|
||||
def test_folded_block_scalar(self) -> None:
|
||||
content = "---\nname: test-skill\ndescription: >\n This is a multi-line\n description block\n---\nBody."
|
||||
result = FileSkillsSource._extract_frontmatter(content, "test.md")
|
||||
assert result is not None
|
||||
assert result.description == "This is a multi-line description block"
|
||||
|
||||
def test_literal_strip_chomping(self) -> None:
|
||||
content = "---\nname: test-skill\ndescription: |-\n No trailing newline\n---\nBody."
|
||||
result = FileSkillsSource._extract_frontmatter(content, "test.md")
|
||||
assert result is not None
|
||||
assert result.description == "No trailing newline"
|
||||
|
||||
def test_folded_strip_chomping(self) -> None:
|
||||
content = "---\nname: test-skill\ndescription: >-\n Folded with\n strip chomping\n---\nBody."
|
||||
result = FileSkillsSource._extract_frontmatter(content, "test.md")
|
||||
assert result is not None
|
||||
assert result.description == "Folded with strip chomping"
|
||||
|
||||
def test_literal_keep_chomping(self) -> None:
|
||||
content = "---\nname: test-skill\ndescription: |+\n Keep trailing\n---\nBody."
|
||||
result = FileSkillsSource._extract_frontmatter(content, "test.md")
|
||||
assert result is not None
|
||||
assert result.description == "Keep trailing\n"
|
||||
|
||||
def test_folded_keep_chomping(self) -> None:
|
||||
content = "---\nname: test-skill\ndescription: >+\n Keep trailing\n newline\n---\nBody."
|
||||
result = FileSkillsSource._extract_frontmatter(content, "test.md")
|
||||
assert result is not None
|
||||
assert result.description == "Keep trailing newline\n"
|
||||
|
||||
def test_block_scalar_no_continuation_lines(self) -> None:
|
||||
content = "---\nname: test-skill\ndescription: |\nlicense: MIT\n---\nBody."
|
||||
result = FileSkillsSource._extract_frontmatter(content, "test.md")
|
||||
# description becomes empty string which fails validation (empty/whitespace)
|
||||
assert result is None
|
||||
|
||||
def test_block_scalar_varying_indentation(self) -> None:
|
||||
content = (
|
||||
"---\n"
|
||||
"name: test-skill\n"
|
||||
"description: |\n"
|
||||
" Line with 4-space indent\n"
|
||||
" Line with 4-space indent\n"
|
||||
"---\n"
|
||||
"Body."
|
||||
)
|
||||
result = FileSkillsSource._extract_frontmatter(content, "test.md")
|
||||
assert result is not None
|
||||
assert result.description == "Line with 4-space indent\nLine with 4-space indent\n"
|
||||
|
||||
def test_folded_block_scalar_real_skill_format(self) -> None:
|
||||
"""End-to-end test matching the format used in .github/skills/ SKILL.md files."""
|
||||
content = (
|
||||
"---\n"
|
||||
"name: python-development\n"
|
||||
"description: >\n"
|
||||
" Coding standards, conventions, and patterns for developing Python code in the\n"
|
||||
" Agent Framework repository. Use this when writing or modifying Python source\n"
|
||||
" files in the python/ directory.\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Python Development Standards\n"
|
||||
)
|
||||
result = FileSkillsSource._extract_frontmatter(content, "test.md")
|
||||
assert result is not None
|
||||
assert result.description == (
|
||||
"Coding standards, conventions, and patterns for developing Python code in the "
|
||||
"Agent Framework repository. Use this when writing or modifying Python source "
|
||||
"files in the python/ directory."
|
||||
)
|
||||
|
||||
def test_block_scalar_with_other_fields_after(self) -> None:
|
||||
content = "---\nname: test-skill\ndescription: >\n A folded\n description\nlicense: MIT\n---\nBody."
|
||||
result = FileSkillsSource._extract_frontmatter(content, "test.md")
|
||||
assert result is not None
|
||||
assert result.description == "A folded description"
|
||||
assert result.license == "MIT"
|
||||
|
||||
def test_plain_value_unchanged(self) -> None:
|
||||
"""Non-block-scalar values must not be affected by the block scalar logic."""
|
||||
content = "---\nname: test-skill\ndescription: A simple description.\n---\nBody."
|
||||
result = FileSkillsSource._extract_frontmatter(content, "test.md")
|
||||
assert result is not None
|
||||
assert result.description == "A simple description."
|
||||
|
||||
def test_block_scalar_content_with_colons(self) -> None:
|
||||
"""Lines inside a block scalar that look like YAML key-value pairs must be preserved verbatim."""
|
||||
content = (
|
||||
"---\nname: test-skill\ndescription: |\n Some text with colon: in it\n Another: line here\n---\nBody."
|
||||
)
|
||||
result = FileSkillsSource._extract_frontmatter(content, "test.md")
|
||||
assert result is not None
|
||||
assert result.description == "Some text with colon: in it\nAnother: line here\n"
|
||||
|
||||
def test_block_scalar_on_license_field(self) -> None:
|
||||
"""Block scalars should work on any field, not only description."""
|
||||
content = (
|
||||
"---\n"
|
||||
"name: test-skill\n"
|
||||
"description: A skill.\n"
|
||||
"license: >\n"
|
||||
" Custom license\n"
|
||||
" spanning multiple lines\n"
|
||||
"---\n"
|
||||
"Body."
|
||||
)
|
||||
result = FileSkillsSource._extract_frontmatter(content, "test.md")
|
||||
assert result is not None
|
||||
assert result.license == "Custom license spanning multiple lines"
|
||||
|
||||
def test_block_scalar_tab_indentation(self) -> None:
|
||||
"""Tab characters should count as indentation for block scalar continuation lines."""
|
||||
content = "---\nname: test-skill\ndescription: |\n\tTab-indented line one\n\tTab-indented line two\n---\nBody."
|
||||
result = FileSkillsSource._extract_frontmatter(content, "test.md")
|
||||
assert result is not None
|
||||
assert result.description == "Tab-indented line one\nTab-indented line two\n"
|
||||
|
||||
def test_block_scalar_blank_line_within_block(self) -> None:
|
||||
"""Blank lines within a block scalar should be preserved as paragraph separators."""
|
||||
content = "---\nname: test-skill\ndescription: |\n First paragraph\n\n Second paragraph\n---\nBody."
|
||||
result = FileSkillsSource._extract_frontmatter(content, "test.md")
|
||||
assert result is not None
|
||||
assert result.description == "First paragraph\n\nSecond paragraph\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Skill spec fields (via SkillFrontmatter)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -5498,9 +5633,7 @@ class TestArrayStyleScriptArgs:
|
||||
return "ok"
|
||||
|
||||
assert isinstance(my_runner, SkillScriptRunner)
|
||||
skill = FileSkill(
|
||||
frontmatter=SkillFrontmatter(name="s", description="d"), content="c", path=f"{_ABS}/test"
|
||||
)
|
||||
skill = FileSkill(frontmatter=SkillFrontmatter(name="s", description="d"), content="c", path=f"{_ABS}/test")
|
||||
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py")
|
||||
result = my_runner(skill, script, args=["--flag", "value"])
|
||||
assert result == "ok"
|
||||
|
||||
+128
-3
@@ -27,12 +27,15 @@ from __future__ import annotations
|
||||
|
||||
import locale
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal as _Decimal
|
||||
from enum import Enum
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from agent_framework import (
|
||||
@@ -58,6 +61,100 @@ else:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeclarativeEnvConfig:
|
||||
"""Configuration that populates the PowerFx ``Env`` symbol for a workflow.
|
||||
|
||||
Configuration values are always exposed under ``Env.<name>``;
|
||||
``os.environ`` is consulted only when ``restrict_to_configuration``
|
||||
is ``False`` AND the YAML literally references the name in a PowerFx
|
||||
expression (the allowlist enforced via ``referenced_names``).
|
||||
|
||||
Attributes:
|
||||
values: Caller-supplied configuration resolved by name when the
|
||||
workflow YAML references ``=Env.NAME``. Always exposed in
|
||||
the ``Env`` symbol regardless of ``restrict_to_configuration``.
|
||||
restrict_to_configuration: When ``True`` (default), the ``Env``
|
||||
symbol is populated exclusively from ``values``; ``os.environ``
|
||||
is never consulted. Set to ``False`` to additionally fall back
|
||||
to ``os.environ`` for names absent from ``values`` that the
|
||||
workflow YAML explicitly references.
|
||||
referenced_names: The set of ``Env.NAME`` symbols discovered in
|
||||
PowerFx expressions inside the workflow definition. The
|
||||
``os.environ`` fallback is constrained to this allowlist so
|
||||
unrelated environment variables never enter the PowerFx scope.
|
||||
"""
|
||||
|
||||
values: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
|
||||
restrict_to_configuration: bool = True
|
||||
referenced_names: frozenset[str] = field(default_factory=lambda: frozenset[str]())
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Defensive snapshots so the frozen guarantee extends to the
|
||||
# contents of ``values`` / ``referenced_names``: caller mutations
|
||||
# to the original objects after construction cannot leak into
|
||||
# ``resolve()``.
|
||||
object.__setattr__(self, "values", MappingProxyType(dict(self.values)))
|
||||
object.__setattr__(self, "referenced_names", frozenset(self.referenced_names))
|
||||
|
||||
def resolve(self) -> dict[str, str]:
|
||||
"""Return the resolved ``Env`` symbol mapping for the workflow.
|
||||
|
||||
Configuration values are always included (stringified).
|
||||
``os.environ`` is consulted only when ``restrict_to_configuration``
|
||||
is ``False`` and the name appears in ``referenced_names``, so
|
||||
unrelated environment variables never enter the PowerFx scope.
|
||||
Configuration values always win over the environment fallback.
|
||||
"""
|
||||
resolved = {name: str(value) for name, value in self.values.items()}
|
||||
if self.restrict_to_configuration:
|
||||
return resolved
|
||||
for name in self.referenced_names.difference(resolved):
|
||||
env_value = os.environ.get(name)
|
||||
if env_value is not None:
|
||||
resolved[name] = env_value
|
||||
return resolved
|
||||
|
||||
|
||||
def discover_env_references(node: Any) -> set[str]:
|
||||
"""Discover ``Env.NAME`` references in PowerFx expressions inside ``node``.
|
||||
|
||||
Walks any nested ``Mapping``/``list``/scalar structure and inspects every
|
||||
string value. To avoid false positives from doc/description fields that
|
||||
happen to mention ``Env.SOMETHING`` as plain text, the scan only inspects
|
||||
strings that begin with ``=`` (PowerFx expression marker, matching the
|
||||
convention enforced by :meth:`DeclarativeWorkflowState.eval`).
|
||||
|
||||
Args:
|
||||
node: A parsed workflow definition (typically the dict produced by
|
||||
``yaml.safe_load``).
|
||||
|
||||
Returns:
|
||||
The set of ``Env`` identifier names referenced in PowerFx
|
||||
expressions inside ``node``.
|
||||
"""
|
||||
names: set[str] = set()
|
||||
|
||||
def visit(value: Any) -> None:
|
||||
if isinstance(value, str):
|
||||
if value.startswith("="):
|
||||
names.update(_ENV_REFERENCE_RE.findall(value))
|
||||
return
|
||||
if isinstance(value, Mapping):
|
||||
for inner in cast(Mapping[Any, Any], value).values(): # type: ignore[redundant-cast]
|
||||
visit(inner)
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for item in cast(list[Any], value): # type: ignore[redundant-cast]
|
||||
visit(item)
|
||||
|
||||
visit(node)
|
||||
return names
|
||||
|
||||
|
||||
class ConversationData(TypedDict):
|
||||
"""Structure for conversation-related state data.
|
||||
|
||||
@@ -169,13 +266,18 @@ class DeclarativeWorkflowState:
|
||||
- Conversation: Conversation history
|
||||
"""
|
||||
|
||||
def __init__(self, state: State):
|
||||
def __init__(self, state: State, env_config: DeclarativeEnvConfig | None = None):
|
||||
"""Initialize with a State instance.
|
||||
|
||||
Args:
|
||||
state: The workflow's state for persistence
|
||||
env_config: Configuration that populates the PowerFx ``Env``
|
||||
symbol when ``_to_powerfx_symbols`` is called. Defaults to
|
||||
an empty configuration which results in no ``Env`` binding,
|
||||
matching the safe default of the :class:`WorkflowFactory`.
|
||||
"""
|
||||
self._state = state
|
||||
self._env_config = env_config if env_config is not None else DeclarativeEnvConfig()
|
||||
|
||||
def initialize(self, inputs: Mapping[str, Any] | None = None) -> None:
|
||||
"""Initialize the declarative state with inputs.
|
||||
@@ -714,6 +816,14 @@ class DeclarativeWorkflowState:
|
||||
# Custom namespaces
|
||||
**state_data.get("Custom", {}),
|
||||
}
|
||||
# Resolve the ``Env`` symbol from the workflow-level
|
||||
# :class:`DeclarativeEnvConfig`. When both ``values`` and the
|
||||
# ``os.environ`` allowlist produce no entries the symbol is
|
||||
# omitted so ``=Env.X`` falls back to the literal expression
|
||||
# string (preserving the legacy "unbound identifier" behaviour).
|
||||
env_bound = self._env_config.resolve()
|
||||
if env_bound:
|
||||
symbols["Env"] = env_bound
|
||||
# Debug log the Local symbols to help diagnose type issues
|
||||
if local_data:
|
||||
for key, value in local_data.items():
|
||||
@@ -867,6 +977,11 @@ class DeclarativeActionExecutor(Executor):
|
||||
action_id = id or action_def.get("id") or f"{action_def.get('kind', 'action')}_{hash(str(action_def)) % 10000}"
|
||||
super().__init__(id=action_id, defer_discovery=True)
|
||||
self._action_def = action_def
|
||||
# The active :class:`DeclarativeEnvConfig` is stamped onto the
|
||||
# executor by :class:`DeclarativeWorkflowBuilder` after construction.
|
||||
# Defaults to an empty configuration so direct ``DeclarativeActionExecutor``
|
||||
# construction (e.g. in unit tests) doesn't expose ``os.environ``.
|
||||
self._declarative_env_config: DeclarativeEnvConfig = DeclarativeEnvConfig()
|
||||
|
||||
# Manually register handlers after initialization
|
||||
self._handlers = {}
|
||||
@@ -874,6 +989,16 @@ class DeclarativeActionExecutor(Executor):
|
||||
self._discover_handlers()
|
||||
self._discover_response_handlers()
|
||||
|
||||
def set_declarative_env_config(self, env_config: DeclarativeEnvConfig) -> None:
|
||||
"""Set the workflow-level :class:`DeclarativeEnvConfig` for this executor.
|
||||
|
||||
Called by :class:`DeclarativeWorkflowBuilder` after each executor is
|
||||
created so that ``_to_powerfx_symbols`` populates the ``Env`` symbol
|
||||
according to the caller-supplied configuration on the
|
||||
:class:`WorkflowFactory`.
|
||||
"""
|
||||
self._declarative_env_config = env_config
|
||||
|
||||
@property
|
||||
def action_def(self) -> dict[str, Any]:
|
||||
"""Get the action definition."""
|
||||
@@ -886,7 +1011,7 @@ class DeclarativeActionExecutor(Executor):
|
||||
|
||||
def _get_state(self, state: State) -> DeclarativeWorkflowState:
|
||||
"""Get the declarative workflow state wrapper."""
|
||||
return DeclarativeWorkflowState(state)
|
||||
return DeclarativeWorkflowState(state, env_config=self._declarative_env_config)
|
||||
|
||||
async def _ensure_state_initialized(
|
||||
self,
|
||||
|
||||
+16
@@ -24,6 +24,7 @@ from agent_framework import (
|
||||
from ._declarative_base import (
|
||||
ConditionResult,
|
||||
DeclarativeActionExecutor,
|
||||
DeclarativeEnvConfig,
|
||||
LoopIterationResult,
|
||||
)
|
||||
from ._errors import DeclarativeWorkflowError
|
||||
@@ -140,6 +141,7 @@ class DeclarativeWorkflowBuilder:
|
||||
max_iterations: int | None = None,
|
||||
http_request_handler: HttpRequestHandler | None = None,
|
||||
mcp_tool_handler: MCPToolHandler | None = None,
|
||||
env_config: DeclarativeEnvConfig | None = None,
|
||||
):
|
||||
"""Initialize the builder.
|
||||
|
||||
@@ -158,6 +160,10 @@ class DeclarativeWorkflowBuilder:
|
||||
mcp_tool_handler: Handler used to dispatch InvokeMcpTool calls.
|
||||
Must be supplied when the workflow contains any InvokeMcpTool;
|
||||
otherwise build raises ``DeclarativeWorkflowError``.
|
||||
env_config: Optional :class:`DeclarativeEnvConfig` controlling
|
||||
how the ``Env`` PowerFx symbol is populated for every
|
||||
executor built by this builder. Defaults to an empty
|
||||
configuration (``Env`` not exposed).
|
||||
"""
|
||||
self._yaml_def = yaml_definition
|
||||
self._workflow_id = workflow_id or yaml_definition.get("name", "declarative_workflow")
|
||||
@@ -171,6 +177,7 @@ class DeclarativeWorkflowBuilder:
|
||||
self._seen_explicit_ids: set[str] = set() # Track explicit IDs for duplicate detection
|
||||
self._http_request_handler = http_request_handler
|
||||
self._mcp_tool_handler = mcp_tool_handler
|
||||
self._env_config: DeclarativeEnvConfig = env_config if env_config is not None else DeclarativeEnvConfig()
|
||||
# Resolve max_iterations: explicit arg > YAML maxTurns > core default
|
||||
resolved = max_iterations if max_iterations is not None else yaml_definition.get("maxTurns")
|
||||
if resolved is not None and (not isinstance(resolved, int) or resolved <= 0):
|
||||
@@ -221,6 +228,15 @@ class DeclarativeWorkflowBuilder:
|
||||
# Resolve pending gotos (back-edges for loops, forward-edges for jumps)
|
||||
self._resolve_pending_gotos(builder)
|
||||
|
||||
# Stamp the resolved DeclarativeEnvConfig onto every executor so they
|
||||
# expose the configured Env binding through their _get_state(). This
|
||||
# happens after _create_executors_for_actions and _resolve_pending_gotos
|
||||
# so it covers the entry node, join nodes, evaluators, foreach
|
||||
# init/next/exit nodes, and goto placeholders.
|
||||
for executor in self._executors.values():
|
||||
if isinstance(executor, DeclarativeActionExecutor):
|
||||
executor.set_declarative_env_config(self._env_config)
|
||||
|
||||
return builder.build()
|
||||
|
||||
def _validate_workflow(self, actions: list[dict[str, Any]]) -> None:
|
||||
|
||||
@@ -26,6 +26,7 @@ from agent_framework import (
|
||||
)
|
||||
|
||||
from .._loader import AgentFactory
|
||||
from ._declarative_base import DeclarativeEnvConfig, discover_env_references
|
||||
from ._declarative_builder import DeclarativeWorkflowBuilder
|
||||
from ._errors import DeclarativeWorkflowError
|
||||
from ._http_handler import HttpRequestHandler
|
||||
@@ -93,6 +94,8 @@ class WorkflowFactory:
|
||||
max_iterations: int | None = None,
|
||||
http_request_handler: HttpRequestHandler | None = None,
|
||||
mcp_tool_handler: MCPToolHandler | None = None,
|
||||
configuration: Mapping[str, str] | None = None,
|
||||
restrict_env_to_configuration: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the workflow factory.
|
||||
|
||||
@@ -119,6 +122,23 @@ class WorkflowFactory:
|
||||
for a default backed by :class:`agent_framework.MCPStreamableHTTPTool`,
|
||||
or supply your own implementation to enforce SSRF guards, allowlisting,
|
||||
or auth/connection resolution.
|
||||
configuration: Optional mapping that populates the PowerFx ``Env``
|
||||
symbol referenced from workflow YAML expressions (e.g.
|
||||
``=Env.MY_KEY``). Keys supplied here are always exposed
|
||||
under ``Env.<key>``; the process ``os.environ`` is consulted
|
||||
only when ``restrict_env_to_configuration`` is ``False``.
|
||||
When neither source produces a value the ``Env`` symbol is
|
||||
omitted so ``=Env.X`` evaluates to the literal expression
|
||||
string.
|
||||
restrict_env_to_configuration: When ``True`` (default), the
|
||||
``Env`` PowerFx symbol is populated exclusively from
|
||||
``configuration``; ``os.environ`` is never consulted. Set to
|
||||
``False`` to additionally fall back to ``os.environ`` for
|
||||
names absent from ``configuration`` that the workflow YAML
|
||||
explicitly references. The fallback is constrained to names
|
||||
discovered in PowerFx expressions inside the workflow
|
||||
definition so unrelated environment variables never enter
|
||||
the PowerFx scope.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
@@ -151,6 +171,18 @@ class WorkflowFactory:
|
||||
checkpoint_storage=FileCheckpointStorage("./checkpoints"),
|
||||
env_file=".env",
|
||||
)
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
|
||||
# Inject named values for =Env.* references in the workflow YAML
|
||||
factory = WorkflowFactory(
|
||||
configuration={
|
||||
"MY_SERVER_URL": "https://example.com",
|
||||
"MY_TOOL_NAME": "search",
|
||||
},
|
||||
)
|
||||
"""
|
||||
self._agent_factory = agent_factory or AgentFactory(env_file_path=env_file)
|
||||
self._agents: dict[str, SupportsAgentRun | AgentExecutor] = dict(agents) if agents else {}
|
||||
@@ -160,6 +192,8 @@ class WorkflowFactory:
|
||||
self._max_iterations = max_iterations
|
||||
self._http_request_handler = http_request_handler
|
||||
self._mcp_tool_handler = mcp_tool_handler
|
||||
self._configuration: dict[str, str] = dict(configuration) if configuration else {}
|
||||
self._restrict_env_to_configuration = restrict_env_to_configuration
|
||||
|
||||
def create_workflow_from_yaml_path(
|
||||
self,
|
||||
@@ -394,6 +428,16 @@ class WorkflowFactory:
|
||||
if description:
|
||||
normalized_def["description"] = description
|
||||
|
||||
# Build the DeclarativeEnvConfig from the factory's configuration and the
|
||||
# set of Env references actually used in the workflow PowerFx expressions.
|
||||
# The referenced-name allowlist constrains ``os.environ`` fallback (when
|
||||
# enabled) so unrelated variables never enter the PowerFx scope.
|
||||
env_config = DeclarativeEnvConfig(
|
||||
values=dict(self._configuration),
|
||||
restrict_to_configuration=self._restrict_env_to_configuration,
|
||||
referenced_names=frozenset(discover_env_references(normalized_def)),
|
||||
)
|
||||
|
||||
# Build the graph-based workflow, passing agents and tools for specialized executors
|
||||
try:
|
||||
graph_builder = DeclarativeWorkflowBuilder(
|
||||
@@ -405,6 +449,7 @@ class WorkflowFactory:
|
||||
max_iterations=self._max_iterations,
|
||||
http_request_handler=self._http_request_handler,
|
||||
mcp_tool_handler=self._mcp_tool_handler,
|
||||
env_config=env_config,
|
||||
)
|
||||
workflow = graph_builder.build()
|
||||
except ValueError as e:
|
||||
|
||||
@@ -33,7 +33,7 @@ import logging
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast, runtime_checkable
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -194,6 +194,21 @@ class DefaultMCPToolHandler:
|
||||
Defaults to ``32``.
|
||||
"""
|
||||
|
||||
LIST_TOOLS_TOOL_NAME: ClassVar[str] = "tools/list"
|
||||
"""Reserved ``tool_name`` that maps an :class:`MCPToolHandler` invocation
|
||||
to the MCP protocol ``tools/list`` discovery operation.
|
||||
|
||||
The constant matches the underlying MCP method name so a single
|
||||
string travels unchanged through host code, YAML, and the protocol
|
||||
wire. When this handler receives an invocation with this name it
|
||||
pages through ``session.list_tools()`` and returns the catalog as a
|
||||
single ``TextContent`` containing JSON of shape
|
||||
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
|
||||
Workflows can reference this name from an ``InvokeMcpTool`` declarative
|
||||
action to introspect a server's tool surface without an extra round-trip
|
||||
from host code.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -217,10 +232,27 @@ class DefaultMCPToolHandler:
|
||||
self._closed = False
|
||||
|
||||
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
||||
"""Invoke ``invocation.tool_name`` on the cached MCP client for the server."""
|
||||
"""Invoke ``invocation.tool_name`` on the cached MCP client for the server.
|
||||
|
||||
The reserved name :attr:`LIST_TOOLS_TOOL_NAME` (``"tools/list"``) is
|
||||
intercepted client-side: instead of being forwarded as a tool call,
|
||||
it is translated to an MCP ``session.list_tools()`` discovery
|
||||
operation (paginated automatically) and returned as a single
|
||||
``TextContent`` containing a JSON tool catalog.
|
||||
"""
|
||||
from agent_framework import Content
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
# Reserved-name args validation runs before connect: rejecting bad
|
||||
# input shouldn't require establishing an MCP session.
|
||||
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME and invocation.arguments:
|
||||
message = f"The reserved MCP '{self.LIST_TOOLS_TOOL_NAME}' operation does not accept tool arguments."
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
|
||||
try:
|
||||
entry = await self._get_or_create_entry(invocation)
|
||||
except Exception as exc:
|
||||
@@ -240,6 +272,8 @@ class DefaultMCPToolHandler:
|
||||
)
|
||||
|
||||
try:
|
||||
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME:
|
||||
return await self._invoke_list_tools(entry)
|
||||
raw = await entry.tool.call_tool(invocation.tool_name, **invocation.arguments)
|
||||
except ToolExecutionException as exc:
|
||||
logger.info(
|
||||
@@ -284,6 +318,59 @@ class DefaultMCPToolHandler:
|
||||
outputs = list(raw)
|
||||
return MCPToolResult(outputs=outputs)
|
||||
|
||||
@staticmethod
|
||||
async def _invoke_list_tools(entry: _CacheEntry) -> MCPToolResult:
|
||||
"""Handle the reserved :attr:`LIST_TOOLS_TOOL_NAME` invocation.
|
||||
|
||||
Pages through ``session.list_tools()`` (mirroring the pagination loop
|
||||
in :meth:`agent_framework.MCPTool.load_tools`) and serialises the
|
||||
full catalog as a single ``TextContent`` containing JSON of shape
|
||||
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
|
||||
|
||||
The output shape, property names, and property order are stable so
|
||||
downstream PowerFx expressions can rely on the schema. ``indent=2``
|
||||
produces human-readable JSON for the conversation log;
|
||||
``allow_nan=False`` guards against producing non-conformant JSON
|
||||
``NaN``/``Infinity`` tokens if a misbehaving server returns such
|
||||
values in a schema.
|
||||
"""
|
||||
from agent_framework import Content
|
||||
|
||||
session = getattr(entry.tool, "session", None)
|
||||
if session is None:
|
||||
message = "MCP session is not connected; cannot list tools."
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
|
||||
# Lazy import keeps ``mcp`` types out of module import time.
|
||||
from mcp import types as mcp_types
|
||||
|
||||
collected: list[Any] = []
|
||||
params: mcp_types.PaginatedRequestParams | None = None
|
||||
while True:
|
||||
tool_list = await session.list_tools(params=params)
|
||||
collected.extend(tool_list.tools)
|
||||
next_cursor = getattr(tool_list, "nextCursor", None)
|
||||
if not next_cursor:
|
||||
break
|
||||
params = mcp_types.PaginatedRequestParams(cursor=next_cursor)
|
||||
|
||||
payload = {
|
||||
"tools": [
|
||||
{
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"inputSchema": tool.inputSchema,
|
||||
"outputSchema": tool.outputSchema,
|
||||
}
|
||||
for tool in collected
|
||||
],
|
||||
}
|
||||
return MCPToolResult(outputs=[Content.from_text(json.dumps(payload, indent=2, allow_nan=False))])
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close all cached MCP clients and the owned httpx clients.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"httpx>=0.27,<1",
|
||||
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
|
||||
@@ -13,6 +13,7 @@ owned-vs-caller httpx close semantics.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
@@ -33,6 +34,55 @@ pytestmark = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
|
||||
class FakeListToolsResult: # noqa: B903 - mimics ``mcp.types.ListToolsResult`` shape, not a value type
|
||||
"""Stand-in for ``mcp.types.ListToolsResult`` returned by ``session.list_tools()``."""
|
||||
|
||||
def __init__(self, tools: list[Any], next_cursor: str | None = None) -> None:
|
||||
self.tools = tools
|
||||
self.nextCursor = next_cursor
|
||||
|
||||
|
||||
class FakeMcpTool:
|
||||
"""Stand-in for an MCP ``Tool`` (subset used by ``_invoke_list_tools``)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
inputSchema: dict[str, Any] | None = None,
|
||||
outputSchema: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.inputSchema = inputSchema if inputSchema is not None else {"type": "object", "properties": {}}
|
||||
self.outputSchema = outputSchema
|
||||
|
||||
|
||||
class FakeMcpSession:
|
||||
"""Stand-in for ``mcp.ClientSession``.
|
||||
|
||||
``list_tools_pages`` lets a test enqueue multiple paginated responses;
|
||||
when None (default), an empty single-page result is returned. ``list_tools_error``
|
||||
raises a synthetic error on the next call when set.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.list_tools_pages: list[FakeListToolsResult] | None = None
|
||||
self.list_tools_calls: list[Any] = []
|
||||
self.list_tools_error: BaseException | None = None
|
||||
|
||||
async def list_tools(self, params: Any = None) -> FakeListToolsResult:
|
||||
self.list_tools_calls.append(params)
|
||||
if self.list_tools_error is not None:
|
||||
raise self.list_tools_error
|
||||
if self.list_tools_pages is None:
|
||||
return FakeListToolsResult(tools=[])
|
||||
index = len(self.list_tools_calls) - 1
|
||||
if index >= len(self.list_tools_pages):
|
||||
return FakeListToolsResult(tools=[])
|
||||
return self.list_tools_pages[index]
|
||||
|
||||
|
||||
class FakeTool:
|
||||
"""Stand-in for ``MCPStreamableHTTPTool``.
|
||||
|
||||
@@ -50,6 +100,7 @@ class FakeTool:
|
||||
self.connect_error: BaseException | None = None
|
||||
self.call_handler: Any = lambda **_a: [Content.from_text("ok")]
|
||||
self._httpx_client: httpx.AsyncClient | None = None
|
||||
self.session: FakeMcpSession | None = None
|
||||
# Mimic MCPStreamableHTTPTool: when no caller client AND header_provider
|
||||
# is set, lazily allocate an owned httpx client during connect.
|
||||
FakeTool.instances.append(self)
|
||||
@@ -63,6 +114,9 @@ class FakeTool:
|
||||
# Mimic lazy httpx allocation when no client provided AND header_provider set.
|
||||
if self.kwargs.get("http_client") is None and self.kwargs.get("header_provider") is not None:
|
||||
self._httpx_client = httpx.AsyncClient()
|
||||
# Mimic MCPStreamableHTTPTool: a live session becomes available after connect.
|
||||
if self.session is None:
|
||||
self.session = FakeMcpSession()
|
||||
|
||||
async def close(self) -> None:
|
||||
self.close_count += 1
|
||||
@@ -541,3 +595,185 @@ class TestCacheKey:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "Bearer-A"})
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "bearer-a"})
|
||||
assert k1 != k2
|
||||
|
||||
|
||||
# ---------- tools/list reserved name --------------------------------------
|
||||
|
||||
|
||||
class TestListTools:
|
||||
"""Exercise the reserved :attr:`DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME` interception path."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_returns_json_catalog(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
# Prime the cache so the FakeTool session exists.
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
|
||||
FakeListToolsResult(
|
||||
tools=[
|
||||
FakeMcpTool(
|
||||
name="search",
|
||||
description="Search docs",
|
||||
inputSchema={"type": "object", "properties": {"q": {"type": "string"}}},
|
||||
outputSchema={"type": "object"},
|
||||
),
|
||||
FakeMcpTool(name="echo", description=None, outputSchema=None),
|
||||
],
|
||||
),
|
||||
]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
assert result.is_error is False
|
||||
assert len(result.outputs) == 1
|
||||
payload = json.loads(result.outputs[0].text) # type: ignore[reportAttributeAccessIssue]
|
||||
assert payload == {
|
||||
"tools": [
|
||||
{
|
||||
"name": "search",
|
||||
"description": "Search docs",
|
||||
"inputSchema": {"type": "object", "properties": {"q": {"type": "string"}}},
|
||||
"outputSchema": {"type": "object"},
|
||||
},
|
||||
{
|
||||
"name": "echo",
|
||||
"description": None,
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
"outputSchema": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_property_order_is_stable(self) -> None:
|
||||
"""JSON property order is stable: name, description, inputSchema, outputSchema."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
|
||||
FakeListToolsResult(tools=[FakeMcpTool(name="t1", description="d")]),
|
||||
]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
|
||||
name_idx = text.find('"name"')
|
||||
desc_idx = text.find('"description"')
|
||||
input_idx = text.find('"inputSchema"')
|
||||
output_idx = text.find('"outputSchema"')
|
||||
assert 0 <= name_idx < desc_idx < input_idx < output_idx
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_indented_output(self) -> None:
|
||||
"""Output is JSON with a 2-space indent so the conversation log is human-readable."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
|
||||
FakeListToolsResult(tools=[FakeMcpTool(name="t1")]),
|
||||
]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
|
||||
# Indented output contains newlines and a 2-space indented key.
|
||||
assert "\n " in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_rejects_arguments(self) -> None:
|
||||
"""Reserved name does NOT accept tool arguments. Fails fast before connect."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
result = await handler.invoke_tool(
|
||||
_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME, arguments={"q": "test"}),
|
||||
)
|
||||
assert result.is_error is True
|
||||
assert "does not accept tool arguments" in (result.error_message or "")
|
||||
# Args validation runs before connect, so no tool was instantiated.
|
||||
assert FakeTool.instances == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_empty_args_dict_is_accepted(self) -> None:
|
||||
"""An empty arguments dict is equivalent to no arguments."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
result = await handler.invoke_tool(
|
||||
_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME, arguments={}),
|
||||
)
|
||||
assert result.is_error is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_paginates(self) -> None:
|
||||
"""Pagination loop calls list_tools repeatedly until nextCursor is empty."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
|
||||
FakeListToolsResult(tools=[FakeMcpTool(name="a")], next_cursor="cursor1"),
|
||||
FakeListToolsResult(tools=[FakeMcpTool(name="b")], next_cursor="cursor2"),
|
||||
FakeListToolsResult(tools=[FakeMcpTool(name="c")], next_cursor=None),
|
||||
]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
payload = json.loads(result.outputs[0].text) # type: ignore[reportAttributeAccessIssue]
|
||||
assert [t["name"] for t in payload["tools"]] == ["a", "b", "c"]
|
||||
session = FakeTool.instances[0].session
|
||||
assert session is not None
|
||||
assert len(session.list_tools_calls) == 3
|
||||
# First call has no cursor; second/third use the cursor from the prior page.
|
||||
assert session.list_tools_calls[0] is None
|
||||
assert getattr(session.list_tools_calls[1], "cursor", None) == "cursor1"
|
||||
assert getattr(session.list_tools_calls[2], "cursor", None) == "cursor2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_shares_cache_with_call_tool(self) -> None:
|
||||
"""tools/list reuses the same cached MCP session as a regular call_tool."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(tool_name="search"))
|
||||
await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
assert len(FakeTool.instances) == 1
|
||||
assert FakeTool.instances[0].connect_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_propagates_session_errors_as_error_result(self) -> None:
|
||||
"""Errors raised by session.list_tools become MCPToolResult(is_error=True), not crashes."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session.list_tools_error = httpx.ReadTimeout("read timed out") # type: ignore[union-attr]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
assert result.is_error is True
|
||||
assert "ReadTimeout" in (result.error_message or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_returns_error_when_session_is_none(self) -> None:
|
||||
"""If somehow the cached tool has no session, return a clear error rather than crashing."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session = None
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
assert result.is_error is True
|
||||
assert "not connected" in (result.error_message or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_does_not_call_call_tool(self) -> None:
|
||||
"""The reserved name is intercepted; the inner call_tool path is bypassed."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
call_tool_invoked = False
|
||||
|
||||
def fail(**_a: Any) -> Any:
|
||||
nonlocal call_tool_invoked
|
||||
call_tool_invoked = True
|
||||
raise AssertionError("call_tool should not run for tools/list")
|
||||
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].call_handler = fail
|
||||
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
|
||||
FakeListToolsResult(tools=[]),
|
||||
]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
assert call_tool_invoked is False
|
||||
assert result.is_error is False
|
||||
|
||||
def test_class_attribute_value(self) -> None:
|
||||
# Constant must equal the MCP protocol method name so a single
|
||||
# string travels unchanged through host code, YAML, and the wire.
|
||||
assert DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME == "tools/list"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
"opentelemetry-sdk>=1.39.0,<2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"durabletask>=1.3.0,<2",
|
||||
"durabletask-azuremanaged>=1.3.0,<2",
|
||||
"python-dateutil>=2.8.0,<3",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-openai>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"agent-framework-openai>=1.4.0,<2",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"azure-ai-projects>=2.1.0,<3.0",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260507"
|
||||
version = "1.0.0a260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"azure-ai-agentserver-core>=2.0.0b3,<3",
|
||||
"azure-ai-agentserver-responses>=1.0.0b5,<2",
|
||||
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-openai>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"agent-framework-openai>=1.4.0,<2",
|
||||
"foundry-local-sdk>=0.5.1,<0.5.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Google Gemini integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260507"
|
||||
version = "1.0.0a260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2.0",
|
||||
"agent-framework-core>=1.4.0,<2.0",
|
||||
"google-genai>=1.65.0,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -520,8 +520,11 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
|
||||
session_context = await self._run_before_providers(session=session, input_messages=input_messages, options=opts)
|
||||
|
||||
# NOTE: session is created after providers run so that future provider-contributed
|
||||
# tools/config could be folded into runtime_options before session creation.
|
||||
# Merge provider-contributed tools into runtime_options before session creation.
|
||||
if session_context.tools:
|
||||
existing = list(opts.get("tools") or [])
|
||||
opts["tools"] = existing + list(session_context.tools)
|
||||
|
||||
copilot_session = await self._get_or_create_session(session, streaming=False, runtime_options=opts)
|
||||
|
||||
# Build the prompt from the full set of messages in the session context,
|
||||
@@ -605,8 +608,11 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
|
||||
session_context = await self._run_before_providers(session=session, input_messages=input_messages, options=opts)
|
||||
|
||||
# NOTE: session is created after providers run so that future provider-contributed
|
||||
# tools/config could be folded into runtime_options before session creation.
|
||||
# Merge provider-contributed tools into runtime_options before session creation.
|
||||
if session_context.tools:
|
||||
existing = list(opts.get("tools") or [])
|
||||
opts["tools"] = existing + list(session_context.tools)
|
||||
|
||||
copilot_session = await self._get_or_create_session(session, streaming=True, runtime_options=opts)
|
||||
|
||||
if _ctx_holder is not None:
|
||||
@@ -891,7 +897,8 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
mcp_servers = opts.get("mcp_servers") or self._mcp_servers or None
|
||||
provider = opts.get("provider") or self._provider or None
|
||||
instruction_directories = opts.get("instruction_directories", self._instruction_directories)
|
||||
tools = self._prepare_tools(self._tools) if self._tools else None
|
||||
all_tools = list(self._tools or []) + list(opts.get("tools") or [])
|
||||
tools = self._prepare_tools(all_tools) if all_tools else None
|
||||
|
||||
return await self._client.create_session(
|
||||
on_permission_request=permission_handler,
|
||||
@@ -929,7 +936,8 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
mcp_servers = opts.get("mcp_servers") or self._mcp_servers or None
|
||||
provider = opts.get("provider") or self._provider or None
|
||||
instruction_directories = opts.get("instruction_directories", self._instruction_directories)
|
||||
tools = self._prepare_tools(self._tools) if self._tools else None
|
||||
all_tools = list(self._tools or []) + list(opts.get("tools") or [])
|
||||
tools = self._prepare_tools(all_tools) if all_tools else None
|
||||
|
||||
return await self._client.resume_session(
|
||||
session_id,
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"github-copilot-sdk>=1.0.0b2,<=1.0.0b2; python_version >= '3.11'",
|
||||
]
|
||||
|
||||
|
||||
@@ -2477,3 +2477,231 @@ class TestGitHubCopilotAgentContextProviders:
|
||||
with pytest.raises(ValueError, match="on_function_approval"):
|
||||
async for _ in agent.run("hello", stream=True, options={"on_function_approval": lambda _c: True}):
|
||||
pass
|
||||
|
||||
async def test_provider_tools_forwarded_to_session(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
assistant_message_event: SessionEvent,
|
||||
) -> None:
|
||||
"""Test that tools added by context providers are forwarded to session creation."""
|
||||
mock_session.send_and_wait.return_value = assistant_message_event
|
||||
|
||||
class ToolInjectingProvider(ContextProvider):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(source_id="tool-injector")
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession,
|
||||
context: Any,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
from agent_framework._tools import normalize_tools
|
||||
|
||||
def load_skill(skill_name: str) -> str:
|
||||
"""Load a skill by name."""
|
||||
return f"Loaded: {skill_name}"
|
||||
|
||||
context.extend_tools(self.source_id, normalize_tools([load_skill]))
|
||||
|
||||
provider = ToolInjectingProvider()
|
||||
agent = GitHubCopilotAgent(client=mock_client, context_providers=[provider])
|
||||
session = agent.create_session()
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
call_kwargs = mock_client.create_session.call_args.kwargs
|
||||
assert call_kwargs.get("tools") is not None
|
||||
tool_names = [t.name for t in call_kwargs["tools"]]
|
||||
assert "load_skill" in tool_names
|
||||
|
||||
async def test_provider_tools_merged_with_constructor_tools(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
assistant_message_event: SessionEvent,
|
||||
) -> None:
|
||||
"""Test that provider tools are merged with constructor tools, not replacing them."""
|
||||
mock_session.send_and_wait.return_value = assistant_message_event
|
||||
|
||||
def my_tool(x: str) -> str:
|
||||
"""A constructor tool."""
|
||||
return x
|
||||
|
||||
class ToolInjectingProvider(ContextProvider):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(source_id="tool-injector")
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession,
|
||||
context: Any,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
from agent_framework._tools import normalize_tools
|
||||
|
||||
def load_skill(skill_name: str) -> str:
|
||||
"""Load a skill by name."""
|
||||
return f"Loaded: {skill_name}"
|
||||
|
||||
context.extend_tools(self.source_id, normalize_tools([load_skill]))
|
||||
|
||||
provider = ToolInjectingProvider()
|
||||
agent = GitHubCopilotAgent(
|
||||
client=mock_client,
|
||||
tools=[my_tool],
|
||||
context_providers=[provider],
|
||||
)
|
||||
session = agent.create_session()
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
call_kwargs = mock_client.create_session.call_args.kwargs
|
||||
assert call_kwargs.get("tools") is not None
|
||||
tool_names = [t.name for t in call_kwargs["tools"]]
|
||||
assert "my_tool" in tool_names
|
||||
assert "load_skill" in tool_names
|
||||
|
||||
async def test_provider_tools_forwarded_in_streaming(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
assistant_delta_event: SessionEvent,
|
||||
session_idle_event: SessionEvent,
|
||||
) -> None:
|
||||
"""Test that provider tools are forwarded in the streaming path."""
|
||||
events = [assistant_delta_event, session_idle_event]
|
||||
|
||||
def mock_on(handler: Any) -> Any:
|
||||
for event in events:
|
||||
handler(event)
|
||||
return lambda: None
|
||||
|
||||
mock_session.on = mock_on
|
||||
|
||||
class ToolInjectingProvider(ContextProvider):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(source_id="tool-injector")
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession,
|
||||
context: Any,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
from agent_framework._tools import normalize_tools
|
||||
|
||||
def load_skill(skill_name: str) -> str:
|
||||
"""Load a skill by name."""
|
||||
return f"Loaded: {skill_name}"
|
||||
|
||||
context.extend_tools(self.source_id, normalize_tools([load_skill]))
|
||||
|
||||
provider = ToolInjectingProvider()
|
||||
agent = GitHubCopilotAgent(client=mock_client, context_providers=[provider])
|
||||
session = agent.create_session()
|
||||
async for _ in agent.run("Hello", stream=True, session=session):
|
||||
pass
|
||||
|
||||
call_kwargs = mock_client.create_session.call_args.kwargs
|
||||
assert call_kwargs.get("tools") is not None
|
||||
tool_names = [t.name for t in call_kwargs["tools"]]
|
||||
assert "load_skill" in tool_names
|
||||
|
||||
async def test_provider_tools_forwarded_to_resume_session(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
assistant_message_event: SessionEvent,
|
||||
) -> None:
|
||||
"""Test that provider tools are forwarded when resuming an existing session."""
|
||||
mock_session.send_and_wait.return_value = assistant_message_event
|
||||
|
||||
class ToolInjectingProvider(ContextProvider):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(source_id="tool-injector")
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession,
|
||||
context: Any,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
from agent_framework._tools import normalize_tools
|
||||
|
||||
def load_skill(skill_name: str) -> str:
|
||||
"""Load a skill by name."""
|
||||
return f"Loaded: {skill_name}"
|
||||
|
||||
context.extend_tools(self.source_id, normalize_tools([load_skill]))
|
||||
|
||||
provider = ToolInjectingProvider()
|
||||
agent = GitHubCopilotAgent(client=mock_client, context_providers=[provider])
|
||||
session = agent.create_session()
|
||||
session.service_session_id = "existing-id"
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
mock_client.create_session.assert_not_called()
|
||||
mock_client.resume_session.assert_called_once()
|
||||
call_kwargs = mock_client.resume_session.call_args.kwargs
|
||||
assert call_kwargs.get("tools") is not None
|
||||
tool_names = [t.name for t in call_kwargs["tools"]]
|
||||
assert "load_skill" in tool_names
|
||||
|
||||
async def test_provider_tools_forwarded_to_resume_session_streaming(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
assistant_delta_event: SessionEvent,
|
||||
session_idle_event: SessionEvent,
|
||||
) -> None:
|
||||
"""Test that provider tools are forwarded when resuming an existing session in streaming mode."""
|
||||
events = [assistant_delta_event, session_idle_event]
|
||||
|
||||
def mock_on(handler: Any) -> Any:
|
||||
for event in events:
|
||||
handler(event)
|
||||
return lambda: None
|
||||
|
||||
mock_session.on = mock_on
|
||||
|
||||
class ToolInjectingProvider(ContextProvider):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(source_id="tool-injector")
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession,
|
||||
context: Any,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
from agent_framework._tools import normalize_tools
|
||||
|
||||
def load_skill(skill_name: str) -> str:
|
||||
"""Load a skill by name."""
|
||||
return f"Loaded: {skill_name}"
|
||||
|
||||
context.extend_tools(self.source_id, normalize_tools([load_skill]))
|
||||
|
||||
provider = ToolInjectingProvider()
|
||||
agent = GitHubCopilotAgent(client=mock_client, context_providers=[provider])
|
||||
session = agent.create_session()
|
||||
session.service_session_id = "existing-id"
|
||||
async for _ in agent.run("Hello", stream=True, session=session):
|
||||
pass
|
||||
|
||||
mock_client.create_session.assert_not_called()
|
||||
mock_client.resume_session.assert_called_once()
|
||||
call_kwargs = mock_client.resume_session.call_args.kwargs
|
||||
assert call_kwargs.get("tools") is not None
|
||||
tool_names = [t.name for t in call_kwargs["tools"]]
|
||||
assert "load_skill" in tool_names
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Hyperlight CodeAct integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"hyperlight-sandbox>=0.4.0,<0.5",
|
||||
"hyperlight-sandbox-backend-wasm>=0.4.0,<0.5 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'",
|
||||
"hyperlight-sandbox-python-guest>=0.4.0,<0.5",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework"
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"mem0ai>=1.0.0,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://learn.microsoft.com/en-us/agent-framework/"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"ollama>=0.5.3,<0.5.4",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
]
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user