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/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/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/Examples.csproj b/Examples/Examples/Examples.csproj
index db2dc64e..0c84b2a1 100644
--- a/Examples/Examples/Examples.csproj
+++ b/Examples/Examples/Examples.csproj
@@ -49,5 +49,8 @@
Always
+
+ Always
+
diff --git a/Examples/Examples/Program.cs b/Examples/Examples/Program.cs
index ba0c8523..7ec26581 100644
--- a/Examples/Examples/Program.cs
+++ b/Examples/Examples/Program.cs
@@ -1,9 +1,12 @@
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,6 +33,9 @@
services.AddSingleton(configuration);
services.AddMaIN(configuration);
+services.AddSkillsFromDirectory("./skills");
+services.AddSingleton();
+
RegisterExamples(services);
var serviceProvider = services.BuildServiceProvider();
@@ -68,6 +74,11 @@ static void RegisterExamples(IServiceCollection services)
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
services.AddTransient();
services.AddTransient();
services.AddTransient();
@@ -183,6 +194,10 @@ 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 (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()),
@@ -195,6 +210,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/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/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/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/MaIN.Core.E2ETests/McpTests.cs b/MaIN.Core.E2ETests/McpTests.cs
new file mode 100644
index 00000000..b1975301
--- /dev/null
+++ b/MaIN.Core.E2ETests/McpTests.cs
@@ -0,0 +1,121 @@
+using MaIN.Core.Hub;
+using MaIN.Domain.Configuration;
+using MaIN.Domain.Entities;
+using MaIN.Domain.Models;
+using MaIN.Domain.Models.Concrete;
+
+namespace MaIN.Core.E2ETests;
+
+[Collection("E2ETests")]
+public class McpTests : IntegrationTestBase
+{
+ private const string McpPrompt =
+ "Generate a fun fact (2-3 sentences, genuinely surprising) and write it to {0} using the write_file tool. " +
+ "After writing, confirm what you saved and share the fun fact.";
+
+ [SkippableFact]
+ public async Task OpenAi_Mcp_Should_WriteFileAndReturnContent()
+ {
+ SkipIfMissingKey(LLMApiRegistry.GetEntry(BackendType.OpenAi)?.ApiKeyEnvName!);
+
+ var tempDir = CreateTempDir();
+ try
+ {
+ var filePath = Path.Combine(tempDir, "funfact.txt").Replace('\\', '/');
+ var result = await AIHub.Mcp()
+ .WithBackend(BackendType.OpenAi)
+ .WithConfig(new Mcp
+ {
+ Name = "filesystem",
+ Command = "npx",
+ Arguments = ["-y", "@modelcontextprotocol/server-filesystem", tempDir],
+ Model = Models.OpenAi.Gpt4oMini
+ })
+ .PromptAsync(string.Format(McpPrompt, filePath));
+
+ Assert.NotNull(result);
+ Assert.NotEmpty(result.Message.Content);
+ Assert.True(File.Exists(filePath), $"Expected file at {filePath}");
+ Assert.NotEmpty(await File.ReadAllTextAsync(filePath));
+ }
+ finally
+ {
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
+
+ [SkippableFact]
+ public async Task Gemini_Mcp_Should_WriteFileAndReturnContent()
+ {
+ SkipIfMissingKey(LLMApiRegistry.GetEntry(BackendType.Gemini)?.ApiKeyEnvName!);
+
+ var tempDir = CreateTempDir();
+ try
+ {
+ var filePath = Path.Combine(tempDir, "funfact.txt").Replace('\\', '/');
+ var result = await AIHub.Mcp()
+ .WithBackend(BackendType.Gemini)
+ .WithConfig(new Mcp
+ {
+ Name = "filesystem",
+ Command = "npx",
+ Arguments = ["-y", "@modelcontextprotocol/server-filesystem", tempDir],
+ Model = Models.Gemini.Gemini2_0Flash
+ })
+ .PromptAsync(string.Format(McpPrompt, filePath));
+
+ Assert.NotNull(result);
+ Assert.NotEmpty(result.Message.Content);
+ Assert.True(File.Exists(filePath), $"Expected file at {filePath}");
+ Assert.NotEmpty(await File.ReadAllTextAsync(filePath));
+ }
+ finally
+ {
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
+
+ [SkippableFact]
+ public async Task Anthropic_Mcp_Should_WriteFileAndReturnContent()
+ {
+ SkipIfMissingKey(LLMApiRegistry.GetEntry(BackendType.Anthropic)?.ApiKeyEnvName!);
+
+ var tempDir = CreateTempDir();
+ try
+ {
+ var filePath = Path.Combine(tempDir, "funfact.txt").Replace('\\', '/');
+ var result = await AIHub.Mcp()
+ .WithBackend(BackendType.Anthropic)
+ .WithConfig(new Mcp
+ {
+ Name = "filesystem",
+ Command = "npx",
+ Arguments = ["-y", "@modelcontextprotocol/server-filesystem", tempDir],
+ Model = Models.Anthropic.ClaudeSonnet4
+ })
+ .PromptAsync(string.Format(McpPrompt, filePath));
+
+ Assert.NotNull(result);
+ Assert.NotEmpty(result.Message.Content);
+ Assert.True(File.Exists(filePath), $"Expected file at {filePath}");
+ Assert.NotEmpty(await File.ReadAllTextAsync(filePath));
+ }
+ finally
+ {
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
+
+ private static string CreateTempDir()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), $"mcp-e2e-{Guid.NewGuid()}");
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ private static void SkipIfMissingKey(string envName)
+ {
+ Skip.If(string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envName)),
+ $"{envName} environment variable not set");
+ }
+}
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.UnitTests/FileSystemSkillLoaderTests.cs b/src/MaIN.Core.UnitTests/FileSystemSkillLoaderTests.cs
new file mode 100644
index 00000000..0c68df7d
--- /dev/null
+++ b/src/MaIN.Core.UnitTests/FileSystemSkillLoaderTests.cs
@@ -0,0 +1,223 @@
+using MaIN.Domain.Entities.Skills;
+using MaIN.Services.Services.Skills;
+
+namespace MaIN.Core.UnitTests;
+
+public class FileSystemSkillLoaderTests : IDisposable
+{
+ private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"skill-tests-{Guid.NewGuid()}");
+
+ public FileSystemSkillLoaderTests() => Directory.CreateDirectory(_tempDir);
+ public void Dispose() => Directory.Delete(_tempDir, recursive: true);
+
+ private void WriteFile(string relativePath, string content)
+ {
+ var full = Path.Combine(_tempDir, relativePath);
+ Directory.CreateDirectory(Path.GetDirectoryName(full)!);
+ File.WriteAllText(full, content);
+ }
+
+ // --- Directory ---
+
+ [Fact]
+ public void LoadAll_NonexistentDirectory_ReturnsEmpty()
+ {
+ var loader = new FileSystemSkillLoader(Path.Combine(_tempDir, "does-not-exist"));
+ Assert.Empty(loader.LoadAll());
+ }
+
+ // --- Parsing ---
+
+ [Fact]
+ public void LoadAll_ValidSkill_ParsedCorrectly()
+ {
+ WriteFile("web-search.md", """
+ ---
+ name: web-search
+ description: Search the web
+ version: 2.0.0
+ steps:
+ - FETCH_DATA
+ - ANSWER
+ placement: before
+ priority: 10
+ tags:
+ - web
+ - search
+ ---
+
+ Search the web and provide sourced answers.
+ """);
+
+ var skills = new FileSystemSkillLoader(_tempDir).LoadAll();
+
+ Assert.Single(skills);
+ var s = skills[0];
+ Assert.Equal("web-search", s.Name);
+ Assert.Equal("Search the web", s.Description);
+ Assert.Equal("2.0.0", s.Version);
+ Assert.Equal(["FETCH_DATA", "ANSWER"], s.Steps);
+ Assert.Equal(SkillStepPlacement.Before, s.StepPlacement);
+ Assert.Equal(10, s.Priority);
+ Assert.Contains("web", s.Tags);
+ Assert.Contains("search", s.Tags);
+ Assert.Contains("Search the web and provide sourced answers.", s.InstructionFragment);
+ }
+
+ [Fact]
+ public void LoadAll_NoFrontmatter_SkipsFile()
+ {
+ WriteFile("no-front.md", "Just markdown without frontmatter.");
+ Assert.Empty(new FileSystemSkillLoader(_tempDir).LoadAll());
+ }
+
+ [Fact]
+ public void LoadAll_MissingName_SkipsFile()
+ {
+ WriteFile("no-name.md", """
+ ---
+ description: No name here
+ steps:
+ - ANSWER
+ ---
+ Do something.
+ """);
+
+ Assert.Empty(new FileSystemSkillLoader(_tempDir).LoadAll());
+ }
+
+ [Fact]
+ public void LoadAll_DefaultPriority_Is100()
+ {
+ WriteFile("default.md", """
+ ---
+ name: default-priority
+ ---
+ """);
+
+ var skills = new FileSystemSkillLoader(_tempDir).LoadAll();
+
+ Assert.Equal(100, skills[0].Priority);
+ }
+
+ [Fact]
+ public void LoadAll_PlacementReplace_ParsedCorrectly()
+ {
+ WriteFile("replace.md", """
+ ---
+ name: replace-skill
+ placement: replace
+ steps:
+ - MCP
+ ---
+ """);
+
+ var skills = new FileSystemSkillLoader(_tempDir).LoadAll();
+
+ Assert.Equal(SkillStepPlacement.Replace, skills[0].StepPlacement);
+ }
+
+ [Fact]
+ public void LoadAll_PlacementAfter_ParsedCorrectly()
+ {
+ WriteFile("after.md", """
+ ---
+ name: after-skill
+ placement: after
+ steps:
+ - SUMMARIZE
+ ---
+ """);
+
+ var skills = new FileSystemSkillLoader(_tempDir).LoadAll();
+
+ Assert.Equal(SkillStepPlacement.After, skills[0].StepPlacement);
+ }
+
+ [Fact]
+ public void LoadAll_UnknownPlacement_DefaultsBefore()
+ {
+ WriteFile("unknown-placement.md", """
+ ---
+ name: unknown-placement-skill
+ placement: sideways
+ ---
+ """);
+
+ var skills = new FileSystemSkillLoader(_tempDir).LoadAll();
+
+ Assert.Equal(SkillStepPlacement.Before, skills[0].StepPlacement);
+ }
+
+ // --- MCP ---
+
+ [Fact]
+ public void LoadAll_WithMcpBlock_ParsesMcpDefinition()
+ {
+ WriteFile("mcp-skill.md", """
+ ---
+ name: fs-tools
+ steps:
+ - MCP
+ placement: replace
+ mcp:
+ command: npx
+ arguments:
+ - -y
+ - "@modelcontextprotocol/server-filesystem"
+ - /tmp
+ ---
+ Use filesystem tools.
+ """);
+
+ var skills = new FileSystemSkillLoader(_tempDir).LoadAll();
+
+ Assert.Single(skills);
+ var mcp = skills[0].Mcp;
+ Assert.NotNull(mcp);
+ Assert.Equal("npx", mcp!.Command);
+ Assert.Equal(["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], mcp.Arguments);
+ }
+
+ // --- Folder-based skills ---
+
+ [Fact]
+ public void LoadAll_FolderSkill_LoadsOnlySkillMd()
+ {
+ WriteFile("my-skill/SKILL.md", """
+ ---
+ name: folder-skill
+ steps:
+ - ANSWER
+ ---
+ Main skill prompt.
+ """);
+
+ // Sibling file in same folder — must NOT be loaded as separate skill
+ WriteFile("my-skill/helper.md", """
+ ---
+ name: should-be-skipped
+ steps:
+ - ANSWER
+ ---
+ Helper content.
+ """);
+
+ var skills = new FileSystemSkillLoader(_tempDir).LoadAll();
+
+ Assert.Single(skills);
+ Assert.Equal("folder-skill", skills[0].Name);
+ }
+
+ [Fact]
+ public void LoadAll_MultipleTopLevelSkills_LoadsAll()
+ {
+ WriteFile("skill-a.md", "---\nname: skill-a\n---\n");
+ WriteFile("skill-b.md", "---\nname: skill-b\n---\n");
+ WriteFile("skill-c.md", "---\nname: skill-c\n---\n");
+
+ var skills = new FileSystemSkillLoader(_tempDir).LoadAll();
+
+ Assert.Equal(3, skills.Count);
+ }
+}
diff --git a/src/MaIN.Core.UnitTests/SkillComposerTests.cs b/src/MaIN.Core.UnitTests/SkillComposerTests.cs
new file mode 100644
index 00000000..86dafff9
--- /dev/null
+++ b/src/MaIN.Core.UnitTests/SkillComposerTests.cs
@@ -0,0 +1,252 @@
+using MaIN.Domain.Configuration;
+using MaIN.Domain.Entities.Agents;
+using MaIN.Domain.Entities.Agents.AgentSource;
+using MaIN.Domain.Entities.Skills;
+using MaIN.Domain.Exceptions.Skills;
+using MaIN.Domain.Models;
+using MaIN.Services.Services;
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace MaIN.Core.UnitTests;
+
+public class SkillComposerTests
+{
+ private readonly SkillComposer _composer = new(Mock.Of>());
+
+ private static Agent MakeAgent(string? modelId = null, List? steps = null) => new()
+ {
+ Id = Guid.NewGuid().ToString(),
+ CurrentBehaviour = "Default",
+ Behaviours = [],
+ Config = new AgentConfig { Steps = steps ?? ["ANSWER"] },
+ Model = modelId ?? ""
+ };
+
+ private static AgentSkill MakeSkill(
+ string name = "test-skill",
+ List? steps = null,
+ SkillStepPlacement placement = SkillStepPlacement.Before,
+ string? instructionFragment = null,
+ SkillMcpDefinition? mcp = null,
+ SkillSourceDefinition? source = null,
+ List? tools = null,
+ Dictionary? behaviours = null,
+ int priority = 100) => new AgentSkill
+ {
+ Name = name,
+ Steps = steps ?? [],
+ StepPlacement = placement,
+ InstructionFragment = instructionFragment,
+ Mcp = mcp,
+ Source = source,
+ Tools = tools ?? [],
+ Behaviours = behaviours ?? [],
+ Priority = priority
+ };
+
+ // --- Steps ---
+
+ [Fact]
+ public void MergeSteps_Replace_OverwritesAgentSteps()
+ {
+ var agent = MakeAgent(steps: ["ANSWER"]);
+ var skill = MakeSkill(steps: ["MCP", "ANSWER"], placement: SkillStepPlacement.Replace);
+
+ _composer.Apply(agent, [skill]);
+
+ Assert.Equal(["MCP", "ANSWER"], agent.Config.Steps);
+ }
+
+ [Fact]
+ public void MergeSteps_Before_PrependsAndDeduplicates()
+ {
+ var agent = MakeAgent(steps: ["ANSWER"]);
+ var skill = MakeSkill(steps: ["FETCH", "ANSWER"], placement: SkillStepPlacement.Before);
+
+ _composer.Apply(agent, [skill]);
+
+ Assert.Equal(["FETCH", "ANSWER"], agent.Config.Steps);
+ }
+
+ [Fact]
+ public void MergeSteps_After_AppendsAndDeduplicates()
+ {
+ var agent = MakeAgent(steps: ["ANSWER"]);
+ var skill = MakeSkill(steps: ["SUMMARIZE"], placement: SkillStepPlacement.After);
+
+ _composer.Apply(agent, [skill]);
+
+ Assert.Equal(["ANSWER", "SUMMARIZE"], agent.Config.Steps);
+ }
+
+ [Fact]
+ public void MergeSteps_ReplaceWinsOverBeforeAfter()
+ {
+ var agent = MakeAgent(steps: ["ANSWER"]);
+ var before = MakeSkill("before", steps: ["FETCH"], placement: SkillStepPlacement.Before, priority: 10);
+ var replace = MakeSkill("replace", steps: ["MCP"], placement: SkillStepPlacement.Replace, priority: 20);
+
+ _composer.Apply(agent, [before, replace]);
+
+ Assert.Equal(["MCP"], agent.Config.Steps);
+ }
+
+ // --- Tools ---
+
+ [Fact]
+ public void MergeTools_DuplicateName_ThrowsSkillConflictException()
+ {
+ var agent = MakeAgent();
+ var tool = new SkillToolDefinition { Name = "calculator", Description = "calc", Parameters = new { } };
+ var skillA = MakeSkill("skill-a", tools: [tool]);
+ var skillB = MakeSkill("skill-b", tools: [new SkillToolDefinition { Name = "calculator", Description = "calc2", Parameters = new { } }]);
+
+ Assert.Throws(() => _composer.Apply(agent, [skillA, skillB]));
+ }
+
+ // --- Source ---
+
+ [Fact]
+ public void MergeSource_TwoSkillsWithSource_ThrowsSkillConflictException()
+ {
+ var agent = MakeAgent();
+ var src = new SkillSourceDefinition
+ {
+ Details = new AgentWebSourceDetails { Url = "https://example.com" },
+ Type = AgentSourceType.Web
+ };
+ var skillA = MakeSkill("skill-a", source: src);
+ var skillB = MakeSkill("skill-b", source: new SkillSourceDefinition
+ {
+ Details = new AgentWebSourceDetails { Url = "https://other.com" },
+ Type = AgentSourceType.Web
+ });
+
+ Assert.Throws(() => _composer.Apply(agent, [skillA, skillB]));
+ }
+
+ [Fact]
+ public void MergeSource_AgentAlreadyHasSource_ThrowsSkillConflictException()
+ {
+ var agent = MakeAgent();
+ agent.Config.Source = new AgentSource
+ {
+ Details = new AgentWebSourceDetails { Url = "https://existing.com" },
+ Type = AgentSourceType.Web
+ };
+ var skill = MakeSkill(source: new SkillSourceDefinition
+ {
+ Details = new AgentWebSourceDetails { Url = "https://new.com" },
+ Type = AgentSourceType.Web
+ });
+
+ Assert.Throws(() => _composer.Apply(agent, [skill]));
+ }
+
+ // --- MCP ---
+
+ [Fact]
+ public void MergeMcp_SetsModelAndBackendFromAgent()
+ {
+ var agent = MakeAgent(modelId: Models.OpenAi.Gpt4oMini);
+ var skill = MakeSkill(mcp: new SkillMcpDefinition
+ {
+ Command = "npx",
+ Arguments = ["-y", "@mcp/server-test"],
+ Environment = [],
+ Properties = []
+ });
+
+ _composer.Apply(agent, [skill]);
+
+ Assert.NotNull(agent.Config.McpConfig);
+ Assert.Equal(Models.OpenAi.Gpt4oMini, agent.Config.McpConfig!.Model);
+ Assert.Equal(BackendType.OpenAi, agent.Config.McpConfig.Backend);
+ }
+
+ [Fact]
+ public void MergeMcp_TwoSkillsWithMcp_ThrowsSkillConflictException()
+ {
+ var agent = MakeAgent();
+ var mcpDef = new SkillMcpDefinition { Command = "npx", Arguments = [], Environment = [], Properties = [] };
+
+ Assert.Throws(() =>
+ _composer.Apply(agent, [MakeSkill("a", mcp: mcpDef), MakeSkill("b", mcp: mcpDef)]));
+ }
+
+ [Fact]
+ public void MergeMcp_AgentAlreadyHasMcpConfig_SkipsSkillMcp()
+ {
+ var agent = MakeAgent();
+ agent.Config.McpConfig = new MaIN.Domain.Entities.Mcp
+ {
+ Name = "existing", Command = "docker", Arguments = [], Model = "existing-model"
+ };
+ var skill = MakeSkill(mcp: new SkillMcpDefinition { Command = "npx", Arguments = [], Environment = [], Properties = [] });
+
+ _composer.Apply(agent, [skill]);
+
+ // existing config not overwritten
+ Assert.Equal("docker", agent.Config.McpConfig.Command);
+ }
+
+ // --- Behaviours ---
+
+ [Fact]
+ public void MergeBehaviours_MergesAllFromSkills()
+ {
+ var agent = MakeAgent();
+ var skill = MakeSkill(behaviours: new Dictionary
+ {
+ ["Journalist"] = "Write a newsletter.",
+ ["Critic"] = "Be critical."
+ });
+
+ _composer.Apply(agent, [skill]);
+
+ Assert.Equal("Write a newsletter.", agent.Behaviours["Journalist"]);
+ Assert.Equal("Be critical.", agent.Behaviours["Critic"]);
+ }
+
+ // --- InstructionFragment ---
+
+ [Fact]
+ public void MergeInstructionFragments_AppendsToExistingInstruction()
+ {
+ var agent = MakeAgent();
+ agent.Config.Instruction = "Base instruction.";
+ var skill = MakeSkill(instructionFragment: "Search the web carefully.");
+
+ _composer.Apply(agent, [skill]);
+
+ Assert.Equal("Base instruction.\n\nSearch the web carefully.", agent.Config.Instruction);
+ }
+
+ [Fact]
+ public void MergeInstructionFragments_MultipleSkillsConcatenatedByPriority()
+ {
+ var agent = MakeAgent();
+ agent.Config.Instruction = null;
+ var skillA = MakeSkill("a", instructionFragment: "Fragment A.", priority: 10);
+ var skillB = MakeSkill("b", instructionFragment: "Fragment B.", priority: 20);
+
+ _composer.Apply(agent, [skillA, skillB]);
+
+ Assert.Equal("Fragment A.\n\nFragment B.", agent.Config.Instruction);
+ }
+
+ // --- No-op ---
+
+ [Fact]
+ public void Apply_EmptySkillList_AgentUnchanged()
+ {
+ var agent = MakeAgent(steps: ["ANSWER"]);
+ var originalInstruction = agent.Config.Instruction;
+
+ _composer.Apply(agent, []);
+
+ Assert.Equal(["ANSWER"], agent.Config.Steps);
+ Assert.Equal(originalInstruction, agent.Config.Instruction);
+ }
+}
diff --git a/src/MaIN.Core.UnitTests/SkillRegistryTests.cs b/src/MaIN.Core.UnitTests/SkillRegistryTests.cs
new file mode 100644
index 00000000..96a047ef
--- /dev/null
+++ b/src/MaIN.Core.UnitTests/SkillRegistryTests.cs
@@ -0,0 +1,165 @@
+using MaIN.Domain.Entities.Skills;
+using MaIN.Domain.Exceptions.Skills;
+using MaIN.Services.Services;
+using MaIN.Services.Services.Abstract;
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace MaIN.Core.UnitTests;
+
+public class SkillRegistryTests
+{
+ private static SkillRegistry MakeRegistry(IEnumerable? initial = null)
+ {
+ var registry = new SkillRegistry([], [], Mock.Of>());
+ foreach (var skill in initial ?? [])
+ registry.Register(skill);
+ return registry;
+ }
+
+ private static AgentSkill MakeSkill(string name, string[]? tags = null) => new()
+ {
+ Name = name,
+ Steps = [],
+ Tags = tags ?? [],
+ Priority = 100,
+ StepPlacement = SkillStepPlacement.Before
+ };
+
+ [Fact]
+ public void GetSkill_Registered_ReturnsSkill()
+ {
+ var registry = MakeRegistry([MakeSkill("calculator")]);
+
+ var result = registry.GetSkill("calculator");
+
+ Assert.Equal("calculator", result.Name);
+ }
+
+ [Fact]
+ public void GetSkill_CaseInsensitive_ReturnsSkill()
+ {
+ var registry = MakeRegistry([MakeSkill("Calculator")]);
+
+ var result = registry.GetSkill("CALCULATOR");
+
+ Assert.Equal("Calculator", result.Name);
+ }
+
+ [Fact]
+ public void GetSkill_NotFound_ThrowsSkillNotFoundException()
+ {
+ var registry = MakeRegistry();
+
+ Assert.Throws(() => registry.GetSkill("unknown"));
+ }
+
+ [Fact]
+ public void TryGetSkill_Found_ReturnsTrueWithSkill()
+ {
+ var registry = MakeRegistry([MakeSkill("my-skill")]);
+
+ var found = registry.TryGetSkill("my-skill", out var skill);
+
+ Assert.True(found);
+ Assert.NotNull(skill);
+ }
+
+ [Fact]
+ public void TryGetSkill_NotFound_ReturnsFalseWithNull()
+ {
+ var registry = MakeRegistry();
+
+ var found = registry.TryGetSkill("unknown", out var skill);
+
+ Assert.False(found);
+ Assert.Null(skill);
+ }
+
+ [Fact]
+ public void GetAll_ReturnsAllRegistered()
+ {
+ var registry = MakeRegistry([MakeSkill("a"), MakeSkill("b"), MakeSkill("c")]);
+
+ Assert.Equal(3, registry.GetAll().Count);
+ }
+
+ [Fact]
+ public void GetByTag_ReturnsOnlyMatchingSkills()
+ {
+ var registry = MakeRegistry([
+ MakeSkill("web-search", ["web", "search"]),
+ MakeSkill("rag-expert", ["knowledge", "rag"]),
+ MakeSkill("journalist", ["web", "persona"])
+ ]);
+
+ var results = registry.GetByTag("web");
+
+ Assert.Equal(2, results.Count);
+ Assert.Contains(results, s => s.Name == "web-search");
+ Assert.Contains(results, s => s.Name == "journalist");
+ }
+
+ [Fact]
+ public void GetByTag_NoMatch_ReturnsEmpty()
+ {
+ var registry = MakeRegistry([MakeSkill("some-skill", ["tag-a"])]);
+
+ var results = registry.GetByTag("tag-b");
+
+ Assert.Empty(results);
+ }
+
+ [Fact]
+ public void GetByTag_CaseInsensitive_ReturnsMatch()
+ {
+ var registry = MakeRegistry([MakeSkill("my-skill", ["Web"])]);
+
+ var results = registry.GetByTag("web");
+
+ Assert.Single(results);
+ }
+
+ [Fact]
+ public void Register_Duplicate_OverwritesPrevious()
+ {
+ var registry = MakeRegistry([MakeSkill("my-skill")]);
+ var updated = new AgentSkill
+ {
+ Name = "my-skill",
+ Description = "updated",
+ Steps = [],
+ Tags = [],
+ Priority = 100,
+ StepPlacement = SkillStepPlacement.Before
+ };
+
+ registry.Register(updated);
+
+ Assert.Equal("updated", registry.GetSkill("my-skill").Description);
+ }
+
+ [Fact]
+ public void Constructor_LoadsFromProviders()
+ {
+ var skill = MakeSkill("provided-skill");
+ var provider = new Mock();
+ provider.Setup(p => p.GetSkill()).Returns(skill);
+
+ var registry = new SkillRegistry([provider.Object], [], Mock.Of>());
+
+ Assert.Equal("provided-skill", registry.GetSkill("provided-skill").Name);
+ }
+
+ [Fact]
+ public void Constructor_LoadsFromLoaders()
+ {
+ var skill = MakeSkill("loaded-skill");
+ var loader = new Mock();
+ loader.Setup(l => l.LoadAll()).Returns([skill]);
+
+ var registry = new SkillRegistry([], [loader.Object], Mock.Of>());
+
+ Assert.Equal("loaded-skill", registry.GetSkill("loaded-skill").Name);
+ }
+}
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..f459b79f 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(),
@@ -45,10 +52,30 @@ internal AgentContext(IAgentService agentService)
};
}
- 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)
+ {
+ _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 ---
@@ -77,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;
}
@@ -194,6 +221,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 +234,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;
@@ -333,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)
@@ -341,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.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/Contexts/McpContext.cs b/src/MaIN.Core/Hub/Contexts/McpContext.cs
index 069da167..a29b45d4 100644
--- a/src/MaIN.Core/Hub/Contexts/McpContext.cs
+++ b/src/MaIN.Core/Hub/Contexts/McpContext.cs
@@ -12,21 +12,25 @@ public sealed class McpContext : IMcpContext
{
private readonly IMcpService _mcpService;
private Mcp? _mcpConfig;
+ private BackendType? _explicitBackend;
internal McpContext(IMcpService mcpService)
{
_mcpService = mcpService;
_mcpConfig = Mcp.NotSet;
}
-
+
public IMcpContext WithConfig(Mcp mcpConfig)
{
_mcpConfig = mcpConfig;
+ if (_explicitBackend.HasValue)
+ _mcpConfig.Backend = _explicitBackend;
return this;
}
-
+
public IMcpContext WithBackend(BackendType backendType)
{
+ _explicitBackend = backendType;
_mcpConfig!.Backend = backendType;
return this;
}
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..fa1f0901
--- /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://feeds.bbci.co.uk/news/rss.xml") : 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..59042857
--- /dev/null
+++ b/src/MaIN.Domain/Entities/Skills/AgentSkill.cs
@@ -0,0 +1,78 @@
+using MaIN.Domain.Entities.Agents.Knowledge;
+
+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;
+}
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..faaf42c4 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,12 +9,14 @@
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;
using MaIN.Services.Services.TTSService;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
namespace MaIN.Services;
@@ -63,6 +66,27 @@ public static IServiceCollection ConfigureMaIN(
// Register the step processor
serviceCollection.AddSingleton();
+ // Register skill infrastructure
+ serviceCollection.AddSingleton(sp => new SkillRegistry(
+ sp.GetServices(),
+ sp.GetServices(),
+ sp.GetRequiredService>()));
+ 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/LLMService/Memory/MemoryService.cs b/src/MaIN.Services/Services/LLMService/Memory/MemoryService.cs
index 251ef2cf..6d3798b0 100644
--- a/src/MaIN.Services/Services/LLMService/Memory/MemoryService.cs
+++ b/src/MaIN.Services/Services/LLMService/Memory/MemoryService.cs
@@ -36,8 +36,8 @@ private async Task ImportTextData((IKernelMemory km, ITextEmbeddingGenerator? ge
foreach (var item in textData)
{
- var cleanedValue = JsonCleaner.CleanAndUnescape(item.Value);
- await memory.km.ImportTextAsync(cleanedValue!, item.Key, cancellationToken: cancellationToken);
+ var cleanedValue = JsonCleaner.CleanAndUnescape(item.Value) ?? item.Value;
+ await memory.km.ImportTextAsync(cleanedValue, item.Key, cancellationToken: cancellationToken);
}
}
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