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