mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e9efee7e | ||
|
|
3ee1bb4f9f | ||
|
|
945647a065 | ||
|
|
401a552735 | ||
|
|
718a1f14fd | ||
|
|
f7c5b8d108 | ||
|
|
e6762ea876 | ||
|
|
08abe9e704 |
@@ -174,6 +174,7 @@
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Agent_Step25_FoundryToolboxMcp.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Agent_Step26_FoundryToolboxMcpSkills.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/Evaluation/">
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
|
||||
@@ -604,6 +605,7 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Mcp/Microsoft.Agents.AI.Mcp.csproj" />
|
||||
|
||||
@@ -20,14 +20,17 @@
|
||||
"src\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.AzureFunctions\\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.AspNetCore\\Microsoft.Agents.AI.Hosting.AspNetCore.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.AzureFunctions\\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj",
|
||||
"src\\Microsoft.Agents.AI.Mcp\\Microsoft.Agents.AI.Mcp.csproj",
|
||||
"src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj",
|
||||
"src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj",
|
||||
"src\\Microsoft.Agents.AI.Purview\\Microsoft.Agents.AI.Purview.csproj",
|
||||
"src\\Microsoft.Agents.AI.Tools.Shell\\Microsoft.Agents.AI.Tools.Shell.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows.Declarative.Foundry\\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows.Declarative.Mcp\\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.7.0</VersionPrefix>
|
||||
<VersionPrefix>1.8.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260526</DateSuffix>
|
||||
<DateSuffix>260528</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.7.0</GitTag>
|
||||
<GitTag>1.8.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Foundry Toolbox MCP Skills.
|
||||
//
|
||||
// Uses AgentSkillsProviderBuilder to discover MCP-based skills from a Foundry
|
||||
// Toolbox endpoint and inject them as AIContextProviders so the agent can
|
||||
// discover and use them at runtime.
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
// --- Configuration ---
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
string toolboxMcpServerUrl = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_MCP_SERVER_URL")
|
||||
?? throw new InvalidOperationException("FOUNDRY_TOOLBOX_MCP_SERVER_URL is not set.");
|
||||
|
||||
// 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.
|
||||
TokenCredential credential = new DefaultAzureCredential();
|
||||
|
||||
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default")
|
||||
{
|
||||
InnerHandler = new HttpClientHandler(),
|
||||
});
|
||||
|
||||
// --- Connect to the Foundry Toolbox MCP endpoint ---
|
||||
await using McpClient mcpClient = await McpClient.CreateAsync(
|
||||
new HttpClientTransport(
|
||||
new HttpClientTransportOptions
|
||||
{
|
||||
Endpoint = new Uri(toolboxMcpServerUrl),
|
||||
Name = "foundry_toolbox",
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
AdditionalHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["Foundry-Features"] = "Toolboxes=V1Preview",
|
||||
},
|
||||
},
|
||||
httpClient));
|
||||
|
||||
// --- Discover MCP-based skills ---
|
||||
var skillsProvider = new AgentSkillsProviderBuilder()
|
||||
.UseMcpSkills(mcpClient)
|
||||
.Build();
|
||||
|
||||
// --- Create the agent ---
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
|
||||
|
||||
AIAgent agent = aiProjectClient.AsAIAgent(
|
||||
options: new ChatClientAgentOptions
|
||||
{
|
||||
Name = "ToolboxMcpSkillsAgent",
|
||||
ChatOptions = new()
|
||||
{
|
||||
ModelId = deploymentName,
|
||||
Instructions = "You are a helpful assistant. Use available skills to answer the user.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider],
|
||||
});
|
||||
|
||||
// --- Interactive prompt ---
|
||||
Console.Write("User: ");
|
||||
string? query = Console.ReadLine();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
Console.WriteLine("No input provided.");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Assistant: {await agent.RunAsync(query)}");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DelegatingHandler: attaches a fresh Foundry bearer token to every request
|
||||
// ---------------------------------------------------------------------------
|
||||
internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : DelegatingHandler
|
||||
{
|
||||
private readonly TokenRequestContext _tokenContext = new([scope]);
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# Foundry Toolbox MCP Skills
|
||||
|
||||
This sample uses
|
||||
`AgentSkillsProviderBuilder` to discover MCP-based skills from a Foundry Toolbox endpoint
|
||||
and inject them as `AIContextProviders` so the agent can discover and use them at runtime.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- Connecting to a Foundry toolbox's MCP endpoint via Streamable HTTP transport
|
||||
- Injecting a fresh Azure AI bearer token (`https://ai.azure.com/.default`) on every MCP request
|
||||
- Using `AgentSkillsProviderBuilder.UseMcpSkills(client)` to discover skills from the toolbox
|
||||
- Injecting the discovered skills into `AIProjectClient.AsAIAgent(...)` via `AIContextProviders`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Microsoft Foundry project with a toolbox already configured
|
||||
- The toolbox MCP endpoint must expose `skill://index.json` with `skill-md` entries (SEP-2640). If the resource is absent, the sample runs but the skills provider will be empty.
|
||||
- Azure CLI installed and authenticated (`az login`)
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
|
||||
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini"
|
||||
$env:FOUNDRY_TOOLBOX_MCP_SERVER_URL="https://your-foundry-service.services.ai.azure.com/api/projects/your-project/toolboxes/your-toolbox/mcp?api-version=v1"
|
||||
```
|
||||
|
||||
## Run the sample
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
@@ -74,6 +74,7 @@ Some samples require extra tool-specific environment variables. See each sample
|
||||
| [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport |
|
||||
| [Code interpreter file download](./Agent_Step24_CodeInterpreterFileDownload/) | Download container files generated by code interpreter |
|
||||
| [Foundry toolbox via MCP](./Agent_Step25_FoundryToolboxMcp/) | Use a Foundry Toolbox from a non-hosted agent via its MCP endpoint |
|
||||
| [Foundry toolbox MCP skills](./Agent_Step26_FoundryToolboxMcpSkills/) | Use a Foundry Toolbox with MCP-based skills discovery (SEP-2640) via AIContextProviders |
|
||||
|
||||
## Running the samples
|
||||
|
||||
|
||||
@@ -72,6 +72,95 @@ public static class AnsiEscapes
|
||||
/// </summary>
|
||||
public static string ResetAttributes => "\x1b[0m";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the visible (printed) length of a string after stripping ANSI escape sequences.
|
||||
/// Escape sequences are zero-width on screen but occupy characters in the raw string.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This counts UTF-16 code units (chars) rather than terminal display cells. Emoji,
|
||||
/// combining characters, variation selectors, and East Asian wide characters may be
|
||||
/// measured incorrectly. For the console harness this is acceptable since content is
|
||||
/// predominantly ASCII, and emoji are padded with surrounding spaces.
|
||||
/// </remarks>
|
||||
public static int VisibleLength(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int length = 0;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\x1b' && i + 1 < text.Length && text[i + 1] == '[')
|
||||
{
|
||||
// Skip the ESC[ and all characters up to and including the final byte (0x40–0x7E).
|
||||
i += 2;
|
||||
while (i < text.Length && text[i] < 0x40)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
// i now points to the final byte of the escape sequence; the for-loop will advance past it.
|
||||
}
|
||||
else if (text[i] != '\n' && text[i] != '\r')
|
||||
{
|
||||
length++;
|
||||
}
|
||||
}
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the number of physical terminal rows a text item will occupy,
|
||||
/// accounting for both explicit newlines and terminal line wrapping.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to measure.</param>
|
||||
/// <param name="terminalWidth">The terminal width in columns. If <= 0, wrapping is ignored (1 row per logical line).</param>
|
||||
/// <returns>The number of physical rows the text occupies.</returns>
|
||||
public static int CountPhysicalLines(string text, int terminalWidth)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int physicalLines = 0;
|
||||
int lineStart = 0;
|
||||
|
||||
for (int i = 0; i <= text.Length; i++)
|
||||
{
|
||||
if (i == text.Length || text[i] == '\n')
|
||||
{
|
||||
if (terminalWidth <= 0)
|
||||
{
|
||||
// No wrapping — each logical line is one physical row
|
||||
physicalLines += 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
string logicalLine = text[lineStart..i];
|
||||
int visibleWidth = VisibleLength(logicalLine);
|
||||
|
||||
physicalLines += visibleWidth == 0
|
||||
? 1
|
||||
: (visibleWidth - 1) / terminalWidth + 1;
|
||||
}
|
||||
|
||||
lineStart = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If text ends with a newline, don't count the trailing empty line
|
||||
if (text[text.Length - 1] == '\n')
|
||||
{
|
||||
physicalLines--;
|
||||
}
|
||||
|
||||
return physicalLines;
|
||||
}
|
||||
|
||||
private static int ConsoleColorToAnsi(ConsoleColor color) => color switch
|
||||
{
|
||||
ConsoleColor.Black => 30,
|
||||
|
||||
@@ -23,16 +23,18 @@ public record TextPanelProps : ConsoleReactiveProps
|
||||
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the height (in lines) needed to render all items.
|
||||
/// Calculates the height (in lines) needed to render all items,
|
||||
/// accounting for terminal line wrapping at the specified width.
|
||||
/// </summary>
|
||||
/// <param name="items">The items to measure.</param>
|
||||
/// <returns>The total number of lines all items will occupy.</returns>
|
||||
public static int CalculateHeight(IReadOnlyList<string> items)
|
||||
/// <param name="terminalWidth">The terminal width in columns. When 0 or negative, wrapping is ignored.</param>
|
||||
/// <returns>The total number of physical lines all items will occupy.</returns>
|
||||
public static int CalculateHeight(IReadOnlyList<string> items, int terminalWidth = 0)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
{
|
||||
total += CountLines(items[i]);
|
||||
total += AnsiEscapes.CountPhysicalLines(items[i], terminalWidth);
|
||||
}
|
||||
|
||||
return total;
|
||||
@@ -47,13 +49,20 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
|
||||
{
|
||||
string text = props.Items[i];
|
||||
string[] lines = text.Split('\n');
|
||||
int lineCount = CountLines(text);
|
||||
int itemLineCount = AnsiEscapes.CountPhysicalLines(text, props.Width);
|
||||
int itemRow = 0;
|
||||
|
||||
for (int j = 0; j < lineCount; j++)
|
||||
for (int j = 0; j < lines.Length && itemRow < itemLineCount; j++)
|
||||
{
|
||||
int linePhysicalRows = props.Width > 0
|
||||
? Math.Max(1, (AnsiEscapes.VisibleLength(lines[j]) - 1) / props.Width + 1)
|
||||
: 1;
|
||||
|
||||
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow));
|
||||
Console.Write(lines[j]);
|
||||
currentRow++;
|
||||
|
||||
currentRow += linePhysicalRows;
|
||||
itemRow += linePhysicalRows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,29 +75,4 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int CountLines(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int count = 1;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\n')
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
// If text ends with a newline, don't count the trailing empty line
|
||||
if (text[text.Length - 1] == '\n')
|
||||
{
|
||||
count--;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,36 +77,12 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
|
||||
Console.Write(props.Items[i]);
|
||||
}
|
||||
|
||||
// Calculate the offset from bottom for the start of the new last item
|
||||
int lastItemLines = CountLines(props.Items[^1]);
|
||||
// Calculate the offset from bottom for the start of the new last item,
|
||||
// accounting for terminal line wrapping at the available width.
|
||||
int lastItemLines = AnsiEscapes.CountPhysicalLines(props.Items[^1], props.Width);
|
||||
this._lastItemOffsetFromBottom = lastItemLines > 0 ? lastItemLines - 1 : 0;
|
||||
|
||||
// Update rendered count
|
||||
this._renderedCount = props.Items.Count;
|
||||
}
|
||||
|
||||
private static int CountLines(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int count = 1;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\n')
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
// If text ends with a newline, don't count the trailing empty line
|
||||
if (text[text.Length - 1] == '\n')
|
||||
{
|
||||
count--;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -13,6 +13,11 @@ public abstract class ConsoleReactiveComponent
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the shared render lock across all component types to prevent ANSI escape sequence interleaving.
|
||||
/// </summary>
|
||||
protected static object RenderLock { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the component's props as the base <see cref="ConsoleReactiveProps"/> type.
|
||||
/// Used by parent components to set layout (X, Y, Width, Height) on children without
|
||||
@@ -40,7 +45,6 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
|
||||
where TProps : ConsoleReactiveProps
|
||||
where TState : ConsoleReactiveState
|
||||
{
|
||||
private readonly object _renderLock = new();
|
||||
private TProps? _lastRenderedProps;
|
||||
private TState? _lastRenderedState;
|
||||
|
||||
@@ -74,7 +78,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
|
||||
/// </summary>
|
||||
public override void Render()
|
||||
{
|
||||
lock (this._renderLock)
|
||||
lock (RenderLock)
|
||||
{
|
||||
if (this.Props is null)
|
||||
{
|
||||
@@ -97,7 +101,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
|
||||
/// <inheritdoc/>
|
||||
public override void Invalidate()
|
||||
{
|
||||
lock (this._renderLock)
|
||||
lock (RenderLock)
|
||||
{
|
||||
this._lastRenderedProps = default;
|
||||
this._lastRenderedState = default;
|
||||
|
||||
@@ -28,6 +28,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
private int _scrollRegionBottom;
|
||||
private bool _resizedSinceLastRender = true;
|
||||
private bool _deactivated;
|
||||
private BottomPanelMode _lastRenderedBottomPanelMode;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
|
||||
@@ -341,7 +342,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
}
|
||||
|
||||
// Calculate queued items panel height
|
||||
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems);
|
||||
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems, state.ConsoleWidth);
|
||||
|
||||
// Build the bottom panel child based on mode
|
||||
ConsoleReactiveComponent bottomChild;
|
||||
@@ -406,6 +407,14 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
bottomChild = this._textInput;
|
||||
}
|
||||
|
||||
// When the bottom panel mode changes, the new child must repaint even if its
|
||||
// props haven't changed — the screen area was overwritten by the previous child.
|
||||
if (state.Mode != this._lastRenderedBottomPanelMode)
|
||||
{
|
||||
bottomChild.Invalidate();
|
||||
this._lastRenderedBottomPanelMode = state.Mode;
|
||||
}
|
||||
|
||||
var ruleProps = new TopBottomRuleProps
|
||||
{
|
||||
Width = state.ConsoleWidth,
|
||||
|
||||
+7
-5
@@ -88,16 +88,17 @@ public sealed class PlanningOutputObserver : ConsoleObserver
|
||||
{
|
||||
planningResponse = JsonSerializer.Deserialize<PlanningResponse>(collectedText);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
catch (JsonException)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
|
||||
await ux.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
|
||||
// JSON parsing failed — fall back to rendering as regular text output.
|
||||
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (planningResponse is null)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
|
||||
// Null result — fall back to rendering as regular text output.
|
||||
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -118,7 +119,8 @@ public sealed class PlanningOutputObserver : ConsoleObserver
|
||||
return new List<FollowUpAction> { this.BuildApprovalAction(question, session) };
|
||||
}
|
||||
|
||||
await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
|
||||
// Unexpected type — fall back to rendering as regular text output.
|
||||
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+88
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
|
||||
using System.Text.Json;
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -53,9 +54,94 @@ public sealed class OpenAIResponsesErrorObserver : ConsoleObserver
|
||||
|
||||
case StreamingResponseIncompleteUpdate incompleteUpdate:
|
||||
string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString();
|
||||
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
|
||||
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
|
||||
if (string.Equals(reason, "content_filter", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string detail = GetContentFilterDetails(incompleteUpdate);
|
||||
const string Message = "🛡️ The service's built-in content filter guardrails were triggered and the response was cut short.";
|
||||
await ux.WriteInfoLineAsync(
|
||||
string.IsNullOrEmpty(detail) ? Message : $"{Message}\n{detail}",
|
||||
ConsoleColor.Yellow);
|
||||
}
|
||||
else
|
||||
{
|
||||
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
|
||||
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts content filter details from the serialized response JSON and returns
|
||||
/// a formatted string showing which specific categories were triggered.
|
||||
/// Returns <see cref="string.Empty"/> if details cannot be extracted.
|
||||
/// </summary>
|
||||
private static string GetContentFilterDetails(StreamingResponseIncompleteUpdate incompleteUpdate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = System.ClientModel.Primitives.ModelReaderWriter.Write(incompleteUpdate);
|
||||
using var doc = JsonDocument.Parse(data.ToString());
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Navigate into the nested response object if present.
|
||||
JsonElement responseElement = root.TryGetProperty("response", out var resp) ? resp : root;
|
||||
|
||||
if (!responseElement.TryGetProperty("content_filters", out var filtersArray)
|
||||
|| filtersArray.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
foreach (var filter in filtersArray.EnumerateArray())
|
||||
{
|
||||
if (!filter.TryGetProperty("content_filter_results", out var results)
|
||||
|| results.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect category data for aligned output.
|
||||
var categories = new List<(string Name, bool Filtered, string? Severity)>();
|
||||
foreach (var category in results.EnumerateObject())
|
||||
{
|
||||
if (category.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool filtered = category.Value.TryGetProperty("filtered", out var f) && f.GetBoolean();
|
||||
string? severity = category.Value.TryGetProperty("severity", out var s) ? s.GetString() : null;
|
||||
categories.Add((category.Name, filtered, severity));
|
||||
}
|
||||
|
||||
// Build all category lines into a single string.
|
||||
int maxNameLen = categories.Count > 0 ? categories.Max(c => c.Name.Length) : 0;
|
||||
var lines = new List<string>();
|
||||
|
||||
foreach (var (name, filtered, severity) in categories)
|
||||
{
|
||||
string paddedName = name.PadRight(maxNameLen);
|
||||
string icon = filtered ? "❌" : "✅";
|
||||
string statusText = filtered ? "Filtered " : "Not Filtered";
|
||||
string severityText = severity is not null ? $" Severity: {severity}" : "";
|
||||
|
||||
lines.Add($" {icon} {paddedName} {statusText}{severityText}");
|
||||
}
|
||||
|
||||
if (lines.Count > 0)
|
||||
{
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Parsing not critical — skip silently if it fails.
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,10 @@ else
|
||||
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
|
||||
}
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
builder.AddA2AServer(hostA2AAgent);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -49,6 +49,10 @@ var agent = new AzureOpenAIClient(
|
||||
AGUIServerSerializerContext.Default.Options)
|
||||
]);
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
// Register the agent with the host and configure it to use an in-memory session store
|
||||
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
|
||||
builder
|
||||
|
||||
@@ -28,6 +28,10 @@ builder.AddDevUI();
|
||||
builder.AddOpenAIChatCompletions();
|
||||
builder.AddOpenAIResponses();
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
var pirateAgentBuilder = builder.AddAIAgent(
|
||||
"pirate",
|
||||
instructions: "You are a pirate. Speak like a pirate",
|
||||
@@ -148,6 +152,10 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
|
||||
pirateAgentBuilder.AddA2AServer();
|
||||
knightsKnavesAgentBuilder.AddA2AServer();
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapOpenApi();
|
||||
|
||||
+1
@@ -27,6 +27,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -28,6 +28,23 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agentBuilder">The agent builder whose name identifies the agent.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="agentBuilder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The A2A <c>contextId</c> arrives from the wire
|
||||
/// and is treated as a chain-resume identifier — <em>not</em> as an authorization
|
||||
/// token. The <see cref="AgentSessionStore"/> contract carries no principal/owner
|
||||
/// dimension, so when a persistent store is registered any caller who knows or
|
||||
/// guesses another caller's <c>contextId</c> can resume that other caller's
|
||||
/// persisted thread. Hosts that serve more than one user must compose a principal
|
||||
/// dimension into the lookup key — typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>). When no isolation provider is
|
||||
/// registered, behavior is unchanged — the bare <c>contextId</c> is used as the
|
||||
/// conversation identifier, which is appropriate for first-run / single-user /
|
||||
/// prototyping scenarios but unsafe for multi-user hosts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
@@ -46,6 +63,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
@@ -65,6 +89,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agent">The agent instance to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
@@ -83,6 +114,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
@@ -114,6 +152,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agent">The agent instance to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
@@ -140,9 +185,17 @@ public static class A2AServerServiceCollectionExtensions
|
||||
var agentSessionStore = serviceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
|
||||
var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
|
||||
|
||||
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
|
||||
var isolationKeyProvider = serviceProvider.GetService<SessionIsolationKeyProvider>();
|
||||
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
|
||||
{
|
||||
agentSessionStore ??= new InMemoryAgentSessionStore();
|
||||
agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
|
||||
}
|
||||
|
||||
var hostAgent = new AIHostAgent(
|
||||
innerAgent: agent,
|
||||
sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
|
||||
sessionStore: agentSessionStore);
|
||||
|
||||
agentHandler = new A2AAgentHandler(hostAgent, runMode);
|
||||
}
|
||||
|
||||
+20
@@ -73,6 +73,26 @@ public static class AGUIEndpointRouteBuilderExtensions
|
||||
/// it will be used to persist conversation sessions across requests using the AG-UI thread ID as the
|
||||
/// conversation identifier. If no session store is registered, sessions are ephemeral (not persisted).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The AG-UI <c>RunAgentInput.ThreadId</c> arrives
|
||||
/// from the wire and is treated as a chain-resume identifier — <em>not</em> as an
|
||||
/// authorization token. The <see cref="AgentSessionStore"/> contract carries no
|
||||
/// principal/owner dimension, so when a persistent store is registered any caller
|
||||
/// who knows or guesses another caller's <c>ThreadId</c> can resume that other
|
||||
/// caller's persisted thread. Hosts that serve more than one user must compose a
|
||||
/// principal dimension into the lookup key. The recommended way is to wrap the
|
||||
/// keyed <see cref="AgentSessionStore"/> in
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/>, typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>) and registering the store via the
|
||||
/// <c>WithSessionStore(...)</c> / <c>WithInMemorySessionStore(...)</c> helpers on
|
||||
/// <see cref="IHostedAgentBuilder"/> so that the wrapper is applied. When no
|
||||
/// isolation provider is registered, behavior is unchanged — the bare
|
||||
/// <c>ThreadId</c> is used as the conversation identifier, which is appropriate
|
||||
/// for first-run / single-user / prototyping scenarios but unsafe for
|
||||
/// multi-user hosts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapAGUI(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="SessionIsolationKeyProvider"/> that extracts the session isolation key from a claim
|
||||
/// in the current user's identity, as provided by ASP.NET Core's <see cref="IHttpContextAccessor"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This provider is suitable for ASP.NET Core web applications where session isolation is based on
|
||||
/// authenticated user identity. It reads a specified claim type (e.g., name, email, or a custom identifier)
|
||||
/// from the ambient <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the <see cref="HttpContext"/> is unavailable, the user is not authenticated, or the specified claim
|
||||
/// is missing, the provider returns <see langword="null"/>. The consuming <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// will then enforce strict or pass-through behavior based on its configuration.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This class relies on <see cref="IHttpContextAccessor"/>, which uses <see cref="AsyncLocal{T}"/>
|
||||
/// to provide access to the current <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly IHttpContextAccessor? _httpContextAccessor;
|
||||
private readonly string _claimType;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">
|
||||
/// The <see cref="IHttpContextAccessor"/> used to retrieve the current HTTP context and user claims.
|
||||
/// </param>
|
||||
/// <param name="options">The options for configuring the provider. If null, defaults are used.</param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> is null, empty, or whitespace.
|
||||
/// </exception>
|
||||
public ClaimsIdentitySessionIsolationKeyProvider(
|
||||
IHttpContextAccessor? httpContextAccessor,
|
||||
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
|
||||
{
|
||||
options ??= new ClaimsIdentitySessionIsolationKeyProviderOptions();
|
||||
this._httpContextAccessor = httpContextAccessor;
|
||||
this._claimType = Throw.IfNullOrWhitespace(options.ClaimType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the session isolation key from the current user's claims.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous operation. The task result contains the value of the
|
||||
/// configured claim type from the current user's identity, or <see langword="null"/> if the claim
|
||||
/// is not present or the HTTP context is unavailable.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This method retrieves the claim value from <c>HttpContext.User.Claims</c>. If multiple claims
|
||||
/// of the specified type exist, the first match is returned.
|
||||
/// </remarks>
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Claim? claim = this._httpContextAccessor?
|
||||
.HttpContext?
|
||||
.User?.Claims.FirstOrDefault(c => c.Type == this._claimType);
|
||||
|
||||
return new ValueTask<string?>(claim?.Value);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
public class ClaimsIdentitySessionIsolationKeyProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the claim type to extract from the user's identity for session isolation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Defaults to <see cref="ClaimsIdentity.DefaultNameClaimType"/>, which typically corresponds to
|
||||
/// the user's name or unique identifier claim.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Common alternatives include:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>ClaimTypes.NameIdentifier</c> — Stable user identifier</description></item>
|
||||
/// <item><description><c>ClaimTypes.Email</c> — Email address</description></item>
|
||||
/// <item><description>Custom claim types specific to your authentication provider</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<RootNamespace>Microsoft.Agents.AI.Hosting.AspNetCore</RootNamespace>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<NoWarn>$(NoWarn)</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Hosting ASP.NET Core</Title>
|
||||
<Description>Provides Microsoft Agent Framework support for hosting agents in an ASP.NET Core context.</Description>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring AI hosting services in an <see cref="IServiceCollection"/>.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a <see cref="SessionIsolationKeyProvider"/> that uses claims from the current user's identity
|
||||
/// to generate session isolation keys.
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
|
||||
/// <param name="options"> Optional configuration for the claims-based session isolation key provider.</param>
|
||||
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
|
||||
/// <remarks>
|
||||
/// This method requires <see cref="IHttpContextAccessor"/> to be registered in the service collection.
|
||||
/// Ensure that <c>services.AddHttpContextAccessor()</c> has been called before using this method.
|
||||
/// </remarks>
|
||||
public static IServiceCollection UseClaimsBasedSessionIsolation(
|
||||
this IServiceCollection services,
|
||||
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
|
||||
{
|
||||
options ??= new();
|
||||
ServiceDescriptor descriptor = new(typeof(SessionIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Singleton);
|
||||
services.Add(descriptor);
|
||||
|
||||
return services;
|
||||
|
||||
object CreateIsolationKeyProvider(IServiceProvider serviceProvider)
|
||||
{
|
||||
IHttpContextAccessor contextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
|
||||
|
||||
return new ClaimsIdentitySessionIsolationKeyProvider(contextAccessor, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
@@ -9,9 +11,39 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// Defines the contract for storing and retrieving agent conversation threads.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implementations of this interface enable persistent storage of conversation threads,
|
||||
/// allowing conversations to be resumed across HTTP requests, application restarts,
|
||||
/// or different service instances in hosted scenarios.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The <c>conversationId</c> passed to
|
||||
/// <see cref="GetSessionAsync"/> and <see cref="SaveSessionAsync"/> typically originates
|
||||
/// from the wire (for example, an AG-UI <c>RunAgentInput.ThreadId</c> or an A2A
|
||||
/// <c>contextId</c>). It is a chain-resume identifier, <em>not</em> an authorization
|
||||
/// token, and the <c>(agent, conversationId)</c> tuple carries no principal/owner
|
||||
/// dimension. Hosts that serve more than one user from the same registered store must
|
||||
/// therefore compose a principal dimension into the lookup key, otherwise any caller
|
||||
/// who knows or guesses another caller's <c>conversationId</c> can resume
|
||||
/// that other caller's persisted thread. The framework provides
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> as a decorator that rewrites
|
||||
/// <c>conversationId</c> to include an isolation key resolved from a
|
||||
/// <see cref="SessionIsolationKeyProvider"/> (for example, the ASP.NET Core
|
||||
/// <c>ClaimsIdentitySessionIsolationKeyProvider</c> wired up via
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c>). When no provider is registered, the
|
||||
/// store behaves as a single-namespace persistence layer — appropriate for
|
||||
/// single-user / first-run / prototyping scenarios but unsafe for multi-user hosts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Implementer guidance.</strong> Implementations should treat
|
||||
/// <c>conversationId</c> as opaque: do not parse it, do not impose length
|
||||
/// or character-set constraints on it, and do not assume it round-trips to the value
|
||||
/// the caller originally supplied (decorators such as
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> may rewrite it before forwarding).
|
||||
/// Be aware that any logging, telemetry, or audit sink that surfaces
|
||||
/// <c>conversationId</c> will also surface the isolation prefix when a
|
||||
/// scoping decorator is in the chain.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class AgentSessionStore
|
||||
{
|
||||
@@ -43,4 +75,35 @@ public abstract class AgentSessionStore
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
|
||||
/// <param name="serviceType">The type of object being requested.</param>
|
||||
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
|
||||
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="serviceType"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the <see cref="AgentSessionStore"/>,
|
||||
/// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains
|
||||
/// to verify that specific store implementations are present.
|
||||
/// </remarks>
|
||||
public virtual object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
_ = Throw.IfNull(serviceType);
|
||||
|
||||
return serviceKey is null && serviceType.IsInstanceOfType(this)
|
||||
? this
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of type <typeparamref name="TService"/>.</summary>
|
||||
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
|
||||
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
|
||||
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
|
||||
/// <remarks>
|
||||
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the <see cref="AgentSessionStore"/>,
|
||||
/// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains
|
||||
/// to verify that specific store implementations are present.
|
||||
/// </remarks>
|
||||
public TService? GetService<TService>(object? serviceKey = null)
|
||||
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for agent session stores that delegate operations to an inner store
|
||||
/// instance while allowing for extensibility and customization.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="DelegatingAgentSessionStore"/> implements the decorator pattern for <see cref="AgentSessionStore"/>s,
|
||||
/// enabling the creation of pipelines where each layer can add functionality while delegating core operations to an
|
||||
/// underlying store.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The default implementation provides transparent pass-through behavior, forwarding all operations to the inner store.
|
||||
/// Derived classes can override specific methods to add custom behavior while maintaining compatibility with the store
|
||||
/// interface.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class DelegatingAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DelegatingAgentSessionStore"/> class with the specified inner
|
||||
/// store.
|
||||
/// </summary>
|
||||
/// <param name="innerStore">The underlying session store instance that will handle the core operations.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="innerStore"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// The inner session store serves as the foundation of the delegation chain. All operations not overridden by
|
||||
/// derived classes will be forwarded to this store.
|
||||
/// </remarks>
|
||||
protected DelegatingAgentSessionStore(AgentSessionStore innerStore)
|
||||
{
|
||||
this.InnerStore = Throw.IfNull(innerStore);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the inner session store instance that receives delegated operations.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The underlying <see cref="AgentSessionStore"/> instance that handles core storage operations.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// Derived classes can use this property to access the inner session store for custom delegation scenarios
|
||||
/// or to forward operations with additional processing.
|
||||
/// </remarks>
|
||||
protected AgentSessionStore InnerStore { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.GetSessionAsync(agent, conversationId, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.SaveSessionAsync(agent, conversationId, session, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// This implementation first checks if this instance satisfies the service request.
|
||||
/// If not, it chains the request to the inner store, allowing services to be retrieved
|
||||
/// from any store in the delegation chain.
|
||||
/// </remarks>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
// First, check if this instance satisfies the request
|
||||
object? service = base.GetService(serviceType, serviceKey);
|
||||
if (service is not null)
|
||||
{
|
||||
return service;
|
||||
}
|
||||
|
||||
// Chain to the inner store
|
||||
return this.InnerStore.GetService(serviceType, serviceKey);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
@@ -16,12 +17,11 @@ public static class HostedAgentBuilderExtensions
|
||||
/// Configures the host agent builder to use an in-memory session store for agent session management.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host agent builder to configure with the in-memory session store.</param>
|
||||
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
|
||||
/// <returns>The same <paramref name="builder"/> instance, configured to use an in-memory session store.</returns>
|
||||
public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder)
|
||||
{
|
||||
builder.ServiceCollection.AddKeyedSingleton<AgentSessionStore>(builder.Name, new InMemoryAgentSessionStore());
|
||||
return builder;
|
||||
}
|
||||
public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder, bool withIsolation = true)
|
||||
=> builder.WithSessionStore(new InMemoryAgentSessionStore(), withIsolation);
|
||||
|
||||
/// <summary>
|
||||
/// Registers the specified agent session store with the host agent builder, enabling session-specific storage for
|
||||
@@ -29,12 +29,11 @@ public static class HostedAgentBuilderExtensions
|
||||
/// </summary>
|
||||
/// <param name="builder">The host agent builder to configure with the session store. Cannot be null.</param>
|
||||
/// <param name="store">The agent session store instance to register. Cannot be null.</param>
|
||||
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
|
||||
/// <returns>The same host agent builder instance, allowing for method chaining.</returns>
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store)
|
||||
{
|
||||
builder.ServiceCollection.AddKeyedSingleton(builder.Name, store);
|
||||
return builder;
|
||||
}
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store, bool withIsolation = true)
|
||||
=> builder.WithSessionStore((sp, key) => store, ServiceLifetime.Singleton, withIsolation);
|
||||
|
||||
/// <summary>
|
||||
/// Configures the host agent builder to use a custom session store implementation for agent sessions.
|
||||
@@ -44,16 +43,36 @@ public static class HostedAgentBuilderExtensions
|
||||
/// name.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the session store registration. Defaults to <see cref="ServiceLifetime.Singleton"/>
|
||||
/// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime.</param>
|
||||
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
|
||||
/// <returns>The same host agent builder instance, enabling further configuration.</returns>
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton, bool withIsolation = true)
|
||||
{
|
||||
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, key) =>
|
||||
{
|
||||
Throw.IfNull(key);
|
||||
var keyString = key as string;
|
||||
Throw.IfNullOrEmpty(keyString);
|
||||
return createAgentSessionStore(sp, keyString) ??
|
||||
|
||||
AgentSessionStore store = createAgentSessionStore(sp, keyString) ??
|
||||
throw new InvalidOperationException($"The agent session store factory did not return a valid {nameof(AgentSessionStore)} instance for key '{keyString}'.");
|
||||
|
||||
if (withIsolation && store.GetService<IsolationKeyScopedAgentSessionStore>() is null)
|
||||
{
|
||||
var isolationKeyProvider = sp.GetService<SessionIsolationKeyProvider>();
|
||||
|
||||
// Best efforts options getting
|
||||
IsolationKeyScopedAgentSessionStoreOptions? options = sp.GetService<IsolationKeyScopedAgentSessionStoreOptions>();
|
||||
if (options is null)
|
||||
{
|
||||
var optionsProvider = sp.GetService<IOptions<IsolationKeyScopedAgentSessionStoreOptions>>();
|
||||
options = optionsProvider?.Value;
|
||||
}
|
||||
|
||||
store = new IsolationKeyScopedAgentSessionStore(store, isolationKeyProvider, options ?? new());
|
||||
}
|
||||
|
||||
return store;
|
||||
}, lifetime);
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating <see cref="AgentSessionStore"/> that scopes session keys by an isolation key
|
||||
/// provided by a <see cref="SessionIsolationKeyProvider"/>, ensuring that sessions are isolated
|
||||
/// per logical partition (e.g., user, tenant, or composite key).
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
|
||||
{
|
||||
private readonly SessionIsolationKeyProvider? _keyProvider;
|
||||
private readonly bool _strict;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerStore">The underlying <see cref="AgentSessionStore"/> to delegate to.</param>
|
||||
/// <param name="keyProvider">
|
||||
/// The <see cref="SessionIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
|
||||
/// </param>
|
||||
/// <param name="options">The options for configuring the session store. If null, defaults are used.</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <paramref name="innerStore"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
public IsolationKeyScopedAgentSessionStore(
|
||||
AgentSessionStore innerStore,
|
||||
SessionIsolationKeyProvider? keyProvider,
|
||||
IsolationKeyScopedAgentSessionStoreOptions? options = null)
|
||||
: base(innerStore)
|
||||
{
|
||||
this._keyProvider = keyProvider;
|
||||
options ??= new IsolationKeyScopedAgentSessionStoreOptions();
|
||||
this._strict = options.Strict;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the isolation key from the provider and validates it if in strict mode.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The isolation key string, or <see langword="null"/> if no key is available and non-strict mode is enabled.
|
||||
/// </returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The provider returned <see langword="null"/> and strict mode is enabled.
|
||||
/// </exception>
|
||||
private async ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string? key = this._keyProvider != null
|
||||
? await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
if (this._strict && key == null)
|
||||
{
|
||||
throw new InvalidOperationException("Session isolation key is required but was not provided by the configured SessionIsolationKeyProvider.");
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes special characters in the isolation key to ensure unambiguous scoped conversation IDs.
|
||||
/// </summary>
|
||||
/// <param name="key">The raw isolation key.</param>
|
||||
/// <returns>The escaped isolation key.</returns>
|
||||
/// <remarks>
|
||||
/// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:).
|
||||
/// This ensures the scoped conversation ID format {key}::{conversationId} can be parsed correctly.
|
||||
/// </remarks>
|
||||
private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:");
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a scoped conversation ID by prefixing the bare conversation ID with the escaped isolation key.
|
||||
/// </summary>
|
||||
/// <param name="bareConversationId">The original conversation ID.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The scoped conversation ID in the format {escapedKey}::{conversationId}, or the bare conversation ID
|
||||
/// if no isolation key is available and non-strict mode is enabled.
|
||||
/// </returns>
|
||||
private async ValueTask<string> GetScopedConversationIdAsync(string bareConversationId, CancellationToken cancellationToken)
|
||||
{
|
||||
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (key == null)
|
||||
{
|
||||
return bareConversationId;
|
||||
}
|
||||
|
||||
return $"{EscapeIsolationKey(key)}::{bareConversationId}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
return await this.InnerStore.GetSessionAsync(agent, scopedConversationId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
await this.InnerStore.SaveSessionAsync(agent, scopedConversationId, session, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring <see cref="IsolationKeyScopedAgentSessionStore"/>.
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an exception should be thrown when the isolation key cannot be determined.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// If <see langword="true"/> (default), the store will throw an <see cref="System.InvalidOperationException"/>
|
||||
/// when <see cref="SessionIsolationKeyProvider.GetSessionIsolationKeyAsync"/> returns <see langword="null"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If <see langword="false"/>, the conversation ID is passed through unmodified when the isolation key is absent,
|
||||
/// allowing unscoped access to the underlying session store. This mode is suitable for development scenarios
|
||||
/// or mixed environments where not all requests have isolation keys.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool Strict { get; set; } = true;
|
||||
}
|
||||
@@ -24,6 +24,20 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// For production use with multiple instances or persistence across restarts, use a durable storage implementation
|
||||
/// such as Redis, SQL Server, or Azure Cosmos DB.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Multi-user warning.</strong> This store keys threads by
|
||||
/// <c>(agent.Id, conversationId)</c> only — it has no principal/owner dimension. When
|
||||
/// the conversation identifier originates from the wire (for example, an AG-UI
|
||||
/// <c>RunAgentInput.ThreadId</c> or an A2A <c>contextId</c>), any caller who knows
|
||||
/// or guesses another caller's identifier can resume that other caller's persisted
|
||||
/// thread. Multi-user hosts must wrap this store in
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> (typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>) so that the conversation namespace is
|
||||
/// scoped per principal. See the trust-model remarks on
|
||||
/// <see cref="AgentSessionStore"/> for the full background.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class InMemoryAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for resolving session isolation keys used to scope agent sessions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Session isolation keys enable multi-tenant or multi-user scenarios by scoping agent session storage
|
||||
/// to a specific logical partition (e.g., user ID, tenant ID, or composite key). Derived classes
|
||||
/// implement the key resolution logic appropriate to their hosting environment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When a key is unavailable or cannot be determined, implementations should return <see langword="null"/>.
|
||||
/// The consuming session store can then enforce strict behavior (throwing an exception) or fall back
|
||||
/// to unscoped storage based on its configuration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class SessionIsolationKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the session isolation key for the current request or execution context.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous operation. The task result contains the isolation key string,
|
||||
/// or <see langword="null"/> if no key is available in the current context.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Implementations should extract the key from ambient context (e.g., HTTP request headers, claims,
|
||||
/// or environment variables). If the key cannot be determined, return <see langword="null"/> to allow
|
||||
/// the caller to decide on strict vs. pass-through behavior.
|
||||
/// </remarks>
|
||||
public abstract ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
@@ -57,6 +58,22 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
private string? _name;
|
||||
private string? _description;
|
||||
|
||||
// Autonomous mode configuration. When enabled, an agent's response that doesn't include a
|
||||
// handoff triggers another invocation of that same agent with the continuation prompt, up to
|
||||
// the configured turn limit per workflow turn. Optional per-agent overrides may further restrict
|
||||
// which agents have autonomous mode enabled, or override the turn limit / continuation prompt
|
||||
// on a per-agent basis.
|
||||
private bool _autonomousMode;
|
||||
private int _autonomousTurnLimit = HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit;
|
||||
private string _autonomousContinuationPrompt = HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt;
|
||||
private HashSet<string>? _autonomousEnabledAgentIds;
|
||||
private readonly Dictionary<string, int> _autonomousTurnLimitsByAgentId = [];
|
||||
private readonly Dictionary<string, string> _autonomousContinuationPromptsByAgentId = [];
|
||||
|
||||
// Termination condition. Evaluated after an agent response that does not request a handoff;
|
||||
// if true, the workflow ends (and the autonomous loop, if any, terminates).
|
||||
private Func<IReadOnlyList<ChatMessage>, ValueTask<bool>>? _terminationCondition;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
|
||||
/// </summary>
|
||||
@@ -258,12 +275,204 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
private Dictionary<string, ExecutorBinding> CreateExecutorBindings(WorkflowBuilder builder)
|
||||
/// <summary>
|
||||
/// Adds the specified <paramref name="agents"/> as participants in the handoff workflow without
|
||||
/// defining handoff relationships for them.
|
||||
/// </summary>
|
||||
/// <param name="agents">The agents to add as participants.</param>
|
||||
/// <returns>The updated builder instance.</returns>
|
||||
/// <remarks>
|
||||
/// Use this method when you want a participant to be part of the workflow but you have not
|
||||
/// explicitly defined handoff edges via <see cref="WithHandoff(AIAgent, AIAgent, string?)"/>.
|
||||
/// When no handoffs are explicitly defined (default handoffs), all registered participants are
|
||||
/// automatically wired so that every agent can hand off to every other agent.
|
||||
/// </remarks>
|
||||
public TBuilder AddParticipants(params IEnumerable<AIAgent> agents)
|
||||
{
|
||||
Throw.IfNull(agents);
|
||||
|
||||
foreach (AIAgent agent in agents)
|
||||
{
|
||||
if (agent is null)
|
||||
{
|
||||
Throw.ArgumentNullException(nameof(agents), "One or more agents are null.");
|
||||
}
|
||||
|
||||
this._allAgents.Add(agent);
|
||||
}
|
||||
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables autonomous mode for the handoff workflow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// In autonomous mode, an agent whose response does not include a handoff is invoked again with
|
||||
/// a continuation prompt, up to a configured turn limit. The autonomous loop for a given agent
|
||||
/// ends when the agent invokes a handoff tool, the configured termination condition fires, or
|
||||
/// the per-agent turn limit is reached — at which point the workflow yields control back to the
|
||||
/// caller.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Per-agent turn counting.</b> Autonomous-turn counters are tracked independently per agent
|
||||
/// in the shared handoff state. A counter is incremented each time the End executor loops
|
||||
/// control back to its source agent, and reset to zero in three cases: (1) when that agent
|
||||
/// requests a handoff, (2) when its autonomous loop terminates (limit reached, termination
|
||||
/// fires, or autonomous mode disabled for that agent), and (3) at the start of every fresh user
|
||||
/// turn. As a consequence, if agent A loops twice and then hands off to B, A's counter resets
|
||||
/// to zero; should control later return to A within the same user turn, A starts a new
|
||||
/// autonomous run from zero.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="turnLimit">
|
||||
/// The default maximum number of autonomous continuation iterations per agent per workflow
|
||||
/// turn. Applies to agents not listed in <paramref name="agentTurnLimits"/>. If
|
||||
/// <see langword="null"/>, defaults to
|
||||
/// <see cref="HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit"/> (50).
|
||||
/// </param>
|
||||
/// <param name="continuationPrompt">
|
||||
/// The default user-role prompt fed to an agent on each autonomous continuation. Applies to
|
||||
/// agents not listed in <paramref name="agentContinuationPrompts"/>. If <see langword="null"/>,
|
||||
/// defaults to <see cref="HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt"/>.
|
||||
/// </param>
|
||||
/// <param name="agents">
|
||||
/// Optional allow-list restricting autonomous mode to a specific subset of agents. If
|
||||
/// <see langword="null"/> or empty, autonomous mode is enabled for <i>every</i> participant.
|
||||
/// Agents not in the allow-list always yield control back to the caller after a single
|
||||
/// invocation (when they do not request a handoff).
|
||||
/// </param>
|
||||
/// <param name="agentTurnLimits">
|
||||
/// Optional per-agent turn-limit overrides. Each entry's key is the agent and its value the
|
||||
/// turn limit that overrides <paramref name="turnLimit"/> for that agent. Agents not present
|
||||
/// fall back to the default.
|
||||
/// </param>
|
||||
/// <param name="agentContinuationPrompts">
|
||||
/// Optional per-agent continuation-prompt overrides. Each entry's key is the agent and its
|
||||
/// value the continuation prompt used for that agent. Agents not present fall back to the
|
||||
/// default.
|
||||
/// </param>
|
||||
/// <returns>The updated builder instance.</returns>
|
||||
public TBuilder WithAutonomousMode(
|
||||
int? turnLimit = null,
|
||||
string? continuationPrompt = null,
|
||||
IEnumerable<AIAgent>? agents = null,
|
||||
IReadOnlyDictionary<AIAgent, int>? agentTurnLimits = null,
|
||||
IReadOnlyDictionary<AIAgent, string>? agentContinuationPrompts = null)
|
||||
{
|
||||
if (turnLimit is { } limit && limit <= 0)
|
||||
{
|
||||
Throw.ArgumentOutOfRangeException(nameof(turnLimit), "Turn limit must be greater than zero.");
|
||||
}
|
||||
|
||||
this._autonomousMode = true;
|
||||
this._autonomousTurnLimit = turnLimit ?? HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit;
|
||||
this._autonomousContinuationPrompt = continuationPrompt ?? HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt;
|
||||
|
||||
// Allow-list: null or empty means every participant has autonomous mode enabled. A non-empty
|
||||
// list restricts autonomous mode to exactly those agents.
|
||||
this._autonomousEnabledAgentIds = null;
|
||||
if (agents is not null)
|
||||
{
|
||||
HashSet<string> ids = [];
|
||||
foreach (AIAgent agent in agents)
|
||||
{
|
||||
Throw.IfNull(agent, $"{nameof(agents)} element");
|
||||
ids.Add(agent.Id);
|
||||
}
|
||||
|
||||
if (ids.Count > 0)
|
||||
{
|
||||
this._autonomousEnabledAgentIds = ids;
|
||||
}
|
||||
}
|
||||
|
||||
this._autonomousTurnLimitsByAgentId.Clear();
|
||||
if (agentTurnLimits is not null)
|
||||
{
|
||||
foreach (KeyValuePair<AIAgent, int> kvp in agentTurnLimits)
|
||||
{
|
||||
Throw.IfNull(kvp.Key, $"{nameof(agentTurnLimits)} key");
|
||||
if (kvp.Value <= 0)
|
||||
{
|
||||
Throw.ArgumentOutOfRangeException(
|
||||
nameof(agentTurnLimits),
|
||||
$"Turn limit for agent '{kvp.Key.Name ?? kvp.Key.Id}' must be greater than zero.");
|
||||
}
|
||||
|
||||
this._autonomousTurnLimitsByAgentId[kvp.Key.Id] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
this._autonomousContinuationPromptsByAgentId.Clear();
|
||||
if (agentContinuationPrompts is not null)
|
||||
{
|
||||
foreach (KeyValuePair<AIAgent, string> kvp in agentContinuationPrompts)
|
||||
{
|
||||
Throw.IfNull(kvp.Key, $"{nameof(agentContinuationPrompts)} key");
|
||||
Throw.IfNullOrEmpty(kvp.Value, $"{nameof(agentContinuationPrompts)} value");
|
||||
|
||||
this._autonomousContinuationPromptsByAgentId[kvp.Key.Id] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a synchronous termination condition for the handoff workflow.
|
||||
/// </summary>
|
||||
/// <param name="terminationCondition">
|
||||
/// A predicate that receives the current conversation and returns <see langword="true"/> if the
|
||||
/// workflow should terminate (preventing further autonomous continuation). The synchronous
|
||||
/// predicate is wrapped and forwarded to the async overload.
|
||||
/// </param>
|
||||
/// <returns>The updated builder instance.</returns>
|
||||
/// <remarks>
|
||||
/// The termination condition is evaluated after the agent produces a response that does not
|
||||
/// request a handoff. When it returns <see langword="true"/>, the workflow ends without invoking
|
||||
/// another autonomous continuation.
|
||||
/// </remarks>
|
||||
public TBuilder WithTerminationCondition(Func<IReadOnlyList<ChatMessage>, bool> terminationCondition)
|
||||
{
|
||||
Throw.IfNull(terminationCondition);
|
||||
|
||||
return this.WithTerminationCondition(
|
||||
messages => new ValueTask<bool>(terminationCondition(messages)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets an asynchronous termination condition for the handoff workflow.
|
||||
/// </summary>
|
||||
/// <param name="terminationCondition">
|
||||
/// A predicate that receives the current conversation and asynchronously returns
|
||||
/// <see langword="true"/> if the workflow should terminate (preventing further autonomous
|
||||
/// continuation).
|
||||
/// </param>
|
||||
/// <returns>The updated builder instance.</returns>
|
||||
/// <remarks>
|
||||
/// The termination condition is evaluated after the agent produces a response that does not
|
||||
/// request a handoff. When it returns <see langword="true"/>, the workflow ends without invoking
|
||||
/// another autonomous continuation.
|
||||
/// </remarks>
|
||||
public TBuilder WithTerminationCondition(Func<IReadOnlyList<ChatMessage>, ValueTask<bool>> terminationCondition)
|
||||
{
|
||||
Throw.IfNull(terminationCondition);
|
||||
|
||||
this._terminationCondition = terminationCondition;
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
private Dictionary<string, ExecutorBinding> CreateExecutorBindings(WorkflowBuilder builder, Dictionary<AIAgent, HashSet<HandoffTarget>> effectiveTargets)
|
||||
{
|
||||
HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
|
||||
this._emitAgentResponseEvents,
|
||||
this._emitAgentResponseUpdateEvents,
|
||||
this._toolCallFilteringBehavior);
|
||||
this._toolCallFilteringBehavior)
|
||||
{
|
||||
TerminationCondition = this._terminationCondition,
|
||||
};
|
||||
|
||||
// There are two types of ids being used in this method, and it is critical that we are clear about
|
||||
// which one we are using, and where.
|
||||
@@ -277,7 +486,7 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
|
||||
ExecutorBinding CreateFactoryBinding(AIAgent agent)
|
||||
{
|
||||
if (!this._targets.TryGetValue(agent, out HashSet<HandoffTarget>? handoffs))
|
||||
if (!effectiveTargets.TryGetValue(agent, out HashSet<HandoffTarget>? handoffs))
|
||||
{
|
||||
handoffs = new();
|
||||
}
|
||||
@@ -287,10 +496,16 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
{
|
||||
foreach (HandoffTarget handoff in handoffs)
|
||||
{
|
||||
sb.AddCase<HandoffState>(state => state?.RequestedHandoffTargetAgentId == handoff.Target.Id, // Use AgentId for target matching
|
||||
// Each handoff case also requires the turn to NOT be terminated; otherwise the
|
||||
// turn falls through to the default branch, which routes to HandoffEndExecutor.
|
||||
string targetAgentId = handoff.Target.Id;
|
||||
sb.AddCase<HandoffState>(state => state?.RequestedHandoffTargetAgentId == targetAgentId // Use AgentId for target matching
|
||||
&& state.IsTerminated != true,
|
||||
HandoffAgentExecutor.IdFor(handoff.Target)); // Use ExecutorId in for routing at the workflow level
|
||||
}
|
||||
|
||||
// Default branch catches: (a) turns with no handoff requested, and (b) terminated turns
|
||||
// (whose handoff cases have been excluded above via the !IsTerminated guard).
|
||||
sb.WithDefault(HandoffEndExecutor.ExecutorId);
|
||||
});
|
||||
|
||||
@@ -309,6 +524,47 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<AIAgent, HashSet<HandoffTarget>> BuildDefaultHandoffTargets()
|
||||
{
|
||||
// Default handoffs: when the caller has not explicitly registered any handoffs via
|
||||
// WithHandoff/WithHandoffs, every registered participant is wired to hand off to every other
|
||||
// participant.
|
||||
// The handoff "reason" is derived from the target agent's description/name/instructions,
|
||||
// matching the resolution rules used in WithHandoff(). If no reason can be derived, we throw —
|
||||
// same contract as the explicit handoff path.
|
||||
Dictionary<AIAgent, HashSet<HandoffTarget>> defaultTargets = [];
|
||||
|
||||
foreach (AIAgent source in this._allAgents)
|
||||
{
|
||||
HashSet<HandoffTarget> targets = [];
|
||||
foreach (AIAgent target in this._allAgents)
|
||||
{
|
||||
if (AIAgentIDEqualityComparer.Instance.Equals(source, target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? reason = (string.IsNullOrWhiteSpace(target.Description) ? null : target.Description)
|
||||
?? (string.IsNullOrWhiteSpace(target.Name) ? null : $"handoff to {target.Name}")
|
||||
?? target.GetService<ChatClientAgent>()?.Instructions;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
Throw.InvalidOperationException(
|
||||
$"Cannot build default handoffs: target agent '{(string.IsNullOrWhiteSpace(target.Name) ? target.Id : target.Name)}' " +
|
||||
"has no description, name, or instructions from which to derive a handoff reason. Either provide one of these " +
|
||||
"on the agent, or define handoffs explicitly via WithHandoff/WithHandoffs.");
|
||||
}
|
||||
|
||||
targets.Add(new HandoffTarget(target, reason));
|
||||
}
|
||||
|
||||
defaultTargets[source] = targets;
|
||||
}
|
||||
|
||||
return defaultTargets;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="Workflow"/> composed of agents that operate via handoffs, with the next
|
||||
/// agent to process messages selected by the current agent.
|
||||
@@ -317,11 +573,25 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
public Workflow Build()
|
||||
{
|
||||
HandoffStartExecutor start = new(this._returnToPrevious);
|
||||
HandoffEndExecutor end = new(this._returnToPrevious);
|
||||
HandoffEndExecutor end = new(
|
||||
returnToPrevious: this._returnToPrevious,
|
||||
autonomousMode: this._autonomousMode,
|
||||
autonomousTurnLimit: this._autonomousTurnLimit,
|
||||
autonomousContinuationPrompt: this._autonomousContinuationPrompt,
|
||||
autonomousEnabledAgentIds: this._autonomousEnabledAgentIds,
|
||||
autonomousTurnLimitsByAgentId: this._autonomousTurnLimitsByAgentId,
|
||||
autonomousContinuationPromptsByAgentId: this._autonomousContinuationPromptsByAgentId);
|
||||
WorkflowBuilder builder = new(start);
|
||||
|
||||
// Default handoffs: when the caller has not explicitly registered any handoffs via
|
||||
// WithHandoff/WithHandoffs, every registered participant is wired to hand off to every other
|
||||
// participant.
|
||||
Dictionary<AIAgent, HashSet<HandoffTarget>> effectiveTargets = this._targets.Count == 0
|
||||
? this.BuildDefaultHandoffTargets()
|
||||
: this._targets;
|
||||
|
||||
// Create an factory-based ExecutorBinding for each agent.
|
||||
Dictionary<string, ExecutorBinding> executors = this.CreateExecutorBindings(builder);
|
||||
Dictionary<string, ExecutorBinding> executors = this.CreateExecutorBindings(builder, effectiveTargets);
|
||||
|
||||
// Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled).
|
||||
if (this._returnToPrevious)
|
||||
@@ -346,6 +616,21 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
builder.AddEdge(start, executors[this._initialAgent.Id]);
|
||||
}
|
||||
|
||||
// Autonomous-mode loop-back: when enabled, the End executor may emit a HandoffState targeting
|
||||
// the source agent (carrying the synthesized continuation prompt in the shared conversation).
|
||||
// A switch downstream of End routes that message back to the matching agent executor.
|
||||
if (this._autonomousMode)
|
||||
{
|
||||
builder.AddSwitch(end, sb =>
|
||||
{
|
||||
foreach (AIAgent agent in this._allAgents)
|
||||
{
|
||||
string agentId = agent.Id;
|
||||
sb.AddCase<HandoffState>(state => state?.RequestedHandoffTargetAgentId == agentId, executors[agentId]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(this._name))
|
||||
{
|
||||
builder.WithName(this._name);
|
||||
|
||||
@@ -30,6 +30,17 @@ internal sealed class HandoffAgentExecutorOptions
|
||||
public bool? EmitAgentResponseUpdateEvents { get; set; }
|
||||
|
||||
public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly;
|
||||
|
||||
// Termination condition. When provided, evaluated after the agent responds and no handoff was
|
||||
// requested. If it returns true, the outgoing HandoffState is stamped with IsTerminated = true
|
||||
// so the per-agent routing switch routes the turn to HandoffEndExecutor instead of continuing.
|
||||
public Func<IReadOnlyList<ChatMessage>, ValueTask<bool>>? TerminationCondition { get; set; }
|
||||
}
|
||||
|
||||
internal static class HandoffWorkflowBuilderDefaults
|
||||
{
|
||||
public const string DefaultAutonomousContinuationPrompt = "User did not respond. Continue assisting autonomously.";
|
||||
public const int DefaultAutonomousTurnLimit = 50;
|
||||
}
|
||||
|
||||
internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId)
|
||||
@@ -250,6 +261,7 @@ internal sealed class HandoffAgentExecutor :
|
||||
}
|
||||
|
||||
int newConversationBookmark = state.ConversationBookmark;
|
||||
List<ChatMessage>? conversationSnapshot = null;
|
||||
await this._sharedStateRef.InvokeWithStateAsync(
|
||||
(sharedState, ctx, ct) =>
|
||||
{
|
||||
@@ -285,12 +297,25 @@ internal sealed class HandoffAgentExecutor :
|
||||
}
|
||||
|
||||
_ = sharedState.Conversation.AddMessage(handoffCallResultMessage);
|
||||
|
||||
// Reset this agent's autonomous-turn counter when it chooses to hand off, so that
|
||||
// if control returns to this agent later in the turn (e.g. via another handoff),
|
||||
// its autonomous loop starts fresh rather than carrying over prior iterations.
|
||||
sharedState.AutonomousTurnsByAgent[this._agent.Id] = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages);
|
||||
}
|
||||
|
||||
// Snapshot the conversation for termination evaluation while we still hold shared state access.
|
||||
// Termination is only relevant when no handoff was requested — a requested handoff always
|
||||
// routes to the target agent regardless of termination.
|
||||
if (this._options.TerminationCondition is not null && !result.IsHandoffRequested)
|
||||
{
|
||||
conversationSnapshot = sharedState.Conversation.CloneHistory();
|
||||
}
|
||||
|
||||
return new ValueTask();
|
||||
},
|
||||
context,
|
||||
@@ -298,18 +323,27 @@ internal sealed class HandoffAgentExecutor :
|
||||
|
||||
// We send on the HandoffState even if handoff is not requested because we might be terminating the processing, but this only
|
||||
// happens if we have no outstanding requests.
|
||||
if (!this.HasOutstandingRequests)
|
||||
if (this.HasOutstandingRequests)
|
||||
{
|
||||
HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id);
|
||||
|
||||
await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// reset the state for the next handoff, making sure to keep track of the conversation bookmark, and avoid resetting the
|
||||
// agent session. (return-to-current is modeled as a new handoff turn, as opposed to "HITL", which can be a bit confusing.)
|
||||
return state with { IncomingState = null, ConversationBookmark = newConversationBookmark };
|
||||
return state with { ConversationBookmark = newConversationBookmark };
|
||||
}
|
||||
|
||||
return state;
|
||||
// Evaluate the termination condition (when configured and no handoff was requested) and stamp
|
||||
// the result onto the outgoing HandoffState so the per-agent routing switch can route the turn
|
||||
// to HandoffEndExecutor instead of dispatching another handoff or autonomous continuation.
|
||||
bool isTerminated = false;
|
||||
if (conversationSnapshot is not null)
|
||||
{
|
||||
isTerminated = await this._options.TerminationCondition!(conversationSnapshot).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id, isTerminated);
|
||||
|
||||
await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Reset the turn-local state; keep the conversation bookmark and the agent session so the
|
||||
// next invocation (handoff back, autonomous loop-back, or new user turn) resumes cleanly.
|
||||
return state with { IncomingState = null, ConversationBookmark = newConversationBookmark };
|
||||
}
|
||||
|
||||
public override ValueTask HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -8,18 +8,76 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event.</summary>
|
||||
internal sealed class HandoffEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
|
||||
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event,
|
||||
/// and in autonomous mode to loop control back to the source agent.</summary>
|
||||
/// <remarks>
|
||||
/// Autonomous-turn counters are tracked per source agent in <see cref="HandoffSharedState.AutonomousTurnsByAgent"/>.
|
||||
/// On each invocation where the source agent did not request a handoff and termination has not fired,
|
||||
/// the counter for that agent is incremented and control is sent back to that agent (via the
|
||||
/// autonomous-return switch wired downstream of this executor). When the counter reaches the per-agent
|
||||
/// turn limit — or when termination fires, or when autonomous mode is disabled for that agent — the
|
||||
/// counter is reset to zero and the conversation is yielded as workflow output.
|
||||
/// </remarks>
|
||||
internal sealed class HandoffEndExecutor : Executor, IResettableExecutor
|
||||
{
|
||||
public const string ExecutorId = "HandoffEnd";
|
||||
|
||||
private readonly bool _returnToPrevious;
|
||||
private readonly bool _autonomousMode;
|
||||
private readonly int _autonomousTurnLimit;
|
||||
private readonly string _autonomousContinuationPrompt;
|
||||
private readonly HashSet<string>? _autonomousEnabledAgentIds;
|
||||
private readonly IReadOnlyDictionary<string, int> _autonomousTurnLimitsByAgentId;
|
||||
private readonly IReadOnlyDictionary<string, string> _autonomousContinuationPromptsByAgentId;
|
||||
|
||||
private readonly StateRef<HandoffSharedState> _sharedStateRef = new(HandoffConstants.HandoffSharedStateKey,
|
||||
HandoffConstants.HandoffSharedStateScope);
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
|
||||
protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<HandoffState>(
|
||||
(handoff, context, cancellationToken) => this.HandleAsync(handoff, context, cancellationToken)))
|
||||
.YieldsOutput<List<ChatMessage>>();
|
||||
public HandoffEndExecutor(
|
||||
bool returnToPrevious,
|
||||
bool autonomousMode = false,
|
||||
int autonomousTurnLimit = HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit,
|
||||
string autonomousContinuationPrompt = HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt,
|
||||
HashSet<string>? autonomousEnabledAgentIds = null,
|
||||
IReadOnlyDictionary<string, int>? autonomousTurnLimitsByAgentId = null,
|
||||
IReadOnlyDictionary<string, string>? autonomousContinuationPromptsByAgentId = null)
|
||||
: base(ExecutorId, declareCrossRunShareable: true)
|
||||
{
|
||||
this._returnToPrevious = returnToPrevious;
|
||||
this._autonomousMode = autonomousMode;
|
||||
this._autonomousTurnLimit = autonomousTurnLimit;
|
||||
this._autonomousContinuationPrompt = autonomousContinuationPrompt;
|
||||
this._autonomousEnabledAgentIds = autonomousEnabledAgentIds;
|
||||
this._autonomousTurnLimitsByAgentId = autonomousTurnLimitsByAgentId ?? new Dictionary<string, int>();
|
||||
this._autonomousContinuationPromptsByAgentId = autonomousContinuationPromptsByAgentId ?? new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
private bool IsAutonomousEnabledFor(string agentId) =>
|
||||
// Null allow-list means every participant has autonomous mode enabled.
|
||||
this._autonomousEnabledAgentIds?.Contains(agentId) ?? true;
|
||||
|
||||
private int TurnLimitFor(string agentId) =>
|
||||
this._autonomousTurnLimitsByAgentId.TryGetValue(agentId, out int limit) ? limit : this._autonomousTurnLimit;
|
||||
|
||||
private string ContinuationPromptFor(string agentId) =>
|
||||
this._autonomousContinuationPromptsByAgentId.TryGetValue(agentId, out string? prompt) ? prompt : this._autonomousContinuationPrompt;
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
ProtocolBuilder pb = protocolBuilder
|
||||
.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<HandoffState>(
|
||||
(handoff, context, cancellationToken) => this.HandleAsync(handoff, context, cancellationToken)))
|
||||
.YieldsOutput<List<ChatMessage>>();
|
||||
|
||||
// Only advertise the outgoing-message capability when autonomous mode is enabled, since the
|
||||
// downstream return switch (Builder.AddSwitch on End) is only wired in that case.
|
||||
if (this._autonomousMode)
|
||||
{
|
||||
pb = pb.SendsMessage<HandoffState>();
|
||||
}
|
||||
|
||||
return pb;
|
||||
}
|
||||
|
||||
private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -31,7 +89,56 @@ internal sealed class HandoffEndExecutor(bool returnToPrevious) : Executor(Execu
|
||||
throw new InvalidOperationException("Handoff Orchestration shared state was not properly initialized.");
|
||||
}
|
||||
|
||||
if (returnToPrevious)
|
||||
// Autonomous mode: when the agent did not request a handoff and termination has not fired,
|
||||
// loop control back to the same agent (up to that agent's turn limit). Per-agent overrides
|
||||
// (enabled-agents allow-list, turn limit, continuation prompt) are honored here.
|
||||
bool canContinueAutonomously = this._autonomousMode
|
||||
&& !handoff.IsTerminated
|
||||
&& handoff.RequestedHandoffTargetAgentId is null
|
||||
&& handoff.PreviousAgentId is not null
|
||||
&& this.IsAutonomousEnabledFor(handoff.PreviousAgentId!);
|
||||
|
||||
if (canContinueAutonomously)
|
||||
{
|
||||
string agentId = handoff.PreviousAgentId!;
|
||||
int turns = sharedState.AutonomousTurnsByAgent.TryGetValue(agentId, out int existing) ? existing : 0;
|
||||
int limit = this.TurnLimitFor(agentId);
|
||||
|
||||
if (turns < limit)
|
||||
{
|
||||
sharedState.AutonomousTurnsByAgent[agentId] = turns + 1;
|
||||
|
||||
// Append a synthetic user message containing the continuation prompt so the agent
|
||||
// has fresh input to act on for the next autonomous iteration.
|
||||
sharedState.Conversation.AddMessage(new ChatMessage(ChatRole.User, this.ContinuationPromptFor(agentId))
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
});
|
||||
|
||||
// Send a HandoffState targeting the source agent. The downstream
|
||||
// HandoffAutonomousReturnSwitch routes it to the matching agent executor.
|
||||
HandoffState loopBack = new(
|
||||
handoff.TurnToken,
|
||||
RequestedHandoffTargetAgentId: agentId,
|
||||
PreviousAgentId: agentId,
|
||||
IsTerminated: false);
|
||||
|
||||
await context.SendMessageAsync(loopBack, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return sharedState;
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal path: either termination fired, autonomous mode is disabled, or the turn
|
||||
// limit is reached. Reset this agent's autonomous counter so a subsequent user turn
|
||||
// starts fresh, then yield the conversation as workflow output.
|
||||
if (handoff.PreviousAgentId is not null)
|
||||
{
|
||||
sharedState.AutonomousTurnsByAgent[handoff.PreviousAgentId] = 0;
|
||||
}
|
||||
|
||||
if (this._returnToPrevious)
|
||||
{
|
||||
sharedState.PreviousAgentId = handoff.PreviousAgentId;
|
||||
}
|
||||
|
||||
@@ -25,21 +25,32 @@ internal static class HandoffConstants
|
||||
internal sealed class HandoffSharedState
|
||||
{
|
||||
[JsonConstructor]
|
||||
internal HandoffSharedState(MultiPartyConversation conversation, string? previousAgentId)
|
||||
internal HandoffSharedState(MultiPartyConversation conversation, string? previousAgentId, Dictionary<string, int>? autonomousTurnsByAgent)
|
||||
{
|
||||
this.Conversation = conversation;
|
||||
this.PreviousAgentId = previousAgentId;
|
||||
this.AutonomousTurnsByAgent = autonomousTurnsByAgent ?? [];
|
||||
}
|
||||
|
||||
public HandoffSharedState()
|
||||
{
|
||||
this.Conversation = new([]);
|
||||
this.AutonomousTurnsByAgent = [];
|
||||
}
|
||||
|
||||
[JsonInclude]
|
||||
public MultiPartyConversation Conversation { get; internal set; }
|
||||
|
||||
public string? PreviousAgentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the number of autonomous-mode continuation iterations consumed by each agent in the current
|
||||
/// "active" autonomous run. The counter is incremented by <see cref="HandoffEndExecutor"/> each time
|
||||
/// the End executor loops control back to the source agent in autonomous mode, and reset to 0 once
|
||||
/// the autonomous loop terminates (limit reached or termination condition fired).
|
||||
/// </summary>
|
||||
[JsonInclude]
|
||||
public Dictionary<string, int> AutonomousTurnsByAgent { get; internal set; }
|
||||
}
|
||||
|
||||
/// <summary>Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.</summary>
|
||||
@@ -64,6 +75,10 @@ internal sealed class HandoffStartExecutor(bool returnToPrevious) : ChatProtocol
|
||||
sharedState ??= new HandoffSharedState();
|
||||
sharedState.Conversation.AddMessages(messages);
|
||||
|
||||
// Reset all autonomous-mode counters at the start of every fresh user turn so that a
|
||||
// prior turn's counters cannot prematurely terminate the new turn's autonomous loop.
|
||||
sharedState.AutonomousTurnsByAgent.Clear();
|
||||
|
||||
string? previousAgentId = sharedState.PreviousAgentId;
|
||||
|
||||
// If we are configured to return to the previous agent, include the previous agent id in the handoff state.
|
||||
|
||||
@@ -5,4 +5,5 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
internal sealed record class HandoffState(
|
||||
TurnToken TurnToken,
|
||||
string? RequestedHandoffTargetAgentId,
|
||||
string? PreviousAgentId = null);
|
||||
string? PreviousAgentId = null,
|
||||
bool IsTerminated = false);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides contextual information about a discovered file to the
|
||||
/// <see cref="AgentFileSkillsSourceOptions.ScriptFilter"/> and
|
||||
/// <see cref="AgentFileSkillsSourceOptions.ResourceFilter"/> predicates.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class AgentFileSkillFilterContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFileSkillFilterContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="skillName">The name of the skill (from SKILL.md frontmatter).</param>
|
||||
/// <param name="relativeFilePath">
|
||||
/// The path to the script or resource file relative to the skill directory (using forward slashes).
|
||||
/// </param>
|
||||
internal AgentFileSkillFilterContext(string skillName, string relativeFilePath)
|
||||
{
|
||||
this.SkillName = Throw.IfNullOrWhitespace(skillName);
|
||||
this.RelativeFilePath = Throw.IfNullOrWhitespace(relativeFilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the skill as declared in the SKILL.md frontmatter.
|
||||
/// </summary>
|
||||
/// <example><c>unit-converter</c></example>
|
||||
public string SkillName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the script or resource file relative to the skill directory (using forward slashes).
|
||||
/// For root-level files this is just the filename; for nested files it includes the subdirectory.
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// <c>run.py</c> for a script at skill root,
|
||||
/// <c>scripts/convert.js</c> for a nested script, or
|
||||
/// <c>references/guide.md</c> for a nested resource.
|
||||
/// </example>
|
||||
public string RelativeFilePath { get; }
|
||||
}
|
||||
@@ -30,18 +30,12 @@ namespace Microsoft.Agents.AI;
|
||||
internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
{
|
||||
private const string SkillFileName = "SKILL.md";
|
||||
private const int MaxSearchDepth = 2;
|
||||
|
||||
// "." means the skill directory root itself (no subdirectory descent constraint)
|
||||
private const string RootDirectoryIndicator = ".";
|
||||
private const int DefaultSearchDepth = 2;
|
||||
private const int MaxSkillDirectorySearchDepth = 2;
|
||||
|
||||
private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"];
|
||||
private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"];
|
||||
|
||||
// Standard subdirectory names per https://agentskills.io/specification#directory-structure
|
||||
private static readonly string[] s_defaultScriptDirectories = ["scripts"];
|
||||
private static readonly string[] s_defaultResourceDirectories = ["references", "assets"];
|
||||
|
||||
// Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters.
|
||||
// Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block.
|
||||
// The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend.
|
||||
@@ -63,8 +57,9 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
private readonly IEnumerable<string> _skillPaths;
|
||||
private readonly HashSet<string> _allowedResourceExtensions;
|
||||
private readonly HashSet<string> _allowedScriptExtensions;
|
||||
private readonly IReadOnlyList<string> _scriptDirectories;
|
||||
private readonly IReadOnlyList<string> _resourceDirectories;
|
||||
private readonly int _searchDepth;
|
||||
private readonly Func<AgentFileSkillFilterContext, bool>? _scriptFilter;
|
||||
private readonly Func<AgentFileSkillFilterContext, bool>? _resourceFilter;
|
||||
private readonly AgentFileSkillScriptRunner? _scriptRunner;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
@@ -111,13 +106,9 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
options?.AllowedScriptExtensions ?? s_defaultScriptExtensions,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
this._scriptDirectories = options?.ScriptDirectories is not null
|
||||
? [.. ValidateAndNormalizeDirectoryNames(options.ScriptDirectories, this._logger)]
|
||||
: s_defaultScriptDirectories;
|
||||
|
||||
this._resourceDirectories = options?.ResourceDirectories is not null
|
||||
? [.. ValidateAndNormalizeDirectoryNames(options.ResourceDirectories, this._logger)]
|
||||
: s_defaultResourceDirectories;
|
||||
this._searchDepth = Throw.IfLessThan(options?.SearchDepth ?? DefaultSearchDepth, 1);
|
||||
this._scriptFilter = options?.ScriptFilter;
|
||||
this._resourceFilter = options?.ResourceFilter;
|
||||
|
||||
this._scriptRunner = scriptRunner;
|
||||
}
|
||||
@@ -174,7 +165,7 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
results.Add(Path.GetFullPath(directory));
|
||||
}
|
||||
|
||||
if (currentDepth >= MaxSearchDepth)
|
||||
if (currentDepth >= MaxSkillDirectorySearchDepth)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -305,216 +296,246 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans configured resource directories within a skill directory for resource files matching the configured extensions.
|
||||
/// Scans the skill directory recursively (up to the configured search depth) for resource files
|
||||
/// matching the configured extensions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// By default, scans <c>references/</c> and <c>assets/</c> subdirectories as specified by the
|
||||
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
|
||||
/// Configure <see cref="AgentFileSkillsSourceOptions.ResourceDirectories"/> to scan different or
|
||||
/// additional directories, including <c>"."</c> for the skill root itself.
|
||||
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
|
||||
/// If a <see cref="AgentFileSkillsSourceOptions.ResourceFilter"/> predicate is configured, files
|
||||
/// that do not satisfy it are excluded.
|
||||
/// </remarks>
|
||||
private List<AgentFileSkillResource> DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
|
||||
{
|
||||
var resources = new List<AgentFileSkillResource>();
|
||||
|
||||
foreach (string directory in this._resourceDirectories.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal);
|
||||
|
||||
// GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
|
||||
string targetDirectory = isRootDirectory
|
||||
? skillDirectoryFullPath
|
||||
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar;
|
||||
|
||||
if (!Directory.Exists(targetDirectory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Directory-level symlink check: skip if targetDirectory (or any intermediate
|
||||
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
|
||||
// trusted path, and the security boundary guards files within it, not the path itself.
|
||||
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
#if NET
|
||||
var enumerationOptions = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = false,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
|
||||
#else
|
||||
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
|
||||
#endif
|
||||
{
|
||||
string fileName = Path.GetFileName(filePath);
|
||||
|
||||
// Exclude SKILL.md itself
|
||||
if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by extension
|
||||
string extension = Path.GetExtension(filePath);
|
||||
if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize the enumerated path to guard against non-canonical forms.
|
||||
// e.g. "references/../../../etc/shadow" → "/etc/shadow"
|
||||
string resolvedFilePath = Path.GetFullPath(filePath);
|
||||
|
||||
// Path containment: reject if the resolved path escapes the target directory.
|
||||
// e.g. "/etc/shadow".StartsWith("/skills/myskill/references/") → false → skip
|
||||
if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Per-file symlink check: detects if the file (or any intermediate segment)
|
||||
// is a reparse point. e.g. "references/secret.md" → symlink to "/etc/shadow"
|
||||
if (HasSymlinkInPath(resolvedFilePath, targetDirectory))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute relative path and normalize separators.
|
||||
// e.g. "/skills/myskill/references/guide.md" → "references/guide.md"
|
||||
string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
|
||||
|
||||
resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath));
|
||||
}
|
||||
}
|
||||
this.ScanDirectoryForResources(skillDirectoryFullPath, skillDirectoryFullPath, skillName, resources, currentDepth: 1);
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
private void ScanDirectoryForResources(string targetDirectory, string skillDirectoryFullPath, string skillName, List<AgentFileSkillResource> resources, int currentDepth)
|
||||
{
|
||||
if (currentDepth > this._searchDepth)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool isRootDirectory = string.Equals(targetDirectory, skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Directory-level symlink check: skip if targetDirectory (or any intermediate
|
||||
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
|
||||
// trusted path, and the security boundary guards files within it, not the path itself.
|
||||
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(targetDirectory));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
#if NET
|
||||
var enumerationOptions = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = false,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
|
||||
#else
|
||||
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
|
||||
#endif
|
||||
{
|
||||
string fileName = Path.GetFileName(filePath);
|
||||
|
||||
// Exclude SKILL.md itself
|
||||
if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by extension
|
||||
string extension = Path.GetExtension(filePath);
|
||||
if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), string.IsNullOrEmpty(extension) ? "(none)" : extension);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize the enumerated path to guard against non-canonical forms.
|
||||
// e.g. "references/../../../etc/shadow" → "/etc/shadow"
|
||||
string resolvedFilePath = Path.GetFullPath(filePath);
|
||||
|
||||
// Path containment: reject if the resolved path escapes the skill directory.
|
||||
// e.g. "/etc/shadow".StartsWith("/skills/myskill/") → false → skip
|
||||
if (!resolvedFilePath.StartsWith(skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Per-file symlink check: detects if the file (or any intermediate segment)
|
||||
// is a reparse point. e.g. "references/secret.md" → symlink to "/etc/shadow"
|
||||
if (HasSymlinkInPath(resolvedFilePath, skillDirectoryFullPath))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute relative path and normalize separators.
|
||||
// e.g. "/skills/myskill/references/guide.md" → "references/guide.md"
|
||||
string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
|
||||
|
||||
// Apply user-provided filter predicate
|
||||
if (this._resourceFilter is not null && !this._resourceFilter(new AgentFileSkillFilterContext(skillName, relativePath)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath));
|
||||
}
|
||||
|
||||
// Recurse into subdirectories if within depth limit
|
||||
if (currentDepth < this._searchDepth)
|
||||
{
|
||||
#if NET
|
||||
foreach (string subdirectory in Directory.EnumerateDirectories(targetDirectory, "*", enumerationOptions))
|
||||
#else
|
||||
foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory))
|
||||
#endif
|
||||
{
|
||||
this.ScanDirectoryForResources(subdirectory, skillDirectoryFullPath, skillName, resources, currentDepth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans configured script directories within a skill directory for script files matching the configured extensions.
|
||||
/// Scans the skill directory recursively (up to the configured search depth) for script files
|
||||
/// matching the configured extensions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// By default, scans the <c>scripts/</c> subdirectory as specified by the
|
||||
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
|
||||
/// Configure <see cref="AgentFileSkillsSourceOptions.ScriptDirectories"/> to scan different or
|
||||
/// additional directories, including <c>"."</c> for the skill root itself.
|
||||
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
|
||||
/// If a <see cref="AgentFileSkillsSourceOptions.ScriptFilter"/> predicate is configured, files
|
||||
/// that do not satisfy it are excluded.
|
||||
/// </remarks>
|
||||
private List<AgentFileSkillScript> DiscoverScriptFiles(string skillDirectoryFullPath, string skillName)
|
||||
{
|
||||
var scripts = new List<AgentFileSkillScript>();
|
||||
|
||||
foreach (string directory in this._scriptDirectories.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
this.ScanDirectoryForScripts(skillDirectoryFullPath, skillDirectoryFullPath, skillName, scripts, currentDepth: 1);
|
||||
|
||||
return scripts;
|
||||
}
|
||||
|
||||
private void ScanDirectoryForScripts(string targetDirectory, string skillDirectoryFullPath, string skillName, List<AgentFileSkillScript> scripts, int currentDepth)
|
||||
{
|
||||
if (currentDepth > this._searchDepth)
|
||||
{
|
||||
bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal);
|
||||
return;
|
||||
}
|
||||
|
||||
// GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
|
||||
string targetDirectory = isRootDirectory
|
||||
? skillDirectoryFullPath
|
||||
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar;
|
||||
bool isRootDirectory = string.Equals(targetDirectory, skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!Directory.Exists(targetDirectory))
|
||||
// Directory-level symlink check: skip if targetDirectory (or any intermediate
|
||||
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
|
||||
// trusted path, and the security boundary guards files within it, not the path itself.
|
||||
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogScriptSymlinkDirectory(this._logger, skillName, SanitizePathForLog(targetDirectory));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
#if NET
|
||||
var enumerationOptions = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = false,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
|
||||
#else
|
||||
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
|
||||
#endif
|
||||
{
|
||||
// Filter by extension
|
||||
string extension = Path.GetExtension(filePath);
|
||||
if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Directory-level symlink check: skip if targetDirectory (or any intermediate
|
||||
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
|
||||
// trusted path, and the security boundary guards files within it, not the path itself.
|
||||
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
|
||||
// Normalize the enumerated path to guard against non-canonical forms.
|
||||
// e.g. "scripts/../../../etc/shadow" → "/etc/shadow"
|
||||
string resolvedFilePath = Path.GetFullPath(filePath);
|
||||
|
||||
// Path containment: reject if the resolved path escapes the skill directory.
|
||||
// e.g. "/etc/shadow".StartsWith("/skills/myskill/") → false → skip
|
||||
if (!resolvedFilePath.StartsWith(skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogScriptSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory));
|
||||
LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
#if NET
|
||||
var enumerationOptions = new EnumerationOptions
|
||||
// Per-file symlink check: detects if the file (or any intermediate segment)
|
||||
// is a reparse point. e.g. "scripts/run.py" → symlink to "/etc/shadow"
|
||||
if (HasSymlinkInPath(resolvedFilePath, skillDirectoryFullPath))
|
||||
{
|
||||
RecurseSubdirectories = false,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
|
||||
#else
|
||||
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
|
||||
#endif
|
||||
{
|
||||
// Filter by extension
|
||||
string extension = Path.GetExtension(filePath);
|
||||
if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension))
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
continue;
|
||||
LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
// Normalize the enumerated path to guard against non-canonical forms.
|
||||
// e.g. "scripts/../../../etc/shadow" → "/etc/shadow"
|
||||
string resolvedFilePath = Path.GetFullPath(filePath);
|
||||
|
||||
// Path containment: reject if the resolved path escapes the target directory.
|
||||
// e.g. "/etc/shadow".StartsWith("/skills/myskill/scripts/") → false → skip
|
||||
if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Per-file symlink check: detects if the file (or any intermediate segment)
|
||||
// is a reparse point. e.g. "scripts/run.py" → symlink to "/etc/shadow"
|
||||
if (HasSymlinkInPath(resolvedFilePath, targetDirectory))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute relative path and normalize separators.
|
||||
// e.g. "/skills/myskill/scripts/parsepdf.py" → "scripts/parsepdf.py"
|
||||
string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
|
||||
|
||||
scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute relative path and normalize separators.
|
||||
// e.g. "/skills/myskill/scripts/parsepdf.py" → "scripts/parsepdf.py"
|
||||
string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
|
||||
|
||||
// Apply user-provided filter predicate
|
||||
if (this._scriptFilter is not null && !this._scriptFilter(new AgentFileSkillFilterContext(skillName, relativePath)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner));
|
||||
}
|
||||
|
||||
return scripts;
|
||||
// Recurse into subdirectories if within depth limit
|
||||
if (currentDepth < this._searchDepth)
|
||||
{
|
||||
#if NET
|
||||
foreach (string subdirectory in Directory.EnumerateDirectories(targetDirectory, "*", enumerationOptions))
|
||||
#else
|
||||
foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory))
|
||||
#endif
|
||||
{
|
||||
this.ScanDirectoryForScripts(subdirectory, skillDirectoryFullPath, skillName, scripts, currentDepth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -542,6 +563,31 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
return false;
|
||||
}
|
||||
|
||||
#if !NET
|
||||
/// <summary>
|
||||
/// Best-effort directory enumeration for target frameworks without
|
||||
/// <c>EnumerationOptions.IgnoreInaccessible</c> support. Returns an empty
|
||||
/// array when the caller lacks permission to read the directory contents,
|
||||
/// so a single inaccessible child does not abort the entire skill scan.
|
||||
/// </summary>
|
||||
private string[] SafeEnumerateDirectories(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.GetDirectories(path);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogDirectoryAccessDenied(this._logger, SanitizePathForLog(path));
|
||||
}
|
||||
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private static string ParseYamlScalarValue(string yamlContent, Match kvMatch)
|
||||
{
|
||||
string value = kvMatch.Groups[3].Value;
|
||||
@@ -664,46 +710,6 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ValidateAndNormalizeDirectoryNames(IEnumerable<string> directories, ILogger logger)
|
||||
{
|
||||
foreach (string directory in directories)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
throw new ArgumentException("Directory names must not be null or whitespace.", nameof(directories));
|
||||
}
|
||||
|
||||
// "." is valid — it means the skill root directory.
|
||||
if (string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal))
|
||||
{
|
||||
yield return directory;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reject absolute paths and any path segments that escape upward.
|
||||
if (Path.IsPathRooted(directory) || ContainsParentTraversalSegment(directory))
|
||||
{
|
||||
LogDirectoryNameSkippedInvalid(logger, directory);
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return NormalizePath(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ContainsParentTraversalSegment(string directory)
|
||||
{
|
||||
foreach (string segment in directory.Split('/', '\\'))
|
||||
{
|
||||
if (segment == "..")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")]
|
||||
private static partial void LogSkillsDiscovered(ILogger logger, int count);
|
||||
|
||||
@@ -743,6 +749,6 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
[LoggerMessage(LogLevel.Warning, "Skipping script directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")]
|
||||
private static partial void LogScriptSymlinkDirectory(ILogger logger, string skillName, string directoryName);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Skipping invalid directory name '{DirectoryName}': must be a relative path with no '..' segments")]
|
||||
private static partial void LogDirectoryNameSkippedInvalid(ILogger logger, string directoryName);
|
||||
[LoggerMessage(LogLevel.Warning, "Skipping directory '{DirectoryPath}': access denied")]
|
||||
private static partial void LogDirectoryAccessDenied(ILogger logger, string directoryPath);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
@@ -32,28 +33,31 @@ public sealed class AgentFileSkillsSourceOptions
|
||||
public IEnumerable<string>? AllowedScriptExtensions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets relative directory paths to scan for script files within each skill directory.
|
||||
/// Values may be single-segment names (e.g., <c>"scripts"</c>) or multi-segment relative
|
||||
/// paths (e.g., <c>"sub/scripts"</c>). Use <c>"."</c> to include files directly at the
|
||||
/// skill root. Leading <c>"./"</c> prefixes, trailing separators, and backslashes are
|
||||
/// normalized automatically; paths containing <c>".."</c> segments or absolute paths are
|
||||
/// rejected.
|
||||
/// When <see langword="null"/>, defaults to <c>scripts</c> (per the
|
||||
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>).
|
||||
/// When set, replaces the defaults entirely.
|
||||
/// Gets or sets the maximum depth to search for script and resource files within each skill directory.
|
||||
/// A value of <c>1</c> searches only the skill root directory. A value of <c>2</c> searches the root
|
||||
/// and one level of subdirectories.
|
||||
/// When <see langword="null"/>, the source uses the default depth of <c>2</c>.
|
||||
/// </summary>
|
||||
public IEnumerable<string>? ScriptDirectories { get; set; }
|
||||
/// <remarks>
|
||||
/// Must be greater than or equal to <c>1</c>; lower values are rejected by the constructor.
|
||||
/// </remarks>
|
||||
public int? SearchDepth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets relative directory paths to scan for resource files within each skill directory.
|
||||
/// Values may be single-segment names (e.g., <c>"references"</c>) or multi-segment relative
|
||||
/// paths (e.g., <c>"sub/resources"</c>). Use <c>"."</c> to include files directly at the
|
||||
/// skill root. Leading <c>"./"</c> prefixes, trailing separators, and backslashes are
|
||||
/// normalized automatically; paths containing <c>".."</c> segments or absolute paths are
|
||||
/// rejected.
|
||||
/// When <see langword="null"/>, defaults to <c>references</c> and <c>assets</c> (per the
|
||||
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>).
|
||||
/// When set, replaces the defaults entirely.
|
||||
/// Gets or sets a predicate that filters discovered script files.
|
||||
/// The predicate receives an <see cref="AgentFileSkillFilterContext"/> containing the skill's name
|
||||
/// and the file's path relative to the skill directory.
|
||||
/// Return <see langword="true"/> to include the file or <see langword="false"/> to exclude it.
|
||||
/// When <see langword="null"/>, all scripts matching the allowed extensions are included.
|
||||
/// </summary>
|
||||
public IEnumerable<string>? ResourceDirectories { get; set; }
|
||||
public Func<AgentFileSkillFilterContext, bool>? ScriptFilter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a predicate that filters discovered resource files.
|
||||
/// The predicate receives an <see cref="AgentFileSkillFilterContext"/> containing the skill's name
|
||||
/// and the file's path relative to the skill directory.
|
||||
/// Return <see langword="true"/> to include the file or <see langword="false"/> to exclude it.
|
||||
/// When <see langword="null"/>, all resources matching the allowed extensions are included.
|
||||
/// </summary>
|
||||
public Func<AgentFileSkillFilterContext, bool>? ResourceFilter { get; set; }
|
||||
}
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ public sealed class SessionPersistenceTests : IAsyncDisposable
|
||||
|
||||
// Register agent using hosting DI pattern with InMemorySessionStore
|
||||
builder.Services.AddAIAgent("session-test-agent", (_, name) => new FakeSessionAgent(name))
|
||||
.WithInMemorySessionStore();
|
||||
.WithInMemorySessionStore(withIsolation: false);
|
||||
|
||||
this._app = builder.Build();
|
||||
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
{
|
||||
private const string TestUserId = "test-user-id";
|
||||
private const string CustomClaimType = "custom-claim-type";
|
||||
private const string CustomClaimValue = "custom-claim-value";
|
||||
|
||||
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProviderTests"/> class.
|
||||
/// </summary>
|
||||
public ClaimsIdentitySessionIsolationKeyProviderTests()
|
||||
{
|
||||
this._httpContextAccessorMock = new Mock<IHttpContextAccessor>();
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor uses default options when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UsesDefaultOptionsWhenNull()
|
||||
{
|
||||
// Act & Assert - should not throw
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object, options: null);
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor accepts null IHttpContextAccessor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithNullHttpContextAccessor_DoesNotThrow()
|
||||
{
|
||||
// Act & Assert - should not throw
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = null! }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotEmpty()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = string.Empty }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is whitespace.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotWhitespace()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = " " }));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSessionIsolationKeyAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync extracts the claim value from the default claim type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncExtractsDefaultClaimTypeAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TestUserId, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync uses custom claim type when specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncUsesCustomClaimTypeAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(CustomClaimType, CustomClaimValue);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = CustomClaimType });
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(CustomClaimValue, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns null when the specified claim is missing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenClaimMissingAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim("other-claim", "value");
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify behavior when HttpContextAccessor returns null HttpContext.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext?)null);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify behavior when HttpContextAccessor itself is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextAccessorNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns the first matching claim when multiple exist.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsFirstMatchingClaimAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string FirstValue = "first-value";
|
||||
const string SecondValue = "second-value";
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, FirstValue),
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, SecondValue),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
User = principal
|
||||
};
|
||||
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(FirstValue, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync handles empty claim values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncHandlesEmptyClaimValueAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, string.Empty);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupHttpContextWithClaim(string claimType, string claimValue)
|
||||
{
|
||||
var claims = new[] { new Claim(claimType, claimValue) };
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
User = principal
|
||||
};
|
||||
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="DelegatingAgentSessionStore"/> class.
|
||||
/// </summary>
|
||||
public class DelegatingAgentSessionStoreTests
|
||||
{
|
||||
private readonly Mock<AgentSessionStore> _innerStoreMock;
|
||||
private readonly Mock<AIAgent> _agentMock;
|
||||
private readonly TestDelegatingAgentSessionStore _delegatingStore;
|
||||
private readonly AgentSession _testSession;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DelegatingAgentSessionStoreTests"/> class.
|
||||
/// </summary>
|
||||
public DelegatingAgentSessionStoreTests()
|
||||
{
|
||||
this._innerStoreMock = new Mock<AgentSessionStore>();
|
||||
this._agentMock = new Mock<AIAgent>();
|
||||
this._testSession = new TestAgentSession();
|
||||
|
||||
// Setup inner store mock
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
this._delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentNullException when innerStore is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresInnerStore() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("innerStore", () => new TestDelegatingAgentSessionStore(null!));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor sets the inner store correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithValidInnerStore_SetsInnerStore()
|
||||
{
|
||||
// Act
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._innerStoreMock.Object, delegatingStore.InnerStore);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Method Delegation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync delegates to inner store with correct parameters.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncDelegatesToInnerStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedCancellationToken = new CancellationToken();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(
|
||||
It.Is<AIAgent>(a => a == this._agentMock.Object),
|
||||
It.Is<string>(c => c == ExpectedConversationId),
|
||||
It.Is<CancellationToken>(ct => ct == expectedCancellationToken)))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
// Act
|
||||
var session = await this._delegatingStore.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedCancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._testSession, session);
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync delegates to inner store with correct parameters.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncDelegatesToInnerStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedCancellationToken = new CancellationToken();
|
||||
var expectedSession = new TestAgentSession();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(
|
||||
It.Is<AIAgent>(a => a == this._agentMock.Object),
|
||||
It.Is<string>(c => c == ExpectedConversationId),
|
||||
It.Is<AgentSession>(s => s == expectedSession),
|
||||
It.Is<CancellationToken>(ct => ct == expectedCancellationToken)))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
// Act
|
||||
await this._delegatingStore.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedSession,
|
||||
expectedCancellationToken);
|
||||
|
||||
// Assert
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedSession,
|
||||
expectedCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync awaits the inner store's result before returning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncAwaitsInnerStoreResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var taskCompletionSource = new TaskCompletionSource<AgentSession>();
|
||||
|
||||
var innerStoreMock = new Mock<AgentSessionStore>();
|
||||
innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<AgentSession>(taskCompletionSource.Task));
|
||||
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object);
|
||||
|
||||
// Act
|
||||
var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, ExpectedConversationId);
|
||||
|
||||
// Assert
|
||||
Assert.False(resultTask.IsCompleted);
|
||||
taskCompletionSource.SetResult(this._testSession);
|
||||
Assert.True(resultTask.IsCompleted);
|
||||
Assert.Same(this._testSession, await resultTask);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync awaits the inner store's completion before returning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedSession = new TestAgentSession();
|
||||
var taskCompletionSource = new TaskCompletionSource();
|
||||
|
||||
var innerStoreMock = new Mock<AgentSessionStore>();
|
||||
innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask(taskCompletionSource.Task));
|
||||
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object);
|
||||
|
||||
// Act
|
||||
var resultTask = delegatingStore.SaveSessionAsync(this._agentMock.Object, ExpectedConversationId, expectedSession);
|
||||
|
||||
// Assert
|
||||
Assert.False(resultTask.IsCompleted);
|
||||
taskCompletionSource.SetResult();
|
||||
Assert.True(resultTask.IsCompleted);
|
||||
await resultTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetService Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting the exact type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForExactType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting a base type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForBaseType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(DelegatingAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting AgentSessionStore.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForAgentSessionStoreType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(AgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains to inner store when type is not satisfied by outer store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceChainsToInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
|
||||
|
||||
// Act
|
||||
var result = delegatingStore.GetService(typeof(ConcreteAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(innerStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains through multiple delegation layers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceChainsThoughMultipleDelegationLayers()
|
||||
{
|
||||
// Arrange - create a three-layer chain: outer -> middle -> inner
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var middleStore = new AnotherDelegatingAgentSessionStore(innerStore);
|
||||
var outerStore = new TestDelegatingAgentSessionStore(middleStore);
|
||||
|
||||
// Act - request the innermost store type
|
||||
var result = outerStore.GetService(typeof(ConcreteAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(innerStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService can find a store in the middle of the delegation chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceFindsMiddleStoreInChain()
|
||||
{
|
||||
// Arrange - create a three-layer chain: outer -> middle -> inner
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var middleStore = new AnotherDelegatingAgentSessionStore(innerStore);
|
||||
var outerStore = new TestDelegatingAgentSessionStore(middleStore);
|
||||
|
||||
// Act - request the middle store type
|
||||
var result = outerStore.GetService(typeof(AnotherDelegatingAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(middleStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when the requested type is not found in the chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsNullWhenTypeNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
|
||||
|
||||
// Act
|
||||
var result = delegatingStore.GetService(typeof(string));
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when a service key is provided but not matched.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsNullWhenServiceKeyProvided()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore), "some-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService throws ArgumentNullException when serviceType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceThrowsWhenServiceTypeIsNull() =>
|
||||
Assert.Throws<ArgumentNullException>("serviceType", () => this._delegatingStore.GetService(null!));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericReturnsItself()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService<TestDelegatingAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method chains to inner store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericChainsToInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
|
||||
|
||||
// Act
|
||||
var result = delegatingStore.GetService<ConcreteAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(innerStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method returns null when type not found.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericReturnsNullWhenTypeNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService<string>();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Implementation
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of DelegatingAgentSessionStore for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore)
|
||||
{
|
||||
public new AgentSessionStore InnerStore => base.InnerStore;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Another delegating store implementation for testing multi-layer chains.
|
||||
/// </summary>
|
||||
private sealed class AnotherDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore);
|
||||
|
||||
/// <summary>
|
||||
/// Concrete (non-delegating) session store for testing GetService chaining.
|
||||
/// </summary>
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> new(new TestAgentSession());
|
||||
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
|
||||
#endregion
|
||||
}
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="IsolationKeyScopedAgentSessionStore"/>.
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStoreTests
|
||||
{
|
||||
private const string TestIsolationKey = "test-key";
|
||||
private const string TestConversationId = "test-conversation-id";
|
||||
|
||||
private readonly Mock<AgentSessionStore> _innerStoreMock;
|
||||
private readonly Mock<AIAgent> _agentMock;
|
||||
private readonly AgentSession _testSession;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStoreTests"/> class.
|
||||
/// </summary>
|
||||
public IsolationKeyScopedAgentSessionStoreTests()
|
||||
{
|
||||
this._innerStoreMock = new Mock<AgentSessionStore>();
|
||||
this._agentMock = new Mock<AIAgent>();
|
||||
this._testSession = new TestAgentSession();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentNullException when innerStore is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("innerStore", () =>
|
||||
new IsolationKeyScopedAgentSessionStore(null!, provider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor uses default options when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UsesDefaultOptionsWhenNull()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
|
||||
// Act & Assert - should not throw
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider, options: null);
|
||||
Assert.NotNull(store);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSessionAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync scopes the conversation ID with the isolation key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncScopesConversationIdWithKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"{TestIsolationKey}::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync throws InvalidOperationException when key is null in strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = true });
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId));
|
||||
|
||||
Assert.Contains("Session isolation key is required", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync does not throw when key is null in non-strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = false });
|
||||
|
||||
// Act - should not throw
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - conversation ID should be passed through unmodified
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
TestConversationId,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync returns the session from the inner store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncReturnsSessionFromInnerStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
var result = await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._testSession, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SaveSessionAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync scopes the conversation ID with the isolation key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncScopesConversationIdWithKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
var sessionToSave = new TestAgentSession();
|
||||
|
||||
// Act
|
||||
await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave);
|
||||
|
||||
// Assert
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"{TestIsolationKey}::{TestConversationId}",
|
||||
sessionToSave,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync throws InvalidOperationException when key is null in strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = true });
|
||||
var sessionToSave = new TestAgentSession();
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave));
|
||||
|
||||
Assert.Contains("Session isolation key is required", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync does not throw when key is null in non-strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = false });
|
||||
var sessionToSave = new TestAgentSession();
|
||||
|
||||
// Act - should not throw
|
||||
await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave);
|
||||
|
||||
// Assert - conversation ID should be passed through unmodified
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
TestConversationId,
|
||||
sessionToSave,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Escaping Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that colons in the isolation key are escaped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EscapesColonsInIsolationKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string KeyWithColon = "key:with:colons";
|
||||
var provider = new TestSessionIsolationKeyProvider(KeyWithColon);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - colons should be escaped as \:
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"key\\:with\\:colons::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that backslashes in the isolation key are escaped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EscapesBackslashesInIsolationKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string KeyWithBackslash = @"domain\key";
|
||||
var provider = new TestSessionIsolationKeyProvider(KeyWithBackslash);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - backslashes should be escaped as \\
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"domain\\\\key::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that both backslashes and colons in the isolation key are escaped correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EscapesBothBackslashesAndColonsInIsolationKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string KeyWithBoth = @"domain\key:role";
|
||||
var provider = new TestSessionIsolationKeyProvider(KeyWithBoth);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - backslashes escaped first, then colons
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"domain\\\\key\\:role::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Isolation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that different isolation keys result in different scoped conversation IDs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DifferentKeysResultInDifferentScopedConversationIdsAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string Key1 = "key-1";
|
||||
const string Key2 = "key-2";
|
||||
string? capturedConversationId1 = null;
|
||||
string? capturedConversationId2 = null;
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<AIAgent, string, CancellationToken>((_, conversationId, _) =>
|
||||
{
|
||||
if (capturedConversationId1 == null)
|
||||
{
|
||||
capturedConversationId1 = conversationId;
|
||||
}
|
||||
else
|
||||
{
|
||||
capturedConversationId2 = conversationId;
|
||||
}
|
||||
})
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
// Act - Key 1
|
||||
var provider1 = new TestSessionIsolationKeyProvider(Key1);
|
||||
var store1 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider1);
|
||||
await store1.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Act - Key 2
|
||||
var provider2 = new TestSessionIsolationKeyProvider(Key2);
|
||||
var store2 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider2);
|
||||
await store2.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal($"{Key1}::{TestConversationId}", capturedConversationId1);
|
||||
Assert.Equal($"{Key2}::{TestConversationId}", capturedConversationId2);
|
||||
Assert.NotEqual(capturedConversationId1, capturedConversationId2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetService Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService can retrieve IsolationKeyScopedAgentSessionStore from a delegation chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsIsolationKeyScopedAgentSessionStore()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
var result = store.GetService<IsolationKeyScopedAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(store, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains through to find inner store types.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceChainsToInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var concreteInnerStore = new ConcreteAgentSessionStore();
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(concreteInnerStore, provider);
|
||||
|
||||
// Act
|
||||
var result = store.GetService<ConcreteAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(concreteInnerStore, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Classes
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly string? _key;
|
||||
|
||||
public TestSessionIsolationKeyProvider(string? key)
|
||||
{
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask<string?>(this._key);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete (non-delegating) session store for testing GetService chaining.
|
||||
/// </summary>
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> new(new TestAgentSession());
|
||||
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+1
@@ -6,6 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SessionIsolationKeyProvider"/> and its contract.
|
||||
/// </summary>
|
||||
public class SessionIsolationKeyProviderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that a concrete provider can return a non-null isolation key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNonNullKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedKey = "test-key";
|
||||
var provider = new TestSessionIsolationKeyProvider(ExpectedKey);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ExpectedKey, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that a concrete provider can return null when no key is available.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenNoKeyAvailableAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that cancellation token is passed through to the provider implementation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncPassesCancellationTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestCancellableSessionIsolationKeyProvider();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<TaskCanceledException>(
|
||||
async () => await provider.GetSessionIsolationKeyAsync(cts.Token));
|
||||
}
|
||||
|
||||
#region Test Implementations
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly string? _key;
|
||||
|
||||
public TestSessionIsolationKeyProvider(string? key)
|
||||
{
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask<string?>(this._key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation that respects cancellation tokens.
|
||||
/// </summary>
|
||||
private sealed class TestCancellableSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
public override async ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
return "key";
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+20
-31
@@ -111,10 +111,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreNotDiscoveredAsync()
|
||||
public async Task GetSkillsAsync_ScriptsInRootAndSubdirectories_AreDiscoveredByDefaultAsync()
|
||||
{
|
||||
// Arrange — scripts outside configured directories are not discovered; only files directly
|
||||
// inside the configured directory are picked up (no subdirectory recursion)
|
||||
// Arrange — with default depth=2, scripts in root and immediate subdirectories are discovered
|
||||
string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body.");
|
||||
CreateFile(skillDir, "convert.py", "print('root')");
|
||||
CreateFile(skillDir, "tools/helper.sh", "echo 'helper'");
|
||||
@@ -123,9 +122,10 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert — neither file is in the default scripts/ directory, so no scripts are discovered
|
||||
// Assert — both root and subdirectory scripts are discovered
|
||||
Assert.Single(skills);
|
||||
Assert.Null(await skills[0].GetScriptAsync("convert.py"));
|
||||
Assert.NotNull(await skills[0].GetScriptAsync("convert.py"));
|
||||
Assert.NotNull(await skills[0].GetScriptAsync("tools/helper.sh"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -225,13 +225,13 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ScriptDirectoriesWithNestedPath_DiscoversScriptsAsync()
|
||||
public async Task GetSkillsAsync_DeepScript_DiscoveredWithHigherDepthAsync()
|
||||
{
|
||||
// Arrange — ScriptDirectories configured with a multi-segment relative path (f1/f2/f3)
|
||||
// Arrange — script at depth 4 (f1/f2/f3/run.py) discovered with SearchDepth=5
|
||||
string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script directory", "Body.");
|
||||
CreateFile(skillDir, "f1/f2/f3/run.py", "print('nested')");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ScriptDirectories = ["f1/f2/f3"] });
|
||||
new AgentFileSkillsSourceOptions { SearchDepth = 5 });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
@@ -243,36 +243,25 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
Assert.Equal("f1/f2/f3/run.py", nestedScript!.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("./scripts")]
|
||||
[InlineData("./scripts/f1")]
|
||||
[InlineData("./scripts/f1", "./f2")]
|
||||
public async Task GetSkillsAsync_ScriptDirectoryWithDotSlashPrefix_DiscoversScriptsAsync(params string[] directories)
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ScriptFilter_ExcludesFilteredScriptsAsync()
|
||||
{
|
||||
// Arrange — "./"-prefixed directories are equivalent to their counterparts without the prefix;
|
||||
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
|
||||
string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Dot-slash prefix", "Body.");
|
||||
foreach (string directory in directories)
|
||||
{
|
||||
string directoryWithoutDotSlash = directory.Substring(2); // strip "./"
|
||||
CreateFile(skillDir, $"{directoryWithoutDotSlash}/run.py", "print('dotslash')");
|
||||
}
|
||||
|
||||
// Arrange — ScriptFilter excludes scripts in the "f2" subdirectory
|
||||
string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Filter test", "Body.");
|
||||
CreateFile(skillDir, "scripts/run.py", "print('scripts')");
|
||||
CreateFile(skillDir, "f2/run.py", "print('f2')");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ScriptDirectories = directories });
|
||||
new AgentFileSkillsSourceOptions { ScriptFilter = ctx => !ctx.RelativeFilePath.StartsWith("f2/", StringComparison.OrdinalIgnoreCase) });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert — scripts are discovered with names identical to using directories without "./"
|
||||
// Assert — only scripts/ script is included; f2/ is excluded by filter
|
||||
Assert.Single(skills);
|
||||
foreach (string directory in directories)
|
||||
{
|
||||
string expectedName = $"{directory.Substring(2)}/run.py";
|
||||
var script = await skills[0].GetScriptAsync(expectedName);
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal(expectedName, script!.Name);
|
||||
}
|
||||
var script = await skills[0].GetScriptAsync("scripts/run.py");
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("scripts/run.py", script!.Name);
|
||||
Assert.Null(await skills[0].GetScriptAsync("f2/run.py"));
|
||||
}
|
||||
|
||||
private static string CreateSkillDir(string root, string name, string description, string body)
|
||||
|
||||
+94
-130
@@ -425,9 +425,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceInSkillRoot_NotDiscoveredByDefaultAsync()
|
||||
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredByDefaultAsync()
|
||||
{
|
||||
// Arrange — resource files directly in the skill directory (not in a spec subdirectory)
|
||||
// Arrange — resource files directly in the skill directory are discovered with default depth=2
|
||||
string skillDir = Path.Combine(this._testRoot, "root-resource-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
|
||||
@@ -440,29 +440,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — root-level files are NOT discovered unless "." is in ResourceDirectories
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].GetTestResources()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync()
|
||||
{
|
||||
// Arrange — "." in ResourceDirectories opts into root-level resource discovery
|
||||
string skillDir = Path.Combine(this._testRoot, "root-opt-in-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
|
||||
File.WriteAllText(Path.Combine(skillDir, "config.json"), "{}");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: root-opt-in-skill\ndescription: Root opt-in\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "assets", "."] });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — both root-level resource files (and SKILL.md excluded) should be discovered
|
||||
// Assert — root-level files are discovered by default (depth=2 includes root)
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Equal(2, skill.GetTestResources()!.Count);
|
||||
@@ -471,9 +449,22 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceInNonSpecDirectory_NotDiscoveredByDefaultAsync()
|
||||
public void Constructor_SearchDepthBelowOne_Throws()
|
||||
{
|
||||
// Arrange — resource in a non-spec directory (neither references/ nor assets/)
|
||||
// Arrange / Act / Assert — SearchDepth must be >= 1
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { SearchDepth = 0 }));
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { SearchDepth = -1 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceInSubdirectory_DiscoveredByDefaultAsync()
|
||||
{
|
||||
// Arrange — resource in any subdirectory is discovered with default depth=2
|
||||
string skillDir = Path.Combine(this._testRoot, "non-spec-skill");
|
||||
string customDir = Path.Combine(skillDir, "docs");
|
||||
Directory.CreateDirectory(customDir);
|
||||
@@ -486,15 +477,16 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — non-spec directories are not scanned by default
|
||||
// Assert — subdirectory files are discovered by default
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].GetTestResources()!);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal("docs/readme.md", skills[0].GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_CustomResourceDirectories_ReplacesDefaultsAsync()
|
||||
public async Task GetSkillsAsync_ResourceFilter_ExcludesFilteredFilesAsync()
|
||||
{
|
||||
// Arrange — custom ResourceDirectories replaces the spec defaults
|
||||
// Arrange — ResourceFilter excludes files in the "docs" subdirectory
|
||||
string skillDir = Path.Combine(this._testRoot, "custom-directory-skill");
|
||||
string customDir = Path.Combine(skillDir, "docs");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
@@ -506,16 +498,16 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: custom-directory-skill\ndescription: Custom directory\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["docs"] });
|
||||
new AgentFileSkillsSourceOptions { ResourceFilter = ctx => !ctx.RelativeFilePath.StartsWith("docs/", StringComparison.OrdinalIgnoreCase) });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — only docs/ is scanned; references/ is NOT scanned
|
||||
// Assert — only references/ resource is included; docs/ is excluded by filter
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("docs/readme.md", skill.GetTestResources()![0].Name);
|
||||
Assert.Equal("references/ref.md", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -755,9 +747,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomDirectoryAsync()
|
||||
public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsSymlinkedDirectoryAsync()
|
||||
{
|
||||
// Arrange — custom resource directory "sub/resources" where "sub" is a symlink.
|
||||
// Arrange — "sub" directory is a symlink pointing outside the skill directory.
|
||||
// The directory-level HasSymlinkInPath check should detect the intermediate symlink.
|
||||
string skillDir = Path.Combine(this._testRoot, "symlink-intermediate");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
@@ -783,7 +775,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
var source = new AgentFileSkillsSource(
|
||||
this._testRoot,
|
||||
s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["sub/resources"] });
|
||||
new AgentFileSkillsSourceOptions { SearchDepth = 4 });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
@@ -957,54 +949,32 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
Assert.Null(fm.Metadata);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("..")]
|
||||
[InlineData("../escape")]
|
||||
[InlineData("sub/../escape")]
|
||||
[InlineData("/absolute")]
|
||||
[InlineData("\\absolute")]
|
||||
public void Constructor_InvalidDirectoryName_SkipsInvalidDirectories(string badDirectory)
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_SearchDepthOne_OnlyRootFilesDiscoveredAsync()
|
||||
{
|
||||
// Arrange & Act — invalid directories are skipped with a warning rather than throwing
|
||||
var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory] });
|
||||
var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory] });
|
||||
// Arrange — with SearchDepth = 1, only root-level files are discovered
|
||||
string skillDir = Path.Combine(this._testRoot, "depth-one-skill");
|
||||
string scriptsDir = Path.Combine(skillDir, "scripts");
|
||||
Directory.CreateDirectory(scriptsDir);
|
||||
File.WriteAllText(Path.Combine(scriptsDir, "run.py"), "print('hello')");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: depth-one-skill\ndescription: Depth one\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { SearchDepth = 1 });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(source1);
|
||||
Assert.NotNull(source2);
|
||||
}
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Constructor_NullOrWhitespaceDirectoryName_ThrowsArgumentException(string? badDirectory)
|
||||
{
|
||||
// Arrange & Act & Assert — null/whitespace is a contract violation, not a config error
|
||||
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory!] }));
|
||||
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory!] }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("scripts")]
|
||||
[InlineData("my-scripts")]
|
||||
[InlineData("sub/directory")]
|
||||
[InlineData(".")]
|
||||
[InlineData("./scripts")]
|
||||
[InlineData("./scripts/f1")]
|
||||
[InlineData("my..scripts")]
|
||||
public void Constructor_ValidDirectoryName_DoesNotThrow(string validDirectory)
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [validDirectory] });
|
||||
Assert.NotNull(source);
|
||||
// Assert — scripts in subdirectories are NOT discovered at depth 1
|
||||
Assert.Single(skills);
|
||||
Assert.Null(await skills[0].GetScriptAsync("scripts/run.py"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_DuplicateDirectoriesAfterNormalization_NoDuplicateResourcesAsync()
|
||||
public async Task GetSkillsAsync_ResourceInSubdirectory_DiscoveredWithDefaultDepthAsync()
|
||||
{
|
||||
// Arrange — "references" and "./references" refer to the same directory;
|
||||
// after normalization they should be deduplicated so resources appear only once.
|
||||
// Arrange — resources in a subdirectory are discovered by default (depth=2)
|
||||
string skillDir = Path.Combine(this._testRoot, "dedup-directory-skill");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
@@ -1012,45 +982,21 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: dedup-directory-skill\ndescription: Dedup test\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "./references"] });
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — only one copy of the resource despite two equivalent directory entries
|
||||
// Assert — resource is discovered once
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal("references/FAQ.md", skills[0].GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_TrailingSlashDirectoryNormalized_NoDuplicateResourcesAsync()
|
||||
public async Task GetSkillsAsync_ScriptInSubdirectory_DiscoveredWithDefaultDepthAsync()
|
||||
{
|
||||
// Arrange — "references/" should be normalized to "references"
|
||||
string skillDir = Path.Combine(this._testRoot, "trailing-slash-skill");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: trailing-slash-skill\ndescription: Trailing slash test\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "references/"] });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — trailing slash variant deduplicated
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal("references/data.json", skills[0].GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_BackslashDirectoryNormalized_NoDuplicateScriptsAsync()
|
||||
{
|
||||
// Arrange — ".\\scripts" should be normalized to "scripts"
|
||||
// Arrange — scripts in a subdirectory are discovered by default (depth=2)
|
||||
string skillDir = Path.Combine(this._testRoot, "backslash-skill");
|
||||
string scriptsDir = Path.Combine(skillDir, "scripts");
|
||||
Directory.CreateDirectory(scriptsDir);
|
||||
@@ -1058,50 +1004,48 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: backslash-skill\ndescription: Backslash test\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ScriptDirectories = ["scripts", ".\\scripts"] });
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — backslash variant deduplicated
|
||||
// Assert — script is discovered
|
||||
Assert.Single(skills);
|
||||
var script = await skills[0].GetScriptAsync("scripts/run.py");
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("scripts/run.py", script!.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("./references")]
|
||||
[InlineData("./assets/docs")]
|
||||
public async Task GetSkillsAsync_ResourceDirectoryWithDotSlashPrefix_DiscoversResourcesAsync(string directory)
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceFilterWhitelist_OnlyMatchingFilesDiscoveredAsync()
|
||||
{
|
||||
// Arrange — "./references" and "./assets/docs" are equivalent to "references" and "assets/docs";
|
||||
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
|
||||
string directoryWithoutDotSlash = directory.Substring(2); // strip "./"
|
||||
// Arrange — ResourceFilter acts as whitelist: only references/ paths included
|
||||
string skillDir = Path.Combine(this._testRoot, "dotslash-res-skill");
|
||||
string targetDir = Path.Combine(skillDir, directoryWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar));
|
||||
Directory.CreateDirectory(targetDir);
|
||||
File.WriteAllText(Path.Combine(targetDir, "data.json"), "{}");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
string assetsDir = Path.Combine(skillDir, "assets");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
Directory.CreateDirectory(assetsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
|
||||
File.WriteAllText(Path.Combine(assetsDir, "image.txt"), "data");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: dotslash-res-skill\ndescription: Dot-slash prefix\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceDirectories = [directory] });
|
||||
new AgentFileSkillsSourceOptions { ResourceFilter = ctx => ctx.RelativeFilePath.StartsWith("references/", StringComparison.OrdinalIgnoreCase) });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — the resource is discovered with a name identical to using the directory without "./"
|
||||
// Assert — only the references/ resource is included
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].GetTestResources()![0].Name);
|
||||
Assert.Equal("references/data.json", skills[0].GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceDirectoriesWithNestedPath_DiscoversResourcesAsync()
|
||||
public async Task GetSkillsAsync_DeepResource_NotDiscoveredWithDefaultDepthAsync()
|
||||
{
|
||||
// Arrange — ResourceDirectories configured with a multi-segment relative path (f1/f2/f3)
|
||||
// Arrange — resource at depth 3 (f1/f2/f3/data.json) exceeds default depth=2
|
||||
string skillDir = Path.Combine(this._testRoot, "nested-directory-skill");
|
||||
string nestedDir = Path.Combine(skillDir, "f1", "f2", "f3");
|
||||
Directory.CreateDirectory(nestedDir);
|
||||
@@ -1109,8 +1053,29 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: nested-directory-skill\ndescription: Nested directory\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — resource at depth 4 is NOT discovered with default depth=2
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].GetTestResources()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_DeepResource_DiscoveredWithHigherDepthAsync()
|
||||
{
|
||||
// Arrange — resource at depth 4 (f1/f2/f3/data.json) discovered with SearchDepth=5
|
||||
string skillDir = Path.Combine(this._testRoot, "deep-res-skill");
|
||||
string nestedDir = Path.Combine(skillDir, "f1", "f2", "f3");
|
||||
Directory.CreateDirectory(nestedDir);
|
||||
File.WriteAllText(Path.Combine(nestedDir, "data.json"), "{}");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: deep-res-skill\ndescription: Deep resource\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["f1/f2/f3"] });
|
||||
new AgentFileSkillsSourceOptions { SearchDepth = 5 });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
@@ -1171,22 +1136,21 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync()
|
||||
public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredByDefaultAsync()
|
||||
{
|
||||
// Arrange — script file directly in the skill directory with ScriptDirectories = ["."]
|
||||
// Arrange — script file directly in the skill directory is discovered with default depth=2
|
||||
string skillDir = Path.Combine(this._testRoot, "root-script-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(Path.Combine(skillDir, "run.py"), "print('hello')");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: root-script-skill\ndescription: Root script\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ScriptDirectories = ["."] });
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — script at the skill root should be discovered
|
||||
// Assert — script at the skill root is discovered by default
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "root-script-skill");
|
||||
Assert.NotNull(skill);
|
||||
var script = await skill.GetScriptAsync("run.py");
|
||||
|
||||
@@ -1157,6 +1157,299 @@ public class HandoffOrchestrationTests
|
||||
}
|
||||
}
|
||||
|
||||
#region Default Handoffs Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_DefaultHandoffs_AllAgentsCanHandOffToAllOthersAsync()
|
||||
{
|
||||
// Verifies "default handoffs": when no explicit WithHandoff calls are made,
|
||||
// every registered participant is wired to every other participant.
|
||||
|
||||
var agentA = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
// Expect tools to include handoffs for B and C (every other agent).
|
||||
var transferTools = options?.Tools?.Where(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal)).ToList();
|
||||
Assert.NotNull(transferTools);
|
||||
Assert.Equal(2, transferTools!.Count);
|
||||
|
||||
// Pick the first one to hand off (it should route to either B or C).
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferTools[0].Name)]));
|
||||
}), name: "agentA", description: "agent A");
|
||||
|
||||
var agentB = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
new(new ChatMessage(ChatRole.Assistant, "B responded"))),
|
||||
name: "agentB", description: "agent B");
|
||||
|
||||
var agentC = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
new(new ChatMessage(ChatRole.Assistant, "C responded"))),
|
||||
name: "agentC", description: "agent C");
|
||||
|
||||
var workflow =
|
||||
AgentWorkflowBuilder.CreateHandoffBuilderWith(agentA)
|
||||
.AddParticipants(agentB, agentC)
|
||||
.Build();
|
||||
|
||||
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hi")]);
|
||||
|
||||
// The first response handed off — verify the second agent responded.
|
||||
Assert.NotNull(result);
|
||||
Assert.True(updateText is "B responded" or "C responded",
|
||||
$"Expected B or C to respond, got '{updateText}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_DefaultHandoffs_OnlyAppliesWhenNoExplicitHandoffsAsync()
|
||||
{
|
||||
// When explicit handoffs are defined, default handoffs do NOT activate — only the explicit edges apply.
|
||||
|
||||
var agentA = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
// Should only see one handoff tool (to B), not B and C.
|
||||
var transferTools = options?.Tools?.Where(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal)).ToList();
|
||||
Assert.NotNull(transferTools);
|
||||
Assert.Single(transferTools!);
|
||||
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferTools![0].Name)]));
|
||||
}), name: "agentA", description: "agent A");
|
||||
|
||||
var agentB = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
new(new ChatMessage(ChatRole.Assistant, "B responded"))),
|
||||
name: "agentB", description: "agent B");
|
||||
|
||||
// Only define an explicit A->B edge. Default mesh must not activate.
|
||||
var workflow =
|
||||
AgentWorkflowBuilder.CreateHandoffBuilderWith(agentA)
|
||||
.WithHandoff(agentA, agentB)
|
||||
.Build();
|
||||
|
||||
(string updateText, _, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hi")]);
|
||||
|
||||
Assert.Equal("B responded", updateText);
|
||||
}
|
||||
|
||||
#endregion Default Handoffs Tests
|
||||
|
||||
#region Autonomous Mode Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_AutonomousMode_IteratesUntilHandoffAsync()
|
||||
{
|
||||
// With autonomous mode enabled, an agent that does not handoff is invoked again with the
|
||||
// continuation prompt until it eventually invokes a handoff (or hits the turn limit).
|
||||
|
||||
int agentACallCount = 0;
|
||||
const int TargetIterations = 3;
|
||||
|
||||
var agentA = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
agentACallCount++;
|
||||
|
||||
if (agentACallCount < TargetIterations)
|
||||
{
|
||||
// Respond with text only (no handoff) — should trigger autonomous continuation.
|
||||
return new(new ChatMessage(ChatRole.Assistant, $"iteration {agentACallCount}"));
|
||||
}
|
||||
|
||||
// After TargetIterations calls, hand off.
|
||||
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
|
||||
Assert.NotNull(transferFuncName);
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
|
||||
}), name: "agentA");
|
||||
|
||||
var agentB = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
new(new ChatMessage(ChatRole.Assistant, "B final"))),
|
||||
name: "agentB", description: "agent B");
|
||||
|
||||
var workflow =
|
||||
AgentWorkflowBuilder.CreateHandoffBuilderWith(agentA)
|
||||
.WithHandoff(agentA, agentB)
|
||||
.WithAutonomousMode()
|
||||
.Build();
|
||||
|
||||
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "go"),]);
|
||||
|
||||
Assert.Equal(TargetIterations, agentACallCount);
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("B final", updateText);
|
||||
|
||||
// Conversation should contain the continuation prompts injected between A's responses.
|
||||
Assert.Contains(result, m => m.Role == ChatRole.User && m.Text == HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_AutonomousMode_RespectsTurnLimitAsync()
|
||||
{
|
||||
// With a turn limit of N, the agent should be invoked initial+N times before the workflow ends
|
||||
// (when the agent never invokes a handoff).
|
||||
|
||||
int callCount = 0;
|
||||
const int TurnLimit = 2;
|
||||
|
||||
var agentA = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
callCount++;
|
||||
return new(new ChatMessage(ChatRole.Assistant, $"call {callCount}"));
|
||||
}), name: "agentA");
|
||||
|
||||
var agentB = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
Assert.Fail("B should never be reached since A never hands off.");
|
||||
return new();
|
||||
}), name: "agentB", description: "agent B");
|
||||
|
||||
var workflow =
|
||||
AgentWorkflowBuilder.CreateHandoffBuilderWith(agentA)
|
||||
.WithHandoff(agentA, agentB)
|
||||
.WithAutonomousMode(turnLimit: TurnLimit)
|
||||
.Build();
|
||||
|
||||
(_, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "go")]);
|
||||
|
||||
// First call + TurnLimit continuation iterations = TurnLimit + 1 invocations.
|
||||
Assert.Equal(TurnLimit + 1, callCount);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_AutonomousMode_UsesCustomContinuationPromptAsync()
|
||||
{
|
||||
const string CustomPrompt = "Keep going, please.";
|
||||
int callCount = 0;
|
||||
|
||||
var agentA = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount > 1)
|
||||
{
|
||||
// After first call, verify the latest user message is the custom prompt.
|
||||
var lastUserMessage = messages.LastOrDefault(m => m.Role == ChatRole.User);
|
||||
Assert.NotNull(lastUserMessage);
|
||||
Assert.Equal(CustomPrompt, lastUserMessage!.Text);
|
||||
}
|
||||
|
||||
return new(new ChatMessage(ChatRole.Assistant, $"call {callCount}"));
|
||||
}), name: "agentA");
|
||||
|
||||
var agentB = new ChatClientAgent(new MockChatClient((messages, options) => new()),
|
||||
name: "agentB", description: "agent B");
|
||||
|
||||
var workflow =
|
||||
AgentWorkflowBuilder.CreateHandoffBuilderWith(agentA)
|
||||
.WithHandoff(agentA, agentB)
|
||||
.WithAutonomousMode(turnLimit: 2, continuationPrompt: CustomPrompt)
|
||||
.Build();
|
||||
|
||||
(_, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "go")]);
|
||||
|
||||
Assert.Equal(3, callCount); // 1 initial + 2 autonomous continuations
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains(result, m => m.Role == ChatRole.User && m.Text == CustomPrompt);
|
||||
}
|
||||
|
||||
#endregion Autonomous Mode Tests
|
||||
|
||||
#region Termination Condition Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_SyncTerminationCondition_EndsAutonomousLoopAsync()
|
||||
{
|
||||
int callCount = 0;
|
||||
|
||||
var agentA = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
callCount++;
|
||||
return new(new ChatMessage(ChatRole.Assistant, $"response {callCount}"));
|
||||
}), name: "agentA");
|
||||
|
||||
var agentB = new ChatClientAgent(new MockChatClient((messages, options) => new()),
|
||||
name: "agentB", description: "agent B");
|
||||
|
||||
// Sync termination: stop as soon as conversation contains a message with text "response 2".
|
||||
var workflow =
|
||||
AgentWorkflowBuilder.CreateHandoffBuilderWith(agentA)
|
||||
.WithHandoff(agentA, agentB)
|
||||
.WithAutonomousMode(turnLimit: 10)
|
||||
.WithTerminationCondition(conversation => conversation.Any(m => m.Text == "response 2"))
|
||||
.Build();
|
||||
|
||||
(_, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "go")]);
|
||||
|
||||
// Agent should be invoked twice: once initially, once after the autonomous continuation,
|
||||
// at which point the termination condition fires and the loop ends.
|
||||
Assert.Equal(2, callCount);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_AsyncTerminationCondition_EndsAutonomousLoopAsync()
|
||||
{
|
||||
int callCount = 0;
|
||||
|
||||
var agentA = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
callCount++;
|
||||
return new(new ChatMessage(ChatRole.Assistant, $"response {callCount}"));
|
||||
}), name: "agentA");
|
||||
|
||||
var agentB = new ChatClientAgent(new MockChatClient((messages, options) => new()),
|
||||
name: "agentB", description: "agent B");
|
||||
|
||||
// Async termination: same effect, but exercises the async overload.
|
||||
var workflow =
|
||||
AgentWorkflowBuilder.CreateHandoffBuilderWith(agentA)
|
||||
.WithHandoff(agentA, agentB)
|
||||
.WithAutonomousMode(turnLimit: 10)
|
||||
.WithTerminationCondition(async conversation =>
|
||||
{
|
||||
await Task.Yield();
|
||||
return conversation.Any(m => m.Text == "response 3");
|
||||
})
|
||||
.Build();
|
||||
|
||||
(_, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "go")]);
|
||||
|
||||
Assert.Equal(3, callCount);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_TerminationCondition_NotInvokedOnHandoffAsync()
|
||||
{
|
||||
// The termination condition is only evaluated when the agent did not request a handoff.
|
||||
// Verify a handoff occurs without consulting the predicate.
|
||||
|
||||
bool predicateInvoked = false;
|
||||
|
||||
var agentA = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
|
||||
Assert.NotNull(transferFuncName);
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
|
||||
}), name: "agentA");
|
||||
|
||||
var agentB = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
new(new ChatMessage(ChatRole.Assistant, "B done"))),
|
||||
name: "agentB", description: "agent B");
|
||||
|
||||
var workflow =
|
||||
AgentWorkflowBuilder.CreateHandoffBuilderWith(agentA)
|
||||
.WithHandoff(agentA, agentB)
|
||||
.WithTerminationCondition(_ =>
|
||||
{
|
||||
predicateInvoked = true;
|
||||
return true;
|
||||
})
|
||||
.Build();
|
||||
|
||||
(string updateText, _, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "go")]);
|
||||
|
||||
// Only B's response should have ended the workflow; predicate evaluated on B (no further handoff).
|
||||
Assert.Equal("B done", updateText);
|
||||
Assert.True(predicateInvoked, "Predicate should have been invoked at least once (on the terminating agent).");
|
||||
}
|
||||
|
||||
#endregion Termination Condition Tests
|
||||
|
||||
#region Helper Types and Methods
|
||||
|
||||
private sealed record WorkflowRunResult(string UpdateText, List<ChatMessage>? Result, CheckpointInfo? LastCheckpoint, List<RequestInfoEvent> PendingRequests);
|
||||
|
||||
@@ -507,38 +507,45 @@ class Skill(ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def content(self) -> str:
|
||||
"""The full skill content.
|
||||
async def get_content(self) -> str:
|
||||
"""Get the full skill content.
|
||||
|
||||
For file-based skills this is the raw SKILL.md file content,
|
||||
optionally augmented with a synthesized scripts block when scripts
|
||||
are present. For code-defined skills this is a synthesized XML
|
||||
document containing name, description, and body (instructions,
|
||||
resources, scripts).
|
||||
|
||||
Returns:
|
||||
The full skill content string.
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
def resources(self) -> list[SkillResource]:
|
||||
"""Resources associated with this skill.
|
||||
async def get_resource(self, name: str) -> SkillResource | None:
|
||||
"""Get a resource owned by this skill by name.
|
||||
|
||||
The default implementation returns an empty list.
|
||||
Override this property in derived classes to provide skill-specific
|
||||
resources.
|
||||
Args:
|
||||
name: The resource name (e.g. an identifier or a relative path
|
||||
referenced inside the skill content).
|
||||
|
||||
Returns:
|
||||
The :class:`SkillResource`, or ``None`` when no resource with the
|
||||
given name exists.
|
||||
"""
|
||||
return []
|
||||
return None
|
||||
|
||||
@property
|
||||
def scripts(self) -> list[SkillScript]:
|
||||
"""Scripts associated with this skill.
|
||||
async def get_script(self, name: str) -> SkillScript | None:
|
||||
"""Get a script owned by this skill by name.
|
||||
|
||||
The default implementation returns an empty list.
|
||||
Override this property in derived classes to provide skill-specific
|
||||
scripts.
|
||||
Args:
|
||||
name: The script name.
|
||||
|
||||
Returns:
|
||||
The :class:`SkillScript`, or ``None`` when no script with the
|
||||
given name exists.
|
||||
"""
|
||||
return []
|
||||
return None
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.SKILLS)
|
||||
@@ -767,12 +774,14 @@ class InlineSkill(Skill):
|
||||
"""The L1 discovery metadata for this skill."""
|
||||
return self._frontmatter
|
||||
|
||||
@property
|
||||
def content(self) -> str:
|
||||
async def get_content(self) -> str:
|
||||
"""Synthesized XML content with name, description, instructions, resources, and scripts.
|
||||
|
||||
The result is cached after the first access. Adding resources or
|
||||
scripts after the first access will not be reflected.
|
||||
|
||||
Returns:
|
||||
The synthesized XML content string.
|
||||
"""
|
||||
if self._cached_content is not None:
|
||||
return self._cached_content
|
||||
@@ -786,15 +795,31 @@ class InlineSkill(Skill):
|
||||
)
|
||||
return self._cached_content
|
||||
|
||||
@property
|
||||
def resources(self) -> list[SkillResource]:
|
||||
"""Mutable list of :class:`SkillResource` instances."""
|
||||
return self._resources
|
||||
async def get_resource(self, name: str) -> SkillResource | None:
|
||||
"""Get a resource by name.
|
||||
|
||||
@property
|
||||
def scripts(self) -> list[SkillScript]:
|
||||
"""Mutable list of :class:`SkillScript` instances."""
|
||||
return self._scripts
|
||||
Args:
|
||||
name: The resource name to look up (case-insensitive).
|
||||
|
||||
Returns:
|
||||
The :class:`SkillResource`, or ``None`` when no resource with the
|
||||
given name exists.
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
return next((r for r in self._resources if r.name.lower() == name_lower), None)
|
||||
|
||||
async def get_script(self, name: str) -> SkillScript | None:
|
||||
"""Get a script by name.
|
||||
|
||||
Args:
|
||||
name: The script name to look up (case-insensitive).
|
||||
|
||||
Returns:
|
||||
The :class:`SkillScript`, or ``None`` when no script with the
|
||||
given name exists.
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
return next((s for s in self._scripts if s.name.lower() == name_lower), None)
|
||||
|
||||
def resource(
|
||||
self,
|
||||
@@ -1318,11 +1343,13 @@ class ClassSkill(Skill, ABC):
|
||||
self._cached_scripts = scripts
|
||||
return list(self._cached_scripts)
|
||||
|
||||
@property
|
||||
def content(self) -> str:
|
||||
async def get_content(self) -> str:
|
||||
"""Synthesized XML content containing name, description, instructions, resources, and scripts.
|
||||
|
||||
The result is cached after the first access.
|
||||
|
||||
Returns:
|
||||
The synthesized XML content string.
|
||||
"""
|
||||
if self._cached_content is not None:
|
||||
return self._cached_content
|
||||
@@ -1336,6 +1363,32 @@ class ClassSkill(Skill, ABC):
|
||||
)
|
||||
return self._cached_content
|
||||
|
||||
async def get_resource(self, name: str) -> SkillResource | None:
|
||||
"""Get a resource by name from the :attr:`resources` list.
|
||||
|
||||
Args:
|
||||
name: The resource name to look up (case-insensitive).
|
||||
|
||||
Returns:
|
||||
The :class:`SkillResource`, or ``None`` when no resource with the
|
||||
given name exists.
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
return next((r for r in self.resources if r.name.lower() == name_lower), None)
|
||||
|
||||
async def get_script(self, name: str) -> SkillScript | None:
|
||||
"""Get a script by name from the :attr:`scripts` list.
|
||||
|
||||
Args:
|
||||
name: The script name to look up (case-insensitive).
|
||||
|
||||
Returns:
|
||||
The :class:`SkillScript`, or ``None`` when no script with the
|
||||
given name exists.
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
return next((s for s in self.scripts if s.name.lower() == name_lower), None)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.SKILLS)
|
||||
class FileSkill(Skill):
|
||||
@@ -1378,8 +1431,7 @@ class FileSkill(Skill):
|
||||
"""The L1 discovery metadata for this skill."""
|
||||
return self._frontmatter
|
||||
|
||||
@property
|
||||
def content(self) -> str:
|
||||
async def get_content(self) -> str:
|
||||
"""The skill content with appended scripts block.
|
||||
|
||||
When scripts are present, a ``<scripts>`` XML block is appended
|
||||
@@ -1388,6 +1440,9 @@ class FileSkill(Skill):
|
||||
|
||||
The result is cached after the first access. Adding scripts
|
||||
after the first access will not be reflected.
|
||||
|
||||
Returns:
|
||||
The skill content string.
|
||||
"""
|
||||
if self._cached_content is not None:
|
||||
return self._cached_content
|
||||
@@ -1398,15 +1453,31 @@ class FileSkill(Skill):
|
||||
self._cached_content = f"{self._content}\n\n<scripts>\n{script_lines}\n</scripts>"
|
||||
return self._cached_content
|
||||
|
||||
@property
|
||||
def resources(self) -> list[SkillResource]:
|
||||
"""Resources discovered for this skill."""
|
||||
return self._resources
|
||||
async def get_resource(self, name: str) -> SkillResource | None:
|
||||
"""Get a resource by name.
|
||||
|
||||
@property
|
||||
def scripts(self) -> list[SkillScript]:
|
||||
"""Scripts discovered for this skill."""
|
||||
return self._scripts
|
||||
Args:
|
||||
name: The resource name to look up (case-insensitive).
|
||||
|
||||
Returns:
|
||||
The :class:`SkillResource`, or ``None`` when no resource with the
|
||||
given name exists.
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
return next((r for r in self._resources if r.name.lower() == name_lower), None)
|
||||
|
||||
async def get_script(self, name: str) -> SkillScript | None:
|
||||
"""Get a script by name.
|
||||
|
||||
Args:
|
||||
name: The script name to look up (case-insensitive).
|
||||
|
||||
Returns:
|
||||
The :class:`SkillScript`, or ``None`` when no script with the
|
||||
given name exists.
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
return next((s for s in self._scripts if s.name.lower() == name_lower), None)
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -1734,13 +1805,13 @@ class SkillsProvider(ContextProvider):
|
||||
Keyword Args:
|
||||
instruction_template: Custom system-prompt template for
|
||||
advertising skills. Must contain a ``{skills}`` placeholder for the
|
||||
generated skills list. If the provider includes file-based script
|
||||
execution instructions, the template must also contain
|
||||
``{runner_instructions}``. If the provider includes resource-reading
|
||||
instructions, the template must also contain
|
||||
``{resource_instructions}``. Omitting any placeholder required by
|
||||
the resolved skills configuration can raise :class:`ValueError` at
|
||||
runtime. Uses a built-in template when ``None``.
|
||||
generated skills list. May optionally contain
|
||||
``{runner_instructions}`` and/or ``{resource_instructions}``
|
||||
placeholders; when present, they are filled with built-in
|
||||
guidance for script execution and resource reading respectively.
|
||||
When omitted, those instructions are simply not included in the
|
||||
rendered prompt (the corresponding tools are still registered).
|
||||
Uses a built-in template when ``None``.
|
||||
require_script_approval: When ``True``, skill script execution
|
||||
requires explicit user approval before running. Instead of
|
||||
executing immediately, the agent pauses and returns a
|
||||
@@ -1867,29 +1938,20 @@ class SkillsProvider(ContextProvider):
|
||||
def _create_instructions(
|
||||
prompt_template: str | None,
|
||||
skills: Sequence[Skill],
|
||||
include_script_runner_instructions: bool = False,
|
||||
include_resource_instructions: bool = False,
|
||||
) -> str | None:
|
||||
"""Create the system-prompt text that advertises available skills.
|
||||
|
||||
Generates an XML list of ``<skill>`` elements (sorted by name) and
|
||||
inserts it into *prompt_template* at the ``{skills}`` placeholder.
|
||||
When *include_script_runner_instructions* is ``True``, executor-provided
|
||||
instructions are inserted at the ``{runner_instructions}`` placeholder.
|
||||
When *include_resource_instructions* is ``True``, resource-reading
|
||||
instructions are inserted at the ``{resource_instructions}`` placeholder.
|
||||
Script-runner instructions are inserted at the
|
||||
``{runner_instructions}`` placeholder and resource-reading
|
||||
instructions at the ``{resource_instructions}`` placeholder.
|
||||
|
||||
Args:
|
||||
prompt_template: Custom template string with ``{skills}`` and
|
||||
optional ``{runner_instructions}`` and ``{resource_instructions}``
|
||||
placeholders, or ``None`` to use the built-in default.
|
||||
skills: Registered skills.
|
||||
include_script_runner_instructions: When ``True``, include
|
||||
script-runner instructions in the generated prompt.
|
||||
Defaults to ``False``.
|
||||
include_resource_instructions: When ``True``, include
|
||||
resource-reading instructions in the generated prompt.
|
||||
Defaults to ``False``.
|
||||
|
||||
Returns:
|
||||
The formatted instruction string, or ``None`` when *skills* is empty.
|
||||
@@ -1898,8 +1960,8 @@ class SkillsProvider(ContextProvider):
|
||||
ValueError: If *prompt_template* is not a valid format string
|
||||
(e.g. missing ``{skills}`` placeholder).
|
||||
"""
|
||||
runner_instructions = SCRIPT_RUNNER_INSTRUCTIONS if include_script_runner_instructions else None
|
||||
resource_instructions = RESOURCE_INSTRUCTIONS if include_resource_instructions else None
|
||||
runner_instructions = SCRIPT_RUNNER_INSTRUCTIONS
|
||||
resource_instructions = RESOURCE_INSTRUCTIONS
|
||||
template = DEFAULT_SKILLS_INSTRUCTION_PROMPT
|
||||
|
||||
if prompt_template is not None:
|
||||
@@ -1921,16 +1983,6 @@ class SkillsProvider(ContextProvider):
|
||||
raise ValueError(
|
||||
"The provided instruction_template must contain a '{skills}' placeholder." # noqa: RUF027
|
||||
)
|
||||
if runner_instructions and "__EXEC_PROBE__" not in result:
|
||||
raise ValueError(
|
||||
"The provided instruction_template must contain an '{runner_instructions}' placeholder " # noqa: RUF027
|
||||
"when a script runner is configured."
|
||||
)
|
||||
if resource_instructions and "__RES_PROBE__" not in result:
|
||||
raise ValueError(
|
||||
"The provided instruction_template must contain a '{resource_instructions}' placeholder " # noqa: RUF027
|
||||
"when skills have resources."
|
||||
)
|
||||
template = prompt_template
|
||||
|
||||
if not skills:
|
||||
@@ -1964,20 +2016,13 @@ class SkillsProvider(ContextProvider):
|
||||
if not skills:
|
||||
return skills, None, []
|
||||
|
||||
has_scripts = any(s.scripts for s in skills)
|
||||
has_resources = any(s.resources for s in skills)
|
||||
|
||||
instructions = self._create_instructions(
|
||||
prompt_template=self._instruction_template,
|
||||
skills=skills,
|
||||
include_script_runner_instructions=has_scripts,
|
||||
include_resource_instructions=has_resources,
|
||||
)
|
||||
|
||||
tools = self._create_tools(
|
||||
skills=skills,
|
||||
include_script_runner_tool=has_scripts,
|
||||
include_resource_tool=has_resources,
|
||||
require_script_approval=self._require_script_approval,
|
||||
)
|
||||
|
||||
@@ -2046,23 +2091,15 @@ class SkillsProvider(ContextProvider):
|
||||
def _create_tools(
|
||||
self,
|
||||
skills: Sequence[Skill],
|
||||
include_script_runner_tool: bool,
|
||||
include_resource_tool: bool,
|
||||
require_script_approval: bool = False,
|
||||
) -> list[FunctionTool]:
|
||||
"""Create the tool definitions for skill interaction.
|
||||
|
||||
Always includes ``load_skill``. Conditionally includes
|
||||
``read_skill_resource`` (when *include_resource_tool* is ``True``)
|
||||
and ``run_skill_script`` (when *include_script_runner_tool* is
|
||||
``True``).
|
||||
Always includes ``load_skill``, ``read_skill_resource``, and
|
||||
``run_skill_script``.
|
||||
|
||||
Args:
|
||||
skills: The skills to bind to tool handlers.
|
||||
include_script_runner_tool: Whether to include the
|
||||
``run_skill_script`` tool in the returned list.
|
||||
include_resource_tool: Whether to include the
|
||||
``read_skill_resource`` tool in the returned list.
|
||||
require_script_approval: When ``True``, the
|
||||
``run_skill_script`` tool pauses for user approval
|
||||
before each invocation.
|
||||
@@ -2070,11 +2107,23 @@ class SkillsProvider(ContextProvider):
|
||||
Returns:
|
||||
A list of :class:`FunctionTool` instances.
|
||||
"""
|
||||
tools = [
|
||||
|
||||
async def _load(skill_name: str) -> str:
|
||||
return await self._load_skill(skills, skill_name)
|
||||
|
||||
async def _read_resource(skill_name: str, resource_name: str, **kwargs: Any) -> Any:
|
||||
return await self._read_skill_resource(skills, skill_name, resource_name, **kwargs)
|
||||
|
||||
async def _run_script(
|
||||
skill_name: str, script_name: str, args: dict[str, Any] | list[str] | None = None, **kwargs: Any
|
||||
) -> Any:
|
||||
return await self._run_skill_script(skills, skill_name, script_name, args, **kwargs)
|
||||
|
||||
return [
|
||||
FunctionTool(
|
||||
name="load_skill",
|
||||
description="Loads the full instructions for a specific skill.",
|
||||
func=lambda skill_name: self._load_skill(skills, skill_name), # pyright: ignore[reportUnknownArgumentType, reportUnknownLambdaType]
|
||||
func=_load,
|
||||
input_model={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2083,108 +2132,88 @@ class SkillsProvider(ContextProvider):
|
||||
"required": ["skill_name"],
|
||||
},
|
||||
),
|
||||
FunctionTool(
|
||||
name="read_skill_resource",
|
||||
description=(
|
||||
"Reads a resource associated with a skill, such as references, assets, or dynamic data."
|
||||
),
|
||||
func=_read_resource,
|
||||
input_model={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_name": {"type": "string", "description": "The name of the skill."},
|
||||
"resource_name": {
|
||||
"type": "string",
|
||||
"description": "The name of the resource.",
|
||||
},
|
||||
},
|
||||
"required": ["skill_name", "resource_name"],
|
||||
},
|
||||
),
|
||||
FunctionTool(
|
||||
name="run_skill_script",
|
||||
description="Runs a script associated with a skill.",
|
||||
func=_run_script,
|
||||
approval_mode="always_require" if require_script_approval else "never_require",
|
||||
input_model={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_name": {"type": "string", "description": "The name of the skill."},
|
||||
"script_name": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The name of the script to run as listed in the skill, "
|
||||
"preserving any directory prefix exactly as shown. "
|
||||
"Do not add or remove path prefixes."
|
||||
),
|
||||
},
|
||||
"args": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": True,
|
||||
"description": (
|
||||
"Named arguments as key-value pairs "
|
||||
'(e.g. {"length": 24, "uppercase": true}).'
|
||||
),
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": (
|
||||
"Positional CLI arguments as a string array "
|
||||
'(e.g. ["input.docx", "--output", "result.idx"]).'
|
||||
),
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"default": None,
|
||||
"description": (
|
||||
"Arguments to pass to the script. "
|
||||
"Use an array of strings for CLI-style positional arguments "
|
||||
'(e.g. ["input.docx", "--output", "result.idx"]), '
|
||||
"or an object for named parameters "
|
||||
'(e.g. {"length": 24, "uppercase": true}). '
|
||||
"How these values are mapped to the underlying script "
|
||||
"is determined by the script implementation or configured runner."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["skill_name", "script_name"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
if include_resource_tool:
|
||||
|
||||
async def _read_resource(skill_name: str, resource_name: str, **kwargs: Any) -> Any:
|
||||
return await self._read_skill_resource(skills, skill_name, resource_name, **kwargs)
|
||||
|
||||
tools.append(
|
||||
FunctionTool(
|
||||
name="read_skill_resource",
|
||||
description=(
|
||||
"Reads a resource associated with a skill, such as references, assets, or dynamic data."
|
||||
),
|
||||
func=_read_resource,
|
||||
input_model={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_name": {"type": "string", "description": "The name of the skill."},
|
||||
"resource_name": {
|
||||
"type": "string",
|
||||
"description": "The name of the resource.",
|
||||
},
|
||||
},
|
||||
"required": ["skill_name", "resource_name"],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
if include_script_runner_tool:
|
||||
|
||||
async def _run_script(
|
||||
skill_name: str, script_name: str, args: dict[str, Any] | list[str] | None = None, **kwargs: Any
|
||||
) -> Any:
|
||||
return await self._run_skill_script(skills, skill_name, script_name, args, **kwargs)
|
||||
|
||||
tools.append(
|
||||
FunctionTool(
|
||||
name="run_skill_script",
|
||||
description="Runs a script associated with a skill.",
|
||||
func=_run_script,
|
||||
approval_mode="always_require" if require_script_approval else "never_require",
|
||||
input_model={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_name": {"type": "string", "description": "The name of the skill."},
|
||||
"script_name": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The name of the script to run as listed in the skill, "
|
||||
"preserving any directory prefix exactly as shown. "
|
||||
"Do not add or remove path prefixes."
|
||||
),
|
||||
},
|
||||
"args": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": True,
|
||||
"description": (
|
||||
"Named arguments as key-value pairs "
|
||||
'(e.g. {"length": 24, "uppercase": true}).'
|
||||
),
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": (
|
||||
"Positional CLI arguments as a string array "
|
||||
'(e.g. ["input.docx", "--output", "result.idx"]).'
|
||||
),
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"default": None,
|
||||
"description": (
|
||||
"Arguments to pass to the script. "
|
||||
"Use an array of strings for CLI-style positional arguments "
|
||||
'(e.g. ["input.docx", "--output", "result.idx"]), '
|
||||
"or an object for named parameters "
|
||||
'(e.g. {"length": 24, "uppercase": true}). '
|
||||
"How these values are mapped to the underlying script "
|
||||
"is determined by the script implementation or configured runner."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["skill_name", "script_name"],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return tools
|
||||
|
||||
@staticmethod
|
||||
def _find_skill(skills: Sequence[Skill], name: str) -> Skill | None:
|
||||
"""Find a skill by name (case-insensitive linear scan)."""
|
||||
name_lower = name.lower()
|
||||
return next((s for s in skills if s.frontmatter.name.lower() == name_lower), None)
|
||||
|
||||
def _load_skill(self, skills: Sequence[Skill], skill_name: str) -> str:
|
||||
async def _load_skill(self, skills: Sequence[Skill], skill_name: str) -> str:
|
||||
"""Return the full content for the named skill.
|
||||
|
||||
Delegates to the skill's :attr:`~Skill.content` property, which
|
||||
Delegates to the skill's :meth:`~Skill.get_content` method, which
|
||||
handles format differences between file-based and code-defined skills.
|
||||
|
||||
Args:
|
||||
@@ -2204,7 +2233,7 @@ class SkillsProvider(ContextProvider):
|
||||
|
||||
logger.info("Loading skill: %s", skill_name)
|
||||
|
||||
return skill.content
|
||||
return await skill.get_content()
|
||||
|
||||
async def _run_skill_script(
|
||||
self,
|
||||
@@ -2243,7 +2272,7 @@ class SkillsProvider(ContextProvider):
|
||||
if not skill:
|
||||
return f"Error: Skill '{skill_name}' not found."
|
||||
|
||||
script = next((s for s in skill.scripts if s.name.lower() == script_name.lower()), None)
|
||||
script = await skill.get_script(script_name)
|
||||
if not script:
|
||||
return f"Error: Script '{script_name}' not found in skill '{skill_name}'."
|
||||
|
||||
@@ -2284,12 +2313,8 @@ class SkillsProvider(ContextProvider):
|
||||
if skill is None:
|
||||
return f"Error: Skill '{skill_name}' not found."
|
||||
|
||||
# Find resource by name (case-insensitive)
|
||||
resource_name_lower = resource_name.lower()
|
||||
for resource in skill.resources:
|
||||
if resource.name.lower() == resource_name_lower:
|
||||
break
|
||||
else:
|
||||
resource = await skill.get_resource(resource_name)
|
||||
if resource is None:
|
||||
return f"Error: Resource '{resource_name}' not found in skill '{skill_name}'."
|
||||
|
||||
try:
|
||||
@@ -2481,27 +2506,29 @@ class FileSkillsSource(SkillsSource):
|
||||
)
|
||||
continue
|
||||
|
||||
file_skill = FileSkill(
|
||||
frontmatter=frontmatter,
|
||||
content=content,
|
||||
path=skill_path,
|
||||
)
|
||||
|
||||
# Discover and attach file-based resources
|
||||
# Discover file-based resources
|
||||
resources: list[SkillResource] = []
|
||||
for rn in FileSkillsSource._discover_resource_files(
|
||||
skill_path, self._resource_extensions, self._resource_directories
|
||||
):
|
||||
resource_full_path = FileSkillsSource._get_validated_resource_path(skill_path, rn)
|
||||
file_skill.resources.append(_FileSkillResource(name=rn, full_path=resource_full_path))
|
||||
resources.append(_FileSkillResource(name=rn, full_path=resource_full_path))
|
||||
|
||||
# Discover and attach file-based scripts as SkillScript instances
|
||||
# Discover file-based scripts
|
||||
scripts: list[SkillScript] = []
|
||||
for sn in FileSkillsSource._discover_script_files(
|
||||
skill_path, self._script_extensions, self._script_directories
|
||||
):
|
||||
script_full_path = os.path.normpath(os.path.join(skill_path, sn)) # noqa: ASYNC240
|
||||
file_skill.scripts.append(
|
||||
FileSkillScript(name=sn, full_path=script_full_path, runner=self._script_runner)
|
||||
)
|
||||
scripts.append(FileSkillScript(name=sn, full_path=script_full_path, runner=self._script_runner))
|
||||
|
||||
file_skill = FileSkill(
|
||||
frontmatter=frontmatter,
|
||||
content=content,
|
||||
path=skill_path,
|
||||
resources=resources,
|
||||
scripts=scripts,
|
||||
)
|
||||
|
||||
skills[file_skill.frontmatter.name] = file_skill
|
||||
logger.info("Loaded skill: %s", file_skill.frontmatter.name)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user