diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index d38e7cad..e683f6d9 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ using Orbit.Api.OpenApi; using Orbit.Api.RateLimiting; using Orbit.Application.Behaviors; +using Orbit.Application.Chat.FeatureExplanations; using Orbit.Application.Chat.Tools; using Orbit.Application.Chat.Tools.Implementations; using Orbit.Application.Common; @@ -214,6 +215,7 @@ public static WebApplicationBuilder AddOrbitAiServices(this WebApplicationBuilde builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -222,6 +224,7 @@ public static WebApplicationBuilder AddOrbitAiServices(this WebApplicationBuilde builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // Handler Parameter Objects builder.Services.AddScoped(sp => @@ -460,7 +463,8 @@ public static WebApplicationBuilder AddOrbitInfrastructure(this WebApplicationBu .WithTools() .WithTools() .WithTools() - .WithTools(); + .WithTools() + .WithTools(); // Controllers builder.Services.AddControllers() diff --git a/src/Orbit.Api/Mcp/Tools/FeatureTools.cs b/src/Orbit.Api/Mcp/Tools/FeatureTools.cs new file mode 100644 index 00000000..2636b3aa --- /dev/null +++ b/src/Orbit.Api/Mcp/Tools/FeatureTools.cs @@ -0,0 +1,27 @@ +using System.ComponentModel; +using System.Security.Claims; +using ModelContextProtocol.Server; +using Orbit.Application.Chat.FeatureExplanations; + +namespace Orbit.Api.Mcp.Tools; + +/// +/// MCP feature-explanation tools. This is a pure read of user-agnostic embedded content, so it +/// calls directly rather than routing through +/// (which exists only for mutation policy + audit coverage). +/// +[McpServerToolType] +public class FeatureTools(IFeatureExplanationService features) +{ + [McpServerTool(Name = "describe_feature"), Description("Return the authoritative explanation of how an Orbit feature works (streaks, freezes, frequencies, gamification/XP/levels, notifications, free-vs-pro paygate, schedule/overdue math, AI memory). Call before explaining any of these mechanics.")] + public string DescribeFeature( + ClaimsPrincipal user, + [Description("Which Orbit feature to explain: ai-memory, freezes, frequencies, gamification, notifications, paygate, schedule-math, or streaks.")] string featureKey) + { + var explanation = features.Get(featureKey); + if (explanation is null) + return $"Error: unknown feature '{featureKey}'."; + + return $"# {explanation.DisplayName}\n\n{explanation.Body}"; + } +} diff --git a/src/Orbit.Application/Chat/FeatureExplanations/FeatureExplanation.cs b/src/Orbit.Application/Chat/FeatureExplanations/FeatureExplanation.cs new file mode 100644 index 00000000..f32bb89f --- /dev/null +++ b/src/Orbit.Application/Chat/FeatureExplanations/FeatureExplanation.cs @@ -0,0 +1,13 @@ +namespace Orbit.Application.Chat.FeatureExplanations; + +/// +/// A parsed feature explanation: the markdown body plus the frontmatter metadata the +/// assistant surfaces when explaining an Orbit mechanic. +/// +public record FeatureExplanation( + string Key, + string DisplayName, + IReadOnlyList RelatedCapabilities, + IReadOnlyList RelatedSurfaces, + int Version, + string Body); diff --git a/src/Orbit.Application/Chat/FeatureExplanations/FeatureExplanationService.cs b/src/Orbit.Application/Chat/FeatureExplanations/FeatureExplanationService.cs new file mode 100644 index 00000000..5378bef3 --- /dev/null +++ b/src/Orbit.Application/Chat/FeatureExplanations/FeatureExplanationService.cs @@ -0,0 +1,128 @@ +using System.Reflection; +using System.Text; +using Orbit.Application.Common; + +namespace Orbit.Application.Chat.FeatureExplanations; + +/// +/// Loads and parses the embedded feature-explanation markdown bundle so both the chat +/// describe_feature tool and the MCP describe_feature tool can resolve a feature's +/// authoritative explanation and metadata from a single shared source. +/// +public interface IFeatureExplanationService +{ + IReadOnlyList Keys { get; } + FeatureExplanation? Get(string key); +} + +public class FeatureExplanationService : IFeatureExplanationService +{ + private const string ResourcePrefix = "Orbit.Application.Chat.Content.FeatureExplanations."; + private const string ResourceSuffix = ".md"; + + private readonly IReadOnlyDictionary _byKey; + private readonly IReadOnlyList _keys; + + public FeatureExplanationService() + { + var assembly = typeof(AppConstants).Assembly; + var byKey = new Dictionary(StringComparer.Ordinal); + + foreach (var resourceName in assembly.GetManifestResourceNames()) + { + if (!resourceName.StartsWith(ResourcePrefix, StringComparison.Ordinal) || + !resourceName.EndsWith(ResourceSuffix, StringComparison.Ordinal)) + continue; + + var content = ReadResource(assembly, resourceName); + var explanation = Parse(content); + byKey[explanation.Key] = explanation; + } + + _byKey = byKey; + _keys = byKey.Keys.OrderBy(key => key, StringComparer.Ordinal).ToList(); + } + + public IReadOnlyList Keys => _keys; + + public FeatureExplanation? Get(string key) => _byKey.GetValueOrDefault(key); + + private static string ReadResource(Assembly assembly, string resourceName) + { + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Embedded resource '{resourceName}' could not be opened."); + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd(); + } + + private static FeatureExplanation Parse(string content) + { + var normalized = content.Replace("\r\n", "\n"); + if (!normalized.StartsWith("---\n", StringComparison.Ordinal)) + throw new InvalidOperationException("Feature explanation is missing a frontmatter block."); + + var closingFence = normalized.IndexOf("\n---", 3, StringComparison.Ordinal); + if (closingFence < 0) + throw new InvalidOperationException("Feature explanation frontmatter is not terminated."); + + var frontmatter = normalized.Substring(4, closingFence - 4); + var body = normalized[(closingFence + 4)..].TrimStart('\n'); + + string? key = null; + string? displayName = null; + IReadOnlyList relatedCapabilities = []; + IReadOnlyList relatedSurfaces = []; + var version = 0; + + foreach (var rawLine in frontmatter.Split('\n')) + { + var separator = rawLine.IndexOf(':'); + if (separator < 0 || rawLine.StartsWith(" ", StringComparison.Ordinal)) + continue; + + var name = rawLine[..separator].Trim(); + var value = rawLine[(separator + 1)..].Trim(); + + switch (name) + { + case "key": + key = value; + break; + case "display_name": + displayName = value; + break; + case "related_capabilities": + relatedCapabilities = ParseInlineList(value); + break; + case "related_surfaces": + relatedSurfaces = ParseInlineList(value); + break; + case "version": + version = int.TryParse(value, out var parsed) ? parsed : 0; + break; + } + } + + if (string.IsNullOrWhiteSpace(key)) + throw new InvalidOperationException("Feature explanation frontmatter is missing 'key'."); + if (string.IsNullOrWhiteSpace(displayName)) + throw new InvalidOperationException($"Feature explanation '{key}' frontmatter is missing 'display_name'."); + + return new FeatureExplanation(key, displayName, relatedCapabilities, relatedSurfaces, version, body); + } + + private static IReadOnlyList ParseInlineList(string value) + { + var trimmed = value.Trim(); + if (trimmed.Length < 2 || trimmed[0] != '[' || trimmed[^1] != ']') + return []; + + var inner = trimmed[1..^1]; + if (string.IsNullOrWhiteSpace(inner)) + return []; + + return inner + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/DescribeFeatureTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/DescribeFeatureTool.cs new file mode 100644 index 00000000..863d087a --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/DescribeFeatureTool.cs @@ -0,0 +1,48 @@ +using System.Text.Json; +using Orbit.Application.Chat.FeatureExplanations; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class DescribeFeatureTool(IFeatureExplanationService features) : IAiTool +{ + public string Name => "describe_feature"; + public bool IsReadOnly => true; + + public string Description => + "Return the authoritative explanation of how an Orbit feature works (streaks, freezes, frequencies, gamification/XP/levels, notifications, free-vs-pro paygate, schedule/overdue math, AI memory). Call this before explaining any of these mechanics so the answer matches the app's real behavior instead of guessing."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + feature_key = new + { + type = JsonSchemaTypes.String, + description = "Which Orbit feature to explain.", + @enum = JsonSchemaTypes.FeatureKeyEnum + } + }, + required = new[] { "feature_key" } + }; + + public Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var featureKey = JsonArgumentParser.GetOptionalString(args, "feature_key"); + if (string.IsNullOrWhiteSpace(featureKey)) + return Task.FromResult(new ToolResult(false, Error: "feature_key is required.")); + + var explanation = features.Get(featureKey); + if (explanation is null) + return Task.FromResult(new ToolResult(false, Error: $"Unknown feature '{featureKey}'.")); + + return Task.FromResult(new ToolResult(true, Payload: new + { + key = explanation.Key, + display_name = explanation.DisplayName, + related_capabilities = explanation.RelatedCapabilities, + related_surfaces = explanation.RelatedSurfaces, + markdown = explanation.Body + })); + } +} diff --git a/src/Orbit.Application/Chat/Tools/JsonSchemaTypes.cs b/src/Orbit.Application/Chat/Tools/JsonSchemaTypes.cs index f4a90c34..1628c060 100644 --- a/src/Orbit.Application/Chat/Tools/JsonSchemaTypes.cs +++ b/src/Orbit.Application/Chat/Tools/JsonSchemaTypes.cs @@ -14,4 +14,5 @@ internal static class JsonSchemaTypes internal static readonly string[] FrequencyUnitEnum = ["Day", "Week", "Month", "Year"]; internal static readonly string[] ScheduledReminderWhenEnum = ["day_before", "same_day"]; + internal static readonly string[] FeatureKeyEnum = ["ai-memory", "freezes", "frequencies", "gamification", "notifications", "paygate", "schedule-math", "streaks"]; } diff --git a/src/Orbit.Domain/Models/AgentContracts.cs b/src/Orbit.Domain/Models/AgentContracts.cs index eae19c53..41124d0d 100644 --- a/src/Orbit.Domain/Models/AgentContracts.cs +++ b/src/Orbit.Domain/Models/AgentContracts.cs @@ -235,6 +235,7 @@ public static class AgentCapabilityIds public const string CatalogCapabilitiesRead = "catalog.capabilities.read"; public const string CatalogDataRead = "catalog.data.read"; public const string CatalogSurfacesRead = "catalog.surfaces.read"; + public const string DescribeFeature = "feature.describe"; public const string ConfigRead = "config.read"; public const string HabitsRead = "habits.read"; public const string HabitsWrite = "habits.write"; diff --git a/src/Orbit.Infrastructure/Services/AgentCatalogService.cs b/src/Orbit.Infrastructure/Services/AgentCatalogService.cs index fff341c6..d07ce111 100644 --- a/src/Orbit.Infrastructure/Services/AgentCatalogService.cs +++ b/src/Orbit.Infrastructure/Services/AgentCatalogService.cs @@ -424,6 +424,19 @@ private static IReadOnlyList BuildCapabilities() mcpTools: ["list_app_surfaces_v2"], controllerActions: ["AiController.GetAppSurfaces"]), + CreateCapability( + AgentCapabilityIds.DescribeFeature, + "Describe Feature", + "Returns an authoritative explanation of an Orbit feature's mechanics.", + "catalog", + AgentScopes.CatalogRead, + AgentRiskClass.Low, + isMutation: false, + isPhaseOneReadOnly: false, + AgentConfirmationRequirement.None, + chatTools: ["describe_feature"], + mcpTools: ["describe_feature"]), + CreateCapability( AgentCapabilityIds.ConfigRead, "Read App Config", diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs index c166bdc4..ca125aea 100644 --- a/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs @@ -36,6 +36,7 @@ 9. NEVER expose internal habit IDs (GUIDs) to the user in your messages. Refer t - If NO habit in the index clearly matches, do NOT substitute a related habit. Tell the user briefly that you don't see a matching habit and ask if they want to create one. - When the user describes multiple activities, log exactly the habits they described - no more, no fewer. - This rule restricts SUBSTITUTION ONLY. Indirect references like "log that one", "mark the first one done", "skip it", or "complete it" after you have already named a specific habit are still valid - resolve them to the habit you were just discussing, then act. + 19. EXPLAIN MECHANICS WITH describe_feature. When the user asks how an Orbit mechanic actually works (streaks, freezes, frequencies, XP/levels/achievements, free-vs-pro limits, reminders/notifications, schedule/overdue rules, or AI memory), call describe_feature with the matching feature_key and base your answer on what it returns instead of guessing. """); return sb.ToString(); } diff --git a/tests/Orbit.Application.Tests/Chat/FeatureExplanationDriftTests.cs b/tests/Orbit.Application.Tests/Chat/FeatureExplanationDriftTests.cs new file mode 100644 index 00000000..4112e079 --- /dev/null +++ b/tests/Orbit.Application.Tests/Chat/FeatureExplanationDriftTests.cs @@ -0,0 +1,75 @@ +using System.Globalization; +using FluentAssertions; +using Orbit.Application.Chat.FeatureExplanations; +using Orbit.Application.Common; +using Orbit.Application.Gamification; + +namespace Orbit.Application.Tests.Chat; + +/// +/// Guards the pre-authored feature-explanation prose against drifting from the constants and +/// level table it documents. If a constant changes without the matching markdown, these fail. +/// +public class FeatureExplanationDriftTests +{ + private readonly FeatureExplanationService _service = new(); + + private string Body(string key) + { + var explanation = _service.Get(key); + explanation.Should().NotBeNull(); + return explanation!.Body; + } + + private static string N(int value) => value.ToString(CultureInfo.InvariantCulture); + + [Fact] + public void Freezes_MatchesStreakFreezeConstants() + { + var body = Body("freezes"); + + body.Should().Contain(N(AppConstants.StreakDaysPerFreeze)); + body.Should().Contain(N(AppConstants.MaxStreakFreezesAccumulated)); + body.Should().Contain(N(AppConstants.MaxStreakFreezesPerMonth)); + } + + [Fact] + public void Streaks_MatchesLookbackConstant() + { + Body("streaks").Should().Contain(N(AppConstants.MaxStreakLookbackDays)); + } + + [Fact] + public void Gamification_MatchesEveryLevelThresholdAndTitle() + { + var body = Body("gamification"); + + foreach (var level in LevelDefinitions.All) + { + body.Should().Contain(N(level.XpRequired), $"level {level.Level} XP threshold should appear"); + body.Should().Contain(level.Title, $"level {level.Level} title should appear"); + } + } + + [Fact] + public void Paygate_MatchesPlanLimitConstants() + { + var body = Body("paygate"); + + body.Should().Contain(N(AppConstants.DefaultFreeMaxHabits)); + body.Should().Contain(N(AppConstants.DefaultFreeAiMessages)); + body.Should().Contain(N(AppConstants.DefaultProAiMessages)); + } + + [Fact] + public void AiMemory_MatchesMaxUserFactsConstant() + { + Body("ai-memory").Should().Contain(N(AppConstants.MaxUserFacts)); + } + + [Fact] + public void ScheduleMath_MatchesOverdueWindowConstant() + { + Body("schedule-math").Should().Contain(N(AppConstants.DefaultOverdueWindowDays)); + } +} diff --git a/tests/Orbit.Application.Tests/Chat/Tools/DescribeFeatureToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/DescribeFeatureToolTests.cs new file mode 100644 index 00000000..2c71ad07 --- /dev/null +++ b/tests/Orbit.Application.Tests/Chat/Tools/DescribeFeatureToolTests.cs @@ -0,0 +1,109 @@ +using System.Reflection; +using System.Text.Json; +using FluentAssertions; +using Orbit.Application.Chat.FeatureExplanations; +using Orbit.Application.Chat.Tools.Implementations; +using Orbit.Application.Common; + +namespace Orbit.Application.Tests.Chat.Tools; + +public class DescribeFeatureToolTests +{ + private const string ResourcePrefix = "Orbit.Application.Chat.Content.FeatureExplanations."; + + private static readonly string[] FeatureKeyEnum = + [ + "ai-memory", + "freezes", + "frequencies", + "gamification", + "notifications", + "paygate", + "schedule-math", + "streaks", + ]; + + private readonly FeatureExplanationService _service = new(); + + [Fact] + public void FeatureKeyEnum_MatchesEmbeddedResourceKeys() + { + var embeddedKeys = typeof(AppConstants).Assembly + .GetManifestResourceNames() + .Where(name => name.StartsWith(ResourcePrefix, StringComparison.Ordinal)) + .Select(name => name[ResourcePrefix.Length..^".md".Length]) + .ToList(); + + embeddedKeys.Should().BeEquivalentTo(FeatureKeyEnum); + _service.Keys.Should().BeEquivalentTo(FeatureKeyEnum); + } + + [Fact] + public void EnumSchema_ExposesEveryFeatureKey() + { + var schema = JsonSerializer.Serialize(new DescribeFeatureTool(_service).GetParameterSchema()); + + schema.Should().Contain("feature_key"); + foreach (var key in FeatureKeyEnum) + schema.Should().Contain(key); + } + + [Theory] + [InlineData("ai-memory")] + [InlineData("freezes")] + [InlineData("frequencies")] + [InlineData("gamification")] + [InlineData("notifications")] + [InlineData("paygate")] + [InlineData("schedule-math")] + [InlineData("streaks")] + public void EveryEnumKey_ResolvesToAFullyPopulatedExplanation(string key) + { + var explanation = _service.Get(key); + + explanation.Should().NotBeNull(); + explanation!.Key.Should().Be(key); + explanation.DisplayName.Should().NotBeNullOrWhiteSpace(); + explanation.Body.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void Metadata_IsReadOnlyDescribeFeature() + { + var tool = new DescribeFeatureTool(_service); + + tool.Name.Should().Be("describe_feature"); + tool.IsReadOnly.Should().BeTrue(); + tool.Description.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task ExecuteAsync_ValidKey_ReturnsMarkdownAndMetadataPayload() + { + var tool = new DescribeFeatureTool(_service); + + var result = await tool.ExecuteAsync(Args("freezes"), Guid.NewGuid(), CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Payload.Should().NotBeNull(); + + var payload = JsonSerializer.Serialize(result.Payload); + payload.Should().Contain("markdown"); + payload.Should().Contain("related_surfaces"); + payload.Should().Contain("Streak Freezes"); + } + + [Fact] + public async Task ExecuteAsync_UnknownKey_ReturnsError() + { + var tool = new DescribeFeatureTool(_service); + + var result = await tool.ExecuteAsync(Args("does-not-exist"), Guid.NewGuid(), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Error.Should().NotBeNullOrWhiteSpace(); + } + + private static JsonElement Args(string featureKey) => + JsonDocument.Parse($$"""{"feature_key":"{{featureKey}}"}""").RootElement; +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/PromptSectionTests.cs b/tests/Orbit.Infrastructure.Tests/Services/PromptSectionTests.cs index 1b71cfe4..61b3a0be 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/PromptSectionTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/PromptSectionTests.cs @@ -95,6 +95,15 @@ public void Build_ContainsNoSubstitutionRule() result.Should().Contain("no more, no fewer"); result.Should().Contain("Indirect references"); } + + [Fact] + public void Build_ContainsDescribeFeaturePointer() + { + var ctx = new PromptContext(new List(), new List(), false, null, null, null, null); + var result = new GlobalRulesSection().Build(ctx); + + result.Should().Contain("describe_feature"); + } } public class StructuringStrategySectionTests