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 | ||
|
|
68357b0250 | ||
|
|
410268b624 | ||
|
|
67f3db6280 | ||
|
|
2ef20cd0aa | ||
|
|
27671974c2 | ||
|
|
7432105ebe | ||
|
|
3256550c55 | ||
|
|
190ca75b6a | ||
|
|
8058fb1c5b | ||
|
|
189e64bfdd |
@@ -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.
|
||||
@@ -242,6 +242,7 @@
|
||||
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
|
||||
@@ -326,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>
|
||||
|
||||
@@ -478,6 +478,17 @@ internal static class WorkflowSamples
|
||||
ExpectedOutputDescription = ["The output should show a workflow invoking a function tool (e.g. a menu plugin) to answer a question about the soup of the day."],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Workflow_Declarative_InvokeFoundryToolboxMcp",
|
||||
ProjectPath = "samples/03-workflows/Declarative/InvokeFoundryToolboxMcp",
|
||||
RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME", "FOUNDRY_TOOLBOX_NAME", "FOUNDRY_AGENT_TOOLSET_API_VERSION"],
|
||||
Inputs = ["How do I use Azure OpenAI with my data?"],
|
||||
InputDelayMs = 3000,
|
||||
ExpectedOutputDescription = ["The output should show a workflow using Foundry Toolbox MCP tools to search Microsoft Learn documentation and web search to provide a summary of results."],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Workflow_Declarative_InvokeMcpTool",
|
||||
|
||||
+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),
|
||||
});
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
|
||||
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
|
||||
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="OpenAI" />
|
||||
<PackageReference Include="System.ClientModel" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Foundry\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Mcp\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="InvokeFoundryToolboxMcp.yaml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
#
|
||||
# This workflow demonstrates invoking MCP tools through a Foundry toolbox MCP proxy.
|
||||
#
|
||||
# The toolbox is provisioned with TWO different tool types:
|
||||
# 1. A Foundry built-in web_search tool
|
||||
# 2. A Microsoft Learn MCP server (microsoft_docs)
|
||||
# Both are surfaced through the same MCP-compatible toolbox endpoint.
|
||||
#
|
||||
# The workflow:
|
||||
# 1. Accepts a documentation/web search query as input
|
||||
# 2. Lists the tools exposed by the Foundry toolbox using reserved toolName: tools/list
|
||||
# 3. Invokes the microsoft_docs_search MCP tool
|
||||
# 4. Invokes the built-in web_search tool against the same toolbox endpoint
|
||||
# 5. Uses an agent to summarize and combine both result sets
|
||||
#
|
||||
# Example input:
|
||||
# How do I use Azure OpenAI with my data?
|
||||
#
|
||||
kind: Workflow
|
||||
trigger:
|
||||
|
||||
kind: OnConversationStart
|
||||
id: workflow_invoke_foundry_toolbox_mcp
|
||||
actions:
|
||||
|
||||
# Set the search query from user input.
|
||||
- kind: SetVariable
|
||||
id: set_search_query
|
||||
variable: Local.SearchQuery
|
||||
value: =System.LastMessage.Text
|
||||
|
||||
# List tools exposed by the Foundry toolbox MCP proxy.
|
||||
- kind: InvokeMcpTool
|
||||
id: list_toolbox_tools
|
||||
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
|
||||
serverLabel: foundry_toolbox
|
||||
toolName: tools/list
|
||||
conversationId: =System.ConversationId
|
||||
headers:
|
||||
Foundry-Features: Toolboxes=V1Preview
|
||||
output:
|
||||
autoSend: true
|
||||
result: Local.ToolboxTools
|
||||
|
||||
# Invoke a specific tool exposed through the toolbox and add the result to the conversation.
|
||||
- kind: InvokeMcpTool
|
||||
id: search_docs_with_toolbox
|
||||
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
|
||||
serverLabel: foundry_toolbox
|
||||
toolName: =Env.FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL & "___microsoft_docs_search"
|
||||
conversationId: =System.ConversationId
|
||||
headers:
|
||||
Foundry-Features: Toolboxes=V1Preview
|
||||
arguments:
|
||||
query: =Local.SearchQuery
|
||||
output:
|
||||
autoSend: true
|
||||
result: Local.SearchResult
|
||||
|
||||
# Invoke the web_search built-in tool through the same toolbox proxy. The toolbox surfaces
|
||||
# built-in Foundry tools (like web_search) alongside MCP tools through one MCP-compatible
|
||||
# endpoint. Note that web_search expects argument 'search_query' (not 'query').
|
||||
- kind: InvokeMcpTool
|
||||
id: search_web_with_toolbox
|
||||
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
|
||||
serverLabel: foundry_toolbox
|
||||
toolName: =Env.FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME
|
||||
conversationId: =System.ConversationId
|
||||
headers:
|
||||
Foundry-Features: Toolboxes=V1Preview
|
||||
arguments:
|
||||
search_query: =Local.SearchQuery
|
||||
output:
|
||||
autoSend: true
|
||||
result: Local.WebSearchResult
|
||||
|
||||
# Use the agent to summarize what happened and answer from the toolbox result.
|
||||
- kind: InvokeAzureAgent
|
||||
id: summarize_toolbox_result
|
||||
agent:
|
||||
name: FoundryToolboxMcpAgent
|
||||
conversationId: =System.ConversationId
|
||||
input:
|
||||
messages: =UserMessage("Combine the Microsoft Learn docs results and the Foundry web search results in the conversation to answer the query " & Local.SearchQuery)
|
||||
output:
|
||||
autoSend: true
|
||||
messages: Local.Summary
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates using InvokeMcpTool to call MCP tools through a Foundry toolbox.
|
||||
// It creates a sample toolbox that exposes Microsoft Learn MCP tools, lists the toolbox tools
|
||||
// through the reserved tools/list operation, then calls microsoft_docs_search from the workflow.
|
||||
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http.Headers;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Mcp;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using OpenAI.Responses;
|
||||
using Shared.Foundry;
|
||||
using Shared.Workflows;
|
||||
|
||||
#pragma warning disable OPENAI001 // Experimental API
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental
|
||||
|
||||
namespace Demo.Workflows.Declarative.InvokeFoundryToolboxMcp;
|
||||
|
||||
/// <summary>
|
||||
/// Demonstrates a workflow that uses InvokeMcpTool to call MCP tools exposed through a Foundry toolbox.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This sample provisions a toolbox with Microsoft Learn MCP tools, uses the reserved
|
||||
/// <c>tools/list</c> tool name to list the toolbox tools, calls one specific toolbox tool,
|
||||
/// and has a Foundry agent summarize the results.
|
||||
/// </remarks>
|
||||
internal sealed class Program
|
||||
{
|
||||
private const string ToolboxNameSetting = "FOUNDRY_TOOLBOX_NAME";
|
||||
private const string ToolboxApiVersionSetting = "FOUNDRY_AGENT_TOOLSET_API_VERSION";
|
||||
private const string ToolboxMcpServerUrlSetting = "FOUNDRY_TOOLBOX_MCP_SERVER_URL";
|
||||
private const string DocsServerLabelSetting = "FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL";
|
||||
private const string WebSearchToolNameSetting = "FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME";
|
||||
private const string DefaultToolboxName = "declarative_foundry_toolbox_mcp";
|
||||
private const string DefaultToolboxApiVersion = "v1";
|
||||
private const string DefaultDocsServerLabel = "microsoft_docs";
|
||||
private const string DefaultWebSearchToolName = "web_search";
|
||||
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
// Initialize configuration
|
||||
IConfiguration configuration = Application.InitializeConfig();
|
||||
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
|
||||
string toolboxName = configuration[ToolboxNameSetting] ?? DefaultToolboxName;
|
||||
string toolboxApiVersion = configuration[ToolboxApiVersionSetting] ?? DefaultToolboxApiVersion;
|
||||
string docsServerLabel = configuration[DocsServerLabelSetting] ?? DefaultDocsServerLabel;
|
||||
string webSearchToolName = configuration[WebSearchToolNameSetting] ?? DefaultWebSearchToolName;
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
DefaultAzureCredential credential = new();
|
||||
|
||||
// Ensure sample toolbox and agent exist in Foundry
|
||||
string toolboxEndpoint = await CreateSampleToolboxAsync(toolboxName, docsServerLabel, foundryEndpoint, credential);
|
||||
string toolboxMcpServerUrl = BuildToolboxMcpServerUrl(toolboxEndpoint, toolboxName, toolboxApiVersion);
|
||||
IConfiguration workflowConfiguration = new ConfigurationBuilder()
|
||||
.AddConfiguration(configuration)
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[ToolboxMcpServerUrlSetting] = toolboxMcpServerUrl,
|
||||
[DocsServerLabelSetting] = docsServerLabel,
|
||||
[WebSearchToolNameSetting] = webSearchToolName,
|
||||
})
|
||||
.Build();
|
||||
|
||||
await CreateAgentAsync(foundryEndpoint, configuration, credential);
|
||||
|
||||
// Get input from command line or console
|
||||
string workflowInput = Application.GetInput(args);
|
||||
|
||||
// Create the MCP tool handler for invoking the Foundry toolbox MCP proxy.
|
||||
ConcurrentBag<HttpClient> createdHttpClients = [];
|
||||
DefaultMcpToolHandler mcpToolHandler = new(
|
||||
httpClientProvider: async (serverUrl, _) =>
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
|
||||
if (!string.Equals(serverUrl, toolboxMcpServerUrl, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
FoundryToolboxBearerTokenHandler handler = new(credential)
|
||||
{
|
||||
InnerHandler = new HttpClientHandler()
|
||||
};
|
||||
HttpClient httpClient = new(handler);
|
||||
createdHttpClients.Add(httpClient);
|
||||
return httpClient;
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
// Create the workflow factory with MCP tool provider
|
||||
WorkflowFactory workflowFactory = new("InvokeFoundryToolboxMcp.yaml", foundryEndpoint)
|
||||
{
|
||||
Configuration = workflowConfiguration,
|
||||
McpToolHandler = mcpToolHandler
|
||||
};
|
||||
|
||||
// Execute the workflow
|
||||
WorkflowRunner runner = new() { UseJsonCheckpoints = true };
|
||||
await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up connections and dispose created HttpClients
|
||||
await mcpToolHandler.DisposeAsync();
|
||||
|
||||
foreach (HttpClient httpClient in createdHttpClients)
|
||||
{
|
||||
httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration, TokenCredential credential)
|
||||
{
|
||||
AIProjectClient aiProjectClient = new(foundryEndpoint, credential);
|
||||
|
||||
await aiProjectClient.CreateAgentAsync(
|
||||
agentName: "FoundryToolboxMcpAgent",
|
||||
agentDefinition: DefineToolboxAgent(configuration),
|
||||
agentDescription: "Summarizes Foundry toolbox MCP tool results");
|
||||
}
|
||||
|
||||
private static DeclarativeAgentDefinition DefineToolboxAgent(IConfiguration configuration)
|
||||
{
|
||||
return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel))
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
You are a helpful assistant that explains results produced by tools exposed through a Foundry toolbox.
|
||||
The conversation history contains output from BOTH a Microsoft Learn documentation search (MCP) and a Foundry web search.
|
||||
Synthesize an answer that draws on both sources, calls out where they agree or differ, and notes which toolbox tool produced each fact when it is relevant.
|
||||
Be concise.
|
||||
"""
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<string> CreateSampleToolboxAsync(string name, string serverLabel, Uri foundryEndpoint, TokenCredential credential)
|
||||
{
|
||||
AgentAdministrationClientOptions options = new();
|
||||
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
|
||||
AgentAdministrationClient adminClient = new(foundryEndpoint, credential, options);
|
||||
AgentToolboxes toolboxClient = adminClient.GetAgentToolboxes();
|
||||
|
||||
try
|
||||
{
|
||||
await toolboxClient.DeleteToolboxAsync(name);
|
||||
Console.WriteLine($"Deleted existing toolbox '{name}'");
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Toolbox does not exist.
|
||||
}
|
||||
|
||||
ProjectsAgentTool webTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateWebSearchTool());
|
||||
|
||||
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
|
||||
serverLabel: serverLabel,
|
||||
serverUri: new Uri("https://learn.microsoft.com/api/mcp"),
|
||||
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
|
||||
|
||||
ToolboxVersion created = (await toolboxClient.CreateToolboxVersionAsync(
|
||||
name: name,
|
||||
tools: [webTool, mcpTool],
|
||||
description: "Sample toolbox combining Foundry web search with the Microsoft Learn MCP tools for the declarative InvokeFoundryToolboxMcp sample.")).Value;
|
||||
|
||||
Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))");
|
||||
|
||||
return $"{foundryEndpoint.ToString().TrimEnd('/')}/toolboxes";
|
||||
}
|
||||
|
||||
private static string BuildToolboxMcpServerUrl(string toolboxEndpoint, string toolboxName, string apiVersion) =>
|
||||
$"{toolboxEndpoint.TrimEnd('/')}/{toolboxName}/mcp?api-version={Uri.EscapeDataString(apiVersion)}";
|
||||
|
||||
private sealed class FoundryToolboxBearerTokenHandler(TokenCredential credential) : DelegatingHandler
|
||||
{
|
||||
private static readonly TokenRequestContext s_tokenContext =
|
||||
new(["https://ai.azure.com/.default"]);
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AccessToken token = await credential.GetTokenAsync(s_tokenContext, cancellationToken);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
|
||||
{
|
||||
private const string FeatureHeader = "Foundry-Features";
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Add(FeatureHeader, feature);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Add(FeatureHeader, feature);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
@@ -24,6 +27,14 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp;
|
||||
/// </remarks>
|
||||
public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Reserved <c>toolName</c> value that maps an <see cref="IMcpToolHandler.InvokeToolAsync"/> request
|
||||
/// to the MCP protocol <c>tools/list</c> discovery operation.
|
||||
/// </summary>
|
||||
public const string ListToolsToolName = "tools/list";
|
||||
|
||||
private static readonly JsonWriterOptions s_toolListJsonWriterOptions = new() { Indented = true };
|
||||
|
||||
private readonly Func<string, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
|
||||
private readonly Dictionary<string, McpClient> _clients = [];
|
||||
private readonly Dictionary<string, HttpClient> _ownedHttpClients = [];
|
||||
@@ -53,9 +64,18 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore
|
||||
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
|
||||
if (IsListToolsToolName(toolName))
|
||||
{
|
||||
ThrowIfListToolsArgumentsSpecified(arguments);
|
||||
McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
|
||||
IList<McpClientTool> tools = await listToolsClient.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool));
|
||||
}
|
||||
|
||||
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
|
||||
|
||||
// Convert IDictionary to IReadOnlyDictionary for CallToolAsync
|
||||
IReadOnlyDictionary<string, object?>? readOnlyArguments = arguments is null
|
||||
? null
|
||||
@@ -72,6 +92,23 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
return resultContent;
|
||||
}
|
||||
|
||||
internal static bool IsListToolsToolName(string toolName) =>
|
||||
string.Equals(toolName, ListToolsToolName, StringComparison.Ordinal);
|
||||
|
||||
internal static McpServerToolResultContent CreateListToolsResultContent(IEnumerable<Tool> tools)
|
||||
{
|
||||
Throw.IfNull(tools);
|
||||
|
||||
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString())
|
||||
{
|
||||
Outputs = []
|
||||
};
|
||||
|
||||
resultContent.Outputs.Add(new TextContent(SerializeToolsList(tools)));
|
||||
|
||||
return resultContent;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
@@ -183,6 +220,16 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
return hashCode.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static void ThrowIfListToolsArgumentsSpecified(IDictionary<string, object?>? arguments)
|
||||
{
|
||||
if (arguments is { Count: > 0 })
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The reserved MCP '{ListToolsToolName}' operation does not accept tool arguments.",
|
||||
nameof(arguments));
|
||||
}
|
||||
}
|
||||
|
||||
private static void PopulateResultContent(McpServerToolResultContent resultContent, CallToolResult result)
|
||||
{
|
||||
// Ensure Outputs list is initialized
|
||||
@@ -230,6 +277,17 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
TextContentBlock text => new TextContent(text.Text),
|
||||
ImageContentBlock image => CreateDataContent(image.Data, image.MimeType ?? "image/*"),
|
||||
AudioContentBlock audio => CreateDataContent(audio.Data, audio.MimeType ?? "audio/*"),
|
||||
EmbeddedResourceBlock embedded => ConvertEmbeddedResource(embedded),
|
||||
_ => new TextContent(block.ToString() ?? string.Empty),
|
||||
};
|
||||
}
|
||||
|
||||
private static AIContent ConvertEmbeddedResource(EmbeddedResourceBlock block)
|
||||
{
|
||||
return block.Resource switch
|
||||
{
|
||||
TextResourceContents text => new TextContent(text.Text),
|
||||
BlobResourceContents blob => CreateDataContent(blob.Blob, blob.MimeType ?? "application/octet-stream"),
|
||||
_ => new TextContent(block.ToString() ?? string.Empty),
|
||||
};
|
||||
}
|
||||
@@ -255,4 +313,39 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
|
||||
return new DataContent($"data:{mediaType};base64,{base64}", mediaType);
|
||||
}
|
||||
|
||||
private static string SerializeToolsList(IEnumerable<Tool> tools)
|
||||
{
|
||||
using MemoryStream stream = new();
|
||||
using (Utf8JsonWriter writer = new(stream, s_toolListJsonWriterOptions))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteStartArray("tools");
|
||||
|
||||
foreach (Tool tool in tools)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", tool.Name);
|
||||
writer.WriteString("description", tool.Description);
|
||||
writer.WritePropertyName("inputSchema");
|
||||
tool.InputSchema.WriteTo(writer);
|
||||
writer.WritePropertyName("outputSchema");
|
||||
if (tool.OutputSchema is JsonElement outputSchema)
|
||||
{
|
||||
outputSchema.WriteTo(writer);
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(stream.GetBuffer(), 0, (int)stream.Length);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
private bool _emitAgentResponseUpdateEvents;
|
||||
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
|
||||
private bool _returnToPrevious;
|
||||
private string? _name;
|
||||
private string? _description;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
|
||||
@@ -97,6 +99,20 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="WorkflowBuilder.WithName(string)"/>
|
||||
public TBuilder WithName(string name)
|
||||
{
|
||||
this._name = name;
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="WorkflowBuilder.WithDescription(string)"/>
|
||||
public TBuilder WithDescription(string description)
|
||||
{
|
||||
this._description = description;
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a value indicating whether agent streaming update events should be emitted during execution.
|
||||
/// If <see langword="null"/>, the value will be taken from the <see cref="TurnToken"/>
|
||||
@@ -330,7 +346,16 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
builder.AddEdge(start, executors[this._initialAgent.Id]);
|
||||
}
|
||||
|
||||
// Build the workflow.
|
||||
if (!string.IsNullOrWhiteSpace(this._name))
|
||||
{
|
||||
builder.WithName(this._name);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(this._description))
|
||||
{
|
||||
builder.WithDescription(this._description);
|
||||
}
|
||||
|
||||
return builder.WithOutputFrom(end).Build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
@@ -140,7 +141,15 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent)
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="WorkflowBuilder.Build"/>
|
||||
public Workflow Build() => this.ReduceToWorkflowBuilder().Build();
|
||||
public Workflow Build()
|
||||
{
|
||||
if (this._team.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("At least one participant must be added via AddParticipants() before building the workflow.");
|
||||
}
|
||||
|
||||
return this.ReduceToWorkflowBuilder().Build();
|
||||
}
|
||||
|
||||
private TaskLimits Limits => new(
|
||||
MaxRoundCount: this._maxRounds,
|
||||
|
||||
+24
-9
@@ -101,6 +101,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
return base.ConfigureProtocol(protocolBuilder)
|
||||
.SendsMessage<ChatMessage>()
|
||||
.SendsMessage<ResetChatSignal>()
|
||||
.YieldsOutput<List<ChatMessage>>()
|
||||
.ConfigureRoutes(ConfigureRoutes);
|
||||
|
||||
void ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddPortHandler<MagenticPlanReviewRequest, MagenticPlanReviewResponse>(
|
||||
@@ -109,7 +110,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
out this._planReviewPort);
|
||||
}
|
||||
|
||||
private ValueTask SubmitPlanReviewRequestAsync(MagenticTaskContext taskContext, IWorkflowContext workflowContext)
|
||||
private ValueTask SubmitPlanReviewRequestAsync(MagenticTaskContext taskContext, IWorkflowContext workflowContext, bool replanAfterStall = false)
|
||||
{
|
||||
MagenticProgressLedger? progressLedger = taskContext.ProgressLedger;
|
||||
if (progressLedger?.IsStarted is not true)
|
||||
@@ -117,7 +118,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
progressLedger = null;
|
||||
}
|
||||
|
||||
MagenticPlanReviewRequest request = new(taskContext.TaskLedger!.CurrentPlan, progressLedger, taskContext.IsStalled);
|
||||
MagenticPlanReviewRequest request = new(taskContext.TaskLedger!.CurrentPlan, progressLedger, replanAfterStall);
|
||||
|
||||
return this._planReviewPort!.PostRequestAsync(request);
|
||||
}
|
||||
@@ -146,7 +147,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
|
||||
if (this._taskContext.IsTerminated)
|
||||
{
|
||||
throw new InvalidOperationException("Magentic Orchestration has already been terminated and cannot process new messages. Please start a new session.");
|
||||
throw new InvalidOperationException("This Magentic orchestration has already terminated. To process new messages, create a new workflow instance.");
|
||||
}
|
||||
|
||||
if (response.IsApproved)
|
||||
@@ -161,7 +162,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask UpdatePlanAndDelegateAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
private async ValueTask UpdatePlanAndDelegateAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken, bool replanAfterStall = false)
|
||||
{
|
||||
bool isReplan = taskContext.TaskLedger != null;
|
||||
|
||||
@@ -177,7 +178,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
|
||||
if (requirePlanSignoff)
|
||||
{
|
||||
await this.SubmitPlanReviewRequestAsync(taskContext, context).ConfigureAwait(false);
|
||||
await this.SubmitPlanReviewRequestAsync(taskContext, context, replanAfterStall).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -187,9 +188,22 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// First Turn: Initialize the task context and send the initial messages to the planner agent
|
||||
this._taskContext ??= new(messages, team, limits, emitEvents, []);
|
||||
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
if (this._taskContext?.IsTerminated == true)
|
||||
{
|
||||
throw new InvalidOperationException("This Magentic orchestration has already terminated. To process new messages, create a new workflow instance.");
|
||||
}
|
||||
|
||||
if (this._taskContext == null)
|
||||
{
|
||||
// First Turn: Initialize the task context and create the initial plan
|
||||
this._taskContext = new(messages, team, limits, emitEvents, []);
|
||||
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Subsequent turns: agent returned control, go directly to coordination (progress ledger only, no replan)
|
||||
await this.RunCoordinationRoundAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private ChatMessage? _fullTaskLedgerMessage;
|
||||
@@ -288,10 +302,11 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
|
||||
private async ValueTask ResetAndReplanAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
bool wasStalled = taskContext.IsStalled;
|
||||
taskContext.Reset();
|
||||
await context.SendMessageAsync(new ResetChatSignal(), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken, replanAfterStall: wasStalled).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask PrepareFinalAnswerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ internal class MagenticTaskContext(List<ChatMessage> taskDefinition, List<AIAgen
|
||||
|
||||
public bool IsTerminated { get; internal set; }
|
||||
|
||||
public bool IsStalled => this.TaskCounters.StallCount >= this.TaskLimits.MaxStallCount;
|
||||
public bool IsStalled => this.TaskCounters.StallCount > this.TaskLimits.MaxStallCount;
|
||||
|
||||
public (bool HitRoundLimit, bool HitResetLimit) CheckLimits()
|
||||
{
|
||||
|
||||
@@ -36,9 +36,13 @@ public sealed class SwitchBuilder
|
||||
Throw.IfNull(executors);
|
||||
|
||||
HashSet<int> indicies = [];
|
||||
int executorIndex = 0;
|
||||
|
||||
foreach (ExecutorBinding executor in executors)
|
||||
{
|
||||
// Explicit name: null element inside the collection argument.
|
||||
Throw.IfNull(executor, $"{nameof(executors)}[{executorIndex++}]");
|
||||
|
||||
if (!this._executorIndicies.TryGetValue(executor.Id, out int index))
|
||||
{
|
||||
index = this._executors.Count;
|
||||
@@ -64,8 +68,13 @@ public sealed class SwitchBuilder
|
||||
{
|
||||
Throw.IfNull(executors);
|
||||
|
||||
int executorIndex = 0;
|
||||
|
||||
foreach (ExecutorBinding executor in executors)
|
||||
{
|
||||
// Explicit name: null element inside the collection argument.
|
||||
Throw.IfNull(executor, $"{nameof(executors)}[{executorIndex++}]");
|
||||
|
||||
if (!this._executorIndicies.TryGetValue(executor.Id, out int index))
|
||||
{
|
||||
index = this._executors.Count;
|
||||
|
||||
@@ -25,7 +25,11 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="target">The target executor to which messages will be forwarded.</param>
|
||||
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
|
||||
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding target)
|
||||
=> builder.ForwardMessage<TMessage>(source, [target], condition: null);
|
||||
{
|
||||
Throw.IfNull(target, nameof(target));
|
||||
|
||||
return builder.ForwardMessage<TMessage>(source, [target], condition: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds edges to the workflow that forward messages of the specified type from the source executor to
|
||||
@@ -52,6 +56,8 @@ public static class WorkflowBuilderExtensions
|
||||
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
|
||||
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, IEnumerable<ExecutorBinding> targets, Func<TMessage, bool>? condition = null)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(source);
|
||||
Throw.IfNull(targets);
|
||||
|
||||
Func<object?, bool> predicate = WorkflowBuilder.CreateConditionFunc<TMessage>(IsAllowedTypeAndMatchingCondition)!;
|
||||
@@ -62,14 +68,16 @@ public static class WorkflowBuilderExtensions
|
||||
if (targets is ICollection<ExecutorBinding> { Count: 1 })
|
||||
#endif
|
||||
{
|
||||
return builder.AddEdge(source, targets.First(), predicate);
|
||||
return builder.AddEdge(source, Throw.IfNull(targets.First(), nameof(targets)), predicate);
|
||||
}
|
||||
|
||||
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets));
|
||||
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets.Select(ValidateTarget)));
|
||||
|
||||
// The reason we can check for "not null" here is that CreateConditionFunc<T> will do the correct unwrapping
|
||||
// logic for PortableValues.
|
||||
bool IsAllowedTypeAndMatchingCondition(TMessage? message) => message != null && (condition == null || condition(message));
|
||||
|
||||
ExecutorBinding ValidateTarget(ExecutorBinding target) => Throw.IfNull(target, nameof(targets));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -81,7 +89,11 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="target">The target executor to which messages, except those of type <typeparamref name="TMessage"/>, will be forwarded.</param>
|
||||
/// <returns>The updated <see cref="WorkflowBuilder"/> instance with the added edges.</returns>
|
||||
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding target)
|
||||
=> builder.ForwardExcept<TMessage>(source, [target]);
|
||||
{
|
||||
Throw.IfNull(target, nameof(target));
|
||||
|
||||
return builder.ForwardExcept<TMessage>(source, [target]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds edges from the specified source to the provided executors, excluding messages of a specified type.
|
||||
@@ -93,6 +105,8 @@ public static class WorkflowBuilderExtensions
|
||||
/// <returns>The updated <see cref="WorkflowBuilder"/> instance with the added edges.</returns>
|
||||
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, IEnumerable<ExecutorBinding> targets)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(source);
|
||||
Throw.IfNull(targets);
|
||||
|
||||
Func<object?, bool> predicate = WorkflowBuilder.CreateConditionFunc<TMessage>((Func<object?, bool>)IsAllowedType)!;
|
||||
@@ -103,14 +117,16 @@ public static class WorkflowBuilderExtensions
|
||||
if (targets is ICollection<ExecutorBinding> { Count: 1 })
|
||||
#endif
|
||||
{
|
||||
return builder.AddEdge(source, targets.First(), predicate);
|
||||
return builder.AddEdge(source, Throw.IfNull(targets.First(), nameof(targets)), predicate);
|
||||
}
|
||||
|
||||
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets));
|
||||
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets.Select(ValidateTarget)));
|
||||
|
||||
// The reason we can check for "null" here is that CreateConditionFunc<T> will do the correct unwrapping
|
||||
// logic for PortableValues.
|
||||
static bool IsAllowedType(object? message) => message is null;
|
||||
|
||||
ExecutorBinding ValidateTarget(ExecutorBinding target) => Throw.IfNull(target, nameof(targets));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -129,6 +145,7 @@ public static class WorkflowBuilderExtensions
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(source);
|
||||
Throw.IfNull(executors);
|
||||
|
||||
HashSet<string> seenExecutors = [source.Id];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+24
@@ -103,6 +103,30 @@ public class HostApplicationBuilderWorkflowExtensionsTests
|
||||
Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow3");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a handoff workflow can be named from the DI workflow key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddWorkflow_HandoffWorkflowWithName_ResolvesWorkflow()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "handoffWorkflow";
|
||||
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.Setup(a => a.Name).Returns("handoffAgent");
|
||||
|
||||
#pragma warning disable MAAIW001 // This test covers hosting handoff workflows.
|
||||
builder.AddWorkflow(WorkflowName, (sp, key) =>
|
||||
AgentWorkflowBuilder.CreateHandoffBuilderWith(mockAgent.Object)
|
||||
.WithName(key)
|
||||
.Build());
|
||||
#pragma warning restore MAAIW001
|
||||
|
||||
var workflow = builder.Build().Services.GetRequiredKeyedService<Workflow>(WorkflowName);
|
||||
|
||||
Assert.Equal(WorkflowName, workflow.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddWorkflow handles empty strings for name.
|
||||
/// </summary>
|
||||
|
||||
+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);
|
||||
|
||||
+157
@@ -4,6 +4,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
@@ -320,6 +321,92 @@ public sealed class DefaultMcpToolHandlerTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reserved Tools/List Tests
|
||||
|
||||
[Fact]
|
||||
public void IsListToolsToolName_WithReservedName_ShouldReturnTrue()
|
||||
{
|
||||
// Act
|
||||
bool result = DefaultMcpToolHandler.IsListToolsToolName(DefaultMcpToolHandler.ListToolsToolName);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsListToolsToolName_WithRegularToolName_ShouldReturnFalse()
|
||||
{
|
||||
// Act
|
||||
bool result = DefaultMcpToolHandler.IsListToolsToolName("search");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeToolAsync_WithListToolsArguments_ShouldThrowArgumentExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
DefaultMcpToolHandler handler = new();
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
Func<Task> act = async () => await handler.InvokeToolAsync(
|
||||
serverUrl: "http://localhost:12345/mcp",
|
||||
serverLabel: "test",
|
||||
toolName: DefaultMcpToolHandler.ListToolsToolName,
|
||||
arguments: new Dictionary<string, object?> { ["ignored"] = true },
|
||||
headers: null,
|
||||
connectionName: null);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithMessage("*does not accept tool arguments*");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await handler.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateListToolsResultContent_WithTools_ShouldSerializeToolMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
JsonElement inputSchema = JsonSerializer.Deserialize<JsonElement>(
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [ "query" ]
|
||||
}
|
||||
""");
|
||||
Tool tool = new()
|
||||
{
|
||||
Name = "search",
|
||||
Description = "Searches documentation.",
|
||||
InputSchema = inputSchema
|
||||
};
|
||||
|
||||
// Act
|
||||
McpServerToolResultContent result = DefaultMcpToolHandler.CreateListToolsResultContent([tool]);
|
||||
|
||||
// Assert
|
||||
TextContent text = result.Outputs.Should().ContainSingle().Subject.Should().BeOfType<TextContent>().Subject;
|
||||
using JsonDocument document = JsonDocument.Parse(text.Text);
|
||||
JsonElement listedTool = document.RootElement.GetProperty("tools")[0];
|
||||
listedTool.GetProperty("name").GetString().Should().Be("search");
|
||||
listedTool.GetProperty("description").GetString().Should().Be("Searches documentation.");
|
||||
listedTool.GetProperty("inputSchema").GetProperty("properties").GetProperty("query").GetProperty("type").GetString().Should().Be("string");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Interface Implementation Tests
|
||||
|
||||
[Fact]
|
||||
@@ -488,5 +575,75 @@ public sealed class DefaultMcpToolHandlerTests
|
||||
dataContent.MediaType.Should().Be("audio/*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_EmbeddedResourceBlock_WithTextResource_ShouldReturnTextContent()
|
||||
{
|
||||
// Arrange
|
||||
EmbeddedResourceBlock block = new()
|
||||
{
|
||||
Resource = new TextResourceContents
|
||||
{
|
||||
Text = "embedded text payload",
|
||||
Uri = "resource://example",
|
||||
MimeType = "text/plain",
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
result.Should().BeOfType<TextContent>()
|
||||
.Which.Text.Should().Be("embedded text payload");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_ShouldReturnDataContent()
|
||||
{
|
||||
// Arrange
|
||||
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
|
||||
EmbeddedResourceBlock block = new()
|
||||
{
|
||||
Resource = new BlobResourceContents
|
||||
{
|
||||
Blob = new ReadOnlyMemory<byte>(base64Bytes),
|
||||
Uri = "resource://example.bin",
|
||||
MimeType = "application/zip",
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
|
||||
dataContent.MediaType.Should().Be("application/zip");
|
||||
dataContent.Uri.Should().Be("data:application/zip;base64,UklGRiQA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_NullMimeType_DefaultsToOctetStream()
|
||||
{
|
||||
// Arrange
|
||||
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
|
||||
EmbeddedResourceBlock block = new()
|
||||
{
|
||||
Resource = new BlobResourceContents
|
||||
{
|
||||
Blob = new ReadOnlyMemory<byte>(base64Bytes),
|
||||
Uri = "resource://example.bin",
|
||||
MimeType = null!,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
|
||||
dataContent.MediaType.Should().Be("application/octet-stream");
|
||||
dataContent.Uri.Should().Be("data:application/octet-stream;base64,UklGRiQA");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
+38
@@ -432,6 +432,44 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl
|
||||
VerifyInvocationEvent(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithReservedListToolsNameAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
const string ListToolsToolName = "tools/list";
|
||||
string? capturedToolName = null;
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithReservedListToolsNameAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: ListToolsToolName);
|
||||
Mock<IMcpToolHandler> mockProvider = new();
|
||||
mockProvider.Setup(provider => provider.InvokeToolAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IDictionary<string, object?>?>(),
|
||||
It.IsAny<IDictionary<string, string>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
|
||||
(_, _, toolName, _, _, _, _) => capturedToolName = toolName)
|
||||
.ReturnsAsync(new McpServerToolResultContent("list-tools-call-id")
|
||||
{
|
||||
Outputs = [new TextContent("{\"tools\":[]}")]
|
||||
});
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
VerifyInvocationEvent(events);
|
||||
Assert.Equal(ListToolsToolName, capturedToolName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithMultipleContentTypesAsync()
|
||||
{
|
||||
|
||||
@@ -86,6 +86,23 @@ public class HandoffOrchestrationTests
|
||||
target.Reason.Should().Be("instructions");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHandoffs_WithNameAndDescription_SetsWorkflowMetadata()
|
||||
{
|
||||
const string WorkflowName = "handoff-workflow";
|
||||
const string WorkflowDescription = "A handoff workflow";
|
||||
|
||||
DoubleEchoAgent agent = new("agent");
|
||||
|
||||
var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(agent)
|
||||
.WithName(WorkflowName)
|
||||
.WithDescription(WorkflowDescription)
|
||||
.Build();
|
||||
|
||||
Assert.Equal(WorkflowName, workflow.Name);
|
||||
Assert.Equal(WorkflowDescription, workflow.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_NoTransfers_ResponseServedByOriginalAgentAsync()
|
||||
{
|
||||
|
||||
+22
-1
@@ -34,7 +34,13 @@ public sealed class InputWaiterTests : IDisposable
|
||||
[Fact]
|
||||
public async Task InputWaiter_WaitForInputAsync_BlocksUntilSignaledAsync()
|
||||
{
|
||||
Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(5));
|
||||
// Use the no-timeout overload so that the wait can only be released by SignalInput.
|
||||
// A finite timeout would make this test's logic racy: the component correctly
|
||||
// honors the timeout, but if the test thread is starved of CPU time (CI load,
|
||||
// GC pause) long enough for the timeout to fire, waitTask completes before
|
||||
// SignalInput is called and the "should not complete before signaled" assertion
|
||||
// flakes. Timeout behavior is covered separately below.
|
||||
Task waitTask = this._waiter.WaitForInputAsync(CancellationToken.None);
|
||||
|
||||
Task completedBeforeSignal = await Task.WhenAny(waitTask, Task.Delay(100));
|
||||
completedBeforeSignal.Should().NotBeSameAs(
|
||||
@@ -100,6 +106,21 @@ public sealed class InputWaiterTests : IDisposable
|
||||
this._waiter.SignalInput();
|
||||
await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InputWaiter_WaitForInputAsync_CompletesWhenTimeoutExpiresAsync()
|
||||
{
|
||||
// Verify that a finite timeout releases the block even without a signal.
|
||||
// We only assert that it *does* complete (within a generous outer bound);
|
||||
// we intentionally do not assert that it stays blocked until the timeout,
|
||||
// because that would re-introduce the same wall-clock flakiness
|
||||
// described in BlocksUntilSignaledAsync (see comment on that test).
|
||||
Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(5)));
|
||||
completed.Should().BeSameAs(waitTask, "the wait task should complete once the timeout expires");
|
||||
await waitTask;
|
||||
}
|
||||
}
|
||||
|
||||
public class OutputFilterTests
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -133,31 +133,31 @@ public sealed class ObservabilityTests : IDisposable
|
||||
activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowCompleted, "activity should have workflow completed event");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_DefaultAsync()
|
||||
{
|
||||
await this.TestWorkflowEndToEndActivitiesAsync("Default");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_OffThreadAsync()
|
||||
{
|
||||
await this.TestWorkflowEndToEndActivitiesAsync("OffThread");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_ConcurrentAsync()
|
||||
{
|
||||
await this.TestWorkflowEndToEndActivitiesAsync("Concurrent");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_LockstepAsync()
|
||||
{
|
||||
await this.TestWorkflowEndToEndActivitiesAsync("Lockstep");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task CreatesWorkflowActivities_WithCorrectNameAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -182,7 +182,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
tags.Should().ContainKey(Tags.WorkflowDefinition);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task TelemetryDisabledByDefault_CreatesNoActivitiesAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -200,7 +200,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
capturedActivities.Should().BeEmpty("No activities should be created when telemetry is disabled (default).");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task WithOpenTelemetry_UsesProvidedActivitySourceAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -235,7 +235,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
"All activities should come from the user-provided ActivitySource.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task DisableWorkflowBuild_PreventsWorkflowBuildActivityAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -255,7 +255,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
"WorkflowBuild activity should be disabled.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task DisableWorkflowRun_PreventsWorkflowRunActivityAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -285,7 +285,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
"Other activities should still be created.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task DisableExecutorProcess_PreventsExecutorProcessActivityAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -312,7 +312,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
"Other activities should still be created.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task DisableEdgeGroupProcess_PreventsEdgeGroupProcessActivityAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -333,7 +333,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
"Other activities should still be created.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task DisableMessageSend_PreventsMessageSendActivityAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -382,7 +382,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
return builder.WithOpenTelemetry(configure: opts => opts.DisableMessageSend = true).Build();
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task EnableSensitiveData_LogsExecutorInputAndOutputAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -413,7 +413,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
tags[Tags.ExecutorOutput].Should().Contain("HELLO", "Output should contain the transformed value.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task EnableSensitiveData_Disabled_DoesNotLogInputOutputAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -442,7 +442,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
tags.Should().NotContainKey(Tags.ExecutorOutput, "Output should NOT be logged when EnableSensitiveData is false.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task EnableSensitiveData_LogsMessageSendContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -474,7 +474,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
tags.Should().ContainKey(Tags.MessageSourceId, "Source ID should be logged.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task EnableSensitiveData_Disabled_DoesNotLogMessageContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
@@ -157,4 +158,301 @@ public partial class WorkflowBuilderSmokeTests
|
||||
workflow3.Name.Should().Be("Named Only");
|
||||
workflow3.Description.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardMessage_WithSingleTarget_CreatesDirectEdge()
|
||||
{
|
||||
// Arrange
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor target = new("target");
|
||||
|
||||
// Act
|
||||
Workflow workflow = new WorkflowBuilder(source.Id)
|
||||
.ForwardMessage<string>(source, target)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Edge edge = GetSingleEdge(workflow, source.Id);
|
||||
edge.Kind.Should().Be(EdgeKind.Direct);
|
||||
edge.DirectEdgeData.Should().NotBeNull();
|
||||
edge.DirectEdgeData!.SourceId.Should().Be(source.Id);
|
||||
edge.DirectEdgeData!.SinkId.Should().Be(target.Id);
|
||||
edge.DirectEdgeData.Condition.Should().NotBeNull();
|
||||
edge.DirectEdgeData.Condition!("message").Should().BeTrue();
|
||||
edge.DirectEdgeData.Condition!(42).Should().BeFalse();
|
||||
edge.DirectEdgeData.Condition!(null).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardMessage_WithMultipleTargets_CreatesFanOutEdge()
|
||||
{
|
||||
// Arrange
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor target1 = new("target1");
|
||||
NoOpExecutor target2 = new("target2");
|
||||
|
||||
// Act
|
||||
Workflow workflow = new WorkflowBuilder(source.Id)
|
||||
.ForwardMessage<string>(source, [target1, target2], message => message == "match")
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Edge edge = GetSingleEdge(workflow, source.Id);
|
||||
edge.Kind.Should().Be(EdgeKind.FanOut);
|
||||
edge.FanOutEdgeData.Should().NotBeNull();
|
||||
edge.FanOutEdgeData!.SourceId.Should().Be(source.Id);
|
||||
edge.FanOutEdgeData!.SinkIds.Should().Equal([target1.Id, target2.Id]);
|
||||
edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull();
|
||||
edge.FanOutEdgeData.EdgeAssigner!("match", 2).Should().Equal([0, 1]);
|
||||
edge.FanOutEdgeData.EdgeAssigner!("other", 2).Should().BeEmpty();
|
||||
edge.FanOutEdgeData.EdgeAssigner!(42, 2).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardExcept_WithSingleTarget_CreatesDirectEdge()
|
||||
{
|
||||
// Arrange
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor target = new("target");
|
||||
|
||||
// Act
|
||||
Workflow workflow = new WorkflowBuilder(source.Id)
|
||||
.ForwardExcept<string>(source, target)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Edge edge = GetSingleEdge(workflow, source.Id);
|
||||
edge.Kind.Should().Be(EdgeKind.Direct);
|
||||
edge.DirectEdgeData.Should().NotBeNull();
|
||||
edge.DirectEdgeData!.SourceId.Should().Be(source.Id);
|
||||
edge.DirectEdgeData!.SinkId.Should().Be(target.Id);
|
||||
edge.DirectEdgeData.Condition.Should().NotBeNull();
|
||||
edge.DirectEdgeData.Condition!("message").Should().BeFalse();
|
||||
edge.DirectEdgeData.Condition!(42).Should().BeTrue();
|
||||
edge.DirectEdgeData.Condition!(null).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardExcept_WithMultipleTargets_CreatesFanOutEdge()
|
||||
{
|
||||
// Arrange
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor target1 = new("target1");
|
||||
NoOpExecutor target2 = new("target2");
|
||||
|
||||
// Act
|
||||
Workflow workflow = new WorkflowBuilder(source.Id)
|
||||
.ForwardExcept<string>(source, [target1, target2])
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Edge edge = GetSingleEdge(workflow, source.Id);
|
||||
edge.Kind.Should().Be(EdgeKind.FanOut);
|
||||
edge.FanOutEdgeData.Should().NotBeNull();
|
||||
edge.FanOutEdgeData!.SourceId.Should().Be(source.Id);
|
||||
edge.FanOutEdgeData!.SinkIds.Should().Equal([target1.Id, target2.Id]);
|
||||
edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull();
|
||||
edge.FanOutEdgeData.EdgeAssigner!(42, 2).Should().Equal([0, 1]);
|
||||
edge.FanOutEdgeData.EdgeAssigner!("message", 2).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddChain_CreatesSequentialDirectEdges()
|
||||
{
|
||||
// Arrange
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor middle = new("middle");
|
||||
NoOpExecutor end = new("end");
|
||||
|
||||
// Act
|
||||
Workflow workflow = new WorkflowBuilder(source.Id)
|
||||
.AddChain(source, [middle, end])
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Edge firstEdge = GetSingleEdge(workflow, source.Id);
|
||||
firstEdge.Kind.Should().Be(EdgeKind.Direct);
|
||||
firstEdge.DirectEdgeData!.SourceId.Should().Be(source.Id);
|
||||
firstEdge.DirectEdgeData.SinkId.Should().Be(middle.Id);
|
||||
|
||||
Edge secondEdge = GetSingleEdge(workflow, middle.Id);
|
||||
secondEdge.Kind.Should().Be(EdgeKind.Direct);
|
||||
secondEdge.DirectEdgeData!.SourceId.Should().Be(middle.Id);
|
||||
secondEdge.DirectEdgeData.SinkId.Should().Be(end.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddChain_WhenExecutorRepeats_Throws()
|
||||
{
|
||||
// Arrange
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor middle = new("middle");
|
||||
|
||||
// Act
|
||||
Action act = () => new WorkflowBuilder(source.Id)
|
||||
.AddChain(source, [middle, source]);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("executors");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddExternalCall_CreatesRequestPortAndRoundTripEdges()
|
||||
{
|
||||
// Arrange
|
||||
const string PortId = "port1";
|
||||
NoOpExecutor source = new("start");
|
||||
|
||||
// Act
|
||||
Workflow workflow = new WorkflowBuilder(source.Id)
|
||||
.AddExternalCall<string, int>(source, PortId)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
workflow.Ports.Should().ContainKey(PortId);
|
||||
workflow.Ports[PortId].Request.Should().Be(typeof(string));
|
||||
workflow.Ports[PortId].Response.Should().Be(typeof(int));
|
||||
workflow.ExecutorBindings.Should().ContainKey(PortId);
|
||||
|
||||
Edge requestEdge = GetSingleEdge(workflow, source.Id);
|
||||
requestEdge.Kind.Should().Be(EdgeKind.Direct);
|
||||
requestEdge.DirectEdgeData!.SourceId.Should().Be(source.Id);
|
||||
requestEdge.DirectEdgeData.SinkId.Should().Be(PortId);
|
||||
|
||||
Edge responseEdge = GetSingleEdge(workflow, PortId);
|
||||
responseEdge.Kind.Should().Be(EdgeKind.Direct);
|
||||
responseEdge.DirectEdgeData!.SourceId.Should().Be(PortId);
|
||||
responseEdge.DirectEdgeData.SinkId.Should().Be(source.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSwitch_CreatesFanOutEdgeWithCasesAndDefault()
|
||||
{
|
||||
// Arrange
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor stringTarget = new("string-target");
|
||||
NoOpExecutor intTarget = new("int-target");
|
||||
NoOpExecutor defaultTarget = new("default-target");
|
||||
|
||||
// Act
|
||||
Workflow workflow = new WorkflowBuilder(source.Id)
|
||||
.AddSwitch(source, switchBuilder => switchBuilder
|
||||
.AddCase<string>(message => message == "match", [stringTarget])
|
||||
.AddCase<int>(message => message > 0, [intTarget])
|
||||
.WithDefault([defaultTarget]))
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Edge edge = GetSingleEdge(workflow, source.Id);
|
||||
edge.Kind.Should().Be(EdgeKind.FanOut);
|
||||
edge.FanOutEdgeData.Should().NotBeNull();
|
||||
edge.FanOutEdgeData!.SourceId.Should().Be(source.Id);
|
||||
edge.FanOutEdgeData!.SinkIds.Should().Equal([stringTarget.Id, intTarget.Id, defaultTarget.Id]);
|
||||
edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull();
|
||||
edge.FanOutEdgeData.EdgeAssigner!("match", 3).Should().Equal([0]);
|
||||
edge.FanOutEdgeData.EdgeAssigner!(2, 3).Should().Equal([1]);
|
||||
edge.FanOutEdgeData.EdgeAssigner!("other", 3).Should().Equal([2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardMessage_InvalidArguments_Throw()
|
||||
{
|
||||
// Arrange
|
||||
WorkflowBuilder builder = new("start");
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor target = new("target");
|
||||
|
||||
// Act/Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).ForwardMessage<string>(source, target));
|
||||
Assert.Throws<ArgumentNullException>("source", () => builder.ForwardMessage<string>(null!, target));
|
||||
Assert.Throws<ArgumentNullException>("target", () => builder.ForwardMessage<string>(source, (ExecutorBinding)null!));
|
||||
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardMessage<string>(source, (IEnumerable<ExecutorBinding>)null!));
|
||||
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardMessage<string>(source, [target, null!]));
|
||||
Assert.Throws<ArgumentException>("targets", () => builder.ForwardMessage<string>(source, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardExcept_InvalidArguments_Throw()
|
||||
{
|
||||
// Arrange
|
||||
WorkflowBuilder builder = new("start");
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor target = new("target");
|
||||
|
||||
// Act/Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).ForwardExcept<string>(source, target));
|
||||
Assert.Throws<ArgumentNullException>("source", () => builder.ForwardExcept<string>(null!, target));
|
||||
Assert.Throws<ArgumentNullException>("target", () => builder.ForwardExcept<string>(source, (ExecutorBinding)null!));
|
||||
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardExcept<string>(source, (IEnumerable<ExecutorBinding>)null!));
|
||||
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardExcept<string>(source, [target, null!]));
|
||||
Assert.Throws<ArgumentException>("targets", () => builder.ForwardExcept<string>(source, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddChain_InvalidArguments_Throw()
|
||||
{
|
||||
// Arrange
|
||||
WorkflowBuilder builder = new("start");
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor target = new("target");
|
||||
NoOpExecutor otherTarget = new("other-target");
|
||||
|
||||
// Act/Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).AddChain(source, [target]));
|
||||
Assert.Throws<ArgumentNullException>("source", () => builder.AddChain(null!, [target]));
|
||||
Assert.Throws<ArgumentNullException>("executors", () => builder.AddChain(source, null!));
|
||||
Assert.Throws<ArgumentNullException>("executors", () => builder.AddChain(source, [target, null!]));
|
||||
Assert.Throws<ArgumentException>("executors", () => builder.AddChain(source, [target, source]));
|
||||
Assert.Throws<ArgumentException>("executors", () => builder.AddChain(source, [target, otherTarget, target]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddExternalCall_InvalidArguments_Throw()
|
||||
{
|
||||
// Arrange
|
||||
WorkflowBuilder builder = new("start");
|
||||
NoOpExecutor source = new("start");
|
||||
|
||||
// Act/Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).AddExternalCall<string, int>(source, "port"));
|
||||
Assert.Throws<ArgumentNullException>("source", () => builder.AddExternalCall<string, int>(null!, "port"));
|
||||
Assert.Throws<ArgumentNullException>("portId", () => builder.AddExternalCall<string, int>(source, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSwitch_InvalidArguments_Throw()
|
||||
{
|
||||
// Arrange
|
||||
WorkflowBuilder builder = new("start");
|
||||
NoOpExecutor source = new("start");
|
||||
|
||||
// Act/Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).AddSwitch(source, _ => { }));
|
||||
Assert.Throws<ArgumentNullException>("source", () => builder.AddSwitch(null!, _ => { }));
|
||||
Assert.Throws<ArgumentNullException>("configureSwitch", () => builder.AddSwitch(source, null!));
|
||||
Assert.Throws<ArgumentException>("targets", () => builder.AddSwitch(source, _ => { }));
|
||||
Assert.Throws<ArgumentException>("targets", () => builder.AddSwitch(source, switchBuilder => switchBuilder.AddCase<string>(_ => true, [])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SwitchBuilder_InvalidArguments_Throw()
|
||||
{
|
||||
// Arrange
|
||||
SwitchBuilder switchBuilder = new();
|
||||
NoOpExecutor target = new("target");
|
||||
|
||||
// Act/Assert
|
||||
Assert.Throws<ArgumentNullException>("predicate", () => switchBuilder.AddCase<string>(null!, [target]));
|
||||
Assert.Throws<ArgumentNullException>("executors", () => switchBuilder.AddCase<string>(_ => true, null!));
|
||||
Assert.Throws<ArgumentNullException>("executors[1]", () => switchBuilder.AddCase<string>(_ => true, [target, null!]));
|
||||
Assert.Throws<ArgumentNullException>("executors", () => switchBuilder.WithDefault(null!));
|
||||
Assert.Throws<ArgumentNullException>("executors[1]", () => switchBuilder.WithDefault([target, null!]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the only edge emitted by the specified workflow source.
|
||||
/// </summary>
|
||||
private static Edge GetSingleEdge(Workflow workflow, string sourceId)
|
||||
=> workflow.Edges[sourceId].Should().ContainSingle().Subject;
|
||||
}
|
||||
|
||||
+6
-6
@@ -67,7 +67,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
|
||||
/// Bug: The Activity created by LockstepRunEventStream.TakeEventStreamAsync is never
|
||||
/// disposed because yield break in async iterators does not trigger using disposal.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task WorkflowRunActivity_IsStopped_LockstepAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -111,7 +111,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
|
||||
/// Verifies that the workflow_invoke Activity is stopped when using the OffThread (Default)
|
||||
/// execution environment (StreamingRunEventStream).
|
||||
/// </summary>
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task WorkflowRunActivity_IsStopped_OffThreadAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -156,7 +156,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
|
||||
/// (StreamingRun.WatchStreamAsync) with the OffThread execution environment.
|
||||
/// This matches the exact usage pattern described in the issue.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task WorkflowRunActivity_IsStopped_Streaming_OffThreadAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -203,7 +203,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
|
||||
/// streaming invocation, even when using the same workflow in a multi-turn pattern,
|
||||
/// and that each session gets its own session activity.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task WorkflowRunActivity_IsStopped_Streaming_OffThread_MultiTurnAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -264,7 +264,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
|
||||
/// Verifies that all started activities (not just workflow_invoke) are properly stopped.
|
||||
/// This ensures no spans are "leaked" without being exported.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task AllActivities_AreStopped_AfterWorkflowCompletionAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -305,7 +305,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
|
||||
/// be parented under the workflow session span. The run activity should
|
||||
/// still nest correctly under the session.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task Lockstep_SessionActivity_DoesNotLeak_IntoCaller_ActivityCurrentAsync()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
+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
|
||||
|
||||
@@ -42,7 +42,7 @@ request_handler = DefaultRequestHandler(
|
||||
app = Starlette(
|
||||
routes=[
|
||||
*create_agent_card_routes(my_agent_card),
|
||||
*create_jsonrpc_routes(request_handler),
|
||||
*create_jsonrpc_routes(request_handler, "/"),
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
@@ -78,7 +78,7 @@ class A2AExecutor(AgentExecutor):
|
||||
app = Starlette(
|
||||
routes=[
|
||||
*create_agent_card_routes(public_agent_card),
|
||||
*create_jsonrpc_routes(request_handler),
|
||||
*create_jsonrpc_routes(request_handler, "/"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -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. "
|
||||
@@ -365,6 +363,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
|
||||
all_updates: list[AgentResponseUpdate] = []
|
||||
streamed_artifact_ids_by_task: dict[str, set[str]] = {}
|
||||
# In non-streaming mode, accumulate intermediate status content so it
|
||||
# can be surfaced when the terminal event arrives (mirroring v0.3.x
|
||||
# behavior where the full Task history was available at completion).
|
||||
pending_updates_by_task: dict[str, list[AgentResponseUpdate]] = {}
|
||||
async for item in a2a_stream:
|
||||
payload_type = item.WhichOneof("payload")
|
||||
if payload_type == "message":
|
||||
@@ -391,27 +393,55 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
)
|
||||
if task.status.state in TERMINAL_TASK_STATES:
|
||||
streamed_artifact_ids_by_task.pop(task.id, None)
|
||||
# If the terminal Task has no content, flush accumulated updates
|
||||
if not updates or all(not u.contents for u in updates):
|
||||
pending = pending_updates_by_task.pop(task.id, [])
|
||||
for update in pending:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
else:
|
||||
pending_updates_by_task.pop(task.id, None)
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
elif payload_type == "status_update":
|
||||
status_event = item.status_update
|
||||
updates = self._updates_from_task_update_event(status_event)
|
||||
is_terminal = status_event.status.state in TERMINAL_TASK_STATES
|
||||
if emit_intermediate:
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
elif is_terminal:
|
||||
if updates:
|
||||
# Terminal event with content — discard accumulated intermediates
|
||||
pending_updates_by_task.pop(status_event.task_id, None)
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
else:
|
||||
# Terminal event with NO content — flush accumulated updates
|
||||
pending = pending_updates_by_task.pop(status_event.task_id, [])
|
||||
for update in pending:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
else:
|
||||
# Non-streaming intermediate: accumulate for later
|
||||
if updates:
|
||||
pending_updates_by_task.setdefault(status_event.task_id, []).extend(updates)
|
||||
elif payload_type == "artifact_update":
|
||||
artifact_event = item.artifact_update
|
||||
updates = self._updates_from_task_update_event(artifact_event)
|
||||
# Always yield artifact updates — they carry actual response
|
||||
# content (files, data). Track IDs so that a subsequent
|
||||
# terminal Task doesn't duplicate the same artifacts.
|
||||
if updates:
|
||||
streamed_artifact_ids_by_task.setdefault(artifact_event.task_id, set()).add(
|
||||
artifact_event.artifact.artifact_id
|
||||
)
|
||||
if emit_intermediate:
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
else:
|
||||
raise NotImplementedError(f"Unsupported StreamResponse payload: {payload_type}")
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -1570,4 +1570,102 @@ async def test_none_metadata_leaves_additional_properties_empty(
|
||||
assert not response.additional_properties
|
||||
|
||||
|
||||
async def test_non_streaming_terminal_status_update_surfaces_content(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Non-streaming run() should surface content from terminal status_update events."""
|
||||
completed_msg = A2AMessage(
|
||||
message_id="msg-complete",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Done! Here is your answer.")],
|
||||
)
|
||||
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=completed_msg)
|
||||
event = TaskStatusUpdateEvent(task_id="task-ts", context_id="ctx-ts", status=status)
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=event))
|
||||
|
||||
response = await a2a_agent.run("Hello")
|
||||
|
||||
assert len(response.messages) == 1
|
||||
assert response.messages[0].text == "Done! Here is your answer."
|
||||
|
||||
|
||||
async def test_non_streaming_accumulates_working_content_for_empty_terminal(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Non-streaming run() accumulates WORKING content and flushes on empty terminal event."""
|
||||
# Intermediate WORKING event with content
|
||||
working_msg = A2AMessage(
|
||||
message_id="msg-working",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Here is your answer from working state.")],
|
||||
)
|
||||
working_status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=working_msg)
|
||||
working_event = TaskStatusUpdateEvent(task_id="task-acc", context_id="ctx-acc", status=working_status)
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=working_event))
|
||||
|
||||
# Terminal COMPLETED event with NO content
|
||||
completed_status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED)
|
||||
completed_event = TaskStatusUpdateEvent(task_id="task-acc", context_id="ctx-acc", status=completed_status)
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=completed_event))
|
||||
|
||||
response = await a2a_agent.run("Hello")
|
||||
|
||||
# The accumulated WORKING content is flushed when terminal arrives empty
|
||||
assert len(response.messages) == 1
|
||||
assert response.messages[0].text == "Here is your answer from working state."
|
||||
|
||||
|
||||
async def test_non_streaming_intermediate_discarded_when_terminal_has_content(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Non-streaming: if terminal event has content, intermediate content is discarded."""
|
||||
# Intermediate WORKING event
|
||||
working_msg = A2AMessage(
|
||||
message_id="msg-working",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Still thinking...")],
|
||||
)
|
||||
working_status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=working_msg)
|
||||
working_event = TaskStatusUpdateEvent(task_id="task-wi", context_id="ctx-wi", status=working_status)
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=working_event))
|
||||
|
||||
# Terminal COMPLETED event WITH content
|
||||
completed_msg = A2AMessage(
|
||||
message_id="msg-final",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Final answer")],
|
||||
)
|
||||
completed_status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=completed_msg)
|
||||
completed_event = TaskStatusUpdateEvent(task_id="task-wi", context_id="ctx-wi", status=completed_status)
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=completed_event))
|
||||
|
||||
response = await a2a_agent.run("Hello")
|
||||
|
||||
# Terminal content supersedes accumulated intermediates
|
||||
assert len(response.messages) == 1
|
||||
assert response.messages[0].text == "Final answer"
|
||||
|
||||
|
||||
async def test_non_streaming_artifact_update_surfaces_content(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Non-streaming run() should surface content from artifact_update events."""
|
||||
artifact = Artifact(
|
||||
artifact_id="art-ns",
|
||||
parts=[Part(text="Artifact content")],
|
||||
)
|
||||
event = TaskArtifactUpdateEvent(task_id="task-anu", context_id="ctx-anu", artifact=artifact, append=False)
|
||||
mock_a2a_client.responses.append(StreamResponse(artifact_update=event))
|
||||
|
||||
# Terminal task with the same artifact ID — should be deduped
|
||||
mock_a2a_client.add_task_response("task-anu", [{"id": "art-ns", "content": "Artifact content"}])
|
||||
|
||||
response = await a2a_agent.run("Hello")
|
||||
|
||||
# Artifact update + terminal task with same artifact ID = content emitted once from
|
||||
# the artifact_update, then the duplicate from the task is filtered by streamed_artifact_ids
|
||||
assert len(response.messages) == 1
|
||||
assert response.messages[0].text == "Artifact content"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user