diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs index 84174154a2..3572bff826 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs @@ -102,12 +102,18 @@ public abstract class AgentClassSkill< private readonly Lazy?> _resources; private readonly Lazy?> _scripts; private readonly Lazy _content; + private readonly Func? _argumentMarshaler; /// /// Initializes a new instance of the class. /// - protected AgentClassSkill() + /// + /// Optional argument marshaler applied to all scripts in this skill. + /// When , the default marshaler is used which expects arguments as a JSON object. + /// + protected AgentClassSkill(Func? argumentMarshaler = null) { + this._argumentMarshaler = argumentMarshaler; this._resources = new Lazy?>(this.DiscoverResources); this._scripts = new Lazy?>(this.DiscoverScripts); this._content = new Lazy(() => AgentInlineSkillContentBuilder.Build( @@ -240,7 +246,7 @@ protected AgentSkillResource CreateResource(string name, Delegate method, string /// /// A new instance. protected AgentSkillScript CreateScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) - => new AgentInlineSkillScript(name, method, description, serializerOptions ?? this.SerializerOptions); + => new AgentInlineSkillScript(name, method, description, serializerOptions ?? this.SerializerOptions, this._argumentMarshaler); private List? DiscoverResources() { @@ -356,7 +362,8 @@ private static void ValidateResourceMethodParameters(MethodInfo method, Type ski method: method, target: method.IsStatic ? null : this, description: method.GetCustomAttribute()?.Description, - serializerOptions: this.SerializerOptions)); + serializerOptions: this.SerializerOptions, + argumentMarshaler: this._argumentMarshaler)); } return scripts; diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs index 2465431622..ce65777b8e 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs @@ -30,6 +30,7 @@ public sealed class AgentInlineSkill : AgentSkill { private readonly string _instructions; private readonly JsonSerializerOptions? _serializerOptions; + private readonly Func? _argumentMarshaler; private List? _resources; private List? _scripts; private string? _cachedContent; @@ -45,11 +46,16 @@ public sealed class AgentInlineSkill : AgentSkill /// added to this skill. Individual and /// calls can override this default. When , is used. /// - public AgentInlineSkill(AgentSkillFrontmatter frontmatter, string instructions, JsonSerializerOptions? serializerOptions = null) + /// + /// Optional argument marshaler applied by default to all scripts added to this skill. + /// When , the default marshaler is used which expects arguments as a JSON object. + /// + public AgentInlineSkill(AgentSkillFrontmatter frontmatter, string instructions, JsonSerializerOptions? serializerOptions = null, Func? argumentMarshaler = null) { this.Frontmatter = Throw.IfNull(frontmatter); this._instructions = Throw.IfNullOrWhitespace(instructions); this._serializerOptions = serializerOptions; + this._argumentMarshaler = argumentMarshaler; } /// @@ -68,6 +74,10 @@ public AgentInlineSkill(AgentSkillFrontmatter frontmatter, string instructions, /// added to this skill. Individual and /// calls can override this default. When , is used. /// + /// + /// Optional argument marshaler applied by default to all scripts added to this skill. + /// When , the default marshaler is used which expects arguments as a JSON object. + /// public AgentInlineSkill( string name, string description, @@ -76,7 +86,8 @@ public AgentInlineSkill( string? compatibility = null, string? allowedTools = null, AdditionalPropertiesDictionary? metadata = null, - JsonSerializerOptions? serializerOptions = null) + JsonSerializerOptions? serializerOptions = null, + Func? argumentMarshaler = null) : this( new AgentSkillFrontmatter(name, description, compatibility) { @@ -85,7 +96,8 @@ public AgentInlineSkill( Metadata = metadata, }, instructions, - serializerOptions) + serializerOptions, + argumentMarshaler) { } @@ -169,7 +181,7 @@ public AgentInlineSkill AddResource(string name, Delegate method, string? descri /// This instance, for chaining. public AgentInlineSkill AddScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) { - (this._scripts ??= []).Add(new AgentInlineSkillScript(name, method, description, serializerOptions ?? this._serializerOptions)); + (this._scripts ??= []).Add(new AgentInlineSkillScript(name, method, description, serializerOptions ?? this._serializerOptions, this._argumentMarshaler)); return this; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs index c0abc73252..e482cc45ac 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs @@ -20,6 +20,7 @@ namespace Microsoft.Agents.AI; internal sealed class AgentInlineSkillScript : AgentSkillScript { private readonly AIFunction _function; + private readonly Func _argumentMarshaler; /// /// Initializes a new instance of the class from a delegate. @@ -32,13 +33,18 @@ internal sealed class AgentInlineSkillScript : AgentSkillScript /// Optional used to marshal the delegate's parameters and return value. /// When , is used. /// - public AgentInlineSkillScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) + /// + /// Optional function for converting raw JSON arguments into . + /// When , the default marshaler is used which expects arguments as a JSON object. + /// + public AgentInlineSkillScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null, Func? argumentMarshaler = null) : base(Throw.IfNullOrWhitespace(name), description) { Throw.IfNull(method); var options = new AIFunctionFactoryOptions { Name = this.Name, SerializerOptions = serializerOptions }; this._function = AIFunctionFactory.Create(method, options); + this._argumentMarshaler = argumentMarshaler ?? ConvertToFunctionArguments; } /// @@ -53,13 +59,18 @@ public AgentInlineSkillScript(string name, Delegate method, string? description /// Optional used to marshal the method's parameters and return value. /// When , is used. /// - public AgentInlineSkillScript(string name, MethodInfo method, object? target, string? description = null, JsonSerializerOptions? serializerOptions = null) + /// + /// Optional function for converting raw JSON arguments into . + /// When , the default marshaler is used which expects arguments as a JSON object. + /// + public AgentInlineSkillScript(string name, MethodInfo method, object? target, string? description = null, JsonSerializerOptions? serializerOptions = null, Func? argumentMarshaler = null) : base(Throw.IfNullOrWhitespace(name), description) { Throw.IfNull(method); var options = new AIFunctionFactoryOptions { Name = this.Name, SerializerOptions = serializerOptions }; this._function = AIFunctionFactory.Create(method, target, options); + this._argumentMarshaler = argumentMarshaler ?? ConvertToFunctionArguments; } /// @@ -70,19 +81,15 @@ public AgentInlineSkillScript(string name, MethodInfo method, object? target, st /// public override async Task RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) { - var funcArgs = ConvertToFunctionArguments(arguments); + var funcArgs = this._argumentMarshaler(arguments); funcArgs.Services = serviceProvider; return await this._function.InvokeAsync(funcArgs, cancellationToken).ConfigureAwait(false); } /// - /// Converts a raw to for delegate invocation. + /// Default argument marshaling: expects arguments as a JSON object whose properties map to the delegate's parameters. /// - /// - /// 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 || diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/SkillScriptArgumentMarshalerTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/SkillScriptArgumentMarshalerTests.cs new file mode 100644 index 0000000000..325ba3f826 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/SkillScriptArgumentMarshalerTests.cs @@ -0,0 +1,344 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for argument marshaling integration via Func<JsonElement?, AIFunctionArguments>. +/// +public sealed class SkillScriptArgumentMarshalerTests +{ + /// + /// Creates a JsonElement with ValueKind.String containing the given string value. + /// This simulates how vLLM backends send arguments as a string-wrapped JSON object. + /// + private static JsonElement CreateStringElement(string value) + { + // JSON encoding of a string value: surround with quotes and escape inner quotes + string json = "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + [Fact] + public async Task DefaultMarshaler_NullArguments_ReturnsEmptyAsync() + { + // Arrange + var script = new AgentInlineSkillScript("test", () => "ok"); + var skill = new AgentInlineSkill("s", "d", "i"); + + // Act + var result = await script.RunAsync(skill, null, null, CancellationToken.None); + + // Assert + Assert.Equal("ok", result?.ToString()); + } + + [Fact] + public async Task DefaultMarshaler_JsonNull_ReturnsEmptyAsync() + { + // Arrange + var script = new AgentInlineSkillScript("test", () => "ok"); + var skill = new AgentInlineSkill("s", "d", "i"); + using var doc = JsonDocument.Parse("null"); + var element = doc.RootElement.Clone(); + + // Act + var result = await script.RunAsync(skill, element, null, CancellationToken.None); + + // Assert + Assert.Equal("ok", result?.ToString()); + } + + [Fact] + public async Task DefaultMarshaler_UndefinedArguments_ReturnsEmptyAsync() + { + // Arrange + var script = new AgentInlineSkillScript("test", () => "ok"); + var skill = new AgentInlineSkill("s", "d", "i"); + JsonElement? element = default(JsonElement); // ValueKind == Undefined + + // Act + var result = await script.RunAsync(skill, element, null, CancellationToken.None); + + // Assert + Assert.Equal("ok", result?.ToString()); + } + + [Fact] + public async Task DefaultMarshaler_ObjectArguments_PassesPropertiesAsync() + { + // Arrange + var script = new AgentInlineSkillScript("test", (string query, int maxResults) => $"{query}:{maxResults}"); + var skill = new AgentInlineSkill("s", "d", "i"); + using var doc = JsonDocument.Parse("""{"query":"hello","maxResults":5}"""); + var element = doc.RootElement.Clone(); + + // Act + var result = await script.RunAsync(skill, element, null, CancellationToken.None); + + // Assert + Assert.Equal("hello:5", result?.ToString()); + } + + [Fact] + public async Task DefaultMarshaler_StringArguments_ThrowsAsync() + { + // Arrange + var script = new AgentInlineSkillScript("test", (string query) => query); + var skill = new AgentInlineSkill("s", "d", "i"); + var element = CreateStringElement("{\"query\": \"hello\"}"); + + // Act & Assert + var ex = await Assert.ThrowsAsync( + () => script.RunAsync(skill, element, null, CancellationToken.None)); + Assert.Contains("String", ex.Message); + } + + [Fact] + public async Task DefaultMarshaler_NumberArguments_ThrowsAsync() + { + // Arrange + var script = new AgentInlineSkillScript("test", (string query) => query); + var skill = new AgentInlineSkill("s", "d", "i"); + using var doc = JsonDocument.Parse("42"); + var element = doc.RootElement.Clone(); + + // Act & Assert + var ex = await Assert.ThrowsAsync( + () => script.RunAsync(skill, element, null, CancellationToken.None)); + Assert.Contains("Number", ex.Message); + } + + [Fact] + public async Task InlineSkillScript_UsesCustomMarshaler_WhenProvidedAsync() + { + // Arrange + var script = new AgentInlineSkillScript( + "test-script", + (string query) => $"result: {query}", + argumentMarshaler: StringParsingMarshaler); + var skill = new AgentInlineSkill("test-skill", "desc", "instructions"); + + // Create string-wrapped JSON arguments (simulating vLLM behavior) + var stringArgs = CreateStringElement("{\"query\":\"hello\"}"); + + // Act + var result = await script.RunAsync(skill, stringArgs, null, CancellationToken.None); + + // Assert + Assert.Equal("result: hello", result?.ToString()); + } + + [Fact] + public async Task InlineSkill_SkillLevelMarshaler_InheritedByScriptsAsync() + { + // Arrange + var skill = new AgentInlineSkill("test-skill", "desc", "instructions", argumentMarshaler: StringParsingMarshaler) + .AddScript("search", (string query) => $"found: {query}"); + + var script = await skill.GetScriptAsync("search", CancellationToken.None); + Assert.NotNull(script); + + // Create string-wrapped JSON arguments + var stringArgs = CreateStringElement("{\"query\":\"world\"}"); + + // Act + var result = await script.RunAsync(skill, stringArgs, null, CancellationToken.None); + + // Assert + Assert.Equal("found: world", result?.ToString()); + } + + [Fact] + public async Task InlineSkillScript_ScriptLevelMarshaler_OverridesSkillLevelAsync() + { + // Arrange — skill has a throwing marshaler, but script has its own + Func throwingMarshaler = ThrowingMarshaler; + + var skill = new AgentInlineSkill("test-skill", "desc", "instructions", argumentMarshaler: throwingMarshaler); + // Create a script directly with a per-script marshaler + var script = new AgentInlineSkillScript("search", (string query) => $"found: {query}", argumentMarshaler: StringParsingMarshaler); + + // Create string-wrapped JSON arguments + var stringArgs = CreateStringElement("{\"query\":\"test\"}"); + + // Act — should use scriptMarshaler (not the throwing skill marshaler) + var result = await script.RunAsync(skill, stringArgs, null, CancellationToken.None); + + // Assert + Assert.Equal("found: test", result?.ToString()); + } + + [Fact] + public async Task ClassSkill_ArgumentMarshaler_UsedByDiscoveredScriptsAsync() + { + // Arrange + var skill = new TestClassSkillWithMarshaler(); + var script = await skill.GetScriptAsync("greet", CancellationToken.None); + Assert.NotNull(script); + + // Create string-wrapped JSON arguments + var stringArgs = CreateStringElement("{\"name\":\"Alice\"}"); + + // Act + var result = await script.RunAsync(skill, stringArgs, null, CancellationToken.None); + + // Assert + Assert.Equal("Hello, Alice!", result?.ToString()); + } + + [Fact] + public async Task ClassSkill_CreateScript_UsesArgumentMarshalerAsync() + { + // Arrange — a class skill that defines its scripts programmatically via CreateScript + var skill = new TestClassSkillWithCreateScript(); + var script = await skill.GetScriptAsync("echo", CancellationToken.None); + Assert.NotNull(script); + + // Create string-wrapped JSON arguments (simulating vLLM behavior) + var stringArgs = CreateStringElement("{\"value\":\"hi\"}"); + + // Act + var result = await script.RunAsync(skill, stringArgs, null, CancellationToken.None); + + // Assert + Assert.Equal("echo: hi", result?.ToString()); + } + + [Fact] + public async Task ClassSkill_NoMarshaler_UsesDefaultObjectMarshalingAsync() + { + // Arrange — a class skill without a custom marshaler falls back to default behavior + var skill = new TestClassSkillWithoutMarshaler(); + var script = await skill.GetScriptAsync("greet", CancellationToken.None); + Assert.NotNull(script); + + using var doc = JsonDocument.Parse("""{"name":"Bob"}"""); + var objectArgs = doc.RootElement.Clone(); + + // Act — plain JSON object is marshaled by the default marshaler + var result = await script.RunAsync(skill, objectArgs, null, CancellationToken.None); + + // Assert + Assert.Equal("Hello, Bob!", result?.ToString()); + } + + [Fact] + public async Task ClassSkill_NoMarshaler_StringArguments_ThrowsAsync() + { + // Arrange — default marshaler rejects string-wrapped arguments + var skill = new TestClassSkillWithoutMarshaler(); + var script = await skill.GetScriptAsync("greet", CancellationToken.None); + Assert.NotNull(script); + + var stringArgs = CreateStringElement("{\"name\":\"Bob\"}"); + + // Act & Assert + var ex = await Assert.ThrowsAsync( + () => script.RunAsync(skill, stringArgs, null, CancellationToken.None)); + Assert.Contains("String", ex.Message); + } + + /// + /// A custom marshaler function that handles string-wrapped JSON (simulating the vLLM fix). + /// + private static AIFunctionArguments StringParsingMarshaler(JsonElement? arguments) + { + if (arguments is null || + arguments.Value.ValueKind == JsonValueKind.Null || + arguments.Value.ValueKind == JsonValueKind.Undefined) + { + return []; + } + + JsonElement element = arguments.Value; + + if (element.ValueKind == JsonValueKind.String) + { + string? raw = element.GetString(); + if (raw is not null) + { + using var innerDoc = JsonDocument.Parse(raw); + element = innerDoc.RootElement.Clone(); + } + } + + if (element.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException($"Cannot marshal arguments of kind '{element.ValueKind}'."); + } + + var dict = new Dictionary(); + foreach (var property in element.EnumerateObject()) + { + dict[property.Name] = property.Value; + } + + return new AIFunctionArguments(dict); + } + + /// + /// A marshaler function that always throws — used to verify override behavior. + /// + private static AIFunctionArguments ThrowingMarshaler(JsonElement? arguments) + { + throw new InvalidOperationException("ThrowingMarshaler should not be called."); + } + + /// + /// A class-based skill with a custom argument marshaler. + /// + private sealed class TestClassSkillWithMarshaler : AgentClassSkill + { + public TestClassSkillWithMarshaler() + : base(argumentMarshaler: StringParsingMarshaler) + { + } + + public override AgentSkillFrontmatter Frontmatter { get; } = new("test-class-skill", "A test class skill."); + + protected override string Instructions => "Test instructions."; + + [AgentSkillScript("greet")] + public static string Greet(string name) => $"Hello, {name}!"; + } + + /// + /// A class-based skill that defines its scripts programmatically via CreateScript, + /// passing the constructor-supplied argument marshaler through to each script. + /// + private sealed class TestClassSkillWithCreateScript : AgentClassSkill + { + public TestClassSkillWithCreateScript() + : base(argumentMarshaler: StringParsingMarshaler) + { + } + + public override AgentSkillFrontmatter Frontmatter { get; } = new("create-script-skill", "A test class skill using CreateScript."); + + protected override string Instructions => "Test instructions."; + + public override IReadOnlyList? Scripts => + [this.CreateScript("echo", (string value) => $"echo: {value}")]; + } + + /// + /// A class-based skill without a custom argument marshaler (uses default object marshaling). + /// + private sealed class TestClassSkillWithoutMarshaler : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("default-marshaler-skill", "A test class skill without a marshaler."); + + protected override string Instructions => "Test instructions."; + + [AgentSkillScript("greet")] + public static string Greet(string name) => $"Hello, {name}!"; + } +}