diff --git a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs index e95bde61df..b2068c4c0b 100644 --- a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs +++ b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs @@ -5,16 +5,16 @@ // This is provided for demonstration purposes only. using System.Diagnostics; +using System.Text.Json; using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; /// /// Executes file-based skill scripts as local subprocesses. /// /// -/// This runner uses the script's absolute path, converts the arguments -/// to CLI flags, and returns captured output. It is intended for -/// demonstration purposes only. +/// This runner uses the script's absolute path and converts the arguments +/// to CLI arguments. When the LLM sends a JSON array, each element is used +/// as a positional argument. It is intended for demonstration purposes only. /// internal static class SubprocessScriptRunner { @@ -24,7 +24,8 @@ internal static class SubprocessScriptRunner public static async Task RunAsync( AgentFileSkill skill, AgentFileSkillScript script, - AIFunctionArguments arguments, + JsonElement? arguments, + IServiceProvider? serviceProvider, CancellationToken cancellationToken) { if (!File.Exists(script.FullPath)) @@ -61,24 +62,27 @@ internal static class SubprocessScriptRunner startInfo.FileName = script.FullPath; } - if (arguments is not null) + if (arguments is { ValueKind: JsonValueKind.Array } json) { - foreach (var (key, value) in arguments) + // Positional CLI arguments + foreach (var element in json.EnumerateArray()) { - if (value is bool boolValue) + if (element.ValueKind != JsonValueKind.String) { - if (boolValue) - { - startInfo.ArgumentList.Add(NormalizeKey(key)); - } - } - else if (value is not null) - { - startInfo.ArgumentList.Add(NormalizeKey(key)); - startInfo.ArgumentList.Add(value.ToString()!); + throw new InvalidOperationException( + $"File-based skill scripts only accept string CLI arguments but received a JSON element of kind '{element.ValueKind}'. " + + "All array elements must be JSON strings."); } + + startInfo.ArgumentList.Add(element.GetString()!); } } + else if (arguments is not null && arguments.Value.ValueKind != JsonValueKind.Null && arguments.Value.ValueKind != JsonValueKind.Undefined) + { + throw new InvalidOperationException( + $"Expected a JSON array of CLI arguments but received {arguments.Value.ValueKind}. " + + "File-based skill scripts expect positional arguments as a JSON array of strings."); + } Process? process = null; try @@ -128,10 +132,4 @@ internal static class SubprocessScriptRunner process?.Dispose(); } } - - /// - /// Normalizes a parameter key to a consistent --flag format. - /// Models may return keys with or without leading dashes (e.g., "value" vs "--value"). - /// - private static string NormalizeKey(string key) => "--" + key.TrimStart('-'); } diff --git a/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml index a8268863bd..6a2c790f22 100644 --- a/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml +++ b/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml @@ -1,6 +1,20 @@  + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -29,6 +43,13 @@ lib/net10.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) @@ -43,6 +64,20 @@ lib/net10.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -71,6 +106,13 @@ lib/net472/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) @@ -85,6 +127,20 @@ lib/net472/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -113,6 +169,13 @@ lib/net8.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) @@ -127,6 +190,20 @@ lib/net8.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -155,6 +232,13 @@ lib/net9.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) @@ -169,6 +253,20 @@ lib/net9.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -197,6 +295,13 @@ lib/netstandard2.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) @@ -211,4 +316,39 @@ lib/netstandard2.0/Microsoft.Agents.AI.dll true + + CP0005 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + + + CP0005 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll + true + + + CP0005 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll + true + + + CP0005 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll + true + + + CP0005 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll + true + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs index 4c5ca8dbc3..6f549301d0 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs @@ -35,7 +35,8 @@ public abstract class AgentSkill /// Gets the full skill content. /// /// - /// For file-based skills this is the raw SKILL.md file content. + /// For file-based skills this is the raw SKILL.md file content, optionally + /// augmented with a synthesized scripts block when scripts are present. /// For code-defined skills this is a synthesized XML document /// containing name, description, and body (instructions, resources, scripts). /// diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs index 1ac44bfac8..bbfbcb8616 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs @@ -1,10 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -46,8 +46,9 @@ protected AgentSkillScript(string name, string? description = null) /// Runs the script with the given arguments. /// /// The skill that owns this script. - /// Arguments for script execution. + /// Raw JSON arguments for script execution, preserving the original format (object or array) sent by the caller. + /// Optional service provider for dependency injection. /// Cancellation token. /// The script execution result. - public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); + public abstract Task RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs index b5598e19d3..af1225c9df 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Security; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -243,7 +244,7 @@ private IList BuildTools(IList skills, bool hasScripts, } AIFunction scriptFunction = AIFunctionFactory.Create( - (string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) => + (string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) => this.RunSkillScriptAsync(skills, skillName, scriptName, arguments, serviceProvider, cancellationToken), name: "run_skill_script", description: "Runs a script associated with a skill."); @@ -340,7 +341,7 @@ private string LoadSkill(IList skills, string skillName) } } - private async Task RunSkillScriptAsync(IList skills, string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + private async Task RunSkillScriptAsync(IList skills, string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(skillName)) { @@ -366,7 +367,7 @@ private string LoadSkill(IList skills, string skillName) try { - return await script.RunAsync(skill, new AIFunctionArguments(arguments) { Services = serviceProvider }, cancellationToken).ConfigureAwait(false); + return await script.RunAsync(skill, arguments, serviceProvider, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs index 4bb62e99a8..3e10557968 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs @@ -15,6 +15,8 @@ public sealed class AgentFileSkill : AgentSkill { private readonly IReadOnlyList _resources; private readonly IReadOnlyList _scripts; + private readonly string _originalContent; + private string? _content; /// /// Initializes a new instance of the class. @@ -32,7 +34,7 @@ internal AgentFileSkill( IReadOnlyList? scripts = null) { this.Frontmatter = Throw.IfNull(frontmatter); - this.Content = Throw.IfNull(content); + this._originalContent = Throw.IfNull(content); this.Path = Throw.IfNullOrWhitespace(path); this._resources = resources ?? []; this._scripts = scripts ?? []; @@ -42,7 +44,18 @@ internal AgentFileSkill( public override AgentSkillFrontmatter Frontmatter { get; } /// - public override string Content { get; } + /// + /// Returns the raw SKILL.md content. When the skill has scripts, a + /// <scripts><script name="..."><parameters_schema>...</parameters_schema></script></scripts> + /// block is appended with a per-script entry describing the expected argument format. + /// The result is cached after the first access. + /// + public override string Content + { + get => this._content ??= this._scripts is { Count: > 0 } + ? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts) + : this._originalContent; + } /// /// Gets the directory path where the skill was discovered. diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs index 116847126f..74c0cd2f01 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs @@ -2,9 +2,9 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -16,6 +16,11 @@ namespace Microsoft.Agents.AI; [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class AgentFileSkillScript : AgentSkillScript { + /// + /// Cached JSON schema element describing the expected argument format: a string array of CLI arguments. + /// + private static readonly JsonElement s_defaultSchema = CreateDefaultSchema(); + private readonly AgentFileSkillScriptRunner? _runner; /// @@ -37,7 +42,14 @@ internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScript public string FullPath { get; } /// - public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) + /// + /// Returns a fixed schema describing a string array of CLI arguments: + /// {"type":"array","items":{"type":"string"}}. + /// + public override JsonElement? ParametersSchema => s_defaultSchema; + + /// + public override async Task RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) { if (skill is not AgentFileSkill fileSkill) { @@ -51,6 +63,12 @@ internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScript $"Supply a script runner when constructing {nameof(AgentFileSkillsSource)} to enable script execution."); } - return await this._runner(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false); + return await this._runner(fileSkill, this, arguments, serviceProvider, cancellationToken).ConfigureAwait(false); + } + + private static JsonElement CreateDefaultSchema() + { + using JsonDocument document = JsonDocument.Parse("""{"type":"array","items":{"type":"string"}}"""); + return document.RootElement.Clone(); } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs index c19d19e056..1746150ca2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs @@ -1,9 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI; @@ -13,15 +14,19 @@ namespace Microsoft.Agents.AI; /// /// /// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment). +/// The parameter preserves the raw JSON sent by the caller, in the shape +/// described by . /// /// The skill that owns the script. /// The file-based script to run. -/// Optional arguments for the script, provided by the agent/LLM. +/// Raw JSON arguments for the script, in the shape described by . +/// Optional service provider for dependency injection. /// Cancellation token. /// The script execution result. [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public delegate Task AgentFileSkillScriptRunner( AgentFileSkill skill, AgentFileSkillScript script, - AIFunctionArguments arguments, + JsonElement? arguments, + IServiceProvider? serviceProvider, CancellationToken cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs index f90d67dd1d..dabf75fa1a 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs @@ -59,33 +59,57 @@ public static string Build( if (scripts is { Count: > 0 }) { - sb.Append("\n\n\n"); - foreach (var script in scripts) + sb.Append('\n'); + sb.Append(BuildScriptsBlock(scripts)); + } + + return sb.ToString(); + } + + /// + /// Builds a <scripts>...</scripts> XML block for the given scripts. + /// Each script is emitted as a <script name="..."> element with optional + /// description attribute and <parameters_schema> child element. + /// + /// The scripts to include in the block. + /// An XML string starting with \n<scripts>, or an empty string if the list is empty. + public static string BuildScriptsBlock(IReadOnlyList scripts) + { + _ = Throw.IfNull(scripts); + + if (scripts.Count == 0) + { + return string.Empty; + } + + var sb = new StringBuilder(); + sb.Append("\n\n"); + + foreach (var script in scripts) + { + var parametersSchema = script.ParametersSchema; + + if (script.Description is null && parametersSchema is null) + { + sb.Append($" \n"); - } + sb.Append(" \n"); } - - sb.Append(""); } + sb.Append(""); + return sb.ToString(); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs index 1e3041aafc..c0abc73252 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.Json; @@ -67,8 +68,42 @@ public AgentInlineSkillScript(string name, MethodInfo method, object? target, st public override JsonElement? ParametersSchema => this._function.JsonSchema; /// - public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) + public override async Task RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) { - return await this._function.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false); + var funcArgs = ConvertToFunctionArguments(arguments); + funcArgs.Services = serviceProvider; + + return await this._function.InvokeAsync(funcArgs, cancellationToken).ConfigureAwait(false); + } + + /// + /// Converts a raw to for delegate invocation. + /// + /// + /// Thrown when is provided but is not a JSON object. + /// Inline skill scripts expect arguments as a JSON object whose properties map to the delegate's parameters. + /// + private static AIFunctionArguments ConvertToFunctionArguments(JsonElement? arguments) + { + if (arguments is null || + arguments.Value.ValueKind == JsonValueKind.Null || + arguments.Value.ValueKind == JsonValueKind.Undefined) + { + return []; + } + + if (arguments.Value.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + $"Inline skill scripts expect arguments as a JSON object but received a JSON element of kind '{arguments.Value.ValueKind}'."); + } + + var dict = new Dictionary(); + foreach (var property in arguments.Value.EnumerateObject()) + { + dict[property.Name] = property.Value; + } + + return new AIFunctionArguments(dict); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs index 1dc7d5b3f9..dc83fad119 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs @@ -8,7 +8,6 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Agents.AI.UnitTests.AgentSkills; @@ -128,8 +127,9 @@ public async Task CreateScriptAndResource_WithSerializerOptions_HandleCustomType // Act — script with custom type deserialization var script = skill.Scripts![0]; var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 5 }, jso); - var args = new AIFunctionArguments { ["request"] = inputJson }; - var scriptResult = await script.RunAsync(skill, args, CancellationToken.None); + using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }"""); + var args = argsDoc.RootElement; + var scriptResult = await script.RunAsync(skill, args, null, CancellationToken.None); // Assert Assert.NotNull(scriptResult); @@ -173,12 +173,14 @@ public async Task Scripts_DiscoveredViaAttribute_StaticAndInstance_CanBeInvokedA // Act & Assert — static method var doWorkScript = skill.Scripts!.First(s => s.Name == "do-work"); - var doWorkResult = await doWorkScript.RunAsync(skill, new AIFunctionArguments { ["input"] = "hello" }, CancellationToken.None); + using var doWorkDoc = JsonDocument.Parse("""{"input":"hello"}"""); + var doWorkResult = await doWorkScript.RunAsync(skill, doWorkDoc.RootElement, null, CancellationToken.None); Assert.Equal("HELLO", doWorkResult?.ToString()); // Act & Assert — instance method var appendScript = skill.Scripts!.First(s => s.Name == "append"); - var appendResult = await appendScript.RunAsync(skill, new AIFunctionArguments { ["input"] = "test" }, CancellationToken.None); + using var appendDoc = JsonDocument.Parse("""{"input":"test"}"""); + var appendResult = await appendScript.RunAsync(skill, appendDoc.RootElement, null, CancellationToken.None); Assert.Equal("test-suffix", appendResult?.ToString()); } @@ -367,7 +369,7 @@ public async Task MixedStaticAndInstance_AllDiscoveredAndInvocableAsync() // Act & Assert — all scripts produce values foreach (var script in skill.Scripts!) { - var result = await script.RunAsync(skill, new AIFunctionArguments(), CancellationToken.None); + var result = await script.RunAsync(skill, null, null, CancellationToken.None); Assert.NotNull(result); } } @@ -382,8 +384,9 @@ public async Task SerializerOptions_UsedForReflectedMembersAsync() // Act & Assert — script with custom JSO var script = skill.Scripts![0]; var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso); - var args = new AIFunctionArguments { ["request"] = inputJson }; - var scriptResult = await script.RunAsync(skill, args, CancellationToken.None); + using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }"""); + var args = argsDoc.RootElement; + var scriptResult = await script.RunAsync(skill, args, null, CancellationToken.None); Assert.NotNull(scriptResult); Assert.Contains("test", scriptResult!.ToString()!); Assert.Contains("3", scriptResult!.ToString()!); @@ -497,8 +500,9 @@ public async Task CreateScript_FallsBackToSerializerOptions_WhenNoExplicitJsoAsy var script = skill.Scripts!.First(s => s.Name == "Lookup"); var jso = SkillTestJsonContext.Default.Options; var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "fallback", MaxResults = 7 }, jso); - var args = new AIFunctionArguments { ["request"] = inputJson }; - var result = await script.RunAsync(skill, args, CancellationToken.None); + using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }"""); + var args = argsDoc.RootElement; + var result = await script.RunAsync(skill, args, null, CancellationToken.None); // Assert Assert.NotNull(result); @@ -531,8 +535,9 @@ public async Task CreateScript_UsesExplicitJso_OverSerializerOptionsAsync() var script = skill.Scripts!.First(s => s.Name == "Lookup"); var jso = SkillTestJsonContext.Default.Options; var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "explicit", MaxResults = 2 }, jso); - var args = new AIFunctionArguments { ["request"] = inputJson }; - var result = await script.RunAsync(skill, args, CancellationToken.None); + using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }"""); + var args = argsDoc.RootElement; + var result = await script.RunAsync(skill, args, null, CancellationToken.None); // Assert Assert.NotNull(result); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs index eb4f706f30..e638380019 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.UnitTests.AgentSkills; @@ -16,13 +16,13 @@ public sealed class AgentFileSkillScriptTests public async Task RunAsync_SkillIsNotAgentFileSkill_ThrowsInvalidOperationExceptionAsync() { // Arrange - static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult("result"); + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult("result"); var script = CreateScript("test-script", "/path/to/script.py", RunnerAsync); var nonFileSkill = new TestAgentSkill("my-skill", "A skill", "Instructions."); // Act & Assert await Assert.ThrowsAsync( - () => script.RunAsync(nonFileSkill, new AIFunctionArguments(), CancellationToken.None)); + () => script.RunAsync(nonFileSkill, null, null, CancellationToken.None)); } [Fact] @@ -30,7 +30,7 @@ public async Task RunAsync_WithAgentFileSkill_DelegatesToRunnerAsync() { // Arrange var runnerCalled = false; - Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct) { runnerCalled = true; return Task.FromResult("executed"); @@ -42,7 +42,7 @@ public async Task RunAsync_WithAgentFileSkill_DelegatesToRunnerAsync() "/skills/my-skill"); // Act - var result = await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + var result = await script.RunAsync(fileSkill, null, null, CancellationToken.None); // Assert Assert.True(runnerCalled); @@ -55,7 +55,7 @@ public async Task RunAsync_RunnerReceivesCorrectArgumentsAsync() // Arrange AgentFileSkill? capturedSkill = null; AgentFileSkillScript? capturedScript = null; - Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct) { capturedSkill = skill; capturedScript = scriptArg; @@ -68,7 +68,7 @@ public async Task RunAsync_RunnerReceivesCorrectArgumentsAsync() "/skills/owner-skill"); // Act - await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + await script.RunAsync(fileSkill, null, null, CancellationToken.None); // Assert Assert.Same(fileSkill, capturedSkill); @@ -79,7 +79,7 @@ public async Task RunAsync_RunnerReceivesCorrectArgumentsAsync() public void Script_HasCorrectNameAndPath() { // Arrange & Act - static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult(null); + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult(null); var script = CreateScript("my-script", "/path/to/my-script.py", RunnerAsync); // Assert @@ -87,10 +87,173 @@ public void Script_HasCorrectNameAndPath() Assert.Equal("/path/to/my-script.py", script.FullPath); } + [Fact] + public void ParametersSchema_ReturnsExpectedArraySchema() + { + // Arrange + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult(null); + var script = CreateScript("my-script", "/path/to/script.py", RunnerAsync); + + // Act + var schema = script.ParametersSchema; + + // Assert + Assert.NotNull(schema); + var raw = schema!.Value.GetRawText(); + Assert.Contains("\"type\":\"array\"", raw); + Assert.Contains("\"items\":{\"type\":\"string\"}", raw); + } + + [Fact] + public void Content_WithScripts_AppendsPerScriptEntries() + { + // Arrange + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult(null); + var script1 = CreateScript("build", "/scripts/build.sh", RunnerAsync); + var script2 = CreateScript("deploy", "/scripts/deploy.sh", RunnerAsync); + var fileSkill = new AgentFileSkill( + new AgentSkillFrontmatter("my-skill", "A skill"), + "Original content", + "/skills/my-skill", + scripts: [script1, script2]); + + // Act + var content = fileSkill.Content; + + // Assert — content starts with original and appends per-script entries + Assert.StartsWith("Original content", content); + Assert.Contains("", content); + Assert.Contains("