diff --git a/README.md b/README.md index 4d5a9a30fc..50c0e271fe 100644 --- a/README.md +++ b/README.md @@ -120,38 +120,38 @@ if __name__ == "__main__": ``` ### Basic Agent - .NET +Create a simple Agent, using Microsoft Foundry with token-based auth, that writes a haiku about the Microsoft Agent Framework + +```c# +// dotnet add package Microsoft.Agents.AI.Foundry +// Use `az login` to authenticate with Azure CLI +using Azure.AI.Projects; +using Azure.Identity; +using System; +using Azure.AI.Projects; +using Azure.Identity; + +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-5.4-mini"; + +var agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) + .AsAIAgent(model: deploymentName, name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully."); + +Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework.")); +``` Create a simple Agent, using OpenAI Responses, that writes a haiku about the Microsoft Agent Framework ```c# -// dotnet add package Microsoft.Agents.AI.OpenAI --prerelease -using Microsoft.Agents.AI; +// dotnet add package Microsoft.Agents.AI.OpenAI +using System; using OpenAI; using OpenAI.Responses; // Replace the with your OpenAI API key. var agent = new OpenAIClient("") - .GetResponsesClient("gpt-4o-mini") - .AsAIAgent(name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully."); - -Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework.")); -``` - -Create a simple Agent, using Microsoft Foundry with token-based auth, that writes a haiku about the Microsoft Agent Framework - -```c# -// dotnet add package Microsoft.Agents.AI.AzureAI --prerelease -// dotnet add package Azure.Identity -// Use `az login` to authenticate with Azure CLI -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; - -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 agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) - .AsAIAgent(model: deploymentName, name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully."); + .GetResponsesClient() + .AsAIAgent(model: "gpt-5.4-mini", name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully."); Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework.")); ``` @@ -207,4 +207,9 @@ The samples typically read configuration from environment variables. Common requ ## Important Notes -If you use the Microsoft Agent Framework to build applications that operate with third-party servers or agents, you do so at your own risk. We recommend reviewing all data being shared with third-party servers or agents and being cognizant of third-party practices for retention and location of data. It is your responsibility to manage whether your data will flow outside of your organization's Azure compliance and geographic boundaries and any related implications. +> [!IMPORTANT] +> If you use Microsoft Agent Framework to build applications that operate with any third-party servers, agents, code, or non-Azure Direct models (“Third-Party Systems”), you do so at your own risk. Third-Party Systems are Non-Microsoft Products under the Microsoft Product Terms and are governed by their own third-party license terms. You are responsible for any usage and associated costs. +> +>We recommend reviewing all data being shared with and received from Third-Party Systems and being cognizant of third-party practices for handling, sharing, retention and location of data. It is your responsibility to manage whether your data will flow outside of your organization’s Azure compliance and geographic boundaries and any related implications, and that appropriate permissions, boundaries and approvals are provisioned. +> +>You are responsible for carefully reviewing and testing applications you build using Microsoft Agent Framework in the context of your specific use cases, and making all appropriate decisions and customizations. This includes implementing your own responsible AI mitigations such as metaprompt, content filters, or other safety systems, and ensuring your applications meet appropriate quality, reliability, security, and trustworthiness standards. See also: [Transparency FAQ](./TRANSPARENCY_FAQ.md) diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj index 7bd94bd716..9b717d9447 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj @@ -6,7 +6,7 @@ enable enable - $(NoWarn);OPENAICUA001 + $(NoWarn);OPENAICUA001;MEAI001 @@ -19,13 +19,13 @@ - + Always - + Always - + Always diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_browser_search.jpg b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_browser_search.jpg new file mode 100644 index 0000000000..372916a298 Binary files /dev/null and b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_browser_search.jpg differ diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_browser_search.png b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_browser_search.png deleted file mode 100644 index 5984b95cb3..0000000000 Binary files a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_browser_search.png and /dev/null differ diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_results.jpg b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_results.jpg new file mode 100644 index 0000000000..02920b3fd7 Binary files /dev/null and b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_results.jpg differ diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_results.png b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_results.png deleted file mode 100644 index ed3ab3d8d4..0000000000 Binary files a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_results.png and /dev/null differ diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_typed.jpg b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_typed.jpg new file mode 100644 index 0000000000..3d6100f5b7 Binary files /dev/null and b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_typed.jpg differ diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_typed.png b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_typed.png deleted file mode 100644 index 04d76e2075..0000000000 Binary files a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_typed.png and /dev/null differ diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/ComputerUseUtil.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/ComputerUseUtil.cs index 1ee421b465..d1df3e7ccd 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/ComputerUseUtil.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/ComputerUseUtil.cs @@ -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 { - /// - /// Load and convert screenshot images to base64 data URLs. - /// - internal static Dictionary LoadScreenshotAssets() + internal static async Task> 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 screenshots = []; - foreach (var (key, fileName) in screenshotFiles) + Dictionary 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 screenshots) + { + foreach (var (_, fileId) in screenshots) + { + try + { + await fileClient.DeleteAsync(fileId); + } + catch + { + } + } + } + /// - /// 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. /// - internal static (SearchState CurrentState, byte[] ImageBytes) HandleComputerActionAndTakeScreenshot( + internal static async Task<(SearchState State, string FileId)> GetScreenshotAsync( ComputerCallAction action, SearchState currentState, - Dictionary screenshots) + Dictionary 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)); } diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs index d79c1122b7..00e4e02843 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs @@ -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 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 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 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()) + .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); +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md index ecaa18e10f..eee05e2a69 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md @@ -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 diff --git a/dotnet/samples/03-workflows/Concurrent/Concurrent/Concurrent.csproj b/dotnet/samples/03-workflows/Concurrent/Concurrent/Concurrent.csproj index 35897932e0..b4a3f86230 100644 --- a/dotnet/samples/03-workflows/Concurrent/Concurrent/Concurrent.csproj +++ b/dotnet/samples/03-workflows/Concurrent/Concurrent/Concurrent.csproj @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/03-workflows/Concurrent/Concurrent/Program.cs b/dotnet/samples/03-workflows/Concurrent/Concurrent/Program.cs index 57b650ce82..c5f77c80f4 100644 --- a/dotnet/samples/03-workflows/Concurrent/Concurrent/Program.cs +++ b/dotnet/samples/03-workflows/Concurrent/Concurrent/Program.cs @@ -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 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); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs index b1d97a5c9c..6a40c129ec 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs @@ -109,6 +109,27 @@ public sealed class FoundryAgent : DelegatingAIAgent #region Convenience methods + /// + /// Creates a new agent session instance using an existing conversation identifier to continue that conversation. + /// + /// The identifier of an existing conversation to continue. + /// The to monitor for cancellation requests. + /// + /// A value task representing the asynchronous operation. The task result contains a new instance configured to work with the specified conversation. + /// + /// + /// + /// This method creates an that relies on server-side chat history storage, where the chat history + /// is maintained by the underlying AI service rather than by a local . + /// + /// + /// Agent sessions created with this method will only work with + /// instances that support server-side conversation storage through their underlying . + /// + /// + public ValueTask CreateSessionAsync(string conversationId, CancellationToken cancellationToken = default) + => ((ChatClientAgent)this.InnerAgent).CreateSessionAsync(conversationId, cancellationToken); + /// /// Creates a server-side conversation session that appears in the Foundry Project UI. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs index 49f146d46a..e50e65b5a3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs @@ -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() diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs index c6dc3bc629..79595f17a2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -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 _skillPaths; private readonly HashSet _allowedResourceExtensions; private readonly HashSet _allowedScriptExtensions; + private readonly IReadOnlyList _scriptFolders; + private readonly IReadOnlyList _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(); - var resolvedOptions = options ?? new AgentFileSkillsSourceOptions(); - - ValidateExtensions(resolvedOptions.AllowedResourceExtensions); - ValidateExtensions(resolvedOptions.AllowedScriptExtensions); + ValidateExtensions(options?.AllowedResourceExtensions); + ValidateExtensions(options?.AllowedScriptExtensions); this._allowedResourceExtensions = new HashSet( - resolvedOptions.AllowedResourceExtensions ?? s_defaultResourceExtensions, + options?.AllowedResourceExtensions ?? s_defaultResourceExtensions, StringComparer.OrdinalIgnoreCase); this._allowedScriptExtensions = new HashSet( - 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(); } /// @@ -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 } /// - /// 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. /// /// - /// Recursively walks and collects files whose extension - /// matches the allowed set, excluding SKILL.md itself. Each candidate - /// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with - /// a warning. + /// By default, scans references/ and assets/ sub-folders as specified by the + /// Agent Skills specification. + /// Configure to scan different or + /// additional directories, including "." for the skill root itself. + /// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped. /// private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName) { - string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; - var resources = new List(); + 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; } /// - /// 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. /// /// - /// 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 scripts/ sub-folder as specified by the + /// Agent Skills specification. + /// Configure to scan different or + /// additional directories, including "." for the skill root itself. + /// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped. /// private List DiscoverScriptFiles(string skillDirectoryFullPath, string skillName) { - string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; var scripts = new List(); + 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 /// /// Checks whether any segment in the path (relative to the directory) is a symlink. /// - 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 } /// - /// 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. /// 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 ValidateAndNormalizeFolderNames(IEnumerable 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); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs index edaec327fa..fcd9398104 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs @@ -30,4 +30,30 @@ public sealed class AgentFileSkillsSourceOptions /// .ps1, .cs, .csx. /// public IEnumerable? AllowedScriptExtensions { get; set; } + + /// + /// Gets or sets relative folder paths to scan for script files within each skill directory. + /// Values may be single-segment names (e.g., "scripts") or multi-segment relative + /// paths (e.g., "sub/scripts"). Use "." to include files directly at the + /// skill root. Leading "./" prefixes, trailing separators, and backslashes are + /// normalized automatically; paths containing ".." segments or absolute paths are + /// rejected. + /// When , defaults to scripts (per the + /// Agent Skills specification). + /// When set, replaces the defaults entirely. + /// + public IEnumerable? ScriptFolders { get; set; } + + /// + /// Gets or sets relative folder paths to scan for resource files within each skill directory. + /// Values may be single-segment names (e.g., "references") or multi-segment relative + /// paths (e.g., "sub/resources"). Use "." to include files directly at the + /// skill root. Leading "./" prefixes, trailing separators, and backslashes are + /// normalized automatically; paths containing ".." segments or absolute paths are + /// rejected. + /// When , defaults to references and assets (per the + /// Agent Skills specification). + /// When set, replaces the defaults entirely. + /// + public IEnumerable? ResourceFolders { get; set; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs index 1ddc8c7c82..31f981d5c6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs @@ -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(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(session); + Assert.Null(chatSession.ConversationId); + } + + #endregion + #region Functional tests [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs index ef8f7780a6..d524b6142a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs @@ -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); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs index e9dc2e0358..ca0884ea43 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs @@ -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(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [badFolder!] })); + Assert.Throws(() => 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(() => 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 } diff --git a/python/samples/02-agents/context_providers/neo4j/README.md b/python/samples/02-agents/context_providers/neo4j/README.md new file mode 100644 index 0000000000..a1b51a7a97 --- /dev/null +++ b/python/samples/02-agents/context_providers/neo4j/README.md @@ -0,0 +1,19 @@ +# Neo4j Context Providers + +Neo4j offers two context providers for the Agent Framework, each serving a different purpose: + +| | [Neo4j Memory](../neo4j_memory/README.md) | [Neo4j GraphRAG](../../../05-end-to-end/neo4j_graphrag/README.md) | +|---|---|---| +| **What it does** | Read-write memory — stores conversations, builds knowledge graphs, learns from interactions | Read-only retrieval from a pre-existing knowledge base with optional graph traversal | +| **Data source** | Agent interactions (grows over time) | Pre-loaded documents and indexes | +| **Python package** | [`neo4j-agent-memory`](https://pypi.org/project/neo4j-agent-memory/) | [`agent-framework-neo4j`](https://pypi.org/project/agent-framework-neo4j/) | +| **Database setup** | Empty — creates its own schema | Requires pre-indexed documents with vector or fulltext indexes | +| **Example use case** | "Remember my preferences", "What did we discuss last time?" | "Search our documents", "What risks does Acme Corp face?" | + +## Which should I use? + +**Use [Neo4j Memory](../neo4j_memory/README.md)** when your agent needs to remember things across sessions — user preferences, past conversations, extracted entities, and reasoning traces. The memory provider writes to the database on every interaction, building a knowledge graph that grows over time. + +**Use [Neo4j GraphRAG](../../../05-end-to-end/neo4j_graphrag/README.md)** when your agent needs to search an existing knowledge base — documents, articles, product catalogs — and optionally enrich results by traversing graph relationships. The GraphRAG provider is read-only and does not modify your data. + +You can use both together: GraphRAG for domain knowledge retrieval, Memory for personalization and learning. \ No newline at end of file diff --git a/python/samples/02-agents/context_providers/neo4j_memory/README.md b/python/samples/02-agents/context_providers/neo4j_memory/README.md new file mode 100644 index 0000000000..78be8f0dbb --- /dev/null +++ b/python/samples/02-agents/context_providers/neo4j_memory/README.md @@ -0,0 +1,9 @@ +# Neo4j Memory Context Provider + +[Neo4j Agent Memory](https://github.com/neo4j-labs/agent-memory) is a graph-native memory system for AI agents that stores conversations, builds knowledge graphs from interactions, and lets agents learn from their own reasoning — all backed by Neo4j. + +For full documentation, installation instructions, code examples, and configuration details, see the [Neo4j Memory integration guide on Microsoft Learn](https://learn.microsoft.com/agent-framework/integrations/neo4j-memory). + +For a runnable example, see the [retail assistant sample](https://github.com/neo4j-labs/agent-memory/tree/main/examples/microsoft_agent_retail_assistant). + +For help choosing between the Memory and GraphRAG providers, see the [Neo4j Context Providers overview](../neo4j/README.md). diff --git a/python/samples/05-end-to-end/neo4j_graphrag/README.md b/python/samples/05-end-to-end/neo4j_graphrag/README.md index ab7f5e590e..c721e1538f 100644 --- a/python/samples/05-end-to-end/neo4j_graphrag/README.md +++ b/python/samples/05-end-to-end/neo4j_graphrag/README.md @@ -4,6 +4,8 @@ The [Neo4j GraphRAG context provider](https://github.com/neo4j-labs/neo4j-maf-pr This sample keeps setup lightweight by using a pre-built Neo4j fulltext index plus a graph-enrichment query. +For full documentation, see the [Neo4j GraphRAG integration guide on Microsoft Learn](https://learn.microsoft.com/agent-framework/integrations/neo4j-graphrag). + ## Example | File | Description |