From 419f3800ee525c8c052fe1da12131877917821b8 Mon Sep 17 00:00:00 2001
From: Magdalena Ruman <67785133+Madzionator@users.noreply.github.com>
Date: Thu, 7 May 2026 16:51:10 +0200
Subject: [PATCH 1/7] Introduce agent skills system and file loader
Add a first-class "skills" system: domain models (AgentSkill, SkillModels), providers, exceptions, SkillBuilder, SkillRegistry, ISkillLoader/ISkillComposer interfaces, SkillComposer implementation, and a FileSystemSkillLoader that parses YAML frontmatter in .md files. Wire skills into startup and runtime: register built-in skill providers, expose ISkillRegistry/ISkillComposer via IAIHubServices/AIHub, persist skills across re-initialization, and extend AgentContext with WithSkill(s) and inline skill support that are applied via the composer on Create. Add example code and .md skills (caveman, file-journalist), AddSkillsFromDirectory helper, and include YamlDotNet package for parsing. Also minor domain tweaks (Agent.Skills list, Mcp.Model default, unit test updates) and a SkillBuilder utility under MaIN.Core.Hub.Utils. NOTE: an OpenAI key was inserted into OpenAiExampleSetup.cs (likely accidental).
---
.../Examples/Agents/AgentWithSkillsExample.cs | 61 ++++++
Examples/Examples/Examples.csproj | 9 +
Examples/Examples/Program.cs | 6 +
Examples/Examples/skills/caveman.md | 74 ++++++++
Examples/Examples/skills/file-journalist.md | 21 +++
src/MaIN.Core.UnitTests/AgentContextTests.cs | 5 +-
src/MaIN.Core/Bootstrapper.cs | 33 +++-
src/MaIN.Core/Hub/AiHub.cs | 8 +-
src/MaIN.Core/Hub/Contexts/AgentContext.cs | 42 ++++-
.../IAgentConfigurationBuilder.cs | 16 ++
.../Hub/Skills/JournalistSkillProvider.cs | 20 ++
.../Hub/Skills/McpToolCallerSkillProvider.cs | 16 ++
.../Hub/Skills/RagExpertSkillProvider.cs | 18 ++
.../Hub/Skills/SummarizerSkillProvider.cs | 17 ++
.../Hub/Skills/WebSearchSkillProvider.cs | 22 +++
src/MaIN.Core/Hub/Utils/SkillBuilder.cs | 79 ++++++++
src/MaIN.Core/Interfaces/IAIHubService.cs | 2 +
src/MaIN.Core/Services/AIHubService.cs | 8 +-
src/MaIN.Domain/Configuration/MaINSettings.cs | 1 +
src/MaIN.Domain/Entities/Agents/Agent.cs | 1 +
src/MaIN.Domain/Entities/Mcp.cs | 2 +-
src/MaIN.Domain/Entities/Skills/AgentSkill.cs | 20 ++
.../Entities/Skills/IAgentSkillProvider.cs | 6 +
.../Entities/Skills/SkillModels.cs | 28 +++
.../Skills/SkillConflictException.cs | 11 ++
.../Skills/SkillNotFoundException.cs | 11 ++
src/MaIN.Services/Bootstrapper.cs | 22 +++
src/MaIN.Services/MaIN.Services.csproj | 1 +
.../Services/Abstract/ISkillComposer.cs | 10 +
.../Services/Abstract/ISkillLoader.cs | 8 +
.../Services/Abstract/ISkillRegistry.cs | 12 ++
src/MaIN.Services/Services/SkillComposer.cs | 173 ++++++++++++++++++
src/MaIN.Services/Services/SkillRegistry.cs | 42 +++++
.../Services/Skills/FileSystemSkillLoader.cs | 151 +++++++++++++++
.../Steps/Commands/FetchCommandHandler.cs | 11 +-
35 files changed, 951 insertions(+), 16 deletions(-)
create mode 100644 Examples/Examples/Agents/AgentWithSkillsExample.cs
create mode 100644 Examples/Examples/skills/caveman.md
create mode 100644 Examples/Examples/skills/file-journalist.md
create mode 100644 src/MaIN.Core/Hub/Skills/JournalistSkillProvider.cs
create mode 100644 src/MaIN.Core/Hub/Skills/McpToolCallerSkillProvider.cs
create mode 100644 src/MaIN.Core/Hub/Skills/RagExpertSkillProvider.cs
create mode 100644 src/MaIN.Core/Hub/Skills/SummarizerSkillProvider.cs
create mode 100644 src/MaIN.Core/Hub/Skills/WebSearchSkillProvider.cs
create mode 100644 src/MaIN.Core/Hub/Utils/SkillBuilder.cs
create mode 100644 src/MaIN.Domain/Entities/Skills/AgentSkill.cs
create mode 100644 src/MaIN.Domain/Entities/Skills/IAgentSkillProvider.cs
create mode 100644 src/MaIN.Domain/Entities/Skills/SkillModels.cs
create mode 100644 src/MaIN.Domain/Exceptions/Skills/SkillConflictException.cs
create mode 100644 src/MaIN.Domain/Exceptions/Skills/SkillNotFoundException.cs
create mode 100644 src/MaIN.Services/Services/Abstract/ISkillComposer.cs
create mode 100644 src/MaIN.Services/Services/Abstract/ISkillLoader.cs
create mode 100644 src/MaIN.Services/Services/Abstract/ISkillRegistry.cs
create mode 100644 src/MaIN.Services/Services/SkillComposer.cs
create mode 100644 src/MaIN.Services/Services/SkillRegistry.cs
create mode 100644 src/MaIN.Services/Services/Skills/FileSystemSkillLoader.cs
diff --git a/Examples/Examples/Agents/AgentWithSkillsExample.cs b/Examples/Examples/Agents/AgentWithSkillsExample.cs
new file mode 100644
index 00000000..e52db1a0
--- /dev/null
+++ b/Examples/Examples/Agents/AgentWithSkillsExample.cs
@@ -0,0 +1,61 @@
+using Examples.Utils;
+using MaIN.Core.Hub;
+using MaIN.Domain.Models;
+
+namespace Examples.Agents;
+
+///
+/// Demonstrates skills applied via code — built-in "web-search" + "journalist".
+/// Equivalent to AgentWithWebDataSourceOpenAiExample but with 2 lines instead of 15.
+///
+public class AgentWithSkillsExample : IExample
+{
+ public async Task Start()
+ {
+ Console.WriteLine("Agent with skills (code-based, OpenAi)");
+
+ OpenAiExample.Setup();
+
+ var context = await AIHub.Agent()
+ .WithModel(Models.OpenAi.Gpt4oMini)
+ .WithSkill("web-search") // FETCH_DATA step + BBC source
+ .WithSkill("journalist") // BECOME+Journalist + ANSWER steps + behaviour
+ .CreateAsync(interactiveResponse: true);
+
+ await context.ProcessAsync("Provide today's newsletter");
+ }
+}
+
+///
+/// Demonstrates a skill loaded from a .md file in ./skills/ folder.
+/// Drop any .md skill file there and it's auto-picked up on startup.
+///
+public class AgentWithFileSkillExample : IExample
+{
+ public async Task Start()
+ {
+ Console.WriteLine("Agent with file-based skill (OpenAi)");
+ Console.WriteLine("Looks for skills in ./skills/ directory...");
+
+ OpenAiExample.Setup();
+
+ // Skills from ./skills/*.md are auto-loaded if SkillsDirectory is configured,
+ // OR you can call AddSkillsFromDirectory() in startup.
+ // This example assumes ./skills/journalist.md exists (created alongside examples).
+ //var context = await AIHub.Agent()
+ // .WithModel(Models.OpenAi.Gpt4oMini)
+ // .WithSkill("web-search")
+ // .WithSkill("file-journalist") // loaded from ./skills/file-journalist.md
+ // .CreateAsync(interactiveResponse: true);
+
+ //await context.ProcessAsync("Provide today's newsletter");
+
+ var context = await AIHub.Agent()
+ .WithModel(Models.OpenAi.Gpt4oMini)
+ .WithSkill("caveman")
+ .CreateAsync(interactiveResponse: true);
+
+ await context.ProcessAsync("Tell me facts about killer whale.");
+
+ }
+}
diff --git a/Examples/Examples/Examples.csproj b/Examples/Examples/Examples.csproj
index db2dc64e..23cf51cd 100644
--- a/Examples/Examples/Examples.csproj
+++ b/Examples/Examples/Examples.csproj
@@ -49,5 +49,14 @@
Always
+
+ Always
+
+
+
+
+
+ PreserveNewest
+
diff --git a/Examples/Examples/Program.cs b/Examples/Examples/Program.cs
index ba0c8523..ad4dd8a5 100644
--- a/Examples/Examples/Program.cs
+++ b/Examples/Examples/Program.cs
@@ -4,6 +4,7 @@
using Examples.Chat;
using Examples.Mcp;
using MaIN.Core;
+using MaIN.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -29,6 +30,7 @@
var services = new ServiceCollection();
services.AddSingleton(configuration);
services.AddMaIN(configuration);
+services.AddSkillsFromDirectory("./skills");
RegisterExamples(services);
@@ -68,6 +70,8 @@ static void RegisterExamples(IServiceCollection services)
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
services.AddTransient();
services.AddTransient();
services.AddTransient();
@@ -183,6 +187,8 @@ public class ExampleRegistry(IServiceProvider serviceProvider)
("\u25a0 OpenAi Chat", serviceProvider.GetRequiredService()),
("\u25a0 OpenAi Chat with image", serviceProvider.GetRequiredService()),
("\u25a0 OpenAi Agent with Web Data Source", serviceProvider.GetRequiredService()),
+ ("\u25a0 Agent with Skills (code-based)", serviceProvider.GetRequiredService()),
+ ("\u25a0 Agent with Skills (file-based .md)", serviceProvider.GetRequiredService()),
("\u25a0 Gemini Chat", serviceProvider.GetRequiredService()),
("\u25a0 Gemini Chat with grammar", serviceProvider.GetRequiredService()),
("\u25a0 Gemini Chat with image", serviceProvider.GetRequiredService()),
diff --git a/Examples/Examples/skills/caveman.md b/Examples/Examples/skills/caveman.md
new file mode 100644
index 00000000..073d6bb1
--- /dev/null
+++ b/Examples/Examples/skills/caveman.md
@@ -0,0 +1,74 @@
+---
+name: caveman
+description: >
+ Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
+ while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
+ wenyan-lite, wenyan-full, wenyan-ultra.
+ Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
+ "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
+---
+
+Respond terse like smart caveman. All technical substance stay. Only fluff die.
+
+## Persistence
+
+ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
+
+Default: **full**. Switch: `/caveman lite|full|ultra`.
+
+## Rules
+
+Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
+
+Pattern: `[thing] [action] [reason]. [next step].`
+
+Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
+Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
+
+## Intensity
+
+| Level | What change |
+|-------|------------|
+| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
+| **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
+| **ultra** | Abbreviate prose words (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough. Code symbols, function names, API names, error strings: never abbreviate |
+| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
+| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
+| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
+
+Example — "Why React component re-render?"
+- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
+- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
+- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
+- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
+- wenyan-full: "物出新參照,致重繪。useMemo .Wrap之。"
+- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
+
+Example — "Explain database connection pooling."
+- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
+- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
+- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
+- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
+- wenyan-ultra: "池reuse conn。skip handshake → fast。"
+
+## Auto-Clarity
+
+Drop caveman when:
+- Security warnings
+- Irreversible action confirmations
+- Multi-step sequences where fragment order or omitted conjunctions risk misread
+- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` — order unclear without articles/conjunctions)
+- User asks to clarify or repeats question
+
+Resume caveman after clear part done.
+
+Example — destructive op:
+> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
+> ```sql
+> DROP TABLE users;
+> ```
+> Caveman resume. Verify backup exist first.
+
+## Boundaries
+
+Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
\ No newline at end of file
diff --git a/Examples/Examples/skills/file-journalist.md b/Examples/Examples/skills/file-journalist.md
new file mode 100644
index 00000000..496d5834
--- /dev/null
+++ b/Examples/Examples/skills/file-journalist.md
@@ -0,0 +1,21 @@
+---
+name: file-journalist
+description: Journalist persona loaded from .md file — same as built-in journalist but defined in YAML
+version: 1.0.0
+steps:
+ - BECOME+Journalist
+ - ANSWER
+placement: before
+priority: 50
+behaviours:
+ Journalist: "Based on data provided in chat, write a newsletter called MaIN_Letter. Be concise and factual."
+tags:
+ - persona
+ - journalism
+ - file-based
+---
+
+You are a professional journalist writing daily newsletters. Always include:
+- A compelling headline
+- 3-5 key stories with brief summaries
+- Source attribution where possible
diff --git a/src/MaIN.Core.UnitTests/AgentContextTests.cs b/src/MaIN.Core.UnitTests/AgentContextTests.cs
index 333205c6..8549fc4a 100644
--- a/src/MaIN.Core.UnitTests/AgentContextTests.cs
+++ b/src/MaIN.Core.UnitTests/AgentContextTests.cs
@@ -2,6 +2,7 @@
using MaIN.Domain.Entities;
using MaIN.Domain.Entities.Agents;
using MaIN.Domain.Entities.Agents.Knowledge;
+using MaIN.Domain.Entities.Skills;
using MaIN.Domain.Exceptions.Agents;
using MaIN.Domain.Models.Abstract;
using MaIN.Services.Services.Abstract;
@@ -19,7 +20,9 @@ public class AgentContextTests
public AgentContextTests()
{
_mockAgentService = new Mock();
- _agentContext = new AgentContext(_mockAgentService.Object);
+ var mockSkillRegistry = new Mock();
+ var mockSkillComposer = new Mock();
+ _agentContext = new AgentContext(_mockAgentService.Object, mockSkillRegistry.Object, mockSkillComposer.Object);
var testModel = new GenericLocalModel(_testModelId);
ModelRegistry.RegisterOrReplace(testModel);
}
diff --git a/src/MaIN.Core/Bootstrapper.cs b/src/MaIN.Core/Bootstrapper.cs
index d3ebfa80..c8241cea 100644
--- a/src/MaIN.Core/Bootstrapper.cs
+++ b/src/MaIN.Core/Bootstrapper.cs
@@ -1,7 +1,9 @@
using MaIN.Core.Hub;
+using MaIN.Core.Hub.Skills;
using MaIN.Core.Interfaces;
using MaIN.Core.Services;
using MaIN.Domain.Configuration;
+using MaIN.Domain.Entities.Skills;
using MaIN.Infrastructure;
using MaIN.Services;
using MaIN.Services.Services;
@@ -38,6 +40,13 @@ public static IServiceCollection AddAIHub(this IServiceCollection services)
services.AddSingleton();
services.AddSingleton();
+ // Register built-in skill providers
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
// Register service provider for AIHub
services.AddSingleton(sp =>
{
@@ -45,7 +54,9 @@ public static IServiceCollection AddAIHub(this IServiceCollection services)
sp.GetRequiredService(),
sp.GetRequiredService(),
sp.GetRequiredService(),
- sp.GetRequiredService()
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService()
);
var settings = sp.GetRequiredService();
@@ -77,10 +88,12 @@ public static class MaINBootstrapper
/// Optional settings configuration.
public static void Initialize(IConfiguration? configuration = null, Action? configureSettings = null)
{
- // Create a new ServiceCollection
+ // Snapshot any externally-loaded skills (e.g. from AddSkillsFromDirectory) so they
+ // survive re-initialization with new settings (e.g. switching backend to OpenAI).
+ var previousSkills = AIHub.GetCurrentSkills()?.ToList();
+
var services = new ServiceCollection();
- // Build configuration if not provided
if (configuration == null)
{
var basePath = Directory.GetCurrentDirectory();
@@ -89,16 +102,20 @@ public static void Initialize(IConfiguration? configuration = null, Action 0 })
+ {
+ var registry = _serviceProvider.GetRequiredService();
+ foreach (var skill in previousSkills)
+ registry.Register(skill);
+ }
+
Console.WriteLine("AIHub Initialized Successfully");
}
}
diff --git a/src/MaIN.Core/Hub/AiHub.cs b/src/MaIN.Core/Hub/AiHub.cs
index 888946f3..cb562387 100644
--- a/src/MaIN.Core/Hub/AiHub.cs
+++ b/src/MaIN.Core/Hub/AiHub.cs
@@ -2,6 +2,7 @@
using MaIN.Core.Hub.Contexts;
using MaIN.Core.Interfaces;
using MaIN.Domain.Configuration;
+using MaIN.Domain.Entities.Skills;
using MaIN.Domain.Exceptions;
using MaIN.Services.Services.Abstract;
@@ -13,6 +14,11 @@ public static class AIHub
private static MaINSettings _settings = null!;
private static IHttpClientFactory _httpClientFactory = null!;
+ internal static bool IsInitialized => _services is not null;
+
+ internal static IReadOnlyList? GetCurrentSkills() =>
+ _services?.SkillRegistry.GetAll();
+
internal static void Initialize(IAIHubServices services,
MaINSettings settings,
IHttpClientFactory httpClientFactory)
@@ -28,7 +34,7 @@ internal static void Initialize(IAIHubServices services,
public static ModelContext Model() => new(_settings, _httpClientFactory);
public static ChatContext Chat() => new(Services.ChatService);
- public static AgentContext Agent() => new(Services.AgentService);
+ public static AgentContext Agent() => new(Services.AgentService, Services.SkillRegistry, Services.SkillComposer);
public static FlowContext Flow() => new(Services.FlowService, Services.AgentService);
public static McpContext Mcp() => new(Services.McpService);
diff --git a/src/MaIN.Core/Hub/Contexts/AgentContext.cs b/src/MaIN.Core/Hub/Contexts/AgentContext.cs
index 0ac257ab..c4b7052a 100644
--- a/src/MaIN.Core/Hub/Contexts/AgentContext.cs
+++ b/src/MaIN.Core/Hub/Contexts/AgentContext.cs
@@ -4,6 +4,7 @@
using MaIN.Domain.Entities.Agents;
using MaIN.Domain.Entities.Agents.AgentSource;
using MaIN.Domain.Entities.Agents.Knowledge;
+using MaIN.Domain.Entities.Skills;
using MaIN.Domain.Entities.Tools;
using MaIN.Domain.Exceptions.Agents;
using MaIN.Domain.Models;
@@ -17,6 +18,10 @@ namespace MaIN.Core.Hub.Contexts;
public sealed class AgentContext : IAgentBuilderEntryPoint, IAgentConfigurationBuilder, IAgentContextExecutor
{
private readonly IAgentService _agentService;
+ private readonly ISkillRegistry? _skillRegistry;
+ private readonly ISkillComposer? _skillComposer;
+ private readonly List _pendingSkillNames = [];
+ private readonly List _pendingInlineSkills = [];
private IBackendInferenceParams? _inferenceParams;
private MemoryParams? _memoryParams;
private bool _disableCache;
@@ -24,9 +29,11 @@ public sealed class AgentContext : IAgentBuilderEntryPoint, IAgentConfigurationB
private readonly Agent _agent;
internal Knowledge? _knowledge;
- internal AgentContext(IAgentService agentService)
+ internal AgentContext(IAgentService agentService, ISkillRegistry skillRegistry, ISkillComposer skillComposer)
{
_agentService = agentService;
+ _skillRegistry = skillRegistry;
+ _skillComposer = skillComposer;
_agent = new Agent
{
Id = Guid.NewGuid().ToString(),
@@ -51,6 +58,24 @@ internal AgentContext(IAgentService agentService, Agent existingAgent)
_agent = existingAgent;
}
+ public IAgentConfigurationBuilder WithSkill(string skillName)
+ {
+ _pendingSkillNames.Add(skillName);
+ return this;
+ }
+
+ public IAgentConfigurationBuilder WithSkills(params string[] skillNames)
+ {
+ _pendingSkillNames.AddRange(skillNames);
+ return this;
+ }
+
+ public IAgentConfigurationBuilder WithSkill(AgentSkill skill)
+ {
+ _pendingInlineSkills.Add(skill);
+ return this;
+ }
+
// --- IAgentActions ---
public string GetAgentId() => _agent.Id;
public Agent GetAgent() => _agent;
@@ -194,6 +219,8 @@ public IAgentConfigurationBuilder WithBehaviour(string name, string instruction)
public async Task CreateAsync(bool flow = false, bool interactiveResponse = false)
{
+ ApplyPendingSkills();
+
if (_ensureModelDownloaded && !string.IsNullOrWhiteSpace(_agent.Model))
{
await AIHub.Model().EnsureDownloadedAsync(_agent.Model);
@@ -205,10 +232,23 @@ public async Task CreateAsync(bool flow = false, bool int
public IAgentContextExecutor Create(bool flow = false, bool interactiveResponse = false)
{
+ ApplyPendingSkills();
_ = _agentService.CreateAgent(_agent, flow, interactiveResponse, _inferenceParams, _memoryParams, _disableCache).Result;
return this;
}
+ private void ApplyPendingSkills()
+ {
+ if (_skillComposer is null || _skillRegistry is null) return;
+ if (_pendingSkillNames.Count == 0 && _pendingInlineSkills.Count == 0) return;
+
+ var namedSkills = _pendingSkillNames.Select(name => _skillRegistry.GetSkill(name));
+ var allSkills = namedSkills.Concat(_pendingInlineSkills).ToList();
+
+ _skillComposer.Apply(_agent, allSkills, _knowledge);
+ _agent.Skills.AddRange(_pendingSkillNames);
+ }
+
public IAgentConfigurationBuilder WithTools(ToolsConfiguration toolsConfiguration)
{
_agent.ToolsConfiguration = toolsConfiguration;
diff --git a/src/MaIN.Core/Hub/Contexts/Interfaces/AgentContext/IAgentConfigurationBuilder.cs b/src/MaIN.Core/Hub/Contexts/Interfaces/AgentContext/IAgentConfigurationBuilder.cs
index a7fe0643..e6befa40 100644
--- a/src/MaIN.Core/Hub/Contexts/Interfaces/AgentContext/IAgentConfigurationBuilder.cs
+++ b/src/MaIN.Core/Hub/Contexts/Interfaces/AgentContext/IAgentConfigurationBuilder.cs
@@ -2,6 +2,7 @@
using MaIN.Domain.Entities;
using MaIN.Domain.Entities.Agents.AgentSource;
using MaIN.Domain.Entities.Agents.Knowledge;
+using MaIN.Domain.Entities.Skills;
using MaIN.Domain.Entities.Tools;
namespace MaIN.Core.Hub.Contexts.Interfaces.AgentContext;
@@ -137,6 +138,21 @@ public interface IAgentConfigurationBuilder : IAgentActions
/// The context instance implementing for method chaining.
IAgentConfigurationBuilder WithBehaviour(string name, string instruction);
+ ///
+ /// Applies a named skill from the registry, enriching the agent's steps, tools, source, and behaviours.
+ ///
+ IAgentConfigurationBuilder WithSkill(string skillName);
+
+ ///
+ /// Applies multiple named skills from the registry.
+ ///
+ IAgentConfigurationBuilder WithSkills(params string[] skillNames);
+
+ ///
+ /// Applies an inline skill defined directly in code (not from the registry).
+ ///
+ IAgentConfigurationBuilder WithSkill(AgentSkill skill);
+
///
/// Synchronously creates the agent in the system.
///
diff --git a/src/MaIN.Core/Hub/Skills/JournalistSkillProvider.cs b/src/MaIN.Core/Hub/Skills/JournalistSkillProvider.cs
new file mode 100644
index 00000000..bad8eb7f
--- /dev/null
+++ b/src/MaIN.Core/Hub/Skills/JournalistSkillProvider.cs
@@ -0,0 +1,20 @@
+using MaIN.Domain.Entities.Skills;
+
+namespace MaIN.Core.Hub.Skills;
+
+public class JournalistSkillProvider : IAgentSkillProvider
+{
+ public AgentSkill GetSkill() => new()
+ {
+ Name = "journalist",
+ Description = "Applies a journalist persona that writes newsletters from fetched data.",
+ Tags = ["persona", "journalism"],
+ Priority = 50,
+ StepPlacement = SkillStepPlacement.Before,
+ Steps = [$"BECOME+Journalist", "ANSWER"],
+ Behaviours = new Dictionary
+ {
+ ["Journalist"] = $"Based on data provided in chat, write a newsletter. Date: {DateTime.UtcNow:D}. Be concise and factual."
+ }
+ };
+}
diff --git a/src/MaIN.Core/Hub/Skills/McpToolCallerSkillProvider.cs b/src/MaIN.Core/Hub/Skills/McpToolCallerSkillProvider.cs
new file mode 100644
index 00000000..037324cb
--- /dev/null
+++ b/src/MaIN.Core/Hub/Skills/McpToolCallerSkillProvider.cs
@@ -0,0 +1,16 @@
+using MaIN.Domain.Entities.Skills;
+
+namespace MaIN.Core.Hub.Skills;
+
+public class McpToolCallerSkillProvider : IAgentSkillProvider
+{
+ public AgentSkill GetSkill() => new()
+ {
+ Name = "mcp-tool-caller",
+ Description = "Adds MCP tool-calling step to the agent pipeline.",
+ Tags = ["mcp", "tools"],
+ Priority = 5,
+ StepPlacement = SkillStepPlacement.Before,
+ Steps = ["MCP"]
+ };
+}
diff --git a/src/MaIN.Core/Hub/Skills/RagExpertSkillProvider.cs b/src/MaIN.Core/Hub/Skills/RagExpertSkillProvider.cs
new file mode 100644
index 00000000..dc52f6b0
--- /dev/null
+++ b/src/MaIN.Core/Hub/Skills/RagExpertSkillProvider.cs
@@ -0,0 +1,18 @@
+using MaIN.Domain.Entities.Skills;
+
+namespace MaIN.Core.Hub.Skills;
+
+public class RagExpertSkillProvider : IAgentSkillProvider
+{
+ public AgentSkill GetSkill() => new()
+ {
+ Name = "rag-expert",
+ Description = "Enables knowledge-augmented answering via RAG.",
+ Tags = ["rag", "knowledge", "retrieval"],
+ Priority = 10,
+ StepPlacement = SkillStepPlacement.Replace,
+ Steps = ["ANSWER+USE_KNOWLEDGE"],
+ InstructionFragment =
+ "When answering, always ground your response in the provided knowledge context."
+ };
+}
diff --git a/src/MaIN.Core/Hub/Skills/SummarizerSkillProvider.cs b/src/MaIN.Core/Hub/Skills/SummarizerSkillProvider.cs
new file mode 100644
index 00000000..c792e6e3
--- /dev/null
+++ b/src/MaIN.Core/Hub/Skills/SummarizerSkillProvider.cs
@@ -0,0 +1,17 @@
+using MaIN.Domain.Entities.Skills;
+
+namespace MaIN.Core.Hub.Skills;
+
+public class SummarizerSkillProvider : IAgentSkillProvider
+{
+ public AgentSkill GetSkill() => new()
+ {
+ Name = "summarizer",
+ Description = "Appends a concise bullet-point summary after answering.",
+ Tags = ["summarize", "compression"],
+ Priority = 80,
+ StepPlacement = SkillStepPlacement.After,
+ Steps = ["ANSWER"],
+ InstructionFragment = "Always conclude your response with a concise 3-bullet summary."
+ };
+}
diff --git a/src/MaIN.Core/Hub/Skills/WebSearchSkillProvider.cs b/src/MaIN.Core/Hub/Skills/WebSearchSkillProvider.cs
new file mode 100644
index 00000000..4341c3e3
--- /dev/null
+++ b/src/MaIN.Core/Hub/Skills/WebSearchSkillProvider.cs
@@ -0,0 +1,22 @@
+using MaIN.Domain.Entities.Agents.AgentSource;
+using MaIN.Domain.Entities.Skills;
+
+namespace MaIN.Core.Hub.Skills;
+
+public class WebSearchSkillProvider(string url = "https://www.bbc.com/") : IAgentSkillProvider
+{
+ public AgentSkill GetSkill() => new()
+ {
+ Name = "web-search",
+ Description = "Fetches content from a web URL and makes it available for answering.",
+ Tags = ["web", "fetch", "data"],
+ Priority = 10,
+ StepPlacement = SkillStepPlacement.Before,
+ Steps = ["FETCH_DATA"],
+ Source = new SkillSourceDefinition
+ {
+ Details = new AgentWebSourceDetails { Url = url },
+ Type = AgentSourceType.Web
+ }
+ };
+}
diff --git a/src/MaIN.Core/Hub/Utils/SkillBuilder.cs b/src/MaIN.Core/Hub/Utils/SkillBuilder.cs
new file mode 100644
index 00000000..05040dd9
--- /dev/null
+++ b/src/MaIN.Core/Hub/Utils/SkillBuilder.cs
@@ -0,0 +1,79 @@
+using MaIN.Domain.Entities.Agents.AgentSource;
+using MaIN.Domain.Entities.Agents.Knowledge;
+using MaIN.Domain.Entities.Skills;
+
+namespace MaIN.Core.Hub.Utils;
+
+public class SkillBuilder
+{
+ private string _name = "";
+ private string? _description;
+ private string _version = "1.0.0";
+ private readonly List _steps = [];
+ private readonly List _tools = [];
+ private SkillSourceDefinition? _source;
+ private SkillMcpDefinition? _mcp;
+ private readonly Dictionary _behaviours = [];
+ private readonly List _knowledgeSeed = [];
+ private string? _instructionFragment;
+ private readonly List _tags = [];
+ private int _priority = 100;
+ private SkillStepPlacement _placement = SkillStepPlacement.Before;
+
+ private SkillBuilder() { }
+
+ public static SkillBuilder Create(string name)
+ {
+ var builder = new SkillBuilder();
+ builder._name = name;
+ return builder;
+ }
+
+ public SkillBuilder WithDescription(string description) { _description = description; return this; }
+ public SkillBuilder WithVersion(string version) { _version = version; return this; }
+ public SkillBuilder WithSteps(params string[] steps) { _steps.AddRange(steps); return this; }
+ public SkillBuilder WithPriority(int priority) { _priority = priority; return this; }
+ public SkillBuilder WithPlacement(SkillStepPlacement placement) { _placement = placement; return this; }
+ public SkillBuilder WithTags(params string[] tags) { _tags.AddRange(tags); return this; }
+ public SkillBuilder WithInstructionFragment(string fragment) { _instructionFragment = fragment; return this; }
+ public SkillBuilder WithBehaviour(string name, string instruction) { _behaviours[name] = instruction; return this; }
+ public SkillBuilder WithTool(SkillToolDefinition tool) { _tools.Add(tool); return this; }
+ public SkillBuilder WithKnowledgeSeed(KnowledgeIndexItem item) { _knowledgeSeed.Add(item); return this; }
+
+ public SkillBuilder WithSource(IAgentSource source, AgentSourceType type)
+ {
+ _source = new SkillSourceDefinition { Details = source, Type = type };
+ return this;
+ }
+
+ public SkillBuilder WithMcp(string command, List arguments,
+ Dictionary? environment = null,
+ Dictionary? properties = null)
+ {
+ _mcp = new SkillMcpDefinition
+ {
+ Command = command,
+ Arguments = arguments,
+ Environment = environment ?? [],
+ Properties = properties ?? []
+ };
+ return this;
+ }
+
+ public AgentSkill Build() => new()
+ {
+ Name = _name,
+ Description = _description,
+ Version = _version,
+ Steps = [.._steps],
+ Tools = [.._tools],
+ Source = _source,
+ Mcp = _mcp,
+ Behaviours = new Dictionary(_behaviours),
+ KnowledgeSeed = [.._knowledgeSeed],
+ InstructionFragment = _instructionFragment,
+ Tags = [.._tags],
+ Priority = _priority,
+ StepPlacement = _placement
+ };
+}
diff --git a/src/MaIN.Core/Interfaces/IAIHubService.cs b/src/MaIN.Core/Interfaces/IAIHubService.cs
index 0afd1bda..907e01f1 100644
--- a/src/MaIN.Core/Interfaces/IAIHubService.cs
+++ b/src/MaIN.Core/Interfaces/IAIHubService.cs
@@ -8,4 +8,6 @@ public interface IAIHubServices
IAgentService AgentService { get; }
IAgentFlowService FlowService { get; }
IMcpService McpService { get; }
+ ISkillRegistry SkillRegistry { get; }
+ ISkillComposer SkillComposer { get; }
}
\ No newline at end of file
diff --git a/src/MaIN.Core/Services/AIHubService.cs b/src/MaIN.Core/Services/AIHubService.cs
index 1c13358b..371de93d 100644
--- a/src/MaIN.Core/Services/AIHubService.cs
+++ b/src/MaIN.Core/Services/AIHubService.cs
@@ -7,11 +7,15 @@ public sealed class AIHubServices(
IChatService chatService,
IAgentService agentService,
IAgentFlowService flowService,
- IMcpService mcpService)
+ IMcpService mcpService,
+ ISkillRegistry skillRegistry,
+ ISkillComposer skillComposer)
: IAIHubServices
{
public IChatService ChatService { get; } = chatService;
public IAgentService AgentService { get; } = agentService;
public IAgentFlowService FlowService { get; } = flowService;
public IMcpService McpService { get; } = mcpService;
-}
\ No newline at end of file
+ public ISkillRegistry SkillRegistry { get; } = skillRegistry;
+ public ISkillComposer SkillComposer { get; } = skillComposer;
+}
diff --git a/src/MaIN.Domain/Configuration/MaINSettings.cs b/src/MaIN.Domain/Configuration/MaINSettings.cs
index 81018957..52fc3972 100644
--- a/src/MaIN.Domain/Configuration/MaINSettings.cs
+++ b/src/MaIN.Domain/Configuration/MaINSettings.cs
@@ -20,6 +20,7 @@ public class MaINSettings
public SqlSettings? SqlSettings { get; set; }
public string? VoicesPath { get; set; }
public GoogleServiceAccountConfig? GoogleServiceAccountAuth { get; set; }
+ public string? SkillsDirectory { get; set; }
}
public enum BackendType
diff --git a/src/MaIN.Domain/Entities/Agents/Agent.cs b/src/MaIN.Domain/Entities/Agents/Agent.cs
index 474ebed9..fc681f67 100644
--- a/src/MaIN.Domain/Entities/Agents/Agent.cs
+++ b/src/MaIN.Domain/Entities/Agents/Agent.cs
@@ -18,4 +18,5 @@ public class Agent
public Dictionary Behaviours { get; set; } = [];
public required string CurrentBehaviour { get; set; }
public ToolsConfiguration? ToolsConfiguration { get; set; }
+ public List Skills { get; set; } = [];
}
diff --git a/src/MaIN.Domain/Entities/Mcp.cs b/src/MaIN.Domain/Entities/Mcp.cs
index 32fea8de..b85cf0c0 100644
--- a/src/MaIN.Domain/Entities/Mcp.cs
+++ b/src/MaIN.Domain/Entities/Mcp.cs
@@ -7,7 +7,7 @@ public class Mcp
public required string Name { get; init; }
public required List Arguments { get; init; }
public required string Command { get; init; }
- public required string Model { get; init; }
+ public string Model { get; init; } = string.Empty;
public string Location { get; set; } = "us-central1";
public Dictionary Properties { get; set; } = [];
public BackendType? Backend { get; set; }
diff --git a/src/MaIN.Domain/Entities/Skills/AgentSkill.cs b/src/MaIN.Domain/Entities/Skills/AgentSkill.cs
new file mode 100644
index 00000000..c1ed4435
--- /dev/null
+++ b/src/MaIN.Domain/Entities/Skills/AgentSkill.cs
@@ -0,0 +1,20 @@
+using MaIN.Domain.Entities.Agents.Knowledge;
+
+namespace MaIN.Domain.Entities.Skills;
+
+public class AgentSkill
+{
+ public required string Name { get; init; }
+ public string? Description { get; init; }
+ public string Version { get; init; } = "1.0.0";
+ public List Steps { get; init; } = [];
+ public List Tools { get; init; } = [];
+ public SkillSourceDefinition? Source { get; init; }
+ public SkillMcpDefinition? Mcp { get; init; }
+ public Dictionary Behaviours { get; init; } = [];
+ public List KnowledgeSeed { get; init; } = [];
+ public string? InstructionFragment { get; init; }
+ public string[] Tags { get; init; } = [];
+ public int Priority { get; init; } = 100;
+ public SkillStepPlacement StepPlacement { get; init; } = SkillStepPlacement.Before;
+}
diff --git a/src/MaIN.Domain/Entities/Skills/IAgentSkillProvider.cs b/src/MaIN.Domain/Entities/Skills/IAgentSkillProvider.cs
new file mode 100644
index 00000000..fa679ebb
--- /dev/null
+++ b/src/MaIN.Domain/Entities/Skills/IAgentSkillProvider.cs
@@ -0,0 +1,6 @@
+namespace MaIN.Domain.Entities.Skills;
+
+public interface IAgentSkillProvider
+{
+ AgentSkill GetSkill();
+}
diff --git a/src/MaIN.Domain/Entities/Skills/SkillModels.cs b/src/MaIN.Domain/Entities/Skills/SkillModels.cs
new file mode 100644
index 00000000..bd8c09ea
--- /dev/null
+++ b/src/MaIN.Domain/Entities/Skills/SkillModels.cs
@@ -0,0 +1,28 @@
+using MaIN.Domain.Entities.Agents.AgentSource;
+
+namespace MaIN.Domain.Entities.Skills;
+
+public enum SkillStepPlacement { Before, After, Replace }
+
+public class SkillToolDefinition
+{
+ public required string Name { get; init; }
+ public required string Description { get; init; }
+ public required object Parameters { get; init; }
+ public Func>? Execute { get; init; }
+ public string? ToolChoice { get; init; }
+}
+
+public class SkillSourceDefinition
+{
+ public required IAgentSource Details { get; init; }
+ public required AgentSourceType Type { get; init; }
+}
+
+public class SkillMcpDefinition
+{
+ public required string Command { get; init; }
+ public required List Arguments { get; init; }
+ public Dictionary Environment { get; init; } = [];
+ public Dictionary Properties { get; init; } = [];
+}
diff --git a/src/MaIN.Domain/Exceptions/Skills/SkillConflictException.cs b/src/MaIN.Domain/Exceptions/Skills/SkillConflictException.cs
new file mode 100644
index 00000000..67b01152
--- /dev/null
+++ b/src/MaIN.Domain/Exceptions/Skills/SkillConflictException.cs
@@ -0,0 +1,11 @@
+using System.Net;
+using MaIN.Domain.Exceptions;
+
+namespace MaIN.Domain.Exceptions.Skills;
+
+public class SkillConflictException(string detail)
+ : MaINCustomException($"Skill conflict: {detail}")
+{
+ public override string PublicErrorMessage => "Skill configuration conflict.";
+ public override HttpStatusCode HttpStatusCode => HttpStatusCode.Conflict;
+}
diff --git a/src/MaIN.Domain/Exceptions/Skills/SkillNotFoundException.cs b/src/MaIN.Domain/Exceptions/Skills/SkillNotFoundException.cs
new file mode 100644
index 00000000..f4e97664
--- /dev/null
+++ b/src/MaIN.Domain/Exceptions/Skills/SkillNotFoundException.cs
@@ -0,0 +1,11 @@
+using System.Net;
+using MaIN.Domain.Exceptions;
+
+namespace MaIN.Domain.Exceptions.Skills;
+
+public class SkillNotFoundException(string skillName)
+ : MaINCustomException($"Skill '{skillName}' was not found in the registry.")
+{
+ public override string PublicErrorMessage => "Skill not found.";
+ public override HttpStatusCode HttpStatusCode => HttpStatusCode.NotFound;
+}
diff --git a/src/MaIN.Services/Bootstrapper.cs b/src/MaIN.Services/Bootstrapper.cs
index 09ad2022..b9a88a3b 100644
--- a/src/MaIN.Services/Bootstrapper.cs
+++ b/src/MaIN.Services/Bootstrapper.cs
@@ -1,5 +1,6 @@
using MaIN.Domain.Configuration;
using MaIN.Domain.Entities;
+using MaIN.Domain.Entities.Skills;
using MaIN.Services.Constants;
using MaIN.Services.Services;
using MaIN.Services.Services.Abstract;
@@ -8,6 +9,7 @@
using MaIN.Services.Services.LLMService.Factory;
using MaIN.Services.Services.LLMService.Memory;
using MaIN.Services.Services.Models.Commands;
+using MaIN.Services.Services.Skills;
using MaIN.Services.Services.Steps;
using MaIN.Services.Services.Steps.Commands;
using MaIN.Services.Services.Steps.Commands.Abstract;
@@ -63,6 +65,26 @@ public static IServiceCollection ConfigureMaIN(
// Register the step processor
serviceCollection.AddSingleton();
+ // Register skill infrastructure
+ serviceCollection.AddSingleton(sp => new SkillRegistry(
+ sp.GetServices(),
+ sp.GetServices()));
+ serviceCollection.AddSingleton();
+
+ // Register skills directory loader if configured
+ if (!string.IsNullOrWhiteSpace(settings.SkillsDirectory))
+ {
+ serviceCollection.AddSingleton(
+ new FileSystemSkillLoader(settings.SkillsDirectory));
+ }
+
+ return serviceCollection;
+ }
+
+ public static IServiceCollection AddSkillsFromDirectory(
+ this IServiceCollection serviceCollection, string directoryPath)
+ {
+ serviceCollection.AddSingleton(new FileSystemSkillLoader(directoryPath));
return serviceCollection;
}
diff --git a/src/MaIN.Services/MaIN.Services.csproj b/src/MaIN.Services/MaIN.Services.csproj
index 187bea5f..50459831 100644
--- a/src/MaIN.Services/MaIN.Services.csproj
+++ b/src/MaIN.Services/MaIN.Services.csproj
@@ -13,6 +13,7 @@
+
diff --git a/src/MaIN.Services/Services/Abstract/ISkillComposer.cs b/src/MaIN.Services/Services/Abstract/ISkillComposer.cs
new file mode 100644
index 00000000..94ea1e33
--- /dev/null
+++ b/src/MaIN.Services/Services/Abstract/ISkillComposer.cs
@@ -0,0 +1,10 @@
+using MaIN.Domain.Entities.Agents;
+using MaIN.Domain.Entities.Agents.Knowledge;
+using MaIN.Domain.Entities.Skills;
+
+namespace MaIN.Services.Services.Abstract;
+
+public interface ISkillComposer
+{
+ void Apply(Agent agent, IReadOnlyList skills, Knowledge? knowledge = null);
+}
diff --git a/src/MaIN.Services/Services/Abstract/ISkillLoader.cs b/src/MaIN.Services/Services/Abstract/ISkillLoader.cs
new file mode 100644
index 00000000..5a08b1c2
--- /dev/null
+++ b/src/MaIN.Services/Services/Abstract/ISkillLoader.cs
@@ -0,0 +1,8 @@
+using MaIN.Domain.Entities.Skills;
+
+namespace MaIN.Services.Services.Abstract;
+
+public interface ISkillLoader
+{
+ IReadOnlyList LoadAll();
+}
diff --git a/src/MaIN.Services/Services/Abstract/ISkillRegistry.cs b/src/MaIN.Services/Services/Abstract/ISkillRegistry.cs
new file mode 100644
index 00000000..1c20be74
--- /dev/null
+++ b/src/MaIN.Services/Services/Abstract/ISkillRegistry.cs
@@ -0,0 +1,12 @@
+using MaIN.Domain.Entities.Skills;
+
+namespace MaIN.Services.Services.Abstract;
+
+public interface ISkillRegistry
+{
+ void Register(AgentSkill skill);
+ AgentSkill GetSkill(string name);
+ bool TryGetSkill(string name, out AgentSkill? skill);
+ IReadOnlyList GetAll();
+ IReadOnlyList GetByTag(params string[] tags);
+}
diff --git a/src/MaIN.Services/Services/SkillComposer.cs b/src/MaIN.Services/Services/SkillComposer.cs
new file mode 100644
index 00000000..2ff407da
--- /dev/null
+++ b/src/MaIN.Services/Services/SkillComposer.cs
@@ -0,0 +1,173 @@
+using MaIN.Domain.Entities;
+using MaIN.Domain.Entities.Agents;
+using MaIN.Domain.Entities.Agents.AgentSource;
+using MaIN.Domain.Entities.Agents.Knowledge;
+using MaIN.Domain.Entities.Skills;
+using MaIN.Domain.Entities.Tools;
+using MaIN.Domain.Exceptions.Skills;
+using MaIN.Services.Services.Abstract;
+using Microsoft.Extensions.Logging;
+
+namespace MaIN.Services.Services;
+
+public class SkillComposer(ILogger logger) : ISkillComposer
+{
+ public void Apply(Agent agent, IReadOnlyList skills, Knowledge? knowledge = null)
+ {
+ if (skills.Count == 0) return;
+
+ var sorted = skills.OrderBy(s => s.Priority).ToList();
+
+ MergeSteps(agent, sorted);
+ MergeTools(agent, sorted);
+ MergeSource(agent, sorted);
+ MergeMcp(agent, sorted);
+ MergeBehaviours(agent, sorted);
+ MergeInstructionFragments(agent, sorted);
+ MergeKnowledgeSeed(knowledge, sorted);
+ }
+
+ private static void MergeSteps(Agent agent, List skills)
+ {
+ var replaceSkill = skills.LastOrDefault(s => s.StepPlacement == SkillStepPlacement.Replace);
+ if (replaceSkill is not null)
+ {
+ agent.Config.Steps = replaceSkill.Steps.Distinct().ToList();
+ return;
+ }
+
+ var before = skills
+ .Where(s => s.StepPlacement == SkillStepPlacement.Before)
+ .SelectMany(s => s.Steps);
+
+ var after = skills
+ .Where(s => s.StepPlacement == SkillStepPlacement.After)
+ .SelectMany(s => s.Steps);
+
+ var existing = agent.Config.Steps ?? [];
+
+ agent.Config.Steps = before
+ .Concat(existing)
+ .Concat(after)
+ .Distinct()
+ .ToList();
+ }
+
+ private static void MergeTools(Agent agent, List skills)
+ {
+ var skillTools = skills.SelectMany(s => s.Tools).ToList();
+ if (skillTools.Count == 0) return;
+
+ var existing = agent.ToolsConfiguration?.Tools ?? [];
+ var existingNames = existing.Select(t => t.Function?.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ var toAdd = new List();
+ foreach (var skillTool in skillTools)
+ {
+ if (existingNames.Contains(skillTool.Name))
+ throw new SkillConflictException(
+ $"Tool '{skillTool.Name}' is already registered on the agent or provided by another skill.");
+
+ existingNames.Add(skillTool.Name);
+ toAdd.Add(new ToolDefinition
+ {
+ Type = "function",
+ Function = new FunctionDefinition
+ {
+ Name = skillTool.Name,
+ Description = skillTool.Description,
+ Parameters = skillTool.Parameters
+ },
+ Execute = skillTool.Execute
+ });
+ }
+
+ agent.ToolsConfiguration ??= new ToolsConfiguration { Tools = [] };
+ agent.ToolsConfiguration.Tools.AddRange(toAdd);
+ }
+
+ private static void MergeSource(Agent agent, List skills)
+ {
+ var sourcedSkills = skills.Where(s => s.Source is not null).ToList();
+ if (sourcedSkills.Count == 0) return;
+
+ if (sourcedSkills.Count > 1 && agent.Config.Source is not null)
+ throw new SkillConflictException(
+ "Multiple skills provide a source configuration. Only one source is allowed per agent.");
+
+ if (sourcedSkills.Count > 1)
+ throw new SkillConflictException(
+ $"Skills '{sourcedSkills[0].Name}' and '{sourcedSkills[1].Name}' both provide a source. Only one source skill is allowed.");
+
+ if (agent.Config.Source is null)
+ {
+ var def = sourcedSkills[0].Source!;
+ agent.Config.Source = new AgentSource
+ {
+ Details = def.Details,
+ Type = def.Type
+ };
+ }
+ }
+
+ private static void MergeMcp(Agent agent, List skills)
+ {
+ var mcpSkills = skills.Where(s => s.Mcp is not null).ToList();
+ if (mcpSkills.Count == 0) return;
+
+ if (mcpSkills.Count > 1)
+ throw new SkillConflictException(
+ $"Skills '{mcpSkills[0].Name}' and '{mcpSkills[1].Name}' both provide MCP configuration. Only one MCP skill is allowed.");
+
+ if (agent.Config.McpConfig is null)
+ {
+ var def = mcpSkills[0].Mcp!;
+ agent.Config.McpConfig = new Mcp
+ {
+ Name = mcpSkills[0].Name,
+ Command = def.Command,
+ Arguments = def.Arguments,
+ EnvironmentVariables = def.Environment,
+ Properties = def.Properties,
+ Model = agent.Model
+ };
+ }
+ }
+
+ private void MergeBehaviours(Agent agent, List skills)
+ {
+ foreach (var skill in skills)
+ {
+ foreach (var (key, value) in skill.Behaviours)
+ {
+ if (agent.Behaviours.ContainsKey(key))
+ logger.LogWarning("Skill '{Skill}' behaviour key '{Key}' overrides existing entry.", skill.Name, key);
+
+ agent.Behaviours[key] = value;
+ }
+ }
+ }
+
+ private static void MergeInstructionFragments(Agent agent, List skills)
+ {
+ var fragments = skills
+ .Where(s => !string.IsNullOrWhiteSpace(s.InstructionFragment))
+ .Select(s => s.InstructionFragment!)
+ .ToList();
+
+ if (fragments.Count == 0) return;
+
+ var existing = agent.Config.Instruction ?? string.Empty;
+ agent.Config.Instruction = string.IsNullOrWhiteSpace(existing)
+ ? string.Join("\n\n", fragments)
+ : existing + "\n\n" + string.Join("\n\n", fragments);
+ }
+
+ private static void MergeKnowledgeSeed(Knowledge? knowledge, List skills)
+ {
+ if (knowledge is null) return;
+
+ foreach (var item in skills.SelectMany(s => s.KnowledgeSeed))
+ knowledge.AddItem(item);
+ }
+}
diff --git a/src/MaIN.Services/Services/SkillRegistry.cs b/src/MaIN.Services/Services/SkillRegistry.cs
new file mode 100644
index 00000000..26348203
--- /dev/null
+++ b/src/MaIN.Services/Services/SkillRegistry.cs
@@ -0,0 +1,42 @@
+using MaIN.Domain.Entities.Skills;
+using MaIN.Domain.Exceptions.Skills;
+using MaIN.Services.Services.Abstract;
+
+namespace MaIN.Services.Services;
+
+public class SkillRegistry : ISkillRegistry
+{
+ private readonly Dictionary _skills =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ public SkillRegistry(
+ IEnumerable providers,
+ IEnumerable loaders)
+ {
+ foreach (var p in providers)
+ Register(p.GetSkill());
+
+ foreach (var l in loaders)
+ foreach (var s in l.LoadAll())
+ Register(s);
+ }
+
+ public void Register(AgentSkill skill) => _skills[skill.Name] = skill;
+
+ public AgentSkill GetSkill(string name) =>
+ _skills.TryGetValue(name, out var skill)
+ ? skill
+ : throw new SkillNotFoundException(name);
+
+ public bool TryGetSkill(string name, out AgentSkill? skill) =>
+ _skills.TryGetValue(name, out skill);
+
+ public IReadOnlyList GetAll() =>
+ _skills.Values.ToList().AsReadOnly();
+
+ public IReadOnlyList GetByTag(params string[] tags) =>
+ _skills.Values
+ .Where(s => s.Tags.Any(t => tags.Contains(t, StringComparer.OrdinalIgnoreCase)))
+ .ToList()
+ .AsReadOnly();
+}
diff --git a/src/MaIN.Services/Services/Skills/FileSystemSkillLoader.cs b/src/MaIN.Services/Services/Skills/FileSystemSkillLoader.cs
new file mode 100644
index 00000000..e15a5c8b
--- /dev/null
+++ b/src/MaIN.Services/Services/Skills/FileSystemSkillLoader.cs
@@ -0,0 +1,151 @@
+using MaIN.Domain.Entities.Agents.AgentSource;
+using MaIN.Domain.Entities.Skills;
+using MaIN.Services.Services.Abstract;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
+namespace MaIN.Services.Services.Skills;
+
+public class FileSystemSkillLoader(string directoryPath) : ISkillLoader
+{
+ private static readonly IDeserializer Deserializer = new DeserializerBuilder()
+ .WithNamingConvention(LowerCaseNamingConvention.Instance)
+ .IgnoreUnmatchedProperties()
+ .Build();
+
+ public IReadOnlyList LoadAll()
+ {
+ var resolvedPath = Path.IsPathRooted(directoryPath)
+ ? directoryPath
+ : Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, directoryPath));
+
+ if (!Directory.Exists(resolvedPath))
+ return [];
+
+ return Directory
+ .GetFiles(resolvedPath, "*.md", SearchOption.AllDirectories)
+ .Select(TryParseSkillFile)
+ .OfType()
+ .ToList()
+ .AsReadOnly();
+ }
+
+ private static AgentSkill? TryParseSkillFile(string filePath)
+ {
+ try
+ {
+ return ParseSkillFile(filePath);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[Skills] Failed to load '{Path.GetFileName(filePath)}': {ex.Message}");
+ return null;
+ }
+ }
+
+ private static AgentSkill? ParseSkillFile(string filePath)
+ {
+ var content = File.ReadAllText(filePath);
+
+ if (!content.TrimStart().StartsWith("---"))
+ return null;
+
+ var parts = content.Split(["---"], 3, StringSplitOptions.None);
+ if (parts.Length < 3)
+ return null;
+
+ var frontmatter = parts[1].Trim();
+ var body = parts[2].Trim();
+
+ var dto = Deserializer.Deserialize(frontmatter);
+ if (string.IsNullOrWhiteSpace(dto?.name))
+ return null;
+
+ return new AgentSkill
+ {
+ Name = dto.name,
+ Description = dto.description,
+ Version = dto.version ?? "1.0.0",
+ Steps = dto.steps ?? [],
+ Priority = dto.priority,
+ Tags = (dto.tags ?? []).ToArray(),
+ StepPlacement = ParsePlacement(dto.placement),
+ Behaviours = dto.behaviours ?? [],
+ Source = BuildSource(dto.source),
+ Mcp = BuildMcp(dto.mcp),
+ InstructionFragment = string.IsNullOrWhiteSpace(body) ? null : body
+ };
+ }
+
+ private static SkillStepPlacement ParsePlacement(string? placement) =>
+ placement?.ToLowerInvariant() switch
+ {
+ "after" => SkillStepPlacement.After,
+ "replace" => SkillStepPlacement.Replace,
+ _ => SkillStepPlacement.Before
+ };
+
+ private static SkillSourceDefinition? BuildSource(SkillFileSourceDto? dto)
+ {
+ if (dto is null || string.IsNullOrWhiteSpace(dto.type)) return null;
+
+ return dto.type.ToUpperInvariant() switch
+ {
+ "WEB" when !string.IsNullOrWhiteSpace(dto.url) =>
+ new SkillSourceDefinition
+ {
+ Details = new AgentWebSourceDetails { Url = dto.url },
+ Type = AgentSourceType.Web
+ },
+ "FILE" when !string.IsNullOrWhiteSpace(dto.url) =>
+ new SkillSourceDefinition
+ {
+ Details = new AgentFileSourceDetails { Files = [dto.url] },
+ Type = AgentSourceType.File
+ },
+ _ => null
+ };
+ }
+
+ private static SkillMcpDefinition? BuildMcp(SkillFileMcpDto? dto)
+ {
+ if (dto is null || string.IsNullOrWhiteSpace(dto.command)) return null;
+
+ return new SkillMcpDefinition
+ {
+ Command = dto.command,
+ Arguments = dto.arguments ?? [],
+ Environment = dto.environment ?? [],
+ Properties = dto.properties ?? []
+ };
+ }
+}
+
+internal class SkillFileDto
+{
+ public string name { get; set; } = "";
+ public string? description { get; set; }
+ public string? version { get; set; }
+ public List? steps { get; set; }
+ public string? placement { get; set; }
+ public int priority { get; set; } = 100;
+ public List? tags { get; set; }
+ public Dictionary? behaviours { get; set; }
+ public SkillFileSourceDto? source { get; set; }
+ public SkillFileMcpDto? mcp { get; set; }
+}
+
+internal class SkillFileSourceDto
+{
+ public string type { get; set; } = "";
+ public string? url { get; set; }
+ public string? path { get; set; }
+}
+
+internal class SkillFileMcpDto
+{
+ public string command { get; set; } = "";
+ public List? arguments { get; set; }
+ public Dictionary? environment { get; set; }
+ public Dictionary? properties { get; set; }
+}
diff --git a/src/MaIN.Services/Services/Steps/Commands/FetchCommandHandler.cs b/src/MaIN.Services/Services/Steps/Commands/FetchCommandHandler.cs
index b23f84a5..0ac272bd 100644
--- a/src/MaIN.Services/Services/Steps/Commands/FetchCommandHandler.cs
+++ b/src/MaIN.Services/Services/Steps/Commands/FetchCommandHandler.cs
@@ -85,12 +85,19 @@ public class FetchCommandHandler(
return response;
}
+ private static string GetDetailsJson(object? details) => details switch
+ {
+ string s => s,
+ null => "{}",
+ var obj => JsonSerializer.Serialize(obj)
+ };
+
private async Task HandleFileSource(
FetchCommand command,
Dictionary properties,
BackendType backend)
{
- var fileData = JsonSerializer.Deserialize(command.Context.Source!.Details?.ToString()!);
+ var fileData = JsonSerializer.Deserialize(GetDetailsJson(command.Context.Source!.Details));
var filesDictionary = fileData!.Files.ToDictionary(path => Path.GetFileName(path), path => path);
if (command.Chat.Messages.Count > 0)
@@ -118,7 +125,7 @@ private async Task HandleWebSource(
Dictionary properties,
BackendType backend)
{
- var webData = JsonSerializer.Deserialize(command.Context.Source!.Details?.ToString()!);
+ var webData = JsonSerializer.Deserialize(GetDetailsJson(command.Context.Source!.Details));
if (command.Chat.Messages.Count > 0)
{
From 169a3be62c9af262a875be6112ed33cf9a8f4716 Mon Sep 17 00:00:00 2001
From: Magdalena Ruman <67785133+Madzionator@users.noreply.github.com>
Date: Thu, 7 May 2026 22:49:12 +0200
Subject: [PATCH 2/7] Load folder-based SKILL.md
Add support for folder-based skill packages and include files. FileSystemSkillLoader now treats SKILL.md as a package entrypoint, only loads SKILL.md from package directories, and aggregates any files listed in an includes frontmatter key into the skill InstructionFragment. New helpers ResolveIncludePattern and LoadIncludes handle relative paths and simple wildcards. Also add a code-review skill (SKILL.md plus prompts/examples) and an AgentWithFolderSkillExample, and register the example in Program.cs so the new skill demo is available.
---
.../Examples/Agents/AgentWithSkillsExample.cs | 44 +++++++++---
Examples/Examples/Program.cs | 2 +
Examples/Examples/skills/code-review/SKILL.md | 20 ++++++
.../skills/code-review/examples/bad.md | 12 ++++
.../skills/code-review/examples/good.md | 14 ++++
.../skills/code-review/prompts/review.md | 14 ++++
.../Services/Skills/FileSystemSkillLoader.cs | 69 ++++++++++++++++++-
7 files changed, 162 insertions(+), 13 deletions(-)
create mode 100644 Examples/Examples/skills/code-review/SKILL.md
create mode 100644 Examples/Examples/skills/code-review/examples/bad.md
create mode 100644 Examples/Examples/skills/code-review/examples/good.md
create mode 100644 Examples/Examples/skills/code-review/prompts/review.md
diff --git a/Examples/Examples/Agents/AgentWithSkillsExample.cs b/Examples/Examples/Agents/AgentWithSkillsExample.cs
index e52db1a0..98aa6cec 100644
--- a/Examples/Examples/Agents/AgentWithSkillsExample.cs
+++ b/Examples/Examples/Agents/AgentWithSkillsExample.cs
@@ -41,21 +41,45 @@ public async Task Start()
// Skills from ./skills/*.md are auto-loaded if SkillsDirectory is configured,
// OR you can call AddSkillsFromDirectory() in startup.
- // This example assumes ./skills/journalist.md exists (created alongside examples).
- //var context = await AIHub.Agent()
- // .WithModel(Models.OpenAi.Gpt4oMini)
- // .WithSkill("web-search")
- // .WithSkill("file-journalist") // loaded from ./skills/file-journalist.md
- // .CreateAsync(interactiveResponse: true);
+ var context = await AIHub.Agent()
+ .WithModel(Models.OpenAi.Gpt4oMini)
+ .WithSkill("web-search")
+ .WithSkill("file-journalist") // loaded from ./skills/file-journalist.md
+ .CreateAsync(interactiveResponse: true);
+
+ await context.ProcessAsync("Provide today's newsletter for poland");
+ }
+}
+
+///
+/// Demonstrates a folder-based skill: skills/code-review/SKILL.md is the entrypoint,
+/// prompts/ and examples/ subdirectories are loaded via the "includes" frontmatter key.
+///
+public class AgentWithFolderSkillExample : IExample
+{
+ public async Task Start()
+ {
+ Console.WriteLine("Agent with folder-based skill (code-review, OpenAi)");
+ Console.WriteLine("Skill loaded from: ./skills/code-review/SKILL.md");
- //await context.ProcessAsync("Provide today's newsletter");
+ OpenAiExample.Setup();
var context = await AIHub.Agent()
.WithModel(Models.OpenAi.Gpt4oMini)
- .WithSkill("caveman")
+ .WithSkill("code-review")
.CreateAsync(interactiveResponse: true);
- await context.ProcessAsync("Tell me facts about killer whale.");
-
+ await context.ProcessAsync("""
+ Review this code:
+ public List GetNames(List users)
+ {
+ List names = new List();
+ for (int i = 0; i < users.Count; i++)
+ {
+ names.Add(users[i].Name);
+ }
+ return names;
+ }
+ """);
}
}
diff --git a/Examples/Examples/Program.cs b/Examples/Examples/Program.cs
index ad4dd8a5..6a852168 100644
--- a/Examples/Examples/Program.cs
+++ b/Examples/Examples/Program.cs
@@ -72,6 +72,7 @@ static void RegisterExamples(IServiceCollection services)
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
services.AddTransient();
services.AddTransient();
services.AddTransient();
@@ -189,6 +190,7 @@ public class ExampleRegistry(IServiceProvider serviceProvider)
("\u25a0 OpenAi Agent with Web Data Source", serviceProvider.GetRequiredService()),
("\u25a0 Agent with Skills (code-based)", serviceProvider.GetRequiredService()),
("\u25a0 Agent with Skills (file-based .md)", serviceProvider.GetRequiredService()),
+ ("\u25a0 Agent with Skills (folder-based SKILL.md)", serviceProvider.GetRequiredService()),
("\u25a0 Gemini Chat", serviceProvider.GetRequiredService()),
("\u25a0 Gemini Chat with grammar", serviceProvider.GetRequiredService()),
("\u25a0 Gemini Chat with image", serviceProvider.GetRequiredService()),
diff --git a/Examples/Examples/skills/code-review/SKILL.md b/Examples/Examples/skills/code-review/SKILL.md
new file mode 100644
index 00000000..6ab2bb11
--- /dev/null
+++ b/Examples/Examples/skills/code-review/SKILL.md
@@ -0,0 +1,20 @@
+---
+name: code-review
+description: Reviews code for bugs, security issues, and improvements
+version: 1.0.0
+steps:
+ - ANSWER
+placement: before
+priority: 30
+tags:
+ - code
+ - review
+ - quality
+includes:
+ - prompts/review.md
+ - examples/bad.md
+ - examples/good.md
+---
+
+You are an expert code reviewer with deep knowledge of software engineering best practices.
+Keep reviews concise and actionable.
diff --git a/Examples/Examples/skills/code-review/examples/bad.md b/Examples/Examples/skills/code-review/examples/bad.md
new file mode 100644
index 00000000..b124fad9
--- /dev/null
+++ b/Examples/Examples/skills/code-review/examples/bad.md
@@ -0,0 +1,12 @@
+## Example: Problematic Code
+
+```csharp
+public string GetUser(int id) {
+ var conn = new SqlConnection("Server=prod;Password=admin123");
+ conn.Open();
+ var cmd = new SqlCommand("SELECT * FROM users WHERE id = " + id, conn);
+ return cmd.ExecuteScalar().ToString();
+}
+```
+
+Expected issues: SQL injection, hardcoded connection string with password, no using/dispose, no null check on ExecuteScalar result, synchronous I/O.
diff --git a/Examples/Examples/skills/code-review/examples/good.md b/Examples/Examples/skills/code-review/examples/good.md
new file mode 100644
index 00000000..d4c378ea
--- /dev/null
+++ b/Examples/Examples/skills/code-review/examples/good.md
@@ -0,0 +1,14 @@
+## Example: Well-Written Code
+
+```csharp
+public async Task GetUserAsync(int id, CancellationToken ct = default)
+{
+ await using var conn = new SqlConnection(_connectionString);
+ return await conn.QueryFirstOrDefaultAsync(
+ "SELECT * FROM users WHERE id = @id",
+ new { id },
+ cancellationToken: ct);
+}
+```
+
+Why this is good: async, parameterized query prevents SQL injection, connection string from config, proper disposal via await using, nullable return type communicates that user may not exist.
diff --git a/Examples/Examples/skills/code-review/prompts/review.md b/Examples/Examples/skills/code-review/prompts/review.md
new file mode 100644
index 00000000..81e7dc0d
--- /dev/null
+++ b/Examples/Examples/skills/code-review/prompts/review.md
@@ -0,0 +1,14 @@
+## Review Guidelines
+
+For every code snippet analyze:
+- Bugs and logical errors
+- Security vulnerabilities (injection, unvalidated input, hardcoded secrets)
+- Performance (unnecessary allocations, missing async/await, N+1 queries)
+- Readability (naming, complexity, magic numbers)
+- Missing error handling and edge cases
+
+Response format:
+
+**Issues** — critical problems that must be fixed (numbered list)
+**Suggestions** — improvements worth considering (numbered list)
+**Verdict** — one sentence summary
diff --git a/src/MaIN.Services/Services/Skills/FileSystemSkillLoader.cs b/src/MaIN.Services/Services/Skills/FileSystemSkillLoader.cs
index e15a5c8b..b23ca62e 100644
--- a/src/MaIN.Services/Services/Skills/FileSystemSkillLoader.cs
+++ b/src/MaIN.Services/Services/Skills/FileSystemSkillLoader.cs
@@ -8,6 +8,8 @@ namespace MaIN.Services.Services.Skills;
public class FileSystemSkillLoader(string directoryPath) : ISkillLoader
{
+ private const string FolderEntrypoint = "SKILL.md";
+
private static readonly IDeserializer Deserializer = new DeserializerBuilder()
.WithNamingConvention(LowerCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
@@ -22,8 +24,20 @@ public IReadOnlyList LoadAll()
if (!Directory.Exists(resolvedPath))
return [];
- return Directory
- .GetFiles(resolvedPath, "*.md", SearchOption.AllDirectories)
+ var allMdFiles = Directory.GetFiles(resolvedPath, "*.md", SearchOption.AllDirectories);
+
+ // Directories that contain SKILL.md are "skill packages".
+ // Only SKILL.md is loaded from them — sibling files are includes, not standalone skills.
+ var skillPackageDirs = allMdFiles
+ .Where(f => Path.GetFileName(f).Equals(FolderEntrypoint, StringComparison.OrdinalIgnoreCase))
+ .Select(f => Path.GetDirectoryName(f)!)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ var filesToLoad = allMdFiles.Where(f =>
+ Path.GetFileName(f).Equals(FolderEntrypoint, StringComparison.OrdinalIgnoreCase) ||
+ !skillPackageDirs.Contains(Path.GetDirectoryName(f)!));
+
+ return filesToLoad
.Select(TryParseSkillFile)
.OfType()
.ToList()
@@ -61,6 +75,17 @@ public IReadOnlyList LoadAll()
if (string.IsNullOrWhiteSpace(dto?.name))
return null;
+ var skillDir = Path.GetDirectoryName(filePath)!;
+ var includesContent = LoadIncludes(dto.includes, skillDir);
+
+ var fullFragment = (body, includesContent) switch
+ {
+ ({ Length: > 0 }, { Length: > 0 }) => body + "\n\n" + includesContent,
+ ({ Length: > 0 }, _) => body,
+ (_, { Length: > 0 }) => includesContent,
+ _ => null
+ };
+
return new AgentSkill
{
Name = dto.name,
@@ -73,10 +98,47 @@ public IReadOnlyList LoadAll()
Behaviours = dto.behaviours ?? [],
Source = BuildSource(dto.source),
Mcp = BuildMcp(dto.mcp),
- InstructionFragment = string.IsNullOrWhiteSpace(body) ? null : body
+ InstructionFragment = fullFragment
};
}
+ private static string LoadIncludes(List? includes, string baseDir)
+ {
+ if (includes is null || includes.Count == 0)
+ return string.Empty;
+
+ var parts = new List();
+ foreach (var include in includes)
+ {
+ foreach (var file in ResolveIncludePattern(include, baseDir))
+ {
+ var text = File.ReadAllText(file).Trim();
+ if (!string.IsNullOrWhiteSpace(text))
+ parts.Add(text);
+ }
+ }
+
+ return string.Join("\n\n", parts);
+ }
+
+ // Supports:
+ // "prompts/review.md" — exact relative path
+ // "prompts/*.md" — wildcard in last segment
+ // "examples/**" — not supported, treated as literal (no recursion)
+ private static IEnumerable ResolveIncludePattern(string pattern, string baseDir)
+ {
+ var fullPattern = Path.GetFullPath(Path.Combine(baseDir, pattern));
+ var dir = Path.GetDirectoryName(fullPattern) ?? baseDir;
+ var filePattern = Path.GetFileName(fullPattern);
+
+ if (!Directory.Exists(dir))
+ yield break;
+
+ foreach (var file in Directory.GetFiles(dir, filePattern, SearchOption.TopDirectoryOnly)
+ .OrderBy(f => f))
+ yield return file;
+ }
+
private static SkillStepPlacement ParsePlacement(string? placement) =>
placement?.ToLowerInvariant() switch
{
@@ -133,6 +195,7 @@ internal class SkillFileDto
public Dictionary? behaviours { get; set; }
public SkillFileSourceDto? source { get; set; }
public SkillFileMcpDto? mcp { get; set; }
+ public List? includes { get; set; }
}
internal class SkillFileSourceDto
From f572a787d0d98fba08c77daef4fa7b49830c9325 Mon Sep 17 00:00:00 2001
From: Magdalena Ruman <67785133+Madzionator@users.noreply.github.com>
Date: Thu, 7 May 2026 23:33:35 +0200
Subject: [PATCH 3/7] Organize skill examples, add calculator skill
Reorganize agent skill examples into a new Examples/Agents/Skills folder and introduce a custom code-based skill.
- Removed the old Examples/Agents/AgentWithSkillsExample.cs and reintroduced skill examples under Examples/Agents/Skills: AgentWithSkillsExample.cs, AgentWithFileSkillExample.cs, AgentWithFolderSkillExample.cs.
- Added AgentWithCustomCodeSkillExample.cs which registers a CalculatorSkill that implements IAgentSkillProvider. The CalculatorSkill exposes a "calculate" tool (executes expressions via DataTable.Compute) and an instruction fragment to ensure the agent uses the tool for arithmetic.
- Updated Examples/Examples/Program.cs: added using Examples.Agents.Skills, registered the new examples (including the custom C# skill) in DI, and added a menu entry for the custom skill example.
These changes tidy example layout and demonstrate both file/folder-based skills and executable code skills (tools) in examples.
---
.../Examples/Agents/AgentWithSkillsExample.cs | 85 ----------------
.../Skills/AgentWithCustomCodeSkillExample.cs | 96 +++++++++++++++++++
.../Skills/AgentWithFileSkillExample.cs | 30 ++++++
.../Skills/AgentWithFolderSkillExample.cs | 38 ++++++++
.../Agents/Skills/AgentWithSkillsExample.cs | 27 ++++++
Examples/Examples/Program.cs | 6 ++
src/MaIN.Domain/Entities/Skills/AgentSkill.cs | 58 +++++++++++
7 files changed, 255 insertions(+), 85 deletions(-)
delete mode 100644 Examples/Examples/Agents/AgentWithSkillsExample.cs
create mode 100644 Examples/Examples/Agents/Skills/AgentWithCustomCodeSkillExample.cs
create mode 100644 Examples/Examples/Agents/Skills/AgentWithFileSkillExample.cs
create mode 100644 Examples/Examples/Agents/Skills/AgentWithFolderSkillExample.cs
create mode 100644 Examples/Examples/Agents/Skills/AgentWithSkillsExample.cs
diff --git a/Examples/Examples/Agents/AgentWithSkillsExample.cs b/Examples/Examples/Agents/AgentWithSkillsExample.cs
deleted file mode 100644
index 98aa6cec..00000000
--- a/Examples/Examples/Agents/AgentWithSkillsExample.cs
+++ /dev/null
@@ -1,85 +0,0 @@
-using Examples.Utils;
-using MaIN.Core.Hub;
-using MaIN.Domain.Models;
-
-namespace Examples.Agents;
-
-///
-/// Demonstrates skills applied via code — built-in "web-search" + "journalist".
-/// Equivalent to AgentWithWebDataSourceOpenAiExample but with 2 lines instead of 15.
-///
-public class AgentWithSkillsExample : IExample
-{
- public async Task Start()
- {
- Console.WriteLine("Agent with skills (code-based, OpenAi)");
-
- OpenAiExample.Setup();
-
- var context = await AIHub.Agent()
- .WithModel(Models.OpenAi.Gpt4oMini)
- .WithSkill("web-search") // FETCH_DATA step + BBC source
- .WithSkill("journalist") // BECOME+Journalist + ANSWER steps + behaviour
- .CreateAsync(interactiveResponse: true);
-
- await context.ProcessAsync("Provide today's newsletter");
- }
-}
-
-///
-/// Demonstrates a skill loaded from a .md file in ./skills/ folder.
-/// Drop any .md skill file there and it's auto-picked up on startup.
-///
-public class AgentWithFileSkillExample : IExample
-{
- public async Task Start()
- {
- Console.WriteLine("Agent with file-based skill (OpenAi)");
- Console.WriteLine("Looks for skills in ./skills/ directory...");
-
- OpenAiExample.Setup();
-
- // Skills from ./skills/*.md are auto-loaded if SkillsDirectory is configured,
- // OR you can call AddSkillsFromDirectory() in startup.
- var context = await AIHub.Agent()
- .WithModel(Models.OpenAi.Gpt4oMini)
- .WithSkill("web-search")
- .WithSkill("file-journalist") // loaded from ./skills/file-journalist.md
- .CreateAsync(interactiveResponse: true);
-
- await context.ProcessAsync("Provide today's newsletter for poland");
- }
-}
-
-///
-/// Demonstrates a folder-based skill: skills/code-review/SKILL.md is the entrypoint,
-/// prompts/ and examples/ subdirectories are loaded via the "includes" frontmatter key.
-///
-public class AgentWithFolderSkillExample : IExample
-{
- public async Task Start()
- {
- Console.WriteLine("Agent with folder-based skill (code-review, OpenAi)");
- Console.WriteLine("Skill loaded from: ./skills/code-review/SKILL.md");
-
- OpenAiExample.Setup();
-
- var context = await AIHub.Agent()
- .WithModel(Models.OpenAi.Gpt4oMini)
- .WithSkill("code-review")
- .CreateAsync(interactiveResponse: true);
-
- await context.ProcessAsync("""
- Review this code:
- public List GetNames(List users)
- {
- List names = new List();
- for (int i = 0; i < users.Count; i++)
- {
- names.Add(users[i].Name);
- }
- return names;
- }
- """);
- }
-}
diff --git a/Examples/Examples/Agents/Skills/AgentWithCustomCodeSkillExample.cs b/Examples/Examples/Agents/Skills/AgentWithCustomCodeSkillExample.cs
new file mode 100644
index 00000000..7ff52e8b
--- /dev/null
+++ b/Examples/Examples/Agents/Skills/AgentWithCustomCodeSkillExample.cs
@@ -0,0 +1,96 @@
+using System.Data;
+using System.Text.Json;
+using Examples.Utils;
+using MaIN.Core.Hub;
+using MaIN.Domain.Entities.Skills;
+using MaIN.Domain.Models;
+
+namespace Examples.Agents.Skills;
+
+///
+/// Demonstrates a custom code-based skill defined in the Examples project.
+/// CalculatorSkill implements IAgentSkillProvider and is registered in Program.cs via
+/// services.AddSingleton<IAgentSkillProvider, CalculatorSkill>(). The SkillRegistry
+/// picks it up automatically at startup — no manual Register() call needed here.
+/// This is the key difference from file-based skills: code skills can execute C# functions as tools.
+///
+public class AgentWithCustomCodeSkillExample : IExample
+{
+ public async Task Start()
+ {
+ Console.WriteLine("Agent with custom code-based skill (CalculatorSkill, OpenAi)");
+
+ OpenAiExample.Setup();
+
+ // CalculatorSkill is registered in Program.cs via DI (services.AddSingleton();) and is already in the registry.
+ var context = await AIHub.Agent()
+ .WithModel(Models.OpenAi.Gpt4oMini)
+ .WithSkill("calculator")
+ .CreateAsync(interactiveResponse: true);
+
+ await context.ProcessAsync(
+ "A shop sells 3 items: apple $1.25, banana $0.80, cherry $3.40. " +
+ "I buy 4 apples, 7 bananas and 2 cherries. What is the total cost? " +
+ "Also, if I pay with $30, what is my change?");
+ }
+}
+
+///
+/// Custom code-based skill defined in the Examples project.
+/// Gives the agent a "calculate" tool backed by a real C# function — something
+/// .md file-based skills cannot do (they have no executable code).
+///
+public class CalculatorSkill : IAgentSkillProvider
+{
+ public AgentSkill GetSkill() => new()
+ {
+ Name = "calculator",
+ Description = "Gives the agent a precise calculation tool. Use when the prompt involves arithmetic.",
+ Version = "1.0.0",
+ Steps = ["ANSWER"],
+ StepPlacement = SkillStepPlacement.Before,
+ Priority = 20,
+ Tags = ["math", "tools", "calculator"],
+ Tools =
+ [
+ new SkillToolDefinition
+ {
+ Name = "calculate",
+ Description = "Evaluates a mathematical expression and returns the result. " +
+ "Supports +, -, *, /, %, ^ and parentheses.",
+ Parameters = new
+ {
+ type = "object",
+ properties = new
+ {
+ expression = new
+ {
+ type = "string",
+ description = "The math expression to evaluate, e.g. \"(12 + 8) * 3 / 4\""
+ }
+ },
+ required = new[] { "expression" }
+ },
+ Execute = async args =>
+ {
+ await Task.CompletedTask;
+ try
+ {
+ var doc = JsonDocument.Parse(args);
+ var expression = doc.RootElement.GetProperty("expression").GetString() ?? "";
+ var result = new DataTable().Compute(expression, null);
+ return $"{result}";
+ }
+ catch (Exception ex)
+ {
+ return $"Error: {ex.Message}";
+ }
+ }
+ }
+ ],
+ InstructionFragment =
+ "You have access to a precise calculator tool. " +
+ "For any arithmetic — no matter how simple — always call the calculate tool instead of computing mentally. " +
+ "Show the expression you used and the result."
+ };
+}
\ No newline at end of file
diff --git a/Examples/Examples/Agents/Skills/AgentWithFileSkillExample.cs b/Examples/Examples/Agents/Skills/AgentWithFileSkillExample.cs
new file mode 100644
index 00000000..549a8f15
--- /dev/null
+++ b/Examples/Examples/Agents/Skills/AgentWithFileSkillExample.cs
@@ -0,0 +1,30 @@
+using Examples.Utils;
+using MaIN.Core.Hub;
+using MaIN.Domain.Models;
+
+namespace Examples.Agents.Skills;
+
+///
+/// Demonstrates a skill loaded from a .md file in ./skills/ folder.
+/// Drop any .md skill file there and it's auto-picked up on startup.
+///
+public class AgentWithFileSkillExample : IExample
+{
+ public async Task Start()
+ {
+ Console.WriteLine("Agent with file-based skill (OpenAi)");
+ Console.WriteLine("Looks for skills in ./skills/ directory...");
+
+ OpenAiExample.Setup();
+
+ // Skills from ./skills/*.md are auto-loaded if SkillsDirectory is configured,
+ // OR you can call AddSkillsFromDirectory() in startup.
+ var context = await AIHub.Agent()
+ .WithModel(Models.OpenAi.Gpt4oMini)
+ .WithSkill("web-search")
+ .WithSkill("file-journalist") // loaded from ./skills/file-journalist.md
+ .CreateAsync(interactiveResponse: true);
+
+ await context.ProcessAsync("Provide today's newsletter for poland");
+ }
+}
\ No newline at end of file
diff --git a/Examples/Examples/Agents/Skills/AgentWithFolderSkillExample.cs b/Examples/Examples/Agents/Skills/AgentWithFolderSkillExample.cs
new file mode 100644
index 00000000..2e627e0f
--- /dev/null
+++ b/Examples/Examples/Agents/Skills/AgentWithFolderSkillExample.cs
@@ -0,0 +1,38 @@
+using Examples.Utils;
+using MaIN.Core.Hub;
+using MaIN.Domain.Models;
+
+namespace Examples.Agents.Skills;
+
+///
+/// Demonstrates a folder-based skill: skills/code-review/SKILL.md is the entrypoint,
+/// prompts/ and examples/ subdirectories are loaded via the "includes" frontmatter key.
+///
+public class AgentWithFolderSkillExample : IExample
+{
+ public async Task Start()
+ {
+ Console.WriteLine("Agent with folder-based skill (code-review, OpenAi)");
+ Console.WriteLine("Skill loaded from: ./skills/code-review/SKILL.md");
+
+ OpenAiExample.Setup();
+
+ var context = await AIHub.Agent()
+ .WithModel(Models.OpenAi.Gpt4oMini)
+ .WithSkill("code-review") // name provided in name section in SKILL.md file
+ .CreateAsync(interactiveResponse: true);
+
+ await context.ProcessAsync("""
+ Review this code:
+ public List GetNames(List users)
+ {
+ List names = new List();
+ for (int i = 0; i < users.Count; i++)
+ {
+ names.Add(users[i].Name);
+ }
+ return names;
+ }
+ """);
+ }
+}
\ No newline at end of file
diff --git a/Examples/Examples/Agents/Skills/AgentWithSkillsExample.cs b/Examples/Examples/Agents/Skills/AgentWithSkillsExample.cs
new file mode 100644
index 00000000..9a29fc4b
--- /dev/null
+++ b/Examples/Examples/Agents/Skills/AgentWithSkillsExample.cs
@@ -0,0 +1,27 @@
+using Examples.Utils;
+using MaIN.Core.Hub;
+using MaIN.Domain.Models;
+
+namespace Examples.Agents.Skills;
+
+///
+/// Demonstrates skills applied via code — built-in "web-search" + "journalist".
+/// Equivalent to AgentWithWebDataSourceOpenAiExample but with 2 lines instead of 15.
+///
+public class AgentWithSkillsExample : IExample
+{
+ public async Task Start()
+ {
+ Console.WriteLine("Agent with registered skills (code-based, OpenAi)");
+
+ OpenAiExample.Setup();
+
+ var context = await AIHub.Agent()
+ .WithModel(Models.OpenAi.Gpt4oMini)
+ .WithSkill("web-search") // FETCH_DATA step + BBC source
+ .WithSkill("journalist") // BECOME+Journalist + ANSWER steps + behaviour
+ .CreateAsync(interactiveResponse: true);
+
+ await context.ProcessAsync("Provide today's newsletter");
+ }
+}
\ No newline at end of file
diff --git a/Examples/Examples/Program.cs b/Examples/Examples/Program.cs
index 6a852168..14dfe723 100644
--- a/Examples/Examples/Program.cs
+++ b/Examples/Examples/Program.cs
@@ -1,9 +1,11 @@
using Examples;
using Examples.Agents;
using Examples.Agents.Flows;
+using Examples.Agents.Skills;
using Examples.Chat;
using Examples.Mcp;
using MaIN.Core;
+using MaIN.Domain.Entities.Skills;
using MaIN.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -30,7 +32,9 @@
var services = new ServiceCollection();
services.AddSingleton(configuration);
services.AddMaIN(configuration);
+
services.AddSkillsFromDirectory("./skills");
+services.AddSingleton();
RegisterExamples(services);
@@ -73,6 +77,7 @@ static void RegisterExamples(IServiceCollection services)
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
services.AddTransient();
services.AddTransient();
services.AddTransient();
@@ -191,6 +196,7 @@ public class ExampleRegistry(IServiceProvider serviceProvider)
("\u25a0 Agent with Skills (code-based)", serviceProvider.GetRequiredService()),
("\u25a0 Agent with Skills (file-based .md)", serviceProvider.GetRequiredService()),
("\u25a0 Agent with Skills (folder-based SKILL.md)", serviceProvider.GetRequiredService()),
+ ("\u25a0 Agent with Skills (custom C# skill)", serviceProvider.GetRequiredService()),
("\u25a0 Gemini Chat", serviceProvider.GetRequiredService()),
("\u25a0 Gemini Chat with grammar", serviceProvider.GetRequiredService()),
("\u25a0 Gemini Chat with image", serviceProvider.GetRequiredService()),
diff --git a/src/MaIN.Domain/Entities/Skills/AgentSkill.cs b/src/MaIN.Domain/Entities/Skills/AgentSkill.cs
index c1ed4435..59042857 100644
--- a/src/MaIN.Domain/Entities/Skills/AgentSkill.cs
+++ b/src/MaIN.Domain/Entities/Skills/AgentSkill.cs
@@ -4,17 +4,75 @@ namespace MaIN.Domain.Entities.Skills;
public class AgentSkill
{
+ /// Unique skill identifier used in WithSkill("name"). e.g. "web-search", "calculator"
public required string Name { get; init; }
+
+ /// Human-readable description shown in registries and tooling. e.g. "Fetches and summarizes web content"
public string? Description { get; init; }
+
+ /// Semver string, informational only. e.g. "1.0.0"
public string Version { get; init; } = "1.0.0";
+
+ ///
+ /// Pipeline steps contributed by this skill. e.g. ["FETCH_DATA", "ANSWER"] or ["BECOME+Journalist", "ANSWER"].
+ /// How they merge with the agent's own steps depends on .
+ ///
public List Steps { get; init; } = [];
+
+ ///
+ /// C# function-backed tools injected into the agent's tool registry.
+ /// Each tool needs a name, JSON schema for parameters, and an Execute delegate.
+ /// Cannot be defined in .md file-based skills — requires IAgentSkillProvider.
+ ///
public List Tools { get; init; } = [];
+
+ ///
+ /// Data source wired to the agent (web page, file, API, etc.).
+ /// AgentConfig.Source is a single property (not a list), so only one source per agent is supported
+ /// anywhere in the system — not just via skills. Second skill with a source throws SkillConflictException.
+ ///
public SkillSourceDefinition? Source { get; init; }
+
+ ///
+ /// MCP server configuration injected into the agent.
+ /// e.g. npx @modelcontextprotocol/server-filesystem with allowed directories.
+ /// Model is inherited from the agent at compose time.
+ ///
public SkillMcpDefinition? Mcp { get; init; }
+
+ ///
+ /// Named persona definitions merged into the agent's behaviour map.
+ /// e.g. { "Journalist": "Write concise newsletters based on the data provided." }
+ /// Used with BECOME+Name steps.
+ ///
public Dictionary Behaviours { get; init; } = [];
+
+ ///
+ /// Knowledge items pre-seeded into the agent's knowledge index at creation time.
+ /// Additive — multiple skills can each contribute knowledge without conflict.
+ ///
public List KnowledgeSeed { get; init; } = [];
+
+ ///
+ /// Text appended to the agent's system prompt (Config.Instruction).
+ /// In .md skills this is the Markdown body below the YAML frontmatter.
+ /// Multiple skills' fragments are concatenated in Priority order.
+ ///
public string? InstructionFragment { get; init; }
+
+ /// Categorisation tags for filtering. e.g. ["web", "search"] or ["math", "tools"]
public string[] Tags { get; init; } = [];
+
+ ///
+ /// Merge order when multiple skills are applied. Lower = applied first.
+ /// Built-in skills use 5–80; user skills default to 100.
+ /// Affects step ordering and which InstructionFragment appears first.
+ ///
public int Priority { get; init; } = 100;
+
+ ///
+ /// Controls where this skill's Steps are inserted relative to the agent's own steps.
+ /// Before = prepend, After = append, Replace = discard agent steps entirely.
+ ///
public SkillStepPlacement StepPlacement { get; init; } = SkillStepPlacement.Before;
}
From 85e9ab02ea252197f76fa25ff72bd5a4277390bc Mon Sep 17 00:00:00 2001
From: Magdalena Ruman <67785133+Madzionator@users.noreply.github.com>
Date: Thu, 7 May 2026 23:58:43 +0200
Subject: [PATCH 4/7] small fixes
---
src/MaIN.Core/Hub/Contexts/AgentContext.cs | 14 ++++++++++----
src/MaIN.Services/Bootstrapper.cs | 4 +++-
src/MaIN.Services/Services/SkillComposer.cs | 21 +++++++++------------
src/MaIN.Services/Services/SkillRegistry.cs | 16 ++++++++++++++--
4 files changed, 36 insertions(+), 19 deletions(-)
diff --git a/src/MaIN.Core/Hub/Contexts/AgentContext.cs b/src/MaIN.Core/Hub/Contexts/AgentContext.cs
index c4b7052a..f459b79f 100644
--- a/src/MaIN.Core/Hub/Contexts/AgentContext.cs
+++ b/src/MaIN.Core/Hub/Contexts/AgentContext.cs
@@ -52,10 +52,12 @@ internal AgentContext(IAgentService agentService, ISkillRegistry skillRegistry,
};
}
- internal AgentContext(IAgentService agentService, Agent existingAgent)
+ internal AgentContext(IAgentService agentService, Agent existingAgent, ISkillRegistry? skillRegistry, ISkillComposer? skillComposer)
{
_agentService = agentService;
_agent = existingAgent;
+ _skillRegistry = skillRegistry;
+ _skillComposer = skillComposer;
}
public IAgentConfigurationBuilder WithSkill(string skillName)
@@ -102,7 +104,7 @@ public async Task FromExisting(string agentId)
{
var existingAgent = await _agentService.GetAgentById(agentId) ?? throw new AgentNotFoundException(agentId);
- var context = new AgentContext(_agentService, existingAgent);
+ var context = new AgentContext(_agentService, existingAgent, _skillRegistry, _skillComposer);
context.LoadExistingKnowledgeIfExists();
return context;
}
@@ -373,7 +375,11 @@ public async Task ProcessAsync(
};
}
- public static async Task FromExisting(IAgentService agentService, string agentId)
+ public static async Task FromExisting(
+ IAgentService agentService,
+ string agentId,
+ ISkillRegistry? skillRegistry = null,
+ ISkillComposer? skillComposer = null)
{
var existingAgent = await agentService.GetAgentById(agentId);
if (existingAgent is null)
@@ -381,7 +387,7 @@ public static async Task FromExisting(IAgentService agentService,
throw new AgentNotFoundException(agentId);
}
- var context = new AgentContext(agentService, existingAgent);
+ var context = new AgentContext(agentService, existingAgent, skillRegistry, skillComposer);
context.LoadExistingKnowledgeIfExists();
return context;
}
diff --git a/src/MaIN.Services/Bootstrapper.cs b/src/MaIN.Services/Bootstrapper.cs
index b9a88a3b..faaf42c4 100644
--- a/src/MaIN.Services/Bootstrapper.cs
+++ b/src/MaIN.Services/Bootstrapper.cs
@@ -16,6 +16,7 @@
using MaIN.Services.Services.TTSService;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
namespace MaIN.Services;
@@ -68,7 +69,8 @@ public static IServiceCollection ConfigureMaIN(
// Register skill infrastructure
serviceCollection.AddSingleton(sp => new SkillRegistry(
sp.GetServices(),
- sp.GetServices()));
+ sp.GetServices(),
+ sp.GetRequiredService>()));
serviceCollection.AddSingleton();
// Register skills directory loader if configured
diff --git a/src/MaIN.Services/Services/SkillComposer.cs b/src/MaIN.Services/Services/SkillComposer.cs
index 2ff407da..4b319e2e 100644
--- a/src/MaIN.Services/Services/SkillComposer.cs
+++ b/src/MaIN.Services/Services/SkillComposer.cs
@@ -91,23 +91,20 @@ private static void MergeSource(Agent agent, List skills)
var sourcedSkills = skills.Where(s => s.Source is not null).ToList();
if (sourcedSkills.Count == 0) return;
- if (sourcedSkills.Count > 1 && agent.Config.Source is not null)
- throw new SkillConflictException(
- "Multiple skills provide a source configuration. Only one source is allowed per agent.");
-
if (sourcedSkills.Count > 1)
throw new SkillConflictException(
$"Skills '{sourcedSkills[0].Name}' and '{sourcedSkills[1].Name}' both provide a source. Only one source skill is allowed.");
- if (agent.Config.Source is null)
+ if (agent.Config.Source is not null)
+ throw new SkillConflictException(
+ $"Skill '{sourcedSkills[0].Name}' provides a source but the agent already has one configured.");
+
+ var def = sourcedSkills[0].Source!;
+ agent.Config.Source = new AgentSource
{
- var def = sourcedSkills[0].Source!;
- agent.Config.Source = new AgentSource
- {
- Details = def.Details,
- Type = def.Type
- };
- }
+ Details = def.Details,
+ Type = def.Type
+ };
}
private static void MergeMcp(Agent agent, List skills)
diff --git a/src/MaIN.Services/Services/SkillRegistry.cs b/src/MaIN.Services/Services/SkillRegistry.cs
index 26348203..7f163823 100644
--- a/src/MaIN.Services/Services/SkillRegistry.cs
+++ b/src/MaIN.Services/Services/SkillRegistry.cs
@@ -1,6 +1,7 @@
using MaIN.Domain.Entities.Skills;
using MaIN.Domain.Exceptions.Skills;
using MaIN.Services.Services.Abstract;
+using Microsoft.Extensions.Logging;
namespace MaIN.Services.Services;
@@ -9,10 +10,15 @@ public class SkillRegistry : ISkillRegistry
private readonly Dictionary _skills =
new(StringComparer.OrdinalIgnoreCase);
+ private readonly ILogger _logger;
+
public SkillRegistry(
IEnumerable providers,
- IEnumerable loaders)
+ IEnumerable loaders,
+ ILogger logger)
{
+ _logger = logger;
+
foreach (var p in providers)
Register(p.GetSkill());
@@ -21,7 +27,13 @@ public SkillRegistry(
Register(s);
}
- public void Register(AgentSkill skill) => _skills[skill.Name] = skill;
+ public void Register(AgentSkill skill)
+ {
+ if (_skills.ContainsKey(skill.Name))
+ _logger.LogWarning("Skill '{Name}' already registered — overwriting.", skill.Name);
+
+ _skills[skill.Name] = skill;
+ }
public AgentSkill GetSkill(string name) =>
_skills.TryGetValue(name, out var skill)
From 7b981b6481bad2a5ec60e3786c659f230852deb8 Mon Sep 17 00:00:00 2001
From: Magdalena Ruman <67785133+Madzionator@users.noreply.github.com>
Date: Fri, 8 May 2026 09:38:01 +0200
Subject: [PATCH 5/7] Add MCP examples and MCP HTTP support
Add examples and skill for MCP filesystem usage and extend MCP service to support multiple backends.
- Add examples: McpAnthropicExample and AgentWithMcpFileWriterSkillExample, plus skills/funfact-writer/SKILL.md to demonstrate MCP filesystem writes.
- Register new examples in Examples/Program.cs.
- Overhaul McpService: add HTTP-based MCP loop for OpenAI-compatible backends, Anthropic-specific handling (tool_use/tool_result), SK-based path for Gemini/Vertex, helper methods (GetEndpointAndKey, BuildResult), and JSON tool execution/iteration logic.
- Update SkillComposer to populate Mcp backend when composing agent MCP configs.
These changes enable MCP integration with Anthropic and add a robust HTTP fallback for OpenAI-compatible MCP flows, plus example usage and a file-writer skill.
---
.../AgentWithMcpFileWriterSkillExample.cs | 31 ++
Examples/Examples/Mcp/McpAnthropicExample.cs | 39 ++
Examples/Examples/Program.cs | 4 +
.../Examples/skills/funfact-writer/SKILL.md | 25 +
src/MaIN.Services/Services/McpService.cs | 453 ++++++++++++++----
src/MaIN.Services/Services/SkillComposer.cs | 9 +-
6 files changed, 469 insertions(+), 92 deletions(-)
create mode 100644 Examples/Examples/Agents/Skills/AgentWithMcpFileWriterSkillExample.cs
create mode 100644 Examples/Examples/Mcp/McpAnthropicExample.cs
create mode 100644 Examples/Examples/skills/funfact-writer/SKILL.md
diff --git a/Examples/Examples/Agents/Skills/AgentWithMcpFileWriterSkillExample.cs b/Examples/Examples/Agents/Skills/AgentWithMcpFileWriterSkillExample.cs
new file mode 100644
index 00000000..63bcdef4
--- /dev/null
+++ b/Examples/Examples/Agents/Skills/AgentWithMcpFileWriterSkillExample.cs
@@ -0,0 +1,31 @@
+using Examples.Utils;
+using MaIN.Core.Hub;
+using MaIN.Domain.Models;
+
+namespace Examples.Agents.Skills;
+
+///
+/// Demonstrates a folder-based .md skill that wires up an MCP server.
+/// The skill (skills/funfact-writer/SKILL.md) configures @modelcontextprotocol/server-filesystem
+/// via its mcp: frontmatter — no C# required. The agent uses the MCP write_file tool
+/// to create C:/Users/Public/funfacts/funfact.txt with a generated fun fact.
+///
+public class AgentWithMcpFileWriterSkillExample : IExample
+{
+ public async Task Start()
+ {
+ Console.WriteLine("Agent with MCP file-writer skill (.md, OpenAi)");
+ Console.WriteLine("Skill wires up @modelcontextprotocol/server-filesystem via SKILL.md frontmatter.");
+ Console.WriteLine("Output: C:/Users/Public/funfacts/funfact.txt");
+
+ OpenAiExample.Setup();
+
+ var context = await AIHub.Agent()
+ .WithModel(Models.OpenAi.Gpt4oMini)
+ .WithSkill("funfact-writer") // loaded from ./skills/funfact-writer/SKILL.md
+ .CreateAsync();
+
+ var result = await context.ProcessAsync("Generate a fun fact and save it to the file.");
+ Console.WriteLine(result.Message.Content);
+ }
+}
diff --git a/Examples/Examples/Mcp/McpAnthropicExample.cs b/Examples/Examples/Mcp/McpAnthropicExample.cs
new file mode 100644
index 00000000..47e5580f
--- /dev/null
+++ b/Examples/Examples/Mcp/McpAnthropicExample.cs
@@ -0,0 +1,39 @@
+using Examples.Utils;
+using MaIN.Core.Hub;
+using MaIN.Domain.Configuration;
+using MaIN.Domain.Models;
+
+namespace Examples.Mcp;
+
+///
+/// Demonstrates MCP integration with Anthropic backend.
+/// Uses @modelcontextprotocol/server-filesystem to write a fun fact to C:/Users/Public/funfacts/funfact.txt.
+/// Anthropic uses native tool_use/tool_result protocol (not OpenAI-compatible).
+///
+public class McpAnthropicExample : IExample
+{
+ public async Task Start()
+ {
+ Console.WriteLine("McpAnthropicExample is running!");
+ Console.WriteLine("Uses native Anthropic tool_use protocol via MCP filesystem server.");
+ Console.WriteLine("Output: C:/Users/Public/funfacts/funfact.txt");
+
+ AnthropicExample.Setup();
+
+ var result = await AIHub.Mcp()
+ .WithBackend(BackendType.Anthropic)
+ .WithConfig(new MaIN.Domain.Entities.Mcp
+ {
+ Name = "filesystem",
+ Command = "npx",
+ Arguments = ["-y", "@modelcontextprotocol/server-filesystem", "C:/Users/Public"],
+ Model = Models.Anthropic.ClaudeSonnet4
+ })
+ .PromptAsync(
+ "Generate a fun fact (2-3 sentences, genuinely surprising) and write it to " +
+ "C:/Users/Public/funfacts/funfact.txt using the write_file tool. " +
+ "After writing, confirm what you saved and share the fun fact.");
+
+ Console.WriteLine(result.Message.Content);
+ }
+}
diff --git a/Examples/Examples/Program.cs b/Examples/Examples/Program.cs
index 14dfe723..ba6c84b1 100644
--- a/Examples/Examples/Program.cs
+++ b/Examples/Examples/Program.cs
@@ -63,6 +63,7 @@ static void RegisterExamples(IServiceCollection services)
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
services.AddTransient();
services.AddTransient();
services.AddTransient();
@@ -78,6 +79,7 @@ static void RegisterExamples(IServiceCollection services)
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
services.AddTransient();
services.AddTransient();
services.AddTransient();
@@ -197,6 +199,7 @@ public class ExampleRegistry(IServiceProvider serviceProvider)
("\u25a0 Agent with Skills (file-based .md)", serviceProvider.GetRequiredService()),
("\u25a0 Agent with Skills (folder-based SKILL.md)", serviceProvider.GetRequiredService()),
("\u25a0 Agent with Skills (custom C# skill)", serviceProvider.GetRequiredService()),
+ ("\u25a0 Agent with Skills (MCP file writer)", serviceProvider.GetRequiredService()),
("\u25a0 Gemini Chat", serviceProvider.GetRequiredService()),
("\u25a0 Gemini Chat with grammar", serviceProvider.GetRequiredService()),
("\u25a0 Gemini Chat with image", serviceProvider.GetRequiredService()),
@@ -209,6 +212,7 @@ public class ExampleRegistry(IServiceProvider serviceProvider)
("\u25a0 Ollama Chat", serviceProvider.GetRequiredService()),
("\u25a0 McpClient example", serviceProvider.GetRequiredService()),
("\u25a0 McpAgent example", serviceProvider.GetRequiredService()),
+ ("\u25a0 Mcp Anthropic example", serviceProvider.GetRequiredService()),
("\u25a0 Chat with TTS example", serviceProvider.GetRequiredService()),
("\u25a0 McpAgent example", serviceProvider.GetRequiredService()),
("\u25a0 Chat with custom model ID", serviceProvider.GetRequiredService())
diff --git a/Examples/Examples/skills/funfact-writer/SKILL.md b/Examples/Examples/skills/funfact-writer/SKILL.md
new file mode 100644
index 00000000..3b619a59
--- /dev/null
+++ b/Examples/Examples/skills/funfact-writer/SKILL.md
@@ -0,0 +1,25 @@
+---
+name: funfact-writer
+description: Generates a fun fact and writes it to C:/Users/Public/funfacts/funfact.txt via MCP filesystem tools
+version: 1.0.0
+steps:
+ - MCP
+placement: replace
+priority: 10
+mcp:
+ command: npx
+ arguments:
+ - -y
+ - "@modelcontextprotocol/server-filesystem"
+ - "C:/Users/Public"
+tags:
+ - mcp
+ - filesystem
+ - files
+---
+
+You are a fun facts writer.
+
+Write a fun fact to `C:/Users/Public/funfacts/funfact.txt` using the write_file tool. The content must be the fun fact itself — 2-3 sentences, genuinely surprising. Do not write an empty file.
+
+After writing the file, confirm what you did and share the fun fact with the user.
diff --git a/src/MaIN.Services/Services/McpService.cs b/src/MaIN.Services/Services/McpService.cs
index 8bd1b7f6..eb66974e 100644
--- a/src/MaIN.Services/Services/McpService.cs
+++ b/src/MaIN.Services/Services/McpService.cs
@@ -5,11 +5,14 @@
using MaIN.Services.Services.LLMService.Auth;
using MaIN.Services.Services.LLMService.Utils;
using MaIN.Services.Services.Models;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.Google;
-using Microsoft.SemanticKernel.Connectors.OpenAI;
using ModelContextProtocol.Client;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
#pragma warning disable SKEXP0001
#pragma warning disable SKEXP0070
@@ -30,10 +33,324 @@ public async Task Prompt(Mcp config, List messageHistory)
})
);
+ var tools = await mcpClient.ListToolsAsync();
+ var backendType = config.Backend ?? settings.BackendType;
+
+ return backendType switch
+ {
+ BackendType.Gemini or BackendType.Vertex =>
+ await PromptWithSK(mcpClient, tools, config, messageHistory, backendType),
+ BackendType.Anthropic =>
+ await PromptWithAnthropic(mcpClient, tools, config, messageHistory),
+ BackendType.DeepSeek or BackendType.Ollama or BackendType.Self =>
+ throw new NotSupportedException($"{backendType} does not support MCP integration."),
+ _ => await PromptWithHttp(mcpClient, tools, config, messageHistory, backendType)
+ };
+ }
+
+ // Direct HTTP loop for OpenAI-compatible backends (OpenAI, GroqCloud, xAI, Anthropic-OpenAI-compat).
+ // Bypasses SK.Connectors.OpenAI 1.49.0 which has a binary incompatibility with SK.Core 1.64.0.
+ private async Task PromptWithHttp(
+ IMcpClient mcpClient,
+ IList tools,
+ Mcp config,
+ List messageHistory,
+ BackendType backendType)
+ {
+ var (url, apiKey) = GetEndpointAndKey(backendType, config);
+
+ var httpClientFactory = serviceProvider.GetRequiredService();
+ var client = httpClientFactory.CreateClient();
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
+
+ var toolDefs = tools.Select(t => new Dictionary
+ {
+ ["type"] = "function",
+ ["function"] = new Dictionary
+ {
+ ["name"] = t.Name,
+ ["description"] = t.Description ?? "",
+ ["parameters"] = t.ProtocolTool.InputSchema
+ }
+ }).ToList