Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -214,6 +215,7 @@ public static WebApplicationBuilder AddOrbitAiServices(this WebApplicationBuilde
builder.Services.AddScoped<IAiTool, GetDailySummaryTool>();
builder.Services.AddScoped<IAiTool, GetRetrospectiveTool>();
builder.Services.AddScoped<IAiTool, GetHabitMetricsTool>();
builder.Services.AddScoped<IAiTool, DescribeFeatureTool>();
builder.Services.AddScoped<IAiTool, ListTagsTool>();
builder.Services.AddScoped<IAiTool, CreateTagTool>();
builder.Services.AddScoped<IAiTool, UpdateTagTool>();
Expand All @@ -222,6 +224,7 @@ public static WebApplicationBuilder AddOrbitAiServices(this WebApplicationBuilde
builder.Services.AddScoped<IAiTool, GetReferralCodeTool>();
builder.Services.AddScoped<AiToolRegistry>();
builder.Services.AddSingleton<ISystemPromptBuilder, SystemPromptBuilder>();
builder.Services.AddSingleton<IFeatureExplanationService, FeatureExplanationService>();

// Handler Parameter Objects
builder.Services.AddScoped<Orbit.Application.Habits.Commands.LogHabitRepositories>(sp =>
Expand Down Expand Up @@ -460,7 +463,8 @@ public static WebApplicationBuilder AddOrbitInfrastructure(this WebApplicationBu
.WithTools<NotificationTools>()
.WithTools<SubscriptionTools>()
.WithTools<UserFactTools>()
.WithTools<CalendarTools>();
.WithTools<CalendarTools>()
.WithTools<FeatureTools>();

// Controllers
builder.Services.AddControllers()
Expand Down
27 changes: 27 additions & 0 deletions src/Orbit.Api/Mcp/Tools/FeatureTools.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.ComponentModel;
using System.Security.Claims;
using ModelContextProtocol.Server;
using Orbit.Application.Chat.FeatureExplanations;

namespace Orbit.Api.Mcp.Tools;

/// <summary>
/// MCP feature-explanation tools. This is a pure read of user-agnostic embedded content, so it
/// calls <see cref="IFeatureExplanationService"/> directly rather than routing through
/// <see cref="McpExecutorBridge"/> (which exists only for mutation policy + audit coverage).
/// </summary>
[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}";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Orbit.Application.Chat.FeatureExplanations;

/// <summary>
/// A parsed feature explanation: the markdown body plus the frontmatter metadata the
/// assistant surfaces when explaining an Orbit mechanic.
/// </summary>
public record FeatureExplanation(
string Key,
string DisplayName,
IReadOnlyList<string> RelatedCapabilities,
IReadOnlyList<string> RelatedSurfaces,
int Version,
string Body);
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using System.Reflection;
using System.Text;
using Orbit.Application.Common;

namespace Orbit.Application.Chat.FeatureExplanations;

/// <summary>
/// Loads and parses the embedded feature-explanation markdown bundle so both the chat
/// <c>describe_feature</c> tool and the MCP <c>describe_feature</c> tool can resolve a feature's
/// authoritative explanation and metadata from a single shared source.
/// </summary>
public interface IFeatureExplanationService
{
IReadOnlyList<string> 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<string, FeatureExplanation> _byKey;
private readonly IReadOnlyList<string> _keys;

public FeatureExplanationService()
{
var assembly = typeof(AppConstants).Assembly;
var byKey = new Dictionary<string, FeatureExplanation>(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<string> 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<string> relatedCapabilities = [];
IReadOnlyList<string> 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<string> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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<ToolResult> 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
}));
}
}
1 change: 1 addition & 0 deletions src/Orbit.Application/Chat/Tools/JsonSchemaTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
}
1 change: 1 addition & 0 deletions src/Orbit.Domain/Models/AgentContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
13 changes: 13 additions & 0 deletions src/Orbit.Infrastructure/Services/AgentCatalogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,19 @@ private static IReadOnlyList<AgentCapability> 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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
75 changes: 75 additions & 0 deletions tests/Orbit.Application.Tests/Chat/FeatureExplanationDriftTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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));
}
}
Loading
Loading