diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 2bb11b01b8..424f88d7bf 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -82,6 +82,7 @@ + diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs index 290c3f9b6b..eef57e840a 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs @@ -22,6 +22,9 @@ var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills")); // --- Agent Setup --- +// 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. AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetResponsesClient(deploymentName) .AsAIAgent(new ChatClientAgentOptions diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/Agent_Step02_ScriptExecutionWithCodeInterpreter.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/Agent_Step02_ScriptExecutionWithCodeInterpreter.csproj new file mode 100644 index 0000000000..2a503bbfb2 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/Agent_Step02_ScriptExecutionWithCodeInterpreter.csproj @@ -0,0 +1,28 @@ + + + + Exe + net10.0 + + enable + enable + $(NoWarn);MAAI001 + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/Program.cs new file mode 100644 index 0000000000..2835ec70ab --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/Program.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use Agent Skills with script execution via the hosted code interpreter. +// When FileAgentSkillScriptExecutor.HostedCodeInterpreter() is configured, the agent can load and execute scripts +// from skill resources using the LLM provider's built-in code interpreter. +// +// This sample includes the password-generator skill: +// - A Python script for generating secure passwords + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using OpenAI.Responses; + +// --- Configuration --- +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// --- Skills Provider with Script Execution --- +// Discovers skills and enables script execution via the hosted code interpreter +var skillsProvider = new FileAgentSkillsProvider( + skillPath: Path.Combine(AppContext.BaseDirectory, "skills"), + options: new FileAgentSkillsProviderOptions + { + ScriptExecutor = FileAgentSkillScriptExecutor.HostedCodeInterpreter() + }); + +// --- Agent Setup --- +// 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. +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetResponsesClient(deploymentName) + .AsAIAgent(new ChatClientAgentOptions + { + Name = "SkillsAgent", + ChatOptions = new() + { + Instructions = "You are a helpful assistant that can generate secure passwords.", + }, + AIContextProviders = [skillsProvider], + }); + +// --- Example: Password generation with script execution --- +Console.WriteLine("Example: Generating a password with a skill script"); +Console.WriteLine("---------------------------------------------------"); +AgentResponse response = await agent.RunAsync("Generate a secure password for my database account."); +Console.WriteLine($"Agent: {response.Text}\n"); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/README.md new file mode 100644 index 0000000000..f5bf63c44a --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/README.md @@ -0,0 +1,72 @@ +# Script Execution with Code Interpreter + +This sample demonstrates how to use **Agent Skills** with **script execution** via the hosted code interpreter. + +## What's Different from Step01? + +In the [basic skills sample](../Agent_Step01_BasicSkills/), skills only provide instructions and resources as text. This sample adds **script execution** — the agent can load Python scripts from skill resources and execute them using the LLM provider's built-in code interpreter. + +This is enabled by configuring `FileAgentSkillScriptExecutor.HostedCodeInterpreter()` on the skills provider options: + +```csharp +var skillsProvider = new FileAgentSkillsProvider( + skillPath: Path.Combine(AppContext.BaseDirectory, "skills"), + options: new FileAgentSkillsProviderOptions + { + ScriptExecutor = FileAgentSkillScriptExecutor.HostedCodeInterpreter() + }); +``` + +## Skills Included + +### password-generator +Generates secure passwords using a Python script with configurable length and complexity. +- `scripts/generate.py` — Password generation script +- `references/PASSWORD_GUIDELINES.md` — Recommended length and symbol sets by use case + +## Project Structure + +``` +Agent_Step02_ScriptExecutionWithCodeInterpreter/ +├── Program.cs +├── Agent_Step02_ScriptExecutionWithCodeInterpreter.csproj +└── skills/ + └── password-generator/ + ├── SKILL.md + ├── scripts/ + │ └── generate.py + └── references/ + └── PASSWORD_GUIDELINES.md +``` + +## Running the Sample + +### Prerequisites +- .NET 10.0 SDK +- Azure OpenAI endpoint with a deployed model that supports code interpreter + +### Setup +1. Set environment variables: + ```bash + export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" + export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" + ``` + +2. Run the sample: + ```bash + dotnet run + ``` + +### Example + +The sample asks the agent to generate a secure password. The agent: +1. Loads the password-generator skill +2. Reads the `generate.py` script via `read_skill_resource` +3. Executes the script using the code interpreter with appropriate parameters +4. Returns the generated password + +## Learn More + +- [Agent Skills Specification](https://agentskills.io/) +- [Step01: Basic Skills](../Agent_Step01_BasicSkills/) — Skills without script execution +- [Microsoft Agent Framework Documentation](../../../../../docs/) diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/skills/password-generator/SKILL.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/skills/password-generator/SKILL.md new file mode 100644 index 0000000000..c3ef67401b --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/skills/password-generator/SKILL.md @@ -0,0 +1,16 @@ +--- +name: password-generator +description: Generate secure passwords using a Python script. Use when asked to create passwords or credentials. +--- + +# Password Generator + +This skill generates secure passwords using a Python script. + +## Usage + +When the user requests a password: +1. First, review `references/PASSWORD_GUIDELINES.md` to determine the recommended password length and character sets for the user's use case +2. Load `scripts/generate.py` and adjust its parameters (length, character set) based on the guidelines and user's requirements +3. Execute the script +4. Present the generated password clearly diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/skills/password-generator/references/PASSWORD_GUIDELINES.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/skills/password-generator/references/PASSWORD_GUIDELINES.md new file mode 100644 index 0000000000..be9145a4dd --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/skills/password-generator/references/PASSWORD_GUIDELINES.md @@ -0,0 +1,24 @@ +# Password Generation Guidelines + +## General Rules + +- Never reuse passwords across services. +- Always use cryptographically secure randomness (e.g., `random.SystemRandom()`). +- Avoid dictionary words, keyboard patterns, and personal information. + +## Recommended Settings by Use Case + +| Use Case | Min Length | Character Set | Example | +|-----------------------|-----------|----------------------------------------|--------------------------| +| Web account | 16 | Upper + lower + digits + symbols | `G7!kQp@2xM#nW9$z` | +| Database credential | 24 | Upper + lower + digits + symbols | `aR3$vK8!mN2@pQ7&xL5#wY` | +| Wi-Fi / network key | 20 | Upper + lower + digits + symbols | `Ht4&jL9!rP2#mK7@xQ` | +| API key / token | 32 | Upper + lower + digits (no symbols) | `k8Rm3xQ7nW2pL9vT4jH6yA` | +| Encryption passphrase | 32 | Upper + lower + digits + symbols | `Xp4!kR8@mN2#vQ7&jL9$wT` | + +## Symbol Sets + +- **Standard symbols**: `!@#$%^&*()-_=+` +- **Extended symbols**: `~`{}[]|;:'",.<>?/\` +- **Safe symbols** (URL/shell-safe): `!@#$&*-_=+` +- If the target system restricts symbols, use only the **safe** set. diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/skills/password-generator/scripts/generate.py b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/skills/password-generator/scripts/generate.py new file mode 100644 index 0000000000..b44f3d9731 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/skills/password-generator/scripts/generate.py @@ -0,0 +1,11 @@ +# Password generator script +# Usage: Adjust 'length' as needed, then run + +import random +import string + +length = 16 # desired length + +pool = string.ascii_lowercase + string.ascii_uppercase + string.digits + string.punctuation +password = "".join(random.SystemRandom().choice(pool) for _ in range(length)) +print(f"Generated password ({length} chars): {password}") diff --git a/dotnet/samples/02-agents/AgentSkills/README.md b/dotnet/samples/02-agents/AgentSkills/README.md index 8488ec9eed..477a738fb8 100644 --- a/dotnet/samples/02-agents/AgentSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/README.md @@ -5,3 +5,4 @@ Samples demonstrating Agent Skills capabilities. | Sample | Description | |--------|-------------| | [Agent_Step01_BasicSkills](Agent_Step01_BasicSkills/) | Using Agent Skills with a ChatClientAgent, including progressive disclosure and skill resources | +| [Agent_Step02_ScriptExecutionWithCodeInterpreter](Agent_Step02_ScriptExecutionWithCodeInterpreter/) | Using Agent Skills with script execution via the hosted code interpreter | diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs index f28bad3ab0..da0d0b83dd 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -13,7 +15,8 @@ namespace Microsoft.Agents.AI; /// and a markdown body with instructions. Resource files referenced in the body are validated at /// discovery time and read from disk on demand. /// -internal sealed class FileAgentSkill +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileAgentSkill { /// /// Initializes a new instance of the class. @@ -22,8 +25,8 @@ internal sealed class FileAgentSkill /// The SKILL.md content after the closing --- delimiter. /// Absolute path to the directory containing this skill. /// Relative paths of resource files referenced in the skill body. - public FileAgentSkill( - SkillFrontmatter frontmatter, + internal FileAgentSkill( + FileAgentSkillFrontmatter frontmatter, string body, string sourcePath, IReadOnlyList? resourceNames = null) @@ -37,20 +40,20 @@ public FileAgentSkill( /// /// Gets the parsed YAML frontmatter (name and description). /// - public SkillFrontmatter Frontmatter { get; } + public FileAgentSkillFrontmatter Frontmatter { get; } /// - /// Gets the SKILL.md body content (without the YAML frontmatter). + /// Gets the directory path where the skill was discovered. /// - public string Body { get; } + public string SourcePath { get; } /// - /// Gets the directory path where the skill was discovered. + /// Gets the SKILL.md body content (without the YAML frontmatter). /// - public string SourcePath { get; } + internal string Body { get; } /// /// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md"). /// - public IReadOnlyList ResourceNames { get; } + internal IReadOnlyList ResourceNames { get; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillFrontmatter.cs similarity index 70% rename from dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs rename to dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillFrontmatter.cs index 123a6c43f4..c369ad319f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillFrontmatter.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -7,14 +9,15 @@ namespace Microsoft.Agents.AI; /// /// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description. /// -internal sealed class SkillFrontmatter +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileAgentSkillFrontmatter { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// Skill name. /// Skill description. - public SkillFrontmatter(string name, string description) + internal FileAgentSkillFrontmatter(string name, string description) { this.Name = Throw.IfNullOrWhitespace(name); this.Description = Throw.IfNullOrWhitespace(description); diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs index 8c034b3122..8f55fc93c3 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text; @@ -9,6 +10,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI; @@ -20,7 +22,8 @@ namespace Microsoft.Agents.AI; /// Each file is validated for YAML frontmatter and resource integrity. Invalid skills are excluded /// with logged warnings. Resource paths are checked against path traversal and symlink escape attacks. /// -internal sealed partial class FileAgentSkillLoader +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed partial class FileAgentSkillLoader { private const string SkillFileName = "SKILL.md"; private const int MaxSearchDepth = 2; @@ -33,13 +36,16 @@ internal sealed partial class FileAgentSkillLoader // Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n" private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - // Matches markdown links to local resource files. Group 1 = relative file path. + // Matches resource file references in skill markdown. Group 1 = relative file path. + // Supports two forms: + // 1. Markdown links: [text](path/file.ext) + // 2. Backtick-quoted paths: `path/file.ext` // Supports optional ./ or ../ prefixes; excludes URLs (no ":" in the path character class). // Intentionally conservative: only matches paths with word characters, hyphens, dots, // and forward slashes. Paths with spaces or special characters are not supported. - // Examples: [doc](refs/FAQ.md) → "refs/FAQ.md", [s](./s.json) → "./s.json", + // Examples: [doc](refs/FAQ.md) → "refs/FAQ.md", `./scripts/run.py` → "./scripts/run.py", // [p](../shared/doc.txt) → "../shared/doc.txt" - private static readonly Regex s_resourceLinkRegex = new(@"\[.*?\]\((\.?\.?/?[\w][\w\-./]*\.\w+)\)", RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + private static readonly Regex s_resourceLinkRegex = new(@"(?:\[.*?\]\(|`)(\.?\.?/?[\w][\w\-./]*\.\w+)(?:\)|`)", RegexOptions.Compiled, TimeSpan.FromSeconds(5)); // Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value. // Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values. @@ -111,7 +117,7 @@ internal Dictionary DiscoverAndLoadSkills(IEnumerable /// The resource is not registered, resolves outside the skill directory, or does not exist. /// - internal async Task ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default) + public async Task ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default) { resourceName = NormalizeResourcePath(resourceName); @@ -189,7 +195,7 @@ private static void SearchDirectoriesForSkills(string directory, List re string content = File.ReadAllText(skillFilePath, Encoding.UTF8); - if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body)) + if (!this.TryParseSkillDocument(content, skillFilePath, out FileAgentSkillFrontmatter frontmatter, out string body)) { return null; } @@ -208,7 +214,7 @@ private static void SearchDirectoriesForSkills(string directory, List re resourceNames: resourceNames); } - private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body) + private bool TryParseSkillDocument(string content, string skillFilePath, out FileAgentSkillFrontmatter frontmatter, out string body) { frontmatter = null!; body = null!; @@ -264,7 +270,7 @@ private bool TryParseSkillDocument(string content, string skillFilePath, out Ski return false; } - frontmatter = new SkillFrontmatter(name, description); + frontmatter = new FileAgentSkillFrontmatter(name, description); body = content.Substring(match.Index + match.Length).TrimStart(); return true; diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillScriptExecutionContext.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillScriptExecutionContext.cs new file mode 100644 index 0000000000..c28333a715 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillScriptExecutionContext.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Provides access to loaded skills and the skill loader for use by implementations. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileAgentSkillScriptExecutionContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The loaded skills dictionary. + /// The skill loader for reading resources. + internal FileAgentSkillScriptExecutionContext(Dictionary skills, FileAgentSkillLoader loader) + { + this.Skills = skills; + this.Loader = loader; + } + + /// + /// Gets the loaded skills keyed by name. + /// + public IReadOnlyDictionary Skills { get; } + + /// + /// Gets the skill loader for reading resources. + /// + public FileAgentSkillLoader Loader { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillScriptExecutionDetails.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillScriptExecutionDetails.cs new file mode 100644 index 0000000000..4c12848386 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillScriptExecutionDetails.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents the tools and instructions contributed by a . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileAgentSkillScriptExecutionDetails +{ + /// + /// Gets the additional instructions to provide to the agent for script execution. + /// + public string? Instructions { get; set; } + + /// + /// Gets the additional tools to provide to the agent for script execution. + /// + public IReadOnlyList? Tools { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillScriptExecutor.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillScriptExecutor.cs new file mode 100644 index 0000000000..1171940e72 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillScriptExecutor.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Defines the contract for skill script execution modes. +/// +/// +/// +/// A provides the instructions and tools needed to enable +/// script execution within an agent skill. Concrete implementations determine how scripts +/// are executed (e.g., via the LLM's hosted code interpreter, an external executor, or a hybrid approach). +/// +/// +/// Use the static factory methods to create instances: +/// +/// — executes scripts using the LLM provider's built-in code interpreter. +/// +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class FileAgentSkillScriptExecutor +{ + /// + /// Creates a that uses the LLM provider's hosted code interpreter for script execution. + /// + /// A instance configured for hosted code interpreter execution. + public static FileAgentSkillScriptExecutor HostedCodeInterpreter() => new HostedCodeInterpreterFileAgentSkillScriptExecutor(); + + /// + /// Returns the tools and instructions contributed by this executor. + /// + /// + /// The execution context provided by the skills provider, containing the loaded skills + /// and the skill loader for reading resources. + /// + /// A containing the executor's tools and instructions. + protected internal abstract FileAgentSkillScriptExecutionDetails GetExecutionDetails(FileAgentSkillScriptExecutionContext context); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs index 847bf36a52..7acec160d4 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs @@ -48,21 +48,21 @@ You have access to skills containing domain-specific knowledge and capabilities. Each skill provides specialized instructions, reference documents, and assets for specific tasks. - {0} + {skills} When a task aligns with a skill's domain: - 1. Use `load_skill` to retrieve the skill's instructions - 2. Follow the provided guidance - 3. Use `read_skill_resource` to read any references or other files mentioned by the skill - + - Use `load_skill` to retrieve the skill's instructions + - Follow the provided guidance + - Use `read_skill_resource` to read any references or other files mentioned by the skill, always using the full path as written (e.g. `references/FAQ.md`, not just `FAQ.md`) + {executor_instructions} Only load what is needed, when it is needed. """; private readonly Dictionary _skills; private readonly ILogger _logger; private readonly FileAgentSkillLoader _loader; - private readonly AITool[] _tools; + private readonly IEnumerable _tools; private readonly string? _skillsInstructionPrompt; /// @@ -91,9 +91,13 @@ public FileAgentSkillsProvider(IEnumerable skillPaths, FileAgentSkillsPr this._loader = new FileAgentSkillLoader(this._logger); this._skills = this._loader.DiscoverAndLoadSkills(skillPaths); - this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills); + var executionDetails = options?.ScriptExecutor is { } executor + ? executor.GetExecutionDetails(new(this._skills, this._loader)) + : null; + + this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills, executionDetails?.Instructions); - this._tools = + AITool[] baseTools = [ AIFunctionFactory.Create( this.LoadSkill, @@ -104,6 +108,10 @@ public FileAgentSkillsProvider(IEnumerable skillPaths, FileAgentSkillsPr name: "read_skill_resource", description: "Reads a file associated with a skill, such as references or assets."), ]; + + this._tools = executionDetails?.Tools is { Count: > 0 } executorTools + ? baseTools.Concat(executorTools) + : baseTools; } /// @@ -117,7 +125,7 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co return new ValueTask(new AIContext { Instructions = this._skillsInstructionPrompt, - Tools = this._tools + Tools = this._tools, }); } @@ -166,24 +174,9 @@ private async Task ReadSkillResourceAsync(string skillName, string resou } } - private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary skills) + private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary skills, string? instructions) { - string promptTemplate = DefaultSkillsInstructionPrompt; - - if (options?.SkillsInstructionPrompt is { } optionsInstructions) - { - try - { - promptTemplate = string.Format(optionsInstructions, string.Empty); - } - catch (FormatException ex) - { - throw new ArgumentException( - "The provided SkillsInstructionPrompt is not a valid format string. It must contain a '{0}' placeholder and escape any literal '{' or '}' by doubling them ('{{' or '}}').", - nameof(options), - ex); - } - } + string promptTemplate = options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt; if (skills.Count == 0) { @@ -202,7 +195,9 @@ private async Task ReadSkillResourceAsync(string skillName, string resou sb.AppendLine(" "); } - return string.Format(promptTemplate, sb.ToString().TrimEnd()); + return promptTemplate + .Replace("{skills}", sb.ToString().TrimEnd()) + .Replace("{executor_instructions}", instructions ?? "\n"); } [LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")] diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs index a47841c260..7d86d3b4ae 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs @@ -13,8 +13,20 @@ public sealed class FileAgentSkillsProviderOptions { /// /// Gets or sets a custom system prompt template for advertising skills. - /// Use {0} as the placeholder for the generated skills list. + /// Use {skills} as the placeholder for the generated skills list and + /// {executor_instructions} for executor-provided instructions. /// When , a default template is used. /// public string? SkillsInstructionPrompt { get; set; } + + /// + /// Gets or sets the skill executor that enables script execution for loaded skills. + /// + /// + /// When (the default), script execution is disabled and skills only provide + /// instructions and resources. Set this to a instance (e.g., + /// ) to enable script execution with + /// mode-specific instructions and tools. + /// + public FileAgentSkillScriptExecutor? ScriptExecutor { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/HostedCodeInterpreterFileAgentSkillScriptExecutor.cs b/dotnet/src/Microsoft.Agents.AI/Skills/HostedCodeInterpreterFileAgentSkillScriptExecutor.cs new file mode 100644 index 0000000000..88fb1f86a2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/HostedCodeInterpreterFileAgentSkillScriptExecutor.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI; + +/// +/// A that uses the LLM provider's hosted code interpreter for script execution. +/// +/// +/// This executor directs the LLM to load scripts via read_skill_resource and execute them +/// using the provider's built-in code interpreter. A is +/// registered to signal the provider to enable its code interpreter sandbox. +/// +internal sealed class HostedCodeInterpreterFileAgentSkillScriptExecutor : FileAgentSkillScriptExecutor +{ + private static readonly FileAgentSkillScriptExecutionDetails s_contribution = new() + { + Instructions = + """ + + Some skills include executable scripts (e.g., Python files) in their resources. + When a skill's instructions reference a script: + 1. Use `read_skill_resource` to load the script content + 2. Execute the script using the code interpreter + + """, + Tools = [new HostedCodeInterpreterTool()], + }; + + /// +#pragma warning disable RCS1168 // Parameter name differs from base name + protected internal override FileAgentSkillScriptExecutionDetails GetExecutionDetails(FileAgentSkillScriptExecutionContext _) => s_contribution; +#pragma warning restore RCS1168 // Parameter name differs from base name +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs index c34eb6d7f2..c9e154a277 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs @@ -501,7 +501,7 @@ public async Task ReadSkillResourceAsync_SymlinkInPath_ThrowsInvalidOperationExc } // Manually construct a skill that bypasses discovery validation - var frontmatter = new SkillFrontmatter("symlink-read-skill", "A skill"); + var frontmatter = new FileAgentSkillFrontmatter("symlink-read-skill", "A skill"); var skill = new FileAgentSkill( frontmatter: frontmatter, body: "See [doc](refs/data.md).", @@ -532,6 +532,54 @@ public void DiscoverAndLoadSkills_FileWithUtf8Bom_ParsesSuccessfully() Assert.Equal("Body content.", skills["bom-skill"].Body); } + [Theory] + [InlineData("No resource references.", new string[0])] + [InlineData("Review `refs/FAQ.md` for details.", new[] { "refs/FAQ.md" })] + [InlineData("See [guide](refs/guide.md) then run `scripts/run.py`.", new[] { "refs/guide.md", "scripts/run.py" })] + public void DiscoverAndLoadSkills_ResourceReferences_ExtractsExpectedResourceNames(string body, string[] expectedResources) + { + // Arrange — create skill with resource files on disk so validation passes + string skillDir = Path.Combine(this._testRoot, "res-skill"); + Directory.CreateDirectory(skillDir); + foreach (string resource in expectedResources) + { + string resourcePath = Path.Combine(skillDir, resource.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(resourcePath)!); + File.WriteAllText(resourcePath, "content"); + } + + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: res-skill\ndescription: Resource test\n---\n{body}"); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + var skill = skills["res-skill"]; + Assert.Equal(expectedResources.Length, skill.ResourceNames.Count); + foreach (string expected in expectedResources) + { + Assert.Contains(expected, skill.ResourceNames); + } + } + + [Fact] + public async Task ReadSkillResourceAsync_BacktickResourcePath_ReturnsContentAsync() + { + // Arrange — skill body uses backtick-quoted path + _ = this.CreateSkillDirectoryWithResource("backtick-read", "A skill", "Load `refs/doc.md` first.", "refs/doc.md", "Backtick content."); + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skill = skills["backtick-read"]; + + // Act + string content = await this._loader.ReadSkillResourceAsync(skill, "refs/doc.md"); + + // Assert + Assert.Equal("Backtick content.", content); + } + private string CreateSkillDirectory(string name, string description, string body) { string skillDir = Path.Combine(this._testRoot, name); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillScriptExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillScriptExecutorTests.cs new file mode 100644 index 0000000000..1be56e49c9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillScriptExecutorTests.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for and its integration with . +/// +public sealed class FileAgentSkillScriptExecutorTests : IDisposable +{ + private readonly string _testRoot; + private readonly TestAIAgent _agent = new(); + private static readonly FileAgentSkillScriptExecutionContext s_emptyContext = new( + new Dictionary(StringComparer.OrdinalIgnoreCase), + new FileAgentSkillLoader(NullLogger.Instance)); + + public FileAgentSkillScriptExecutorTests() + { + this._testRoot = Path.Combine(Path.GetTempPath(), "skill-executor-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this._testRoot); + } + + public void Dispose() + { + if (Directory.Exists(this._testRoot)) + { + Directory.Delete(this._testRoot, recursive: true); + } + } + + [Fact] + public void HostedCodeInterpreter_ReturnsNonNullInstance() + { + // Act + var executor = FileAgentSkillScriptExecutor.HostedCodeInterpreter(); + + // Assert + Assert.NotNull(executor); + } + + [Fact] + public void HostedCodeInterpreter_GetExecutionDetails_ReturnsNonNullInstructions() + { + // Arrange + var executor = FileAgentSkillScriptExecutor.HostedCodeInterpreter(); + + // Act + var details = executor.GetExecutionDetails(s_emptyContext); + + // Assert + Assert.NotNull(details); + Assert.NotNull(details.Instructions); + Assert.NotEmpty(details.Instructions); + } + + [Fact] + public void HostedCodeInterpreter_GetExecutionDetails_ReturnsNonEmptyToolsList() + { + // Arrange + var executor = FileAgentSkillScriptExecutor.HostedCodeInterpreter(); + + // Act + var details = executor.GetExecutionDetails(s_emptyContext); + + // Assert + Assert.NotNull(details); + Assert.NotNull(details.Tools); + Assert.NotEmpty(details.Tools); + } + + [Fact] + public async Task Provider_WithExecutor_IncludesExecutorInstructionsInPromptAsync() + { + // Arrange + CreateSkill(this._testRoot, "exec-skill", "Executor test", "Body."); + var executor = FileAgentSkillScriptExecutor.HostedCodeInterpreter(); + var options = new FileAgentSkillsProviderOptions { ScriptExecutor = executor }; + var provider = new FileAgentSkillsProvider(this._testRoot, options); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — executor instructions should be merged into the prompt + Assert.NotNull(result.Instructions); + Assert.Contains("code interpreter", result.Instructions, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Provider_WithExecutor_IncludesExecutorToolsAsync() + { + // Arrange + CreateSkill(this._testRoot, "tools-exec-skill", "Executor tools test", "Body."); + var executor = FileAgentSkillScriptExecutor.HostedCodeInterpreter(); + var options = new FileAgentSkillsProviderOptions { ScriptExecutor = executor }; + var provider = new FileAgentSkillsProvider(this._testRoot, options); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — should have 3 tools: load_skill, read_skill_resource, and HostedCodeInterpreterTool + Assert.NotNull(result.Tools); + Assert.Equal(3, result.Tools!.Count()); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("load_skill", toolNames); + Assert.Contains("read_skill_resource", toolNames); + Assert.Single(result.Tools!, t => t is HostedCodeInterpreterTool); + } + + [Fact] + public async Task Provider_WithoutExecutor_DoesNotIncludeExecutorToolsAsync() + { + // Arrange + CreateSkill(this._testRoot, "no-exec-skill", "No executor test", "Body."); + var provider = new FileAgentSkillsProvider(this._testRoot); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — should only have the two base tools + Assert.NotNull(result.Tools); + Assert.Equal(2, result.Tools!.Count()); + } + + [Fact] + public async Task Provider_WithHostedCodeInterpreter_MergesScriptInstructionsIntoPromptAsync() + { + // Arrange + CreateSkill(this._testRoot, "merge-skill", "Merge test", "Body."); + var executor = FileAgentSkillScriptExecutor.HostedCodeInterpreter(); + var options = new FileAgentSkillsProviderOptions { ScriptExecutor = executor }; + var provider = new FileAgentSkillsProvider(this._testRoot, options); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — prompt should contain both the skill listing and the executor's script instructions + Assert.NotNull(result.Instructions); + string instructions = result.Instructions!; + + // Skill listing is present + Assert.Contains("merge-skill", instructions); + Assert.Contains("Merge test", instructions); + + // Hosted code interpreter script instructions are merged into the prompt + Assert.Contains("executable scripts", instructions); + Assert.Contains("read_skill_resource", instructions); + Assert.Contains("Execute the script using the code interpreter", instructions); + } + + private static void CreateSkill(string root, string name, string description, string body) + { + string skillDir = Path.Combine(root, name); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n{body}"); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs index 6bfaf1b546..f95f3a7080 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs @@ -96,7 +96,7 @@ public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body."); var options = new FileAgentSkillsProviderOptions { - SkillsInstructionPrompt = "Custom template: {0}" + SkillsInstructionPrompt = "Custom template: {skills}" }; var provider = new FileAgentSkillsProvider(this._testRoot, options); var inputContext = new AIContext(); @@ -110,21 +110,6 @@ public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync Assert.StartsWith("Custom template:", result.Instructions); } - [Fact] - public void Constructor_InvalidPromptTemplate_ThrowsArgumentException() - { - // Arrange — template with unescaped braces and no valid {0} placeholder - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "Bad template with {unescaped} braces" - }; - - // Act & Assert - var ex = Assert.Throws(() => new FileAgentSkillsProvider(this._testRoot, options)); - Assert.Contains("SkillsInstructionPrompt", ex.Message); - Assert.Equal("options", ex.ParamName); - } - [Fact] public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/HostedCodeInterpreterFileAgentSkillScriptExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/HostedCodeInterpreterFileAgentSkillScriptExecutorTests.cs new file mode 100644 index 0000000000..84a4446779 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/HostedCodeInterpreterFileAgentSkillScriptExecutorTests.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class HostedCodeInterpreterFileAgentSkillScriptExecutorTests +{ + private static readonly FileAgentSkillScriptExecutionContext s_emptyContext = new( + new Dictionary(StringComparer.OrdinalIgnoreCase), + new FileAgentSkillLoader(NullLogger.Instance)); + + [Fact] + public void GetExecutionDetails_ReturnsScriptExecutionGuidance() + { + // Arrange + var executor = new HostedCodeInterpreterFileAgentSkillScriptExecutor(); + + // Act + var details = executor.GetExecutionDetails(s_emptyContext); + + // Assert + Assert.NotNull(details.Instructions); + Assert.Contains("read_skill_resource", details.Instructions); + Assert.Contains("code interpreter", details.Instructions); + } + + [Fact] + public void GetExecutionDetails_ReturnsSingleHostedCodeInterpreterTool() + { + // Arrange + var executor = new HostedCodeInterpreterFileAgentSkillScriptExecutor(); + + // Act + var details = executor.GetExecutionDetails(s_emptyContext); + + // Assert + Assert.NotNull(details.Tools); + Assert.Single(details.Tools!); + Assert.IsType(details.Tools![0]); + } + + [Fact] + public void GetExecutionDetails_ReturnsSameInstanceOnMultipleCalls() + { + // Arrange + var executor = new HostedCodeInterpreterFileAgentSkillScriptExecutor(); + + // Act + var details1 = executor.GetExecutionDetails(s_emptyContext); + var details2 = executor.GetExecutionDetails(s_emptyContext); + + // Assert — static details should be reused + Assert.Same(details1, details2); + } + + [Fact] + public void FactoryMethod_ReturnsHostedCodeInterpreterFileAgentSkillScriptExecutor() + { + // Act + var executor = FileAgentSkillScriptExecutor.HostedCodeInterpreter(); + + // Assert + Assert.IsType(executor); + } +}