Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
269 changes: 269 additions & 0 deletions BotSharp.sln

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
<PackageVersion Include="Qdrant.Client" Version="1.15.1" />
<PackageVersion Include="Selenium.WebDriver" Version="4.27.0" />
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Abstractions" Version="1.67.1" />
<PackageVersion Include="Microsoft.SemanticKernel.Plugins.Memory" Version="1.16.0-alpha" />
<PackageVersion Include="Microsoft.VisualStudio.Validation" Version="17.13.22" />
Expand Down
40 changes: 40 additions & 0 deletions src/Plugins/BotSharp.Plugin.AgentSkills/AgentSkillsPlugin.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using BotSharp.Abstraction.Agents;
using BotSharp.Abstraction.Settings;
using BotSharp.Plugin.AgentSkills.Functions;
using Microsoft.Extensions.Configuration;

namespace BotSharp.Plugin.AgentSkills;

/// <summary>
/// Agent Skills plugin for BotSharp.
/// Enables AI agents to leverage reusable skills following the Agent Skills specification.
/// </summary>
public class AgentSkillsPlugin : IBotSharpPlugin
{
public string Id => "a5b3e8c1-7d2f-4a9e-b6c4-8f5d1e2a3b4c";
public string Name => "Agent Skills";
public string Description => "Enables AI agents to leverage reusable skills following the Agent Skills specification (https://agentskills.io).";
public string IconUrl => "https://raw.githubusercontent.com/SciSharp/BotSharp/master/docs/static/logos/BotSharp.png";
public string[] AgentIds => [];

public void RegisterDI(IServiceCollection services, IConfiguration config)
{
// Register settings
services.AddScoped(provider =>
{
var settingService = provider.GetRequiredService<ISettingService>();
return settingService.Bind<AgentSkillsSettings>("AgentSkills");
});

// Register skill loader
services.AddScoped<SkillLoader>();

// Register hooks
services.AddScoped<IAgentUtilityHook, AgentSkillsUtilityHook>();

// Register function callbacks
services.AddScoped<IFunctionCallback, ReadSkillFn>();
services.AddScoped<IFunctionCallback, ReadSkillFileFn>();
services.AddScoped<IFunctionCallback, ListSkillDirectoryFn>();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>$(TargetFramework)</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>$(LangVersion)</LangVersion>
<VersionPrefix>$(BotSharpVersion)</VersionPrefix>
<GeneratePackageOnBuild>$(GeneratePackageOnBuild)</GeneratePackageOnBuild>
<GenerateDocumentationFile>$(GenerateDocumentationFile)</GenerateDocumentationFile>
<OutputPath>$(SolutionDir)packages</OutputPath>
</PropertyGroup>

<ItemGroup>
<None Remove="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\skill-read_skill.json" />
<None Remove="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\skill-read_skill_file.json" />
<None Remove="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\skill-list_skill_directory.json" />
<None Remove="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\templates\skill-read_skill.fn.liquid" />
<None Remove="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\templates\skill-read_skill_file.fn.liquid" />
<None Remove="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\templates\skill-list_skill_directory.fn.liquid" />
</ItemGroup>

<ItemGroup>
<Content Include="data\agents\471ca181-375f-b16f-7134-5f868ecd31c6\agent.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="data\agents\471ca181-375f-b16f-7134-5f868ecd31c6\instructions\instruction.liquid">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\skill-read_skill.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\skill-read_skill_file.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\skill-list_skill_directory.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\templates\skill-read_skill.fn.liquid">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\templates\skill-read_skill_file.fn.liquid">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\templates\skill-list_skill_directory.fn.liquid">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>

<ItemGroup>
<PackageReference Include="YamlDotNet" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\Infrastructure\BotSharp.Core\BotSharp.Core.csproj" />
</ItemGroup>

</Project>
9 changes: 9 additions & 0 deletions src/Plugins/BotSharp.Plugin.AgentSkills/Enums/UtilityName.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace BotSharp.Plugin.AgentSkills.Enums;

/// <summary>
/// Utility name constants for Agent Skills plugin.
/// </summary>
public class UtilityName
{
public const string AgentSkills = "agent-skills";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
namespace BotSharp.Plugin.AgentSkills.Functions;

/// <summary>
/// Function that lists contents of a skill's directory.
/// </summary>
public class ListSkillDirectoryFn : IFunctionCallback
{
public string Name => "skill-list_skill_directory";
public string Indication => "Listing skill directory...";

private readonly IServiceProvider _services;
private readonly ILogger<ListSkillDirectoryFn> _logger;
private readonly BotSharpOptions _options;

public ListSkillDirectoryFn(
IServiceProvider services,
ILogger<ListSkillDirectoryFn> logger,
BotSharpOptions options)
{
_services = services;
_logger = logger;
_options = options;
}

public async Task<bool> Execute(RoleDialogModel message)
{
var args = JsonSerializer.Deserialize<ListSkillDirectoryArgs>(message.FunctionArgs, _options.JsonSerializerOptions);
var skillName = args?.SkillName;
var relativePath = args?.RelativePath;

if (string.IsNullOrWhiteSpace(skillName))
{
message.Content = JsonSerializer.Serialize(new
{
success = false,
error = "Skill name is required."
}, _options.JsonSerializerOptions);
return false;
}

try
{
var settings = _services.GetRequiredService<AgentSkillsSettings>();
var loader = _services.GetRequiredService<SkillLoader>();

// Load skills and find the requested one
var state = loader.LoadSkills(settings);
var skill = state.GetSkill(skillName);

if (skill is null)
{
message.Content = JsonSerializer.Serialize(new
{
success = false,
error = $"Skill '{skillName}' not found."
}, _options.JsonSerializerOptions);
return false;
}

var entries = loader.ListSkillDirectory(skill, relativePath).ToList();

message.Content = JsonSerializer.Serialize(new
{
success = true,
skill_name = skill.Name,
path = relativePath ?? "/",
entries = entries.Select(e => new
{
name = e.Name,
type = e.IsDirectory ? "directory" : "file",
size = e.Size
})
}, _options.JsonSerializerOptions);

return true;
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning(ex, "Path traversal attempt for skill: {SkillName}", skillName);
message.Content = JsonSerializer.Serialize(new
{
success = false,
error = "Access denied: path traversal detected."
}, _options.JsonSerializerOptions);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list skill directory: {SkillName}/{Path}", skillName, relativePath);
message.Content = JsonSerializer.Serialize(new
{
success = false,
error = $"Failed to list directory: {ex.Message}"
}, _options.JsonSerializerOptions);
return false;
}
}

private class ListSkillDirectoryArgs
{
[JsonPropertyName("skill_name")]
public string? SkillName { get; set; }

[JsonPropertyName("relative_path")]
public string? RelativePath { get; set; }
}
}
132 changes: 132 additions & 0 deletions src/Plugins/BotSharp.Plugin.AgentSkills/Functions/ReadSkillFileFn.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
namespace BotSharp.Plugin.AgentSkills.Functions;

/// <summary>
/// Function that reads a file within a skill's directory.
/// </summary>
public class ReadSkillFileFn : IFunctionCallback
{
public string Name => "skill-read_skill_file";
public string Indication => "Reading skill file...";

private readonly IServiceProvider _services;
private readonly ILogger<ReadSkillFileFn> _logger;
private readonly BotSharpOptions _options;

public ReadSkillFileFn(
IServiceProvider services,
ILogger<ReadSkillFileFn> logger,
BotSharpOptions options)
{
_services = services;
_logger = logger;
_options = options;
}

public async Task<bool> Execute(RoleDialogModel message)
{
var args = JsonSerializer.Deserialize<ReadSkillFileArgs>(message.FunctionArgs, _options.JsonSerializerOptions);
var skillName = args?.SkillName;
var filePath = args?.FilePath;

if (string.IsNullOrWhiteSpace(skillName))
{
message.Content = JsonSerializer.Serialize(new
{
success = false,
error = "Skill name is required."
}, _options.JsonSerializerOptions);
return false;
}

if (string.IsNullOrWhiteSpace(filePath))
{
message.Content = JsonSerializer.Serialize(new
{
success = false,
error = "File path is required."
}, _options.JsonSerializerOptions);
return false;
}

try
{
var settings = _services.GetRequiredService<AgentSkillsSettings>();
var loader = _services.GetRequiredService<SkillLoader>();

// Load skills and find the requested one
var state = loader.LoadSkills(settings);
var skill = state.GetSkill(skillName);

if (skill is null)
{
message.Content = JsonSerializer.Serialize(new
{
success = false,
error = $"Skill '{skillName}' not found."
}, _options.JsonSerializerOptions);
return false;
}

var content = loader.ReadSkillFile(skill, filePath);

// Truncate if necessary
var maxSize = settings.MaxOutputSizeBytes;
var originalLength = content.Length;
var truncated = content.Length > maxSize;
if (truncated)
{
content = content.Substring(0, maxSize);
}

message.Content = JsonSerializer.Serialize(new
{
success = true,
skill_name = skill.Name,
file_path = filePath,
content,
truncated,
total_length = originalLength
}, _options.JsonSerializerOptions);

return true;
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning(ex, "Path traversal attempt for skill: {SkillName}", skillName);
message.Content = JsonSerializer.Serialize(new
{
success = false,
error = "Access denied: path traversal detected."
}, _options.JsonSerializerOptions);
return false;
}
catch (FileNotFoundException)
{
message.Content = JsonSerializer.Serialize(new
{
success = false,
error = $"File not found: {filePath}"
}, _options.JsonSerializerOptions);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to read skill file: {SkillName}/{FilePath}", skillName, filePath);
message.Content = JsonSerializer.Serialize(new
{
success = false,
error = $"Failed to read file: {ex.Message}"
}, _options.JsonSerializerOptions);
return false;
}
}

private class ReadSkillFileArgs
{
[JsonPropertyName("skill_name")]
public string? SkillName { get; set; }

[JsonPropertyName("file_path")]
public string? FilePath { get; set; }
}
}
Loading