Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 21 additions & 23 deletions dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// Executes file-based skill scripts as local subprocesses.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal static class SubprocessScriptRunner
{
Expand All @@ -24,7 +24,8 @@ internal static class SubprocessScriptRunner
public static async Task<object?> RunAsync(
AgentFileSkill skill,
AgentFileSkillScript script,
AIFunctionArguments arguments,
JsonElement? arguments,
IServiceProvider? serviceProvider,
CancellationToken cancellationToken)
{
if (!File.Exists(script.FullPath))
Expand Down Expand Up @@ -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.");
}
Comment thread
SergeyMenshykh marked this conversation as resolved.

Process? process = null;
try
Expand Down Expand Up @@ -128,10 +132,4 @@ internal static class SubprocessScriptRunner
process?.Dispose();
}
}

/// <summary>
/// Normalizes a parameter key to a consistent --flag format.
/// Models may return keys with or without leading dashes (e.g., "value" vs "--value").
/// </summary>
private static string NormalizeKey(string key) => "--" + key.TrimStart('-');
}
7 changes: 4 additions & 3 deletions dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -46,8 +46,9 @@ protected AgentSkillScript(string name, string? description = null)
/// Runs the script with the given arguments.
/// </summary>
/// <param name="skill">The skill that owns this script.</param>
/// <param name="arguments">Arguments for script execution.</param>
/// <param name="arguments">Raw JSON arguments for script execution, preserving the original format (object or array) sent by the caller.</param>
/// <param name="serviceProvider">Optional service provider for dependency injection.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The script execution result.</returns>
public abstract Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default);
public abstract Task<object?> RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default);
Comment thread
SergeyMenshykh marked this conversation as resolved.
}
7 changes: 4 additions & 3 deletions dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -243,7 +244,7 @@ private IList<AIFunction> BuildTools(IList<AgentSkill> skills, bool hasScripts,
}

AIFunction scriptFunction = AIFunctionFactory.Create(
(string skillName, string scriptName, IDictionary<string, object?>? 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.");
Expand Down Expand Up @@ -340,7 +341,7 @@ private string LoadSkill(IList<AgentSkill> skills, string skillName)
}
}

private async Task<object?> RunSkillScriptAsync(IList<AgentSkill> skills, string skillName, string scriptName, IDictionary<string, object?>? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
private async Task<object?> RunSkillScriptAsync(IList<AgentSkill> skills, string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(skillName))
{
Expand All @@ -366,7 +367,7 @@ private string LoadSkill(IList<AgentSkill> 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)
{
Expand Down
17 changes: 15 additions & 2 deletions dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public sealed class AgentFileSkill : AgentSkill
{
private readonly IReadOnlyList<AgentSkillResource> _resources;
private readonly IReadOnlyList<AgentSkillScript> _scripts;
private readonly string _originalContent;
private string? _content;

/// <summary>
/// Initializes a new instance of the <see cref="AgentFileSkill"/> class.
Expand All @@ -32,7 +34,7 @@ internal AgentFileSkill(
IReadOnlyList<AgentSkillScript>? 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 ?? [];
Expand All @@ -42,7 +44,18 @@ internal AgentFileSkill(
public override AgentSkillFrontmatter Frontmatter { get; }

/// <inheritdoc/>
public override string Content { get; }
/// <remarks>
/// Returns the raw SKILL.md content. When the skill has scripts, a
/// <c>&lt;scripts&gt;&lt;script name="..."&gt;&lt;parameters_schema&gt;...&lt;/parameters_schema&gt;&lt;/script&gt;&lt;/scripts&gt;</c>
/// block is appended with a per-script entry describing the expected argument format.
/// The result is cached after the first access.
/// </remarks>
public override string Content
{
get => this._content ??= this._scripts is { Count: > 0 }
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts)
Comment thread
SergeyMenshykh marked this conversation as resolved.
: this._originalContent;
}

/// <summary>
/// Gets the directory path where the skill was discovered.
Expand Down
19 changes: 16 additions & 3 deletions dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -16,6 +16,12 @@ namespace Microsoft.Agents.AI;
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentFileSkillScript : AgentSkillScript
{
/// <summary>
/// Cached JSON schema element describing the expected argument format: a string array of CLI arguments.
/// </summary>
private static readonly JsonElement s_defaultSchema =
JsonDocument.Parse("""{"type":"array","items":{"type":"string"}}""").RootElement.Clone();

private readonly AgentFileSkillScriptRunner? _runner;

Comment thread
SergeyMenshykh marked this conversation as resolved.
Outdated
/// <summary>
Expand All @@ -37,7 +43,14 @@ internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScript
public string FullPath { get; }

/// <inheritdoc/>
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default)
/// <remarks>
/// Returns a fixed schema describing a string array of CLI arguments:
/// <c>{"type":"array","items":{"type":"string"}}</c>.
/// </remarks>
Comment thread
SergeyMenshykh marked this conversation as resolved.
public override JsonElement? ParametersSchema => s_defaultSchema;

/// <inheritdoc/>
public override async Task<object?> RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default)
{
if (skill is not AgentFileSkill fileSkill)
{
Expand All @@ -51,6 +64,6 @@ 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,15 +14,19 @@ namespace Microsoft.Agents.AI;
/// </summary>
/// <remarks>
/// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment).
/// The <paramref name="arguments"/> parameter preserves the raw JSON format sent by the caller,
/// which may be a JSON array (for positional CLI arguments) or a JSON object (for named parameters).
/// </remarks>
/// <param name="skill">The skill that owns the script.</param>
/// <param name="script">The file-based script to run.</param>
/// <param name="arguments">Optional arguments for the script, provided by the agent/LLM.</param>
/// <param name="arguments">Raw JSON arguments for the script, preserving the original format (object or array) sent by the caller.</param>
/// <param name="serviceProvider">Optional service provider for dependency injection.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The script execution result.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public delegate Task<object?> AgentFileSkillScriptRunner(
AgentFileSkill skill,
AgentFileSkillScript script,
AIFunctionArguments arguments,
JsonElement? arguments,
IServiceProvider? serviceProvider,
CancellationToken cancellationToken);
Original file line number Diff line number Diff line change
Expand Up @@ -59,33 +59,57 @@ public static string Build(

if (scripts is { Count: > 0 })
{
sb.Append("\n\n<scripts>\n");
foreach (var script in scripts)
sb.Append('\n');
sb.Append(BuildScriptsBlock(scripts));
}

return sb.ToString();
}

/// <summary>
/// Builds a <c>&lt;scripts&gt;...&lt;/scripts&gt;</c> XML block for the given scripts.
/// Each script is emitted as a <c>&lt;script name="..."&gt;</c> element with optional
/// <c>description</c> attribute and <c>&lt;parameters_schema&gt;</c> child element.
/// </summary>
/// <param name="scripts">The scripts to include in the block.</param>
/// <returns>An XML string starting with <c>\n&lt;scripts&gt;</c>, or an empty string if the list is empty.</returns>
public static string BuildScriptsBlock(IReadOnlyList<AgentSkillScript> scripts)
{
_ = Throw.IfNull(scripts);

if (scripts.Count == 0)
{
return string.Empty;
}

var sb = new StringBuilder();
sb.Append("\n<scripts>\n");

foreach (var script in scripts)
{
var parametersSchema = script.ParametersSchema;

if (script.Description is null && parametersSchema is null)
{
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
}
else
{
var parametersSchema = script.ParametersSchema;
sb.Append(script.Description is not null
? $" <script name=\"{EscapeXmlString(script.Name)}\" description=\"{EscapeXmlString(script.Description)}\">\n"
: $" <script name=\"{EscapeXmlString(script.Name)}\">\n");

if (script.Description is null && parametersSchema is null)
if (parametersSchema is not null)
{
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
}
else
{
sb.Append(script.Description is not null
? $" <script name=\"{EscapeXmlString(script.Name)}\" description=\"{EscapeXmlString(script.Description)}\">\n"
: $" <script name=\"{EscapeXmlString(script.Name)}\">\n");

if (parametersSchema is not null)
{
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
}

sb.Append(" </script>\n");
}
sb.Append(" </script>\n");
}

sb.Append("</scripts>");
}

sb.Append("</scripts>");

return sb.ToString();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -67,8 +68,42 @@ public AgentInlineSkillScript(string name, MethodInfo method, object? target, st
public override JsonElement? ParametersSchema => this._function.JsonSchema;

/// <inheritdoc/>
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default)
public override async Task<object?> 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);
}

/// <summary>
/// Converts a raw <see cref="JsonElement"/> to <see cref="AIFunctionArguments"/> for delegate invocation.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when <paramref name="arguments"/> 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.
/// </exception>
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<string, object?>();
foreach (var property in arguments.Value.EnumerateObject())
{
dict[property.Name] = property.Value;
}

return new AIFunctionArguments(dict);
}
}
Loading
Loading