Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,8 +215,10 @@ 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<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 @@ -454,7 +457,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