mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into dev/dotnet_workflow/fix_flaky_checkpoint_restore_test
This commit is contained in:
+4
-4
@@ -6,7 +6,7 @@
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);OPENAICUA001</NoWarn>
|
||||
<NoWarn>$(NoWarn);OPENAICUA001;MEAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -19,13 +19,13 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Assets\cua_browser_search.png">
|
||||
<None Update="Assets\cua_browser_search.jpg">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Assets\cua_search_results.png">
|
||||
<None Update="Assets\cua_search_results.jpg">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Assets\cua_search_typed.png">
|
||||
<None Update="Assets\cua_search_typed.jpg">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 402 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 MiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 357 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB |
+51
-56
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Demo.ComputerUse;
|
||||
@@ -16,83 +17,77 @@ internal enum SearchState
|
||||
|
||||
internal static class ComputerUseUtil
|
||||
{
|
||||
/// <summary>
|
||||
/// Load and convert screenshot images to base64 data URLs.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, byte[]> LoadScreenshotAssets()
|
||||
internal static async Task<Dictionary<string, string>> UploadScreenshotAssetsAsync(IHostedFileClient fileClient)
|
||||
{
|
||||
string baseDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets");
|
||||
string assetsDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets");
|
||||
|
||||
ReadOnlySpan<(string key, string fileName)> screenshotFiles =
|
||||
[
|
||||
("browser_search", "cua_browser_search.png"),
|
||||
("search_typed", "cua_search_typed.png"),
|
||||
("search_results", "cua_search_results.png")
|
||||
];
|
||||
(string key, string fileName)[] files =
|
||||
[
|
||||
("browser_search", "cua_browser_search.jpg"),
|
||||
("search_typed", "cua_search_typed.jpg"),
|
||||
("search_results", "cua_search_results.jpg")
|
||||
];
|
||||
|
||||
Dictionary<string, byte[]> screenshots = [];
|
||||
foreach (var (key, fileName) in screenshotFiles)
|
||||
Dictionary<string, string> screenshots = [];
|
||||
|
||||
foreach (var (key, fileName) in files)
|
||||
{
|
||||
string fullPath = Path.GetFullPath(Path.Combine(baseDir, fileName));
|
||||
screenshots[key] = File.ReadAllBytes(fullPath);
|
||||
HostedFileContent result = await fileClient.UploadAsync(
|
||||
Path.Combine(assetsDir, fileName), new HostedFileClientOptions() { Purpose = "assistants" });
|
||||
screenshots[key] = result.FileId;
|
||||
}
|
||||
|
||||
return screenshots;
|
||||
}
|
||||
|
||||
internal static async Task EnsureDeleteScreenshotAssetsAsync(IHostedFileClient fileClient, Dictionary<string, string> screenshots)
|
||||
{
|
||||
foreach (var (_, fileId) in screenshots)
|
||||
{
|
||||
try
|
||||
{
|
||||
await fileClient.DeleteAsync(fileId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a computer action and simulate its execution.
|
||||
/// Simulates executing a computer action by advancing the state
|
||||
/// and returning the screenshot file ID for the new state.
|
||||
/// </summary>
|
||||
internal static (SearchState CurrentState, byte[] ImageBytes) HandleComputerActionAndTakeScreenshot(
|
||||
internal static async Task<(SearchState State, string FileId)> GetScreenshotAsync(
|
||||
ComputerCallAction action,
|
||||
SearchState currentState,
|
||||
Dictionary<string, byte[]> screenshots)
|
||||
Dictionary<string, string> screenshots)
|
||||
{
|
||||
Console.WriteLine($"Simulating the execution of computer action: {action.Kind}");
|
||||
|
||||
SearchState newState = DetermineNextState(action, currentState);
|
||||
string imageKey = GetImageKey(newState);
|
||||
|
||||
return (newState, screenshots[imageKey]);
|
||||
}
|
||||
|
||||
private static SearchState DetermineNextState(ComputerCallAction action, SearchState currentState)
|
||||
{
|
||||
string actionType = action.Kind.ToString();
|
||||
|
||||
if (actionType.Equals("type", StringComparison.OrdinalIgnoreCase) && action.TypeText is not null)
|
||||
if (action.Kind == ComputerCallActionKind.Wait)
|
||||
{
|
||||
return SearchState.Typed;
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
if (IsEnterKeyAction(action, actionType))
|
||||
SearchState nextState = action.Kind switch
|
||||
{
|
||||
Console.WriteLine(" -> Detected ENTER key press");
|
||||
return SearchState.PressedEnter;
|
||||
}
|
||||
ComputerCallActionKind.Click when currentState == SearchState.Typed => SearchState.PressedEnter,
|
||||
ComputerCallActionKind.Type when action.TypeText is not null => SearchState.Typed,
|
||||
ComputerCallActionKind.KeyPress when IsEnterKey(action) => SearchState.PressedEnter,
|
||||
_ => currentState
|
||||
};
|
||||
|
||||
if (actionType.Equals("click", StringComparison.OrdinalIgnoreCase) && currentState == SearchState.Typed)
|
||||
string imageKey = nextState switch
|
||||
{
|
||||
Console.WriteLine(" -> Detected click after typing");
|
||||
return SearchState.PressedEnter;
|
||||
}
|
||||
SearchState.PressedEnter => "search_results",
|
||||
SearchState.Typed => "search_typed",
|
||||
_ => "browser_search"
|
||||
};
|
||||
|
||||
return currentState;
|
||||
return (nextState, screenshots[imageKey]);
|
||||
}
|
||||
|
||||
private static bool IsEnterKeyAction(ComputerCallAction action, string actionType)
|
||||
{
|
||||
return (actionType.Equals("key", StringComparison.OrdinalIgnoreCase) ||
|
||||
actionType.Equals("keypress", StringComparison.OrdinalIgnoreCase)) &&
|
||||
action.KeyPressKeyCodes is not null &&
|
||||
(action.KeyPressKeyCodes.Contains("Return", StringComparer.OrdinalIgnoreCase) ||
|
||||
action.KeyPressKeyCodes.Contains("Enter", StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string GetImageKey(SearchState state) => state switch
|
||||
{
|
||||
SearchState.PressedEnter => "search_results",
|
||||
SearchState.Typed => "search_typed",
|
||||
_ => "browser_search"
|
||||
};
|
||||
private static bool IsEnterKey(ComputerCallAction action) =>
|
||||
action.KeyPressKeyCodes is not null &&
|
||||
(action.KeyPressKeyCodes.Contains("Return", StringComparer.OrdinalIgnoreCase) ||
|
||||
action.KeyPressKeyCodes.Contains("Enter", StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
@@ -1,146 +1,109 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use Computer Use Tool with a ChatClientAgent.
|
||||
// This sample shows how to use the Computer Use tool with AIProjectClient.AsAIAgent(...).
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Demo.ComputerUse;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Demo.ComputerUse;
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_COMPUTER_USE_DEPLOYMENT_NAME") ?? "computer-use-preview";
|
||||
|
||||
internal sealed class Program
|
||||
AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
using IHostedFileClient fileClient = projectClient.GetProjectOpenAIClient().AsIHostedFileClient();
|
||||
|
||||
AIAgent agent = projectClient.AsAIAgent(
|
||||
model: deploymentName,
|
||||
name: "ComputerAgent",
|
||||
instructions: "You are a computer automation assistant.",
|
||||
tools: [FoundryAITool.CreateComputerTool(ComputerToolEnvironment.Browser, 1026, 769)]);
|
||||
|
||||
Dictionary<string, string> screenshots = [];
|
||||
|
||||
try
|
||||
{
|
||||
private static async Task Main(string[] args)
|
||||
// Upload pre-captured screenshots that simulate browser state transitions.
|
||||
screenshots = await ComputerUseUtil.UploadScreenshotAssetsAsync(fileClient);
|
||||
|
||||
// Enable auto-truncation for the Responses API.
|
||||
ChatClientAgentRunOptions runOptions = new()
|
||||
{
|
||||
const string AgentInstructions = @"
|
||||
You are a computer automation assistant.
|
||||
|
||||
Be direct and efficient. When you reach the search results page, read and describe the actual search result titles and descriptions you can see.
|
||||
";
|
||||
|
||||
const string AgentName = "ComputerAgent-RAPI";
|
||||
|
||||
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") ?? "computer-use-preview";
|
||||
|
||||
// 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.
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
// Create a AIAgent with ComputerUseTool.
|
||||
AIAgent agent = aiProjectClient.AsAIAgent(deploymentName,
|
||||
instructions: AgentInstructions,
|
||||
name: AgentName,
|
||||
description: "Computer automation agent with screen interaction capabilities.",
|
||||
tools: [
|
||||
FoundryAITool.CreateComputerTool(ComputerToolEnvironment.Browser, 1026, 769),
|
||||
]);
|
||||
|
||||
await InvokeComputerUseAgentAsync(agent);
|
||||
}
|
||||
|
||||
private static async Task InvokeComputerUseAgentAsync(AIAgent agent)
|
||||
{
|
||||
// Load screenshot assets
|
||||
Dictionary<string, byte[]> screenshots = ComputerUseUtil.LoadScreenshotAssets();
|
||||
|
||||
ChatOptions chatOptions = new();
|
||||
CreateResponseOptions responseCreationOptions = new()
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
TruncationMode = ResponseTruncationMode.Auto
|
||||
};
|
||||
chatOptions.RawRepresentationFactory = (_) => responseCreationOptions;
|
||||
ChatClientAgentRunOptions runOptions = new(chatOptions)
|
||||
{
|
||||
AllowBackgroundResponses = true,
|
||||
};
|
||||
|
||||
ChatMessage message = new(ChatRole.User, [
|
||||
new TextContent("I need you to help me search for 'OpenAI news'. Please type 'OpenAI news' and submit the search. Once you see search results, the task is complete."),
|
||||
new DataContent(new BinaryData(screenshots["browser_search"]), "image/png")
|
||||
]);
|
||||
|
||||
// Initial request with screenshot - start with Bing search page
|
||||
Console.WriteLine("Starting computer automation session (initial screenshot: cua_browser_search.png)...");
|
||||
|
||||
// We use PreviousResponseId to chain calls, sending only the new computer_call_output items
|
||||
// instead of re-sending the full context.
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
AgentResponse response = await agent.RunAsync(message, session: session, options: runOptions);
|
||||
|
||||
// Main interaction loop
|
||||
const int MaxIterations = 10;
|
||||
int iteration = 0;
|
||||
// Initialize state machine
|
||||
SearchState currentState = SearchState.Initial;
|
||||
|
||||
while (true)
|
||||
{
|
||||
// Poll until the response is complete.
|
||||
while (response.ContinuationToken is { } token)
|
||||
{
|
||||
// Wait before polling again.
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
|
||||
// Continue with the token.
|
||||
runOptions.ContinuationToken = token;
|
||||
|
||||
response = await agent.RunAsync(session, runOptions);
|
||||
}
|
||||
|
||||
// Clear the continuation token so the next RunAsync call is a fresh request.
|
||||
runOptions.ContinuationToken = null;
|
||||
|
||||
Console.WriteLine($"Agent response received (ID: {response.ResponseId})");
|
||||
|
||||
if (iteration >= MaxIterations)
|
||||
{
|
||||
Console.WriteLine($"\nReached maximum iterations ({MaxIterations}). Stopping.");
|
||||
break;
|
||||
}
|
||||
|
||||
iteration++;
|
||||
Console.WriteLine($"\n--- Iteration {iteration} ---");
|
||||
|
||||
// Check for computer calls in the response
|
||||
IEnumerable<ComputerCallResponseItem> computerCallResponseItems = response.Messages
|
||||
.SelectMany(x => x.Contents)
|
||||
.Where(c => c.RawRepresentation is ComputerCallResponseItem and not null)
|
||||
.Select(c => (ComputerCallResponseItem)c.RawRepresentation!);
|
||||
|
||||
ComputerCallResponseItem? firstComputerCall = computerCallResponseItems.FirstOrDefault();
|
||||
if (firstComputerCall is null)
|
||||
{
|
||||
Console.WriteLine("No computer call actions found. Ending interaction.");
|
||||
Console.WriteLine($"Final Response: {response}");
|
||||
break;
|
||||
}
|
||||
|
||||
// Process the first computer call response
|
||||
ComputerCallAction action = firstComputerCall.Action;
|
||||
string currentCallId = firstComputerCall.CallId;
|
||||
|
||||
Console.WriteLine($"Processing computer call (ID: {currentCallId})");
|
||||
|
||||
// Simulate executing the action and taking a screenshot
|
||||
(SearchState CurrentState, byte[] ImageBytes) screenInfo = ComputerUseUtil.HandleComputerActionAndTakeScreenshot(action, currentState, screenshots);
|
||||
currentState = screenInfo.CurrentState;
|
||||
|
||||
Console.WriteLine("Sending action result back to agent...");
|
||||
|
||||
// Send only the computer_call_output — the session carries PreviousResponseId for context continuity.
|
||||
AIContent callOutput = new()
|
||||
{
|
||||
RawRepresentation = new ComputerCallOutputResponseItem(
|
||||
currentCallId,
|
||||
output: ComputerCallOutput.CreateScreenshotOutput(new BinaryData(screenInfo.ImageBytes), "image/png"))
|
||||
};
|
||||
|
||||
response = await agent.RunAsync([new ChatMessage(ChatRole.User, [callOutput])], session: session, options: runOptions);
|
||||
RawRepresentationFactory = (_) => new CreateResponseOptions() { TruncationMode = ResponseTruncationMode.Auto },
|
||||
}
|
||||
};
|
||||
|
||||
// Send the initial request with a screenshot of the browser.
|
||||
ChatMessage message = new(ChatRole.User, [
|
||||
new TextContent("Search for 'OpenAI news'. Type it and submit. Once you see results, the task is complete."),
|
||||
new AIContent() { RawRepresentation = ResponseContentPart.CreateInputImagePart(imageFileId: screenshots["browser_search"], imageDetailLevel: ResponseImageDetailLevel.High) }
|
||||
]);
|
||||
|
||||
Console.WriteLine("Starting computer use session...");
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
AgentResponse response = await agent.RunAsync(message, session: session, options: runOptions);
|
||||
|
||||
SearchState currentState = SearchState.Initial;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
// Find the next computer call action.
|
||||
ComputerCallResponseItem? computerCall = response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.Select(c => c.RawRepresentation as ComputerCallResponseItem)
|
||||
.FirstOrDefault(item => item is not null);
|
||||
|
||||
if (computerCall is null)
|
||||
{
|
||||
if (currentState == SearchState.PressedEnter)
|
||||
{
|
||||
Console.WriteLine("No more computer actions. Done.");
|
||||
Console.WriteLine(response);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if the agent is asking for confirmation to proceed, and if so, respond affirmatively.
|
||||
TextContent? textContent = response.Messages
|
||||
.Where(m => m.Role == ChatRole.Assistant)
|
||||
.SelectMany(m => m.Contents.OfType<TextContent>())
|
||||
.FirstOrDefault();
|
||||
|
||||
if (textContent?.Text is { } text && (
|
||||
text.Contains("Would you like me") ||
|
||||
text.Contains("Should I") ||
|
||||
text.Contains("proceed") ||
|
||||
text.Contains('?')))
|
||||
{
|
||||
response = await agent.RunAsync("Please proceed.", session, runOptions);
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[{i + 1}] Action: {computerCall!.Action.Kind}");
|
||||
|
||||
// Simulate the action and get the resulting screenshot.
|
||||
(currentState, string fileId) = await ComputerUseUtil.GetScreenshotAsync(computerCall.Action, currentState, screenshots);
|
||||
|
||||
// Send the screenshot back as the computer call output.
|
||||
AIContent callOutput = new()
|
||||
{
|
||||
RawRepresentation = new ComputerCallOutputResponseItem(
|
||||
computerCall.CallId,
|
||||
output: ComputerCallOutput.CreateScreenshotOutput(screenshotImageFileId: fileId))
|
||||
};
|
||||
|
||||
response = await agent.RunAsync([new ChatMessage(ChatRole.User, [callOutput])], session: session, options: runOptions);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ComputerUseUtil.EnsureDeleteScreenshotAssetsAsync(fileClient, screenshots);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
# Computer Use with the Responses API
|
||||
# Computer Use with the Responses API
|
||||
|
||||
This sample shows how to use the Computer Use tool with a `ChatClientAgent` using the Responses API directly.
|
||||
This sample shows how to use the Computer Use tool with `AIProjectClient.AsAIAgent(...)`.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- Using `FoundryAITool.CreateComputerTool()` with `ChatClientAgent`
|
||||
- Using `FoundryAITool.CreateComputerTool()` to add computer use capabilities
|
||||
- Processing computer call actions (click, type, key press)
|
||||
- Managing the computer use interaction loop with screenshots
|
||||
- Handling the Azure Agents API workaround for `previous_response_id` with `computer_call_output`
|
||||
|
||||
For more information, see [Use the computer tool](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/computer-use?pivots=csharp).
|
||||
|
||||
## How the simulation works
|
||||
|
||||
In a real computer use scenario, the model controls a virtual keyboard and mouse to interact with a live browser — typing text, clicking buttons, and pressing keys. The host application captures a screenshot after each action and sends it back to the model so it can decide what to do next.
|
||||
|
||||
**This sample does not connect to a real browser.** Instead, it intercepts the model's actions and returns pre-captured screenshots as if the actions were actually performed. No real typing, clicking, or key presses happen — the sample fakes the environment so you can explore the computer use protocol without any browser automation setup.
|
||||
|
||||
### State transitions
|
||||
|
||||
The model receives a screenshot as input, analyzes it, and responds with a computer action as output. The sample maps each action to a new state and returns the corresponding screenshot:
|
||||
|
||||
| Step | Model Action | What Happens | Screenshot Sent Back to Model |
|
||||
|------|-----------------|-------------------------------------------|--------------------------------------------------------------|
|
||||
| 1 | | Session starts with the user prompt | `cua_browser_search.jpg` — empty search page |
|
||||
| 2 | Click | Model clicks the search box to focus it | `cua_browser_search.jpg` — same page |
|
||||
| 3 | Type | Model types the search query into the box | `cua_search_typed.jpg` — search text visible in the box |
|
||||
| 3a | *(text response)* | Model may ask for confirmation instead of acting | `cua_search_typed.jpg` — same page |
|
||||
| 4 | KeyPress Enter | Model presses Enter to submit the search | `cua_search_results.jpg` — search results page |
|
||||
|
||||
### Interaction loop
|
||||
|
||||
1. The user prompt and the initial screenshot (`cua_browser_search.jpg` — an empty search page) are sent to the model as input.
|
||||
2. The model analyzes the screenshot and responds with a computer action (e.g., click on the search box to focus it, then type search text, then press Enter).
|
||||
3. The sample intercepts the action, advances the state, and sends back the next pre-captured screenshot as if the action was performed on a real browser.
|
||||
4. Steps 2–3 repeat until the model stops requesting actions or the iteration limit is reached.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -19,7 +45,7 @@ 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="computer-use-preview"
|
||||
$env:AZURE_AI_COMPUTER_USE_DEPLOYMENT_NAME="computer-use-preview"
|
||||
```
|
||||
|
||||
## Run the sample
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using System.Text;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
@@ -31,22 +32,26 @@ public static class Program
|
||||
{
|
||||
private static async Task Main()
|
||||
{
|
||||
// Set up the Azure OpenAI client
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient();
|
||||
// Set up the Azure AI Project client
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
var chatClient = new AIProjectClient(new Uri(endpoint), new AzureCliCredential())
|
||||
.ProjectOpenAIClient.GetChatClient(deploymentName).AsIChatClient();
|
||||
|
||||
// Create the executors
|
||||
ChatClientAgent physicist = new(
|
||||
var physicist = new ChatClientAgent(
|
||||
chatClient,
|
||||
name: "Physicist",
|
||||
instructions: "You are an expert in physics. You answer questions from a physics perspective."
|
||||
);
|
||||
ChatClientAgent chemist = new(
|
||||
).BindAsExecutor(new AIAgentHostOptions { ForwardIncomingMessages = false });
|
||||
|
||||
var chemist = new ChatClientAgent(
|
||||
chatClient,
|
||||
name: "Chemist",
|
||||
instructions: "You are an expert in chemistry. You answer questions from a chemistry perspective."
|
||||
);
|
||||
).BindAsExecutor(new AIAgentHostOptions { ForwardIncomingMessages = false });
|
||||
|
||||
var startExecutor = new ConcurrentStartExecutor();
|
||||
var aggregationExecutor = new ConcurrentAggregationExecutor();
|
||||
|
||||
@@ -61,11 +66,30 @@ public static class Program
|
||||
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input: "What is temperature?");
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
if (evt is WorkflowOutputEvent output)
|
||||
switch (evt)
|
||||
{
|
||||
Console.WriteLine($"Workflow completed with results:\n{output.Data}");
|
||||
case WorkflowOutputEvent workflowOutput:
|
||||
Console.WriteLine($"Workflow completed with results:\n{workflowOutput.Data}");
|
||||
break;
|
||||
|
||||
case WorkflowErrorEvent workflowError:
|
||||
WriteError(workflowError.Exception?.ToString() ?? "Unknown workflow error occurred");
|
||||
break;
|
||||
|
||||
case ExecutorFailedEvent executorFailed:
|
||||
WriteError($"Executor '{executorFailed.ExecutorId}' failed with {(
|
||||
executorFailed.Data == null ? "unknown error" : $"exception {executorFailed.Data}"
|
||||
)}.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void WriteError(string error)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Write(error);
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +116,7 @@ internal sealed partial class ConcurrentStartExecutor() :
|
||||
// the message but will not start processing until they receive a turn token.
|
||||
await context.SendMessageAsync(new ChatMessage(ChatRole.User, message), cancellationToken: cancellationToken);
|
||||
// Broadcast the turn token to kick off the agents.
|
||||
await context.SendMessageAsync(new TurnToken(emitEvents: true), cancellationToken: cancellationToken);
|
||||
await context.SendMessageAsync(new TurnToken(emitEvents: false), cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,11 +140,19 @@ internal sealed partial class ConcurrentAggregationExecutor() :
|
||||
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._messages.AddRange(message);
|
||||
}
|
||||
|
||||
if (this._messages.Count == 2)
|
||||
protected override ValueTask OnMessageDeliveryFinishedAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
StringBuilder resultBuilder = new();
|
||||
foreach (ChatMessage m in this._messages)
|
||||
{
|
||||
var formattedMessages = string.Join(Environment.NewLine, this._messages.Select(m => $"{m.AuthorName}: {m.Text}"));
|
||||
await context.YieldOutputAsync(formattedMessages, cancellationToken);
|
||||
resultBuilder.AppendLine($"{m.AuthorName}: {m.Text}");
|
||||
resultBuilder.AppendLine();
|
||||
}
|
||||
|
||||
this._messages.Clear();
|
||||
|
||||
return context.YieldOutputAsync(resultBuilder.ToString(), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,27 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
|
||||
#region Convenience methods
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new agent session instance using an existing conversation identifier to continue that conversation.
|
||||
/// </summary>
|
||||
/// <param name="conversationId">The identifier of an existing conversation to continue.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A value task representing the asynchronous operation. The task result contains a new <see cref="AgentSession"/> instance configured to work with the specified conversation.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method creates an <see cref="AgentSession"/> that relies on server-side chat history storage, where the chat history
|
||||
/// is maintained by the underlying AI service rather than by a local <see cref="ChatHistoryProvider"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Agent sessions created with this method will only work with <see cref="FoundryAgent"/>
|
||||
/// instances that support server-side conversation storage through their underlying <see cref="IChatClient"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ValueTask<AgentSession> CreateSessionAsync(string conversationId, CancellationToken cancellationToken = default)
|
||||
=> ((ChatClientAgent)this.InnerAgent).CreateSessionAsync(conversationId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a server-side conversation session that appears in the Foundry Project UI.
|
||||
/// </summary>
|
||||
|
||||
@@ -291,10 +291,6 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
{
|
||||
// Discard each event (including InternalCompletionSignals)
|
||||
}
|
||||
|
||||
// After clearing, signal the run loop to continue if needed
|
||||
// The run loop will send a new completion signal when it finishes processing from the restored state
|
||||
this.SignalInput();
|
||||
}
|
||||
|
||||
public async ValueTask StopAsync()
|
||||
|
||||
@@ -4,6 +4,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
@@ -31,9 +32,16 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
private const string SkillFileName = "SKILL.md";
|
||||
private const int MaxSearchDepth = 2;
|
||||
|
||||
// "." means the skill directory root itself (no sub-folder descent constraint)
|
||||
private const string RootFolderIndicator = ".";
|
||||
|
||||
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 sub-folder names per https://agentskills.io/specification#directory-structure
|
||||
private static readonly string[] s_defaultScriptFolders = ["scripts"];
|
||||
private static readonly string[] s_defaultResourceFolders = ["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.
|
||||
@@ -55,6 +63,8 @@ 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> _scriptFolders;
|
||||
private readonly IReadOnlyList<string> _resourceFolders;
|
||||
private readonly AgentFileSkillScriptRunner? _scriptRunner;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
@@ -88,22 +98,28 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
this._skillPaths = Throw.IfNull(skillPaths);
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<AgentFileSkillsSource>();
|
||||
|
||||
var resolvedOptions = options ?? new AgentFileSkillsSourceOptions();
|
||||
|
||||
ValidateExtensions(resolvedOptions.AllowedResourceExtensions);
|
||||
ValidateExtensions(resolvedOptions.AllowedScriptExtensions);
|
||||
ValidateExtensions(options?.AllowedResourceExtensions);
|
||||
ValidateExtensions(options?.AllowedScriptExtensions);
|
||||
|
||||
this._allowedResourceExtensions = new HashSet<string>(
|
||||
resolvedOptions.AllowedResourceExtensions ?? s_defaultResourceExtensions,
|
||||
options?.AllowedResourceExtensions ?? s_defaultResourceExtensions,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
this._allowedScriptExtensions = new HashSet<string>(
|
||||
resolvedOptions.AllowedScriptExtensions ?? s_defaultScriptExtensions,
|
||||
options?.AllowedScriptExtensions ?? s_defaultScriptExtensions,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
this._scriptFolders = options?.ScriptFolders is not null
|
||||
? [.. ValidateAndNormalizeFolderNames(options.ScriptFolders, this._logger)]
|
||||
: s_defaultScriptFolders;
|
||||
|
||||
this._resourceFolders = options?.ResourceFolders is not null
|
||||
? [.. ValidateAndNormalizeFolderNames(options.ResourceFolders, this._logger)]
|
||||
: s_defaultResourceFolders;
|
||||
|
||||
this._scriptRunner = scriptRunner;
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<AgentFileSkillsSource>();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -179,8 +195,13 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
return null;
|
||||
}
|
||||
|
||||
var resources = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name);
|
||||
var scripts = this.DiscoverScriptFiles(skillDirectoryFullPath, frontmatter.Name);
|
||||
// Append a trailing separator so path-containment checks don't false-match
|
||||
// sibling directories. e.g. "/skills/myskill" matches "/skills/myskill-evil/",
|
||||
// but "/skills/myskill/" does not.
|
||||
string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
|
||||
|
||||
var resources = this.DiscoverResourceFiles(normalizedSkillDirectoryFullPath, frontmatter.Name);
|
||||
var scripts = this.DiscoverScriptFiles(normalizedSkillDirectoryFullPath, frontmatter.Name);
|
||||
|
||||
return new AgentFileSkill(
|
||||
frontmatter: frontmatter,
|
||||
@@ -282,147 +303,213 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans a skill directory for resource files matching the configured extensions.
|
||||
/// Scans configured resource folders within a skill directory for resource files matching the configured extensions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Recursively walks <paramref name="skillDirectoryFullPath"/> and collects files whose extension
|
||||
/// matches the allowed set, excluding <c>SKILL.md</c> itself. Each candidate
|
||||
/// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with
|
||||
/// a warning.
|
||||
/// By default, scans <c>references/</c> and <c>assets/</c> sub-folders as specified by the
|
||||
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
|
||||
/// Configure <see cref="AgentFileSkillsSourceOptions.ResourceFolders"/> 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.
|
||||
/// </remarks>
|
||||
private List<AgentFileSkillResource> DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
|
||||
{
|
||||
string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
|
||||
|
||||
var resources = new List<AgentFileSkillResource>();
|
||||
|
||||
foreach (string folder in this._resourceFolders.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal);
|
||||
|
||||
// GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
|
||||
string targetDirectory = isRootFolder
|
||||
? skillDirectoryFullPath
|
||||
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, folder)) + Path.DirectorySeparatorChar;
|
||||
|
||||
if (!Directory.Exists(targetDirectory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Directory-level symlink check: skip if targetDirectory (or any intermediate
|
||||
// segment) is a reparse point. The root folder is excluded — it's a caller-supplied
|
||||
// trusted path, and the security boundary guards files within it, not the path itself.
|
||||
if (!isRootFolder && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogResourceSymlinkFolder(this._logger, skillName, SanitizePathForLog(folder));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
#if NET
|
||||
var enumerationOptions = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
};
|
||||
var enumerationOptions = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = false,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions))
|
||||
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
|
||||
#else
|
||||
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories))
|
||||
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;
|
||||
}
|
||||
string fileName = Path.GetFileName(filePath);
|
||||
|
||||
// Filter by extension
|
||||
string extension = Path.GetExtension(filePath);
|
||||
if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Debug))
|
||||
// Exclude SKILL.md itself
|
||||
if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension);
|
||||
continue;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize the enumerated path to guard against non-canonical forms
|
||||
string resolvedFilePath = Path.GetFullPath(filePath);
|
||||
|
||||
// Path containment check
|
||||
if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
// Filter by extension
|
||||
string extension = Path.GetExtension(filePath);
|
||||
if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension))
|
||||
{
|
||||
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
if (this._logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
// Normalize the enumerated path to guard against non-canonical forms.
|
||||
// e.g. "references/../../../etc/shadow" → "/etc/shadow"
|
||||
string resolvedFilePath = Path.GetFullPath(filePath);
|
||||
|
||||
// Symlink check
|
||||
if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
// Path containment: reject if the resolved path escapes the target folder.
|
||||
// e.g. "/etc/shadow".StartsWith("/skills/myskill/references/") → false → skip
|
||||
if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
// Compute relative path and normalize to forward slashes
|
||||
string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length));
|
||||
resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans a skill directory for script files matching the configured extensions.
|
||||
/// Scans configured script folders within a skill directory for script files matching the configured extensions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Recursively walks the skill directory and collects files whose extension
|
||||
/// matches the allowed set. Each candidate is validated against path-traversal
|
||||
/// and symlink-escape checks; unsafe files are skipped with a warning.
|
||||
/// By default, scans the <c>scripts/</c> sub-folder as specified by the
|
||||
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
|
||||
/// Configure <see cref="AgentFileSkillsSourceOptions.ScriptFolders"/> 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.
|
||||
/// </remarks>
|
||||
private List<AgentFileSkillScript> DiscoverScriptFiles(string skillDirectoryFullPath, string skillName)
|
||||
{
|
||||
string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
|
||||
var scripts = new List<AgentFileSkillScript>();
|
||||
|
||||
foreach (string folder in this._scriptFolders.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal);
|
||||
|
||||
// GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
|
||||
string targetDirectory = isRootFolder
|
||||
? skillDirectoryFullPath
|
||||
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, folder)) + Path.DirectorySeparatorChar;
|
||||
|
||||
if (!Directory.Exists(targetDirectory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Directory-level symlink check: skip if targetDirectory (or any intermediate
|
||||
// segment) is a reparse point. The root folder is excluded — it's a caller-supplied
|
||||
// trusted path, and the security boundary guards files within it, not the path itself.
|
||||
if (!isRootFolder && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogScriptSymlinkFolder(this._logger, skillName, SanitizePathForLog(folder));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
#if NET
|
||||
var enumerationOptions = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
};
|
||||
var enumerationOptions = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = false,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions))
|
||||
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
|
||||
#else
|
||||
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories))
|
||||
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;
|
||||
}
|
||||
|
||||
// Normalize the enumerated path to guard against non-canonical forms
|
||||
string resolvedFilePath = Path.GetFullPath(filePath);
|
||||
|
||||
// Path containment check
|
||||
if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
// Filter by extension
|
||||
string extension = Path.GetExtension(filePath);
|
||||
if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension))
|
||||
{
|
||||
LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
continue;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
// Normalize the enumerated path to guard against non-canonical forms.
|
||||
// e.g. "scripts/../../../etc/shadow" → "/etc/shadow"
|
||||
string resolvedFilePath = Path.GetFullPath(filePath);
|
||||
|
||||
// Symlink check
|
||||
if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
// Path containment: reject if the resolved path escapes the target folder.
|
||||
// e.g. "/etc/shadow".StartsWith("/skills/myskill/scripts/") → false → skip
|
||||
if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
// Compute relative path and normalize to forward slashes
|
||||
string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.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));
|
||||
|
||||
scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner));
|
||||
}
|
||||
}
|
||||
|
||||
return scripts;
|
||||
@@ -431,14 +518,14 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
/// <summary>
|
||||
/// Checks whether any segment in the path (relative to the directory) is a symlink.
|
||||
/// </summary>
|
||||
private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath)
|
||||
private static bool HasSymlinkInPath(string pathToCheck, string trustedBasePath)
|
||||
{
|
||||
string relativePath = fullPath.Substring(normalizedDirectoryPath.Length);
|
||||
string relativePath = pathToCheck.Substring(trustedBasePath.Length);
|
||||
string[] segments = relativePath.Split(
|
||||
new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
|
||||
[Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
|
||||
StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
string currentPath = normalizedDirectoryPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
string currentPath = trustedBasePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
foreach (string segment in segments)
|
||||
{
|
||||
@@ -454,21 +541,28 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a relative path by replacing backslashes with forward slashes
|
||||
/// and trimming a leading "./" prefix.
|
||||
/// Normalizes a relative path or folder name by stripping a leading "./"/".\",
|
||||
/// trimming trailing directory separators, and replacing backslashes with forward
|
||||
/// slashes.
|
||||
/// </summary>
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
// Strip leading "./" or ".\"
|
||||
if (path.StartsWith("./", StringComparison.Ordinal) ||
|
||||
path.StartsWith(".\\", StringComparison.Ordinal))
|
||||
{
|
||||
path = path.Substring(2);
|
||||
}
|
||||
|
||||
// Trim trailing directory separators
|
||||
path = path.TrimEnd('/', '\\');
|
||||
|
||||
// Normalize all separators to forward slashes
|
||||
if (path.IndexOf('\\') >= 0)
|
||||
{
|
||||
path = path.Replace('\\', '/');
|
||||
}
|
||||
|
||||
if (path.StartsWith("./", StringComparison.Ordinal))
|
||||
{
|
||||
path = path.Substring(2);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -508,6 +602,46 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ValidateAndNormalizeFolderNames(IEnumerable<string> folders, ILogger logger)
|
||||
{
|
||||
foreach (string folder in folders)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(folder))
|
||||
{
|
||||
throw new ArgumentException("Folder names must not be null or whitespace.", nameof(folders));
|
||||
}
|
||||
|
||||
// "." is valid — it means the skill root directory.
|
||||
if (string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal))
|
||||
{
|
||||
yield return folder;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reject absolute paths and any path segments that escape upward.
|
||||
if (Path.IsPathRooted(folder) || ContainsParentTraversalSegment(folder))
|
||||
{
|
||||
LogFolderNameSkippedInvalid(logger, folder);
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return NormalizePath(folder);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ContainsParentTraversalSegment(string folder)
|
||||
{
|
||||
foreach (string segment in folder.Split('/', '\\'))
|
||||
{
|
||||
if (segment == "..")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")]
|
||||
private static partial void LogSkillsDiscovered(ILogger logger, int count);
|
||||
|
||||
@@ -532,6 +666,9 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")]
|
||||
private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Skipping resource folder '{FolderName}' in skill '{SkillName}': folder path contains a symlink")]
|
||||
private static partial void LogResourceSymlinkFolder(ILogger logger, string skillName, string folderName);
|
||||
|
||||
[LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")]
|
||||
private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension);
|
||||
|
||||
@@ -540,4 +677,10 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")]
|
||||
private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Skipping script folder '{FolderName}' in skill '{SkillName}': folder path contains a symlink")]
|
||||
private static partial void LogScriptSymlinkFolder(ILogger logger, string skillName, string folderName);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Skipping invalid folder name '{FolderName}': must be a relative path with no '..' segments")]
|
||||
private static partial void LogFolderNameSkippedInvalid(ILogger logger, string folderName);
|
||||
}
|
||||
|
||||
@@ -30,4 +30,30 @@ public sealed class AgentFileSkillsSourceOptions
|
||||
/// <c>.ps1</c>, <c>.cs</c>, <c>.csx</c>.
|
||||
/// </summary>
|
||||
public IEnumerable<string>? AllowedScriptExtensions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets relative folder 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.
|
||||
/// </summary>
|
||||
public IEnumerable<string>? ScriptFolders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets relative folder 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.
|
||||
/// </summary>
|
||||
public IEnumerable<string>? ResourceFolders { get; set; }
|
||||
}
|
||||
|
||||
@@ -196,6 +196,48 @@ public class FoundryAgentTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreateSessionAsync tests
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSessionAsync_WithConversationId_ReturnsChatClientAgentSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
FoundryAgent agent = new(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "Test");
|
||||
|
||||
const string ConversationId = "test-conversation-id";
|
||||
|
||||
// Act
|
||||
AgentSession session = await agent.CreateSessionAsync(ConversationId);
|
||||
|
||||
// Assert
|
||||
ChatClientAgentSession chatSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal(ConversationId, chatSession.ConversationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSessionAsync_WithoutConversationId_ReturnsChatClientAgentSessionWithoutConversationIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
FoundryAgent agent = new(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "Test");
|
||||
|
||||
// Act
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Assert
|
||||
ChatClientAgentSession chatSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Null(chatSession.ConversationId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Functional tests
|
||||
|
||||
[Fact]
|
||||
|
||||
+54
-7
@@ -114,9 +114,10 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreAlsoDiscoveredAsync()
|
||||
public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreNotDiscoveredAsync()
|
||||
{
|
||||
// Arrange — scripts at any depth in the skill directory are discovered
|
||||
// Arrange — scripts outside configured folders are not discovered; only files directly
|
||||
// inside the configured folder are picked up (no subdirectory recursion)
|
||||
string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body.");
|
||||
CreateFile(skillDir, "convert.py", "print('root')");
|
||||
CreateFile(skillDir, "tools/helper.sh", "echo 'helper'");
|
||||
@@ -125,12 +126,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
// Assert — neither file is in the default scripts/ folder, so no scripts are discovered
|
||||
Assert.Single(skills);
|
||||
var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList();
|
||||
Assert.Equal(2, scriptNames.Count);
|
||||
Assert.Contains("convert.py", scriptNames);
|
||||
Assert.Contains("tools/helper.sh", scriptNames);
|
||||
Assert.Empty(skills[0].Scripts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -230,6 +228,55 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
Assert.Equal(1.60934, capturedArgs["factor"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ScriptFoldersWithNestedPath_DiscoversScriptsAsync()
|
||||
{
|
||||
// Arrange — ScriptFolders configured with a multi-segment relative path (f1/f2/f3)
|
||||
string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script folder", "Body.");
|
||||
CreateFile(skillDir, "f1/f2/f3/run.py", "print('nested')");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ScriptFolders = ["f1/f2/f3"] });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert — script file inside the deeply nested folder is discovered
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Scripts!);
|
||||
Assert.Equal("f1/f2/f3/run.py", skills[0].Scripts![0].Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("./scripts")]
|
||||
[InlineData("./scripts/f1")]
|
||||
[InlineData("./scripts/f1", "./f2")]
|
||||
public async Task GetSkillsAsync_ScriptFolderWithDotSlashPrefix_DiscoversScriptsAsync(params string[] folders)
|
||||
{
|
||||
// Arrange — "./"-prefixed folders 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 folder in folders)
|
||||
{
|
||||
string folderWithoutDotSlash = folder.Substring(2); // strip "./"
|
||||
CreateFile(skillDir, $"{folderWithoutDotSlash}/run.py", "print('dotslash')");
|
||||
}
|
||||
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ScriptFolders = folders });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert — scripts are discovered with names identical to using folders without "./"
|
||||
Assert.Single(skills);
|
||||
Assert.Equal(folders.Length, skills[0].Scripts!.Count);
|
||||
foreach (string folder in folders)
|
||||
{
|
||||
string expectedName = $"{folder.Substring(2)}/run.py";
|
||||
Assert.Contains(skills[0].Scripts!, s => s.Name == expectedName);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateSkillDir(string root, string name, string description, string body)
|
||||
{
|
||||
string skillDir = Path.Combine(root, name);
|
||||
|
||||
+495
-35
@@ -199,12 +199,14 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync()
|
||||
{
|
||||
// Arrange — create resource files in the skill directory
|
||||
// Arrange — create resource files in spec-defined sub-folders
|
||||
string skillDir = Path.Combine(this._testRoot, "resource-skill");
|
||||
string refsDir = Path.Combine(skillDir, "refs");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
string assetsDir = Path.Combine(skillDir, "assets");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
Directory.CreateDirectory(assetsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content");
|
||||
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
|
||||
File.WriteAllText(Path.Combine(assetsDir, "data.json"), "{}");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: resource-skill\ndescription: Has resources\n---\nSee docs for details.");
|
||||
@@ -217,18 +219,19 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Equal(2, skill.Resources!.Count);
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("references/FAQ.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("assets/data.json", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync()
|
||||
{
|
||||
// Arrange — create a file with an extension not in the default list
|
||||
// Arrange — create a file with an extension not in the default list inside a spec folder
|
||||
string skillDir = Path.Combine(this._testRoot, "ext-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(Path.Combine(skillDir, "image.png"), "fake image");
|
||||
File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "image.png"), "fake image");
|
||||
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: ext-skill\ndescription: Extension test\n---\nBody.");
|
||||
@@ -241,7 +244,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("data.json", skill.Resources![0].Name);
|
||||
Assert.Equal("references/data.json", skill.Resources![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -249,8 +252,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
{
|
||||
// Arrange — the SKILL.md file itself should not be in the resource list
|
||||
string skillDir = Path.Combine(this._testRoot, "selfref-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(Path.Combine(skillDir, "notes.md"), "notes");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "notes.md"), "notes");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: selfref-skill\ndescription: Self ref test\n---\nBody.");
|
||||
@@ -263,15 +267,18 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("notes.md", skill.Resources![0].Name);
|
||||
Assert.Equal("references/notes.md", skill.Resources![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_NestedResourceFiles_DiscoveredAsync()
|
||||
{
|
||||
// Arrange — resource files in nested subdirectories
|
||||
// Arrange — resource files directly in references/ are discovered; subdirectories are not scanned
|
||||
string skillDir = Path.Combine(this._testRoot, "nested-res-skill");
|
||||
string deepDir = Path.Combine(skillDir, "level1", "level2");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "top.md"), "top content");
|
||||
string deepDir = Path.Combine(refsDir, "level1", "level2");
|
||||
Directory.CreateDirectory(deepDir);
|
||||
File.WriteAllText(Path.Combine(deepDir, "deep.md"), "deep content");
|
||||
File.WriteAllText(
|
||||
@@ -282,21 +289,23 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
// Assert — only the file directly in references/ is discovered; the nested file is not
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("references/top.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(skill.Resources!, r => r.Name.Contains("deep.md", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync()
|
||||
{
|
||||
// Arrange — use a source with custom extensions
|
||||
// Arrange — use a source with custom extensions; files placed in spec folder
|
||||
string skillDir = Path.Combine(this._testRoot, "custom-ext-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(Path.Combine(skillDir, "data.custom"), "custom data");
|
||||
File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "data.custom"), "custom data");
|
||||
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: custom-ext-skill\ndescription: Custom extensions\n---\nBody.");
|
||||
@@ -309,7 +318,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("data.custom", skill.Resources![0].Name);
|
||||
Assert.Equal("references/data.custom", skill.Resources![0].Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -327,7 +336,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
{
|
||||
// Arrange & Act
|
||||
string skillDir = this.CreateSkillDirectory("null-ext", "A skill", "Body.");
|
||||
File.WriteAllText(Path.Combine(skillDir, "notes.md"), "notes");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "notes.md"), "notes");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Assert — default extensions include .md
|
||||
@@ -351,9 +362,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredAsync()
|
||||
public async Task GetSkillsAsync_ResourceInSkillRoot_NotDiscoveredByDefaultAsync()
|
||||
{
|
||||
// Arrange — resource file directly in the skill directory (not in a subdirectory)
|
||||
// Arrange — resource files directly in the skill directory (not in a spec sub-folder)
|
||||
string skillDir = Path.Combine(this._testRoot, "root-resource-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
|
||||
@@ -366,7 +377,29 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — both root-level resource files should be discovered
|
||||
// Assert — root-level files are NOT discovered unless "." is in ResourceFolders
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].Resources!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootFolderConfiguredAsync()
|
||||
{
|
||||
// Arrange — "." in ResourceFolders 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 { ResourceFolders = ["references", "assets", "."] });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — both root-level resource files (and SKILL.md excluded) should be discovered
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Equal(2, skill.Resources!.Count);
|
||||
@@ -374,6 +407,54 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceInNonSpecFolder_NotDiscoveredByDefaultAsync()
|
||||
{
|
||||
// Arrange — resource in a non-spec folder (neither references/ nor assets/)
|
||||
string skillDir = Path.Combine(this._testRoot, "non-spec-skill");
|
||||
string customDir = Path.Combine(skillDir, "docs");
|
||||
Directory.CreateDirectory(customDir);
|
||||
File.WriteAllText(Path.Combine(customDir, "readme.md"), "docs content");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: non-spec-skill\ndescription: Non-spec folder\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — non-spec folders are not scanned by default
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].Resources!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_CustomResourceFolders_ReplacesDefaultsAsync()
|
||||
{
|
||||
// Arrange — custom ResourceFolders replaces the spec defaults
|
||||
string skillDir = Path.Combine(this._testRoot, "custom-folder-skill");
|
||||
string customDir = Path.Combine(skillDir, "docs");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
Directory.CreateDirectory(customDir);
|
||||
Directory.CreateDirectory(refsDir);
|
||||
File.WriteAllText(Path.Combine(customDir, "readme.md"), "docs content");
|
||||
File.WriteAllText(Path.Combine(refsDir, "ref.md"), "ref content");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: custom-folder-skill\ndescription: Custom folder\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceFolders = ["docs"] });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — only docs/ is scanned; references/ is NOT scanned
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("docs/readme.md", skill.Resources![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_NoResourceFiles_ReturnsEmptyResourcesAsync()
|
||||
{
|
||||
@@ -437,14 +518,14 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
[Fact]
|
||||
public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync()
|
||||
{
|
||||
// Arrange — create a skill with a resource file discovered from the directory
|
||||
// Arrange — create a skill with a resource file discovered from the references folder
|
||||
string skillDir = this.CreateSkillDirectory("read-skill", "A skill", "See docs for details.");
|
||||
string refsDir = Path.Combine(skillDir, "refs");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content here.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var resource = skills[0].Resources!.First(r => r.Name == "refs/doc.md");
|
||||
var resource = skills[0].Resources!.First(r => r.Name == "references/doc.md");
|
||||
|
||||
// Act
|
||||
var content = await resource.ReadAsync();
|
||||
@@ -495,16 +576,18 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_SymlinkInPath_SkipsSymlinkedResourcesAsync()
|
||||
{
|
||||
// Arrange — a "refs" subdirectory is a symlink pointing outside the skill directory
|
||||
// Arrange — references/ is a symlink pointing outside the skill directory;
|
||||
// a legitimate file lives in assets/ and should still be discovered.
|
||||
string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(Path.Combine(skillDir, "legit.md"), "legit content");
|
||||
string assetsDir = Path.Combine(skillDir, "assets");
|
||||
Directory.CreateDirectory(assetsDir);
|
||||
File.WriteAllText(Path.Combine(assetsDir, "legit.md"), "legit content");
|
||||
|
||||
string outsideDir = Path.Combine(this._testRoot, "outside");
|
||||
Directory.CreateDirectory(outsideDir);
|
||||
File.WriteAllText(Path.Combine(outsideDir, "secret.md"), "secret content");
|
||||
|
||||
string refsLink = Path.Combine(skillDir, "refs");
|
||||
string refsLink = Path.Combine(skillDir, "references");
|
||||
try
|
||||
{
|
||||
Directory.CreateSymbolicLink(refsLink, outsideDir);
|
||||
@@ -523,11 +606,129 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — skill should still load, but symlinked resources should be excluded
|
||||
// Assert — skill should still load, the symlinked references/ is skipped, assets/legit.md is found
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-escape-skill");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("legit.md", skill.Resources![0].Name);
|
||||
Assert.Equal("assets/legit.md", skill.Resources![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_SymlinkedResourceFolder_SkipsWithoutEnumeratingAsync()
|
||||
{
|
||||
// Arrange — references/ is a symlink pointing outside the skill directory.
|
||||
// The directory-level check should skip it entirely (no file enumeration),
|
||||
// so even files with valid extensions in the target are not discovered.
|
||||
string skillDir = Path.Combine(this._testRoot, "symlink-folder-skip");
|
||||
string assetsDir = Path.Combine(skillDir, "assets");
|
||||
Directory.CreateDirectory(assetsDir);
|
||||
File.WriteAllText(Path.Combine(assetsDir, "legit.md"), "legit content");
|
||||
|
||||
string outsideDir = Path.Combine(this._testRoot, "outside-resources");
|
||||
Directory.CreateDirectory(outsideDir);
|
||||
File.WriteAllText(Path.Combine(outsideDir, "external.md"), "external content");
|
||||
File.WriteAllText(Path.Combine(outsideDir, "data.json"), "{}");
|
||||
|
||||
string refsLink = Path.Combine(skillDir, "references");
|
||||
try
|
||||
{
|
||||
Directory.CreateSymbolicLink(refsLink, outsideDir);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Symlink creation requires elevation on some platforms; skip gracefully.
|
||||
return;
|
||||
}
|
||||
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: symlink-folder-skip\ndescription: Symlinked folder skip\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — only assets/legit.md is found; the symlinked references/ folder is skipped entirely
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-folder-skip");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("assets/legit.md", skill.Resources![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_SymlinkedScriptFolder_SkipsWithoutEnumeratingAsync()
|
||||
{
|
||||
// Arrange — scripts/ is a symlink pointing outside the skill directory.
|
||||
// The directory-level check should skip it entirely.
|
||||
string skillDir = Path.Combine(this._testRoot, "symlink-script-skip");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
|
||||
string outsideDir = Path.Combine(this._testRoot, "outside-scripts");
|
||||
Directory.CreateDirectory(outsideDir);
|
||||
File.WriteAllText(Path.Combine(outsideDir, "malicious.py"), "import os; os.system('rm -rf /')");
|
||||
|
||||
string scriptsLink = Path.Combine(skillDir, "scripts");
|
||||
try
|
||||
{
|
||||
Directory.CreateSymbolicLink(scriptsLink, outsideDir);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: symlink-script-skip\ndescription: Symlinked script folder\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — skill loads but scripts from the symlinked folder are not discovered
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-script-skip");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Empty(skill.Scripts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomFolderAsync()
|
||||
{
|
||||
// Arrange — custom resource folder "sub/resources" where "sub" is a symlink.
|
||||
// The directory-level HasSymlinkInPath check should detect the intermediate symlink.
|
||||
string skillDir = Path.Combine(this._testRoot, "symlink-intermediate");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
|
||||
string outsideDir = Path.Combine(this._testRoot, "outside-intermediate");
|
||||
string outsideResources = Path.Combine(outsideDir, "resources");
|
||||
Directory.CreateDirectory(outsideResources);
|
||||
File.WriteAllText(Path.Combine(outsideResources, "data.md"), "data");
|
||||
|
||||
string subLink = Path.Combine(skillDir, "sub");
|
||||
try
|
||||
{
|
||||
Directory.CreateSymbolicLink(subLink, outsideDir);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: symlink-intermediate\ndescription: Intermediate symlink\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(
|
||||
this._testRoot,
|
||||
s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceFolders = ["sub/resources"] });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — the symlinked intermediate segment causes the folder to be skipped
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-intermediate");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Empty(skill.Resources!);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -693,6 +894,170 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
Assert.Null(fm.Metadata);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("..")]
|
||||
[InlineData("../escape")]
|
||||
[InlineData("sub/../escape")]
|
||||
[InlineData("/absolute")]
|
||||
[InlineData("\\absolute")]
|
||||
public void Constructor_InvalidFolderName_SkipsInvalidFolders(string badFolder)
|
||||
{
|
||||
// Arrange & Act — invalid folders are skipped with a warning rather than throwing
|
||||
var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [badFolder] });
|
||||
var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceFolders = [badFolder] });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(source1);
|
||||
Assert.NotNull(source2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Constructor_NullOrWhitespaceFolderName_ThrowsArgumentException(string? badFolder)
|
||||
{
|
||||
// Arrange & Act & Assert — null/whitespace is a contract violation, not a config error
|
||||
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [badFolder!] }));
|
||||
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceFolders = [badFolder!] }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("scripts")]
|
||||
[InlineData("my-scripts")]
|
||||
[InlineData("sub/folder")]
|
||||
[InlineData(".")]
|
||||
[InlineData("./scripts")]
|
||||
[InlineData("./scripts/f1")]
|
||||
[InlineData("my..scripts")]
|
||||
public void Constructor_ValidFolderName_DoesNotThrow(string validFolder)
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [validFolder] });
|
||||
Assert.NotNull(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_DuplicateFoldersAfterNormalization_NoDuplicateResourcesAsync()
|
||||
{
|
||||
// Arrange — "references" and "./references" refer to the same directory;
|
||||
// after normalization they should be deduplicated so resources appear only once.
|
||||
string skillDir = Path.Combine(this._testRoot, "dedup-folder-skill");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: dedup-folder-skill\ndescription: Dedup test\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "./references"] });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — only one copy of the resource despite two equivalent folder entries
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Resources!);
|
||||
Assert.Equal("references/FAQ.md", skills[0].Resources![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_TrailingSlashFolderNormalized_NoDuplicateResourcesAsync()
|
||||
{
|
||||
// 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 { ResourceFolders = ["references", "references/"] });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — trailing slash variant deduplicated
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Resources!);
|
||||
Assert.Equal("references/data.json", skills[0].Resources![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_BackslashFolderNormalized_NoDuplicateScriptsAsync()
|
||||
{
|
||||
// Arrange — ".\\scripts" should be normalized to "scripts"
|
||||
string skillDir = Path.Combine(this._testRoot, "backslash-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: backslash-skill\ndescription: Backslash test\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ScriptFolders = ["scripts", ".\\scripts"] });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — backslash variant deduplicated
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Scripts!);
|
||||
Assert.Equal("scripts/run.py", skills[0].Scripts![0].Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("./references")]
|
||||
[InlineData("./assets/docs")]
|
||||
public async Task GetSkillsAsync_ResourceFolderWithDotSlashPrefix_DiscoversResourcesAsync(string folder)
|
||||
{
|
||||
// Arrange — "./references" and "./assets/docs" are equivalent to "references" and "assets/docs";
|
||||
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
|
||||
string folderWithoutDotSlash = folder.Substring(2); // strip "./"
|
||||
string skillDir = Path.Combine(this._testRoot, "dotslash-res-skill");
|
||||
string targetDir = Path.Combine(skillDir, folderWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar));
|
||||
Directory.CreateDirectory(targetDir);
|
||||
File.WriteAllText(Path.Combine(targetDir, "data.json"), "{}");
|
||||
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 { ResourceFolders = [folder] });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — the resource is discovered with a name identical to using the folder without "./"
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Resources!);
|
||||
Assert.Equal($"{folderWithoutDotSlash}/data.json", skills[0].Resources![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceFoldersWithNestedPath_DiscoversResourcesAsync()
|
||||
{
|
||||
// Arrange — ResourceFolders configured with a multi-segment relative path (f1/f2/f3)
|
||||
string skillDir = Path.Combine(this._testRoot, "nested-folder-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: nested-folder-skill\ndescription: Nested folder\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceFolders = ["f1/f2/f3"] });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — resource file inside the deeply nested folder is discovered
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("f1/f2/f3/data.json", skill.Resources![0].Name);
|
||||
}
|
||||
|
||||
private string CreateSkillDirectory(string name, string description, string body)
|
||||
{
|
||||
string skillDir = Path.Combine(this._testRoot, name);
|
||||
@@ -710,4 +1075,99 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), rawContent);
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("txt")]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Constructor_InvalidScriptExtension_ThrowsArgumentException(string badExtension)
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(
|
||||
this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { AllowedScriptExtensions = new string[] { badExtension } }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_SkillBeyondMaxDepth_NotDiscoveredAsync()
|
||||
{
|
||||
// Arrange — create a skill at depth 3 (exceeds MaxSearchDepth = 2)
|
||||
string deepDir = Path.Combine(this._testRoot, "l1", "l2", "l3", "deep-skill");
|
||||
Directory.CreateDirectory(deepDir);
|
||||
File.WriteAllText(
|
||||
Path.Combine(deepDir, "SKILL.md"),
|
||||
"---\nname: deep-skill\ndescription: Too deep\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — skill at depth 3 should not be discovered
|
||||
Assert.DoesNotContain(skills, s => s.Frontmatter.Name == "deep-skill");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootFolderConfiguredAsync()
|
||||
{
|
||||
// Arrange — script file directly in the skill directory with ScriptFolders = ["."]
|
||||
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 { ScriptFolders = ["."] });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — script at the skill root should be discovered
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "root-script-skill");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Single(skill.Scripts!);
|
||||
Assert.Equal("run.py", skill.Scripts![0].Name);
|
||||
}
|
||||
|
||||
#if NET
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_SymlinkedFileInRealFolder_SkipsSymlinkedFileAsync()
|
||||
{
|
||||
// Arrange — references/ is a real directory, but one file inside it is a symlink
|
||||
// pointing outside the skill directory. The per-file symlink check should skip it.
|
||||
string skillDir = Path.Combine(this._testRoot, "symlink-file-skill");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "legit.md"), "legit content");
|
||||
|
||||
string outsideDir = Path.Combine(this._testRoot, "outside-file");
|
||||
Directory.CreateDirectory(outsideDir);
|
||||
File.WriteAllText(Path.Combine(outsideDir, "secret.md"), "secret content");
|
||||
|
||||
string symlinkFile = Path.Combine(refsDir, "leak.md");
|
||||
try
|
||||
{
|
||||
File.CreateSymbolicLink(symlinkFile, Path.Combine(outsideDir, "secret.md"));
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Symlink creation requires elevation on some platforms; skip gracefully.
|
||||
return;
|
||||
}
|
||||
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: symlink-file-skill\ndescription: Symlinked file\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — only legit.md should be discovered; the symlinked leak.md is skipped
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-file-skill");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("references/legit.md", skill.Resources![0].Name);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user