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(); + + var messages = messageHistory + .Select(m => (object)new Dictionary + { + ["role"] = m.Role.ToLower(), + ["content"] = m.Content + }) + .ToList(); + + const int maxIterations = 10; + for (int i = 0; i < maxIterations; i++) + { + var requestBody = new Dictionary + { + ["model"] = config.Model, + ["messages"] = messages, + ["tools"] = toolDefs, + ["tool_choice"] = i == 0 ? "required" : "auto" + }; + + var json = JsonSerializer.Serialize(requestBody); + var response = await client.PostAsync(url, + new StringContent(json, Encoding.UTF8, "application/json")); + + response.EnsureSuccessStatusCode(); + var responseText = await response.Content.ReadAsStringAsync(); + var responseDoc = JsonDocument.Parse(responseText); + var message = responseDoc.RootElement + .GetProperty("choices")[0] + .GetProperty("message"); + + var hasToolCalls = message.TryGetProperty("tool_calls", out var toolCalls) + && toolCalls.ValueKind == JsonValueKind.Array + && toolCalls.GetArrayLength() > 0; + + if (!hasToolCalls) + { + var content = message.TryGetProperty("content", out var c) ? c.GetString() ?? "" : ""; + + // Some models return empty content after tool use — ask for explicit summary + if (string.IsNullOrWhiteSpace(content) && i > 0) + { + messages.Add(new Dictionary + { + ["role"] = "user", + ["content"] = "Summarize what you just did in one sentence." + }); + continue; + } + + return BuildResult(content, config.Model); + } + + // Add assistant message with tool calls (preserve raw JSON element) + var assistantMsg = new Dictionary + { + ["role"] = "assistant", + ["content"] = (object)(message.TryGetProperty("content", out var ac) ? ac.GetString() ?? "" : ""), + ["tool_calls"] = toolCalls + }; + messages.Add(assistantMsg); + + // Execute each tool via MCP client + foreach (var toolCall in toolCalls.EnumerateArray()) + { + var toolName = toolCall.GetProperty("function").GetProperty("name").GetString()!; + var argsJson = toolCall.GetProperty("function").GetProperty("arguments").GetString() ?? "{}"; + var toolCallId = toolCall.GetProperty("id").GetString()!; + + var argsDict = JsonSerializer + .Deserialize>(argsJson) + ?.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.ValueKind switch + { + JsonValueKind.String => (object)kvp.Value.GetString()!, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number when kvp.Value.TryGetInt64(out var l) => l, + JsonValueKind.Number => (object)kvp.Value.GetDouble(), + _ => (object)kvp.Value + }) + ?? new Dictionary(); + + var toolResult = await mcpClient.CallToolAsync(toolName, argsDict); + var resultText = string.Join("\n", toolResult.Content + .Where(c => c.Text != null) + .Select(c => c.Text!)); + + if (toolResult.IsError == true) + Console.WriteLine($"[MCP] Tool '{toolName}' returned error: {resultText}"); + + messages.Add(new Dictionary + { + ["role"] = "tool", + ["tool_call_id"] = toolCallId, + ["content"] = resultText + }); + } + } + + return BuildResult("Max tool iterations reached.", config.Model); + } + + // Anthropic uses a different protocol: x-api-key header, input_schema instead of parameters, + // content[] array response, tool_use/tool_result blocks instead of tool_calls. + private async Task PromptWithAnthropic( + IMcpClient mcpClient, + IList tools, + Mcp config, + List messageHistory) + { + var apiKey = GetAnthropicKey() ?? throw new InvalidOperationException("Anthropic API key not configured."); + var httpClientFactory = serviceProvider.GetRequiredService(); + var client = httpClientFactory.CreateClient(); + client.DefaultRequestHeaders.Add("x-api-key", apiKey); + client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); + + var toolDefs = tools.Select(t => (object)new Dictionary + { + ["name"] = t.Name, + ["description"] = t.Description ?? "", + ["input_schema"] = t.ProtocolTool.InputSchema + }).ToList(); + + var systemContent = messageHistory + .FirstOrDefault(m => m.Role.Equals("System", StringComparison.OrdinalIgnoreCase)) + ?.Content; + + var messages = messageHistory + .Where(m => !m.Role.Equals("System", StringComparison.OrdinalIgnoreCase)) + .Select(m => (object)new Dictionary + { + ["role"] = m.Role.ToLower(), + ["content"] = m.Content + }) + .ToList(); + + const int maxIterations = 10; + for (int i = 0; i < maxIterations; i++) + { + var requestBody = new Dictionary + { + ["model"] = config.Model, + ["max_tokens"] = 4096, + ["messages"] = messages, + ["tools"] = toolDefs, + ["tool_choice"] = i == 0 + ? (object)new Dictionary { ["type"] = "any" } + : new Dictionary { ["type"] = "auto" } + }; + if (systemContent != null) + requestBody["system"] = systemContent; + + var json = JsonSerializer.Serialize(requestBody); + var response = await client.PostAsync("https://api.anthropic.com/v1/messages", + new StringContent(json, Encoding.UTF8, "application/json")); + response.EnsureSuccessStatusCode(); + + var responseText = await response.Content.ReadAsStringAsync(); + var responseDoc = JsonDocument.Parse(responseText); + var contentBlocks = responseDoc.RootElement.GetProperty("content").EnumerateArray().ToList(); + var stopReason = responseDoc.RootElement.TryGetProperty("stop_reason", out var sr) + ? sr.GetString() : null; + + var textContent = string.Concat(contentBlocks + .Where(b => b.TryGetProperty("type", out var t) && t.GetString() == "text") + .Select(b => b.TryGetProperty("text", out var txt) ? txt.GetString() ?? "" : "")); + + var toolUses = contentBlocks + .Where(b => b.TryGetProperty("type", out var t) && t.GetString() == "tool_use") + .ToList(); + + if (toolUses.Count == 0 || stopReason == "end_turn") + { + if (string.IsNullOrWhiteSpace(textContent) && i > 0) + { + messages.Add(new Dictionary + { + ["role"] = "assistant", + ["content"] = new List { new Dictionary { ["type"] = "text", ["text"] = " " } } + }); + messages.Add(new Dictionary + { + ["role"] = "user", + ["content"] = "Summarize what you just did in one sentence." + }); + continue; + } + return BuildResult(textContent, config.Model); + } + + // Add assistant turn with tool_use blocks + var assistantContent = new List(); + if (!string.IsNullOrEmpty(textContent)) + assistantContent.Add(new Dictionary { ["type"] = "text", ["text"] = textContent }); + foreach (var tu in toolUses) + assistantContent.Add(new Dictionary + { + ["type"] = "tool_use", + ["id"] = tu.GetProperty("id").GetString()!, + ["name"] = tu.GetProperty("name").GetString()!, + ["input"] = tu.GetProperty("input") + }); + messages.Add(new Dictionary { ["role"] = "assistant", ["content"] = assistantContent }); + + // Execute tools and collect tool_result blocks + var toolResults = new List(); + foreach (var tu in toolUses) + { + var toolName = tu.GetProperty("name").GetString()!; + var toolId = tu.GetProperty("id").GetString()!; + var inputElement = tu.GetProperty("input"); + + var argsDict = JsonSerializer + .Deserialize>(inputElement.GetRawText()) + ?.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.ValueKind switch + { + JsonValueKind.String => (object)kvp.Value.GetString()!, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number when kvp.Value.TryGetInt64(out var l) => l, + JsonValueKind.Number => (object)kvp.Value.GetDouble(), + _ => (object)kvp.Value + }) + ?? new Dictionary(); + + var toolResult = await mcpClient.CallToolAsync(toolName, argsDict); + var resultText = string.Join("\n", toolResult.Content + .Where(c => c.Text != null) + .Select(c => c.Text!)); + + if (toolResult.IsError == true) + Console.WriteLine($"[MCP] Tool '{toolName}' returned error: {resultText}"); + + toolResults.Add(new Dictionary + { + ["type"] = "tool_result", + ["tool_use_id"] = toolId, + ["content"] = resultText + }); + } + messages.Add(new Dictionary { ["role"] = "user", ["content"] = toolResults }); + } + + return BuildResult("Max tool iterations reached.", config.Model); + } + + private (string url, string apiKey) GetEndpointAndKey(BackendType backendType, Mcp config) + { + return backendType switch + { + BackendType.OpenAi => ( + "https://api.openai.com/v1/chat/completions", + GetOpenAiKey() ?? throw new InvalidOperationException("OpenAI API key not configured.")), + BackendType.GroqCloud => ( + "https://api.groq.com/openai/v1/chat/completions", + GetGroqCloudKey() ?? throw new InvalidOperationException("GroqCloud API key not configured.")), + BackendType.Xai => ( + "https://api.x.ai/v1/chat/completions", + GetXaiKey() ?? throw new InvalidOperationException("xAI API key not configured.")), + _ => throw new NotSupportedException($"Backend {backendType} is not supported in MCP HTTP mode.") + }; + } + + // SK-based path for Gemini / Vertex (Google connector 1.64.0 is version-compatible with SK.Core 1.64.0). + private async Task PromptWithSK( + IMcpClient mcpClient, + IList tools, + Mcp config, + List messageHistory, + BackendType backendType) + { var builder = Kernel.CreateBuilder(); - var promptSettings = InitializeChatCompletions(builder, config); + var promptSettings = InitializeGoogleChatCompletions(builder, config, backendType); var kernel = builder.Build(); - var tools = await mcpClient.ListToolsAsync(); kernel.Plugins.AddFromFunctions("Tools", tools.Select(x => x.AsKernelFunction())); var chatHistory = new ChatHistory(); @@ -50,107 +367,61 @@ public async Task Prompt(Mcp config, List messageHistory) } var chatService = kernel.GetRequiredService(); + var result = await chatService.GetChatMessageContentsAsync(chatHistory, promptSettings, kernel); - var result = await chatService.GetChatMessageContentsAsync( - chatHistory, - promptSettings, - kernel); - - return new McpResult - { - CreatedAt = DateTime.Now, - Message = new Message - { - Content = result.Last().Content!, - Role = nameof(AuthorRole.Assistant), - Type = MessageType.CloudLLM - }, - Model = config.Model - }; + return BuildResult(result.Last().Content!, config.Model); } - private PromptExecutionSettings InitializeChatCompletions(IKernelBuilder kernelBuilder, Mcp config) + private PromptExecutionSettings InitializeGoogleChatCompletions(IKernelBuilder kernelBuilder, Mcp config, BackendType backendType) { - var backendType = config.Backend ?? settings.BackendType; var model = config.Model; - switch (backendType) + if (backendType == BackendType.Gemini) { - case BackendType.OpenAi: - kernelBuilder.Services.AddOpenAIChatCompletion(model, GetOpenAiKey() ?? throw new ArgumentNullException(nameof(GetOpenAiKey))); - return new OpenAIPromptExecutionSettings - { - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { RetainArgumentTypes = true }) - }; - - case BackendType.Gemini: - kernelBuilder.Services.AddGoogleAIGeminiChatCompletion(model, GetGeminiKey() ?? throw new ArgumentNullException(nameof(GetGeminiKey))); - return new GeminiPromptExecutionSettings - { - ModelId = model, - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { RetainArgumentTypes = true }) - }; + kernelBuilder.Services.AddGoogleAIGeminiChatCompletion( + model, + GetGeminiKey() ?? throw new InvalidOperationException("Gemini API key not configured.")); - case BackendType.DeepSeek: - throw new NotSupportedException("DeepSeek models does not support MCP integration."); - - case BackendType.GroqCloud: - kernelBuilder.Services.AddOpenAIChatCompletion( - modelId: model, - apiKey: GetGroqCloudKey() ?? throw new ArgumentNullException(nameof(GetGroqCloudKey)), - endpoint: new Uri("https://api.groq.com/openai/v1")); - - return new OpenAIPromptExecutionSettings() - { - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { RetainArgumentTypes = true }) - }; + return new GeminiPromptExecutionSettings + { + ModelId = model, + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { RetainArgumentTypes = true }) + }; + } - case BackendType.Anthropic: - kernelBuilder.AddAnthropicChatCompletion(serviceProvider, model, GetAnthropicKey() ?? throw new ArgumentNullException(nameof(GetAnthropicKey))); - return new PromptExecutionSettings - { - ExtensionData = new Dictionary{ ["max_tokens"] = 4096 } - }; - - case BackendType.Xai: - kernelBuilder.Services.AddOpenAIChatCompletion( - modelId: model, - apiKey: GetXaiKey() ?? throw new ArgumentNullException(nameof(GetXaiKey)), - endpoint: new Uri("https://api.x.ai/v1")); - return new OpenAIPromptExecutionSettings() - { - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { RetainArgumentTypes = true }) - }; - - case BackendType.Vertex: - var auth = settings.GoogleServiceAccountAuth - ?? throw new InvalidOperationException("Vertex AI service account is not configured."); - var tokenProvider = new GoogleServiceAccountTokenProvider(auth); - var httpClient = new HttpClient(); - Func> bearerTokenProvider = async () - => await tokenProvider.GetAccessTokenAsync(httpClient); - - var modelName = model.StartsWith("google/", StringComparison.OrdinalIgnoreCase) - ? model["google/".Length..] - : model; - - kernelBuilder.Services.AddVertexAIGeminiChatCompletion(modelName, bearerTokenProvider, config.Location, auth.ProjectId); - return new GeminiPromptExecutionSettings - { - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { RetainArgumentTypes = true }) - }; + // Vertex + var auth = settings.GoogleServiceAccountAuth + ?? throw new InvalidOperationException("Vertex AI service account is not configured."); + var tokenProvider = new GoogleServiceAccountTokenProvider(auth); + var httpClient = new HttpClient(); + Func> bearerTokenProvider = async () + => await tokenProvider.GetAccessTokenAsync(httpClient); - case BackendType.Ollama: - throw new NotSupportedException("Ollama models does not support MCP integration."); + var modelName = model.StartsWith("google/", StringComparison.OrdinalIgnoreCase) + ? model["google/".Length..] + : model; - case BackendType.Self: - throw new NotSupportedException("Self backend (local models) does not support MCP integration."); + kernelBuilder.Services.AddVertexAIGeminiChatCompletion( + modelName, bearerTokenProvider, config.Location, auth.ProjectId); - default: - throw new ArgumentOutOfRangeException(nameof(backendType)); - } + return new GeminiPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { RetainArgumentTypes = true }) + }; } + private McpResult BuildResult(string content, string model) => new() + { + CreatedAt = DateTime.Now, + Message = new Message + { + Content = content, + Role = nameof(AuthorRole.Assistant), + Type = MessageType.CloudLLM + }, + Model = model + }; + string? GetOpenAiKey() => settings.OpenAiKey ?? Environment.GetEnvironmentVariable(LLMApiRegistry.OpenAi.ApiKeyEnvName); string? GetGeminiKey() @@ -161,4 +432,4 @@ private PromptExecutionSettings InitializeChatCompletions(IKernelBuilder kernelB => settings.AnthropicKey ?? Environment.GetEnvironmentVariable(LLMApiRegistry.Anthropic.ApiKeyEnvName); string? GetXaiKey() => settings.XaiKey ?? Environment.GetEnvironmentVariable(LLMApiRegistry.Xai.ApiKeyEnvName); -} \ No newline at end of file +} diff --git a/src/MaIN.Services/Services/SkillComposer.cs b/src/MaIN.Services/Services/SkillComposer.cs new file mode 100644 index 00000000..fa208638 --- /dev/null +++ b/src/MaIN.Services/Services/SkillComposer.cs @@ -0,0 +1,177 @@ +using MaIN.Domain.Configuration; +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.Domain.Models.Abstract; +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) + 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 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 + { + 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!; + var backend = !string.IsNullOrEmpty(agent.Model) && ModelRegistry.Exists(agent.Model) + ? ModelRegistry.GetById(agent.Model).Backend + : (BackendType?)null; + + agent.Config.McpConfig = new Mcp + { + Name = mcpSkills[0].Name, + Command = def.Command, + Arguments = def.Arguments, + EnvironmentVariables = def.Environment, + Properties = def.Properties, + Model = agent.Model, + Backend = backend + }; + } + } + + 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..7f163823 --- /dev/null +++ b/src/MaIN.Services/Services/SkillRegistry.cs @@ -0,0 +1,54 @@ +using MaIN.Domain.Entities.Skills; +using MaIN.Domain.Exceptions.Skills; +using MaIN.Services.Services.Abstract; +using Microsoft.Extensions.Logging; + +namespace MaIN.Services.Services; + +public class SkillRegistry : ISkillRegistry +{ + private readonly Dictionary _skills = + new(StringComparer.OrdinalIgnoreCase); + + private readonly ILogger _logger; + + public SkillRegistry( + IEnumerable providers, + IEnumerable loaders, + ILogger logger) + { + _logger = logger; + + 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) + { + 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) + ? 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..b23ca62e --- /dev/null +++ b/src/MaIN.Services/Services/Skills/FileSystemSkillLoader.cs @@ -0,0 +1,214 @@ +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 const string FolderEntrypoint = "SKILL.md"; + + 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 []; + + 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() + .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; + + 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, + 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 = 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 + { + "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; } + public List? includes { 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/AnswerCommandHandler.cs b/src/MaIN.Services/Services/Steps/Commands/AnswerCommandHandler.cs index 8a88fc83..75ef26e4 100644 --- a/src/MaIN.Services/Services/Steps/Commands/AnswerCommandHandler.cs +++ b/src/MaIN.Services/Services/Steps/Commands/AnswerCommandHandler.cs @@ -73,9 +73,12 @@ public class AnswerCommandHandler( private async Task ShouldUseKnowledge(Knowledge? knowledge, Chat chat, BackendType backend) { + if (knowledge?.Index.Items is not { Count: > 0 }) + return false; + var originalContent = chat.Messages.Last().Content; - var indexAsKnowledge = knowledge?.Index.Items.ToDictionary(x => x.Name, x => x.Tags); + var indexAsKnowledge = knowledge.Index.Items.ToDictionary(x => x.Name, x => x.Tags); var index = JsonSerializer.Serialize(indexAsKnowledge, _jsonOptions); chat.InferenceGrammar = new Grammar(ServiceConstants.Grammars.DecisionGrammar, GrammarFormat.GBNF); @@ -96,12 +99,21 @@ private async Task ShouldUseKnowledge(Knowledge? knowledge, Chat chat, Bac { SaveConv = false }); - var decision = JsonSerializer.Deserialize(result!.Message.Content, _jsonOptions); - var decisionValue = decision.GetProperty("decision").GetRawText(); + chat.InferenceGrammar = null; - var shouldUseKnowledge = bool.Parse(decisionValue.Trim('"')); chat.Messages.Last().Content = originalContent; - return shouldUseKnowledge; + + try + { + var decision = JsonSerializer.Deserialize(result!.Message.Content, _jsonOptions); + var decisionValue = decision.GetProperty("decision").GetRawText(); + return bool.Parse(decisionValue.Trim('"')); + } + catch (JsonException) + { + // Cloud backends ignore GBNF grammar — response is plain text, not JSON. Skip knowledge. + return false; + } } private async Task ProcessKnowledgeQuery(Knowledge? knowledge, Chat chat, string agentId, ILLMService llmService) diff --git a/src/MaIN.Services/Services/Steps/Commands/FetchCommandHandler.cs b/src/MaIN.Services/Services/Steps/Commands/FetchCommandHandler.cs index b23f84a5..3387e5d9 100644 --- a/src/MaIN.Services/Services/Steps/Commands/FetchCommandHandler.cs +++ b/src/MaIN.Services/Services/Steps/Commands/FetchCommandHandler.cs @@ -9,6 +9,8 @@ using MaIN.Services.Services.Steps.Commands.Abstract; using MaIN.Services.Utils; using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; namespace MaIN.Services.Services.Steps.Commands; @@ -85,12 +87,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,13 +127,16 @@ 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) { var memoryChat = command.MemoryChat; + var client = httpClientFactory.CreateClient(); + var rawContent = await client.GetStringAsync(webData!.Url); + var cleanText = ExtractCleanWebText(rawContent, webData.Url); var result = await llmServiceFactory.CreateService(backend) - .AskMemory(memoryChat!, new ChatMemoryOptions { WebUrls = [webData!.Url] }, new ChatRequestOptions()); + .AskMemory(memoryChat!, new ChatMemoryOptions { TextData = new Dictionary { ["web-content"] = cleanText } }, new ChatRequestOptions()); result!.Message.Role = command.ResponseType == FetchResponseType.AS_System ? "System" : "Assistant"; return result!.Message; } @@ -160,6 +172,58 @@ private async Task ProcessJsonResponse(Message response, FetchCommand c return newMessage; } + private static string ExtractCleanWebText(string rawContent, string url) + { + var trimmed = rawContent.TrimStart(); + if (trimmed.StartsWith(" e.Name.LocalName is "item" or "entry") + .Take(30) + .Select(item => + { + var title = item.Descendants() + .FirstOrDefault(e => e.Name.LocalName == "title")?.Value?.Trim(); + var desc = item.Descendants() + .FirstOrDefault(e => e.Name.LocalName is "description" or "summary")?.Value?.Trim(); + var pubDate = item.Descendants() + .FirstOrDefault(e => e.Name.LocalName is "pubDate" or "published" or "updated")?.Value?.Trim(); + var parts = new[] { title, pubDate, desc } + .Where(p => !string.IsNullOrWhiteSpace(p)); + return string.Join(" | ", parts); + }) + .Where(s => !string.IsNullOrWhiteSpace(s)); + + return string.Join("\n", items); + } + catch + { + return StripHtmlTags(xml); + } + } + + private static string StripHtmlTags(string html) + { + var noScript = Regex.Replace(html, @"]*>.*?", " ", RegexOptions.Singleline | RegexOptions.IgnoreCase); + var noStyle = Regex.Replace(noScript, @"]*>.*?", " ", RegexOptions.Singleline | RegexOptions.IgnoreCase); + var noTags = Regex.Replace(noStyle, @"<[^>]+>", " "); + var clean = Regex.Replace(noTags, @"\s{2,}", " ").Trim(); + return clean.Length > 8000 ? clean[..8000] : clean; + } + private static Message CreateMessage(string content, Dictionary properties, BackendType backend) diff --git a/src/MaIN.Services/Services/Steps/FechDataStepHandler.cs b/src/MaIN.Services/Services/Steps/FechDataStepHandler.cs index bd5cca41..d5112f18 100644 --- a/src/MaIN.Services/Services/Steps/FechDataStepHandler.cs +++ b/src/MaIN.Services/Services/Steps/FechDataStepHandler.cs @@ -58,6 +58,14 @@ private static Chat CreateMemoryChat(StepContext context, string? filterVal) throw new AgentModelNotAvailableException(context.Agent.Id, context.Chat.ModelId); } + var lastUserContent = context.Chat.Messages + .LastOrDefault(m => m.Role?.Equals("user", StringComparison.OrdinalIgnoreCase) == true) + ?.Content; + var behaviour = context.Agent.Behaviours.GetValueOrDefault(context.Agent.CurrentBehaviour) ?? ""; + var query = !string.IsNullOrWhiteSpace(lastUserContent) + ? lastUserContent + : behaviour.Replace("@filter@", filterVal ?? string.Empty); + var backend = model!.Backend; return new Chat { @@ -65,7 +73,7 @@ private static Chat CreateMemoryChat(StepContext context, string? filterVal) [ new() { - Content = context.Agent.Behaviours[context.Agent.CurrentBehaviour].Replace("@filter@", filterVal ?? string.Empty), + Content = query, Type = backend != BackendType.Self ? MessageType.CloudLLM : MessageType.LocalLLM, Role = "User" }