diff --git a/CLAUDE.md b/CLAUDE.md index afa952c..cc131b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ dotnet test SocialAgent.slnx --verbosity normal ## Architecture -SocialAgent is an **A2A-enabled social media monitoring agent** built on .NET 10. It uses the **Microsoft Agents Framework** (`Microsoft.Agents.Hosting.AspNetCore.A2A.Preview`) for A2A protocol support and runs as an ASP.NET Core application in Kubernetes. +SocialAgent is an **A2A-enabled social media monitoring agent** built on .NET 10. It hosts the **A2A 1.0** protocol via the **Microsoft Agent Framework** (`Microsoft.Agents.AI.Hosting.A2A.AspNetCore`) and the upstream **A2A .NET SDK** (`A2A`, `A2A.AspNetCore`), running as an ASP.NET Core application in Kubernetes. **Core design principle:** Plugin-based provider architecture with normalized data models. Each social media platform is an independent provider assembly. @@ -34,18 +34,20 @@ SocialAgent is an **A2A-enabled social media monitoring agent** built on .NET 10 - **`src/SocialAgent.Analytics/`** — Analytics engine computing engagement summaries, top posts, follower insights, platform comparisons - **`src/SocialAgent.Providers.Mastodon/`** — Mastodon REST API client implementing `ISocialMediaProvider` - **`src/SocialAgent.Providers.Bluesky/`** — Bluesky AT Protocol client implementing `ISocialMediaProvider` -- **`src/SocialAgent.Host/`** — ASP.NET Core host, A2A agent handler (`SocialAgentHandler`), background polling service +- **`src/SocialAgent.Host/`** — ASP.NET Core host, A2A request handler (`SocialAgentA2AHandler` implementing `A2A.IAgentHandler`), skill dispatcher (`SkillDispatcher`), skill metadata (`SkillCatalog`), stub `AIAgent` (`SocialAgentStubAgent`, framework-required name carrier), background polling service - **`tests/`** — MSTest unit tests with NSubstitute for mocking - **`deploy/k8s/`** — Kubernetes manifests (Deployment, Service, ConfigMap, Secret) ### A2A Protocol -The agent exposes the Google A2A protocol via the Microsoft Agents SDK: -- **`GET /.well-known/agent.json`** — Agent card with skills (A2A spec) -- **`GET /a2a/.well-known/agent-card.json`** — Agent card (alternate) -- **`POST /a2a`** — JSON-RPC message handling +The agent speaks **A2A protocol version 1.0**. Endpoints: +- **`GET /.well-known/agent-card.json`** — Agent card (A2A 1.0 discovery path) +- **`POST /a2a`** — JSON-RPC binding (methods: `SendMessage`, `GetTask`, etc., per `A2A.A2AMethods`) +- **`POST /a2a/message:send`**, **`/a2a/tasks/{id}`**, etc. — HTTP+JSON binding routes -Seven skills are exposed: `engagement-summary`, `top-posts`, `recent-mentions`, `follower-insights`, `platform-comparison`, `check-notifications`, `provider-status`. +Both transports are mapped to the same `/a2a` prefix; clients pick whichever they prefer. + +Seven skills are exposed: `engagement-summary`, `top-posts`, `recent-mentions`, `follower-insights`, `platform-comparison`, `check-notifications`, `provider-status`. Skill dispatch is deterministic (keyword match, optionally LLM-assisted via `SkillRouter`) — there is no LLM in the request path by default. The `AIAgent` registered with the framework is a stub used only as a name carrier; all request handling flows through `SocialAgentA2AHandler`. ### Provider Plugin Pattern diff --git a/Directory.Packages.props b/Directory.Packages.props index 7d2c383..925a1f6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,8 +3,11 @@ true - - + + + + + @@ -20,11 +23,11 @@ - - - - - + + + + + diff --git a/README.md b/README.md index 2e1e120..72706c3 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,11 @@ The agent starts on `http://localhost:5000` by default (or as configured by `ASP ### A2A Endpoints -- `GET /.well-known/agent.json` — Agent card (A2A spec) -- `GET /a2a/.well-known/agent-card.json` — Agent card (alternate) -- `POST /a2a` — JSON-RPC task interface +The agent speaks **A2A protocol version 1.0**. + +- `GET /.well-known/agent-card.json` — Agent card (A2A 1.0 discovery) +- `POST /a2a` — JSON-RPC binding (`SendMessage`, `GetTask`, etc.) +- `POST /a2a/message:send`, `GET /a2a/tasks/{id}`, etc. — HTTP+JSON binding - `GET /health/ready` — Readiness probe - `GET /health/live` — Liveness probe @@ -98,10 +100,10 @@ The agent runs as a continuous Deployment (not CronJob) for A2A responsiveness. │ SocialAgent.Host (ASP.NET Core) │ │ │ │ ┌──────────────┐ ┌─────────────────────────────────┐ │ -│ │ A2A Endpoints │ │ Background Polling Services │ │ -│ │ (MS Agents │ │ ┌───────────┐ ┌──────────────┐ │ │ -│ │ Framework) │ │ │ Mastodon │ │ Bluesky │ │ │ -│ │ │ │ │ Provider │ │ Provider │ │ │ +│ │ A2A 1.0 │ │ Background Polling Services │ │ +│ │ (MS Agent │ │ ┌───────────┐ ┌──────────────┐ │ │ +│ │ Framework + │ │ │ Mastodon │ │ Bluesky │ │ │ +│ │ A2A SDK) │ │ │ Provider │ │ Provider │ │ │ │ └──────┬───────┘ │ └─────┬─────┘ └──────┬───────┘ │ │ │ │ └───────┼───────────────┼─────────┘ │ │ ┌──────▼───────────────────▼───────────────▼─────────┐ │ diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml index d3bd0fd..aeeb95a 100644 --- a/deploy/k8s/configmap.yaml +++ b/deploy/k8s/configmap.yaml @@ -8,3 +8,4 @@ data: mastodon-instance-url: "https://fosstodon.org" bluesky-enabled: "true" bluesky-handle: "rocky.lhotka.net" + public-base-url: "http://social-agent.rockbot.svc.cluster.local" diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml index 1e73f2b..6b5d9cb 100644 --- a/deploy/k8s/deployment.yaml +++ b/deploy/k8s/deployment.yaml @@ -17,7 +17,7 @@ spec: spec: containers: - name: social-agent - image: rockylhotka/socialagent:latest + image: rockylhotka/socialagent:1.3.4 ports: - containerPort: 8080 env: @@ -27,6 +27,11 @@ spec: value: "http://+:8080" - name: SocialAgent__DatabaseProvider value: "PostgreSQL" + - name: SocialAgent__PublicBaseUrl + valueFrom: + configMapKeyRef: + name: social-agent-config + key: public-base-url - name: ConnectionStrings__SocialAgent valueFrom: secretKeyRef: diff --git a/src/SocialAgent.Host/Program.cs b/src/SocialAgent.Host/Program.cs index 81b8974..c2c1e66 100644 --- a/src/SocialAgent.Host/Program.cs +++ b/src/SocialAgent.Host/Program.cs @@ -1,8 +1,7 @@ -using Microsoft.Agents.Builder; -using Microsoft.Agents.Builder.State; -using Microsoft.Agents.Hosting.A2A; -using Microsoft.Agents.Hosting.AspNetCore; -using Microsoft.Agents.Storage; +using System.Reflection; +using A2A; +using A2A.AspNetCore; +using Microsoft.AspNetCore.Authorization; using Serilog; using SocialAgent.Analytics; using SocialAgent.Data; @@ -22,15 +21,14 @@ builder.Host.UseSerilog((context, configuration) => configuration .ReadFrom.Configuration(context.Configuration)); -// Agent infrastructure -builder.Services.AddSingleton(); -builder.AddAgentApplicationOptions(); - -// Register the agent -builder.AddAgent(); - -// Register A2A adapter -builder.Services.AddA2AAdapter(); +// Register the stub AIAgent and the custom A2A handler under the same agent name. +// The framework's AddA2AServer wires its A2AServer keyed by this name; because we register a +// keyed IAgentHandler here, the framework's default A2AAgentHandler (which would call into the +// AIAgent) is bypassed entirely. +builder.Services.AddKeyedSingleton(SocialAgentStubAgent.AgentName); +builder.Services.AddSingleton(); +builder.Services.AddKeyedSingleton(SocialAgentStubAgent.AgentName); +builder.AddA2AServer(SocialAgentStubAgent.AgentName); // Data layer builder.Services.AddSocialAgentData(builder.Configuration); @@ -76,11 +74,50 @@ app.UseAuthentication(); app.UseAuthorization(); -// Map A2A endpoints (no auth required for development) -app.MapA2AEndpoints(requireAuth: !app.Environment.IsDevelopment()); +// Build the agent card. The A2A v1.0 spec example shows interface URLs as absolute URLs, so +// when SocialAgent:PublicBaseUrl is configured we emit absolute URLs there. In dev, where the +// public base is unknown, we fall back to the relative path "/a2a". +var agentVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "1.0.0"; +var publicBaseUrl = builder.Configuration["SocialAgent:PublicBaseUrl"]?.TrimEnd('/'); +var a2aInterfaceUrl = string.IsNullOrEmpty(publicBaseUrl) ? "/a2a" : $"{publicBaseUrl}/a2a"; +var agentCard = new AgentCard +{ + Name = "SocialAgent", + Description = "Social media monitoring and analytics agent. " + + "Monitors Mastodon, Bluesky, and other platforms for posts, mentions, and engagement. " + + "Provides analytics on engagement trends, top posts, and follower insights.", + Version = agentVersion, + Skills = [.. SkillCatalog.AgentCardSkills], + DefaultInputModes = ["text"], + DefaultOutputModes = ["text"], + SupportedInterfaces = + [ + new AgentInterface { Url = a2aInterfaceUrl, ProtocolBinding = "JSONRPC", ProtocolVersion = "1.0" }, + new AgentInterface { Url = a2aInterfaceUrl, ProtocolBinding = "HTTPJSON", ProtocolVersion = "1.0" } + ] +}; + +// Map A2A endpoints. Both transports share the /a2a path — JSON-RPC handles POST /a2a, HTTP+JSON +// handles the spec routes (e.g., /a2a/tasks/{id}, /a2a/message:send). +var requireAuth = !app.Environment.IsDevelopment(); +var jsonRpcEndpoints = app.MapA2AJsonRpc(SocialAgentStubAgent.AgentName, "/a2a"); +var httpJsonEndpoints = app.MapA2AHttpJson(SocialAgentStubAgent.AgentName, "/a2a"); +var agentCardEndpoints = app.MapWellKnownAgentCard(agentCard); + +if (requireAuth) +{ + var policy = new AuthorizationPolicyBuilder(ApiKeyAuthenticationHandler.SchemeName) + .RequireAuthenticatedUser() + .Build(); + jsonRpcEndpoints.RequireAuthorization(policy); + httpJsonEndpoints.RequireAuthorization(policy); + // Agent card stays anonymous so clients can discover capabilities without credentials. +} -// Health check endpoints +// Health check endpoints (anonymous) app.MapHealthChecks("/health/ready"); app.MapHealthChecks("/health/live"); app.Run(); + +public partial class Program { } diff --git a/src/SocialAgent.Host/SkillCatalog.cs b/src/SocialAgent.Host/SkillCatalog.cs new file mode 100644 index 0000000..574af26 --- /dev/null +++ b/src/SocialAgent.Host/SkillCatalog.cs @@ -0,0 +1,127 @@ +using A2A; +using SocialAgent.Host.Routing; + +namespace SocialAgent.Host; + +internal static class SkillCatalog +{ + // Skill parameters arrive as flat keys on message.metadata, alongside metadata.skill. Parameter + // keys are documented per-skill below. Unknown keys are ignored. Parameter values may be JSON + // strings, numbers, or ISO-8601 timestamps as appropriate; the dispatcher coerces strings to + // numbers/dates where needed. + public static IReadOnlyList AgentCardSkills { get; } = + [ + new() + { + Id = "engagement-summary", + Name = "Engagement Summary", + Description = "Get a summary of recent engagement. Returns total likes, reposts, replies, mentions, new followers, and averages per post. " + + "Optional metadata parameters: providerId (string, e.g. \"mastodon\" or \"bluesky\"; default: union across all providers), since (ISO-8601 timestamp; default: last 7 days).", + Tags = ["social", "analytics", "engagement"] + }, + new() + { + Id = "top-posts", + Name = "Top Posts", + Description = "Get your most-engaged posts ranked by total engagement (likes + reposts + replies). " + + "Optional metadata parameters: providerId (string), count (integer, default 10), since (ISO-8601 timestamp).", + Tags = ["social", "analytics", "posts"] + }, + new() + { + Id = "recent-mentions", + Name = "Recent Mentions", + Description = "Get recent mentions and replies. " + + "Optional metadata parameters: providerId (string; default: union), count (integer, default 20).", + Tags = ["social", "mentions", "notifications"] + }, + new() + { + Id = "follower-insights", + Name = "Follower Insights", + Description = "See who engages most with your content and their interaction patterns. " + + "Optional metadata parameters: providerId (string), count (integer, default 10), since (ISO-8601 timestamp).", + Tags = ["social", "analytics", "followers"] + }, + new() + { + Id = "platform-comparison", + Name = "Platform Comparison", + Description = "Compare engagement metrics across all connected social media platforms. " + + "Optional metadata parameters: since (ISO-8601 timestamp; default: last 7 days). This skill is always all-platforms by design.", + Tags = ["social", "analytics", "comparison"] + }, + new() + { + Id = "check-notifications", + Name = "Check Notifications", + Description = "Get unread notifications. " + + "Optional metadata parameters: providerId (string; default: union).", + Tags = ["social", "notifications"] + }, + new() + { + Id = "provider-status", + Name = "Provider Status", + Description = "Check connectivity and health of all configured social media providers. Takes no parameters.", + Tags = ["social", "status", "health"] + } + ]; + + public static IReadOnlyList RouterDefinitions { get; } = + [ + new("engagement-summary", "Engagement Summary", "Get a summary of recent engagement across all connected social media platforms"), + new("top-posts", "Top Posts", "Get most-engaged posts ranked by total engagement"), + new("recent-mentions", "Recent Mentions", "Get recent mentions and replies across all connected platforms"), + new("follower-insights", "Follower Insights", "See who engages most with your content"), + new("platform-comparison", "Platform Comparison", "Compare engagement metrics across all connected platforms"), + new("check-notifications", "Check Notifications", "Get unread notifications across all connected platforms"), + new("provider-status", "Provider Status", "Check connectivity and health of all configured providers") + ]; + + private static readonly Dictionary SkillKeywords = new() + { + ["engagement-summary"] = ["engagement summary", "engagement-summary"], + ["top-posts"] = ["top posts", "top-posts", "most engaged", "best posts"], + ["recent-mentions"] = ["recent mentions", "recent-mentions", "mentions"], + ["follower-insights"] = ["follower insights", "follower-insights", "followers", "engagers"], + ["platform-comparison"] = ["platform comparison", "platform-comparison", "compare platforms", "compare engagement"], + ["check-notifications"] = ["check notifications", "check-notifications", "notifications", "unread"], + ["provider-status"] = ["provider status", "provider-status", "connectivity", "health check"] + }; + + private static readonly HashSet KnownSkillIds = new(StringComparer.Ordinal) + { + "engagement-summary", + "top-posts", + "recent-mentions", + "follower-insights", + "platform-comparison", + "check-notifications", + "provider-status" + }; + + public static bool IsKnownSkill(string id) => KnownSkillIds.Contains(id); + + public static string MatchSkillByKeywords(string text) + { + var lower = text.ToLowerInvariant(); + + foreach (var skill in SkillKeywords) + { + if (lower == skill.Key) + return skill.Key; + } + + foreach (var skill in SkillKeywords) + { + foreach (var keyword in skill.Value) + { + if (lower.Contains(keyword)) + return skill.Key; + } + } + + return lower; + } +} diff --git a/src/SocialAgent.Host/SkillDispatcher.cs b/src/SocialAgent.Host/SkillDispatcher.cs new file mode 100644 index 0000000..30f30d2 --- /dev/null +++ b/src/SocialAgent.Host/SkillDispatcher.cs @@ -0,0 +1,163 @@ +using System.Globalization; +using System.Text.Json; +using SocialAgent.Core.Analytics; +using SocialAgent.Core.Providers; +using SocialAgent.Data.Repositories; +using SocialAgent.Host.Routing; + +namespace SocialAgent.Host; + +internal sealed class SkillDispatcher(IServiceScopeFactory scopeFactory, ILogger logger) +{ + private static readonly JsonSerializerOptions JsonPretty = new() { WriteIndented = true }; + + public async Task DispatchAsync( + string? explicitSkillId, + string userText, + IReadOnlyDictionary? parameters, + CancellationToken ct) + { + var text = userText?.Trim() ?? string.Empty; + + using var scope = scopeFactory.CreateScope(); + + string skillId; + var parameterSummary = SummarizeParameters(parameters); + if (!string.IsNullOrWhiteSpace(explicitSkillId) && SkillCatalog.IsKnownSkill(explicitSkillId)) + { + skillId = explicitSkillId; + logger.LogInformation("Dispatching skill {SkillId} (from request metadata) parameters={Parameters}", skillId, parameterSummary); + } + else + { + var router = scope.ServiceProvider.GetService(); + skillId = router != null + ? await router.RouteAsync(text, SkillCatalog.RouterDefinitions, ct) ?? SkillCatalog.MatchSkillByKeywords(text) + : SkillCatalog.MatchSkillByKeywords(text); + logger.LogInformation("Dispatching skill {SkillId} (routed from text \"{Input}\") parameters={Parameters}", skillId, text, parameterSummary); + } + + var p = new SkillParameters(parameters); + + return skillId switch + { + "engagement-summary" => await HandleEngagementSummaryAsync(scope.ServiceProvider, p, ct), + "top-posts" => await HandleTopPostsAsync(scope.ServiceProvider, p, ct), + "recent-mentions" => await HandleRecentMentionsAsync(scope.ServiceProvider, p, ct), + "follower-insights" => await HandleFollowerInsightsAsync(scope.ServiceProvider, p, ct), + "platform-comparison" => await HandlePlatformComparisonAsync(scope.ServiceProvider, p, ct), + "check-notifications" => await HandleCheckNotificationsAsync(scope.ServiceProvider, p, ct), + "provider-status" => await HandleProviderStatusAsync(scope.ServiceProvider, ct), + _ => $"Unknown skill '{skillId}'. Available skills: engagement-summary, top-posts, recent-mentions, follower-insights, platform-comparison, check-notifications, provider-status" + }; + } + + private static async Task HandleEngagementSummaryAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct) + { + var analytics = sp.GetRequiredService(); + var summary = await analytics.GetEngagementSummaryAsync(p.ProviderId, p.Since, ct); + return FormatJson(summary); + } + + private static async Task HandleTopPostsAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct) + { + var analytics = sp.GetRequiredService(); + var posts = await analytics.GetTopPostsAsync(p.Count ?? 10, p.ProviderId, p.Since, ct); + return FormatJson(posts); + } + + private static async Task HandleRecentMentionsAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct) + { + var analytics = sp.GetRequiredService(); + var mentions = await analytics.GetRecentMentionsAsync(p.Count ?? 20, p.ProviderId, ct); + return FormatJson(mentions); + } + + private static async Task HandleFollowerInsightsAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct) + { + var analytics = sp.GetRequiredService(); + var engagers = await analytics.GetTopEngagersAsync(p.Count ?? 10, p.ProviderId, p.Since, ct); + return FormatJson(engagers); + } + + private static async Task HandlePlatformComparisonAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct) + { + var analytics = sp.GetRequiredService(); + var comparison = await analytics.GetPlatformComparisonAsync(p.Since, ct); + return FormatJson(comparison); + } + + private static async Task HandleCheckNotificationsAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct) + { + var repository = sp.GetRequiredService(); + var unread = await repository.GetUnreadNotificationsAsync(p.ProviderId, ct); + return FormatJson(unread); + } + + private static async Task HandleProviderStatusAsync(IServiceProvider sp, CancellationToken ct) + { + var providers = sp.GetServices(); + var statuses = new List(); + foreach (var provider in providers) + { + var connected = await provider.ValidateConnectionAsync(ct); + statuses.Add(new + { + provider.ProviderId, + provider.ProviderName, + Connected = connected + }); + } + return FormatJson(statuses); + } + + private static string FormatJson(object data) => JsonSerializer.Serialize(data, JsonPretty); + + // Compact summary of incoming metadata for logging — recognized keys only, with skill/skillId + // dropped because they are already logged separately. Returns "{}" when nothing recognized. + private static string SummarizeParameters(IReadOnlyDictionary? parameters) + { + if (parameters is null || parameters.Count == 0) return "{}"; + var recognized = new[] { "providerId", "count", "since" }; + var parts = new List(); + foreach (var key in recognized) + { + if (parameters.TryGetValue(key, out var element) && element.ValueKind != JsonValueKind.Null) + { + parts.Add($"{key}={element.ToString()}"); + } + } + return parts.Count == 0 ? "{}" : "{" + string.Join(", ", parts) + "}"; + } + + private readonly struct SkillParameters + { + private readonly IReadOnlyDictionary? _values; + public SkillParameters(IReadOnlyDictionary? values) => _values = values; + + public string? ProviderId => GetString("providerId"); + public int? Count => GetInt("count"); + public DateTimeOffset? Since => GetDateTimeOffset("since"); + + private string? GetString(string key) + { + if (_values is null || !_values.TryGetValue(key, out var element)) return null; + return element.ValueKind == JsonValueKind.String ? element.GetString() : null; + } + + private int? GetInt(string key) + { + if (_values is null || !_values.TryGetValue(key, out var element)) return null; + if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out var n)) return n; + if (element.ValueKind == JsonValueKind.String && int.TryParse(element.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var s)) return s; + return null; + } + + private DateTimeOffset? GetDateTimeOffset(string key) + { + if (_values is null || !_values.TryGetValue(key, out var element)) return null; + if (element.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(element.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dto)) return dto; + return null; + } + } +} diff --git a/src/SocialAgent.Host/SocialAgent.Host.csproj b/src/SocialAgent.Host/SocialAgent.Host.csproj index 67c1675..fd48f64 100644 --- a/src/SocialAgent.Host/SocialAgent.Host.csproj +++ b/src/SocialAgent.Host/SocialAgent.Host.csproj @@ -2,9 +2,12 @@ SocialAgent.Host socialagent-host-001 + 1.3.4 - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/SocialAgent.Host/SocialAgentA2AHandler.cs b/src/SocialAgent.Host/SocialAgentA2AHandler.cs new file mode 100644 index 0000000..0228b49 --- /dev/null +++ b/src/SocialAgent.Host/SocialAgentA2AHandler.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using A2A; + +namespace SocialAgent.Host; + +internal sealed class SocialAgentA2AHandler(SkillDispatcher dispatcher, ILogger logger) : IAgentHandler +{ + public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(eventQueue); + + var userText = context.UserText ?? string.Empty; + var explicitSkillId = ReadSkillIdFromMetadata(context); + var parameters = context.Message?.Metadata; + + string responseText; + try + { + responseText = await dispatcher.DispatchAsync(explicitSkillId, userText, parameters, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Skill dispatch failed for input \"{Input}\"", userText); + responseText = $"Error executing skill: {ex.Message}"; + } + + var reply = new Message + { + MessageId = Guid.NewGuid().ToString("N"), + ContextId = context.ContextId, + Role = Role.Agent, + Parts = [new Part { Text = responseText }] + }; + + await eventQueue.EnqueueMessageAsync(reply, cancellationToken); + } + + // Skill ID can arrive on the message (per RockBot's BuildV1SendRequest) or on the request envelope. + // The "skill" key on either Metadata dictionary wins; an "skillId" key is also accepted as a fallback + // because the spec doesn't yet standardize the field name and clients vary. + private static string? ReadSkillIdFromMetadata(RequestContext context) + { + return ReadString(context.Message?.Metadata, "skill") + ?? ReadString(context.Message?.Metadata, "skillId") + ?? ReadString(context.Metadata, "skill") + ?? ReadString(context.Metadata, "skillId"); + } + + private static string? ReadString(Dictionary? metadata, string key) + { + if (metadata is null || !metadata.TryGetValue(key, out var element)) + { + return null; + } + return element.ValueKind == JsonValueKind.String ? element.GetString() : null; + } +} diff --git a/src/SocialAgent.Host/SocialAgentHandler.cs b/src/SocialAgent.Host/SocialAgentHandler.cs deleted file mode 100644 index f7a64e2..0000000 --- a/src/SocialAgent.Host/SocialAgentHandler.cs +++ /dev/null @@ -1,238 +0,0 @@ -using System.Reflection; -using System.Text.Json; -using Microsoft.Agents.Builder; -using Microsoft.Agents.Builder.App; -using Microsoft.Agents.Builder.State; -using Microsoft.Agents.Core.Models; -using Microsoft.Agents.Hosting.A2A; -using Microsoft.Agents.Hosting.A2A.Protocol; -using SocialAgent.Core.Analytics; -using SocialAgent.Core.Providers; -using SocialAgent.Data.Repositories; -using SocialAgent.Host.Routing; - -namespace SocialAgent.Host; - -public class SocialAgentHandler : AgentApplication, IAgentCardHandler -{ - private static readonly JsonSerializerOptions JsonPretty = new() { WriteIndented = true }; - private readonly IServiceScopeFactory _scopeFactory; - - public SocialAgentHandler(AgentApplicationOptions options, IServiceScopeFactory scopeFactory) : base(options) - { - _scopeFactory = scopeFactory; - OnActivity(ActivityTypes.Message, OnMessageAsync); - OnActivity(ActivityTypes.EndOfConversation, OnEndOfConversationAsync); - } - - public Task GetAgentCard(AgentCard initialCard) - { - initialCard.Name = "SocialAgent"; - initialCard.Description = "Social media monitoring and analytics agent. " + - "Monitors Mastodon, Bluesky, and other platforms for posts, mentions, and engagement. " + - "Provides analytics on engagement trends, top posts, and follower insights."; - initialCard.Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "1.0.0"; - initialCard.Skills = - [ - new AgentSkill - { - Id = "engagement-summary", - Name = "Engagement Summary", - Description = "Get a summary of recent engagement across all connected social media platforms. " + - "Returns total likes, reposts, replies, mentions, new followers, and averages per post.", - Tags = ["social", "analytics", "engagement"] - }, - new AgentSkill - { - Id = "top-posts", - Name = "Top Posts", - Description = "Get your most-engaged posts over a configurable time period, ranked by total engagement (likes + reposts + replies).", - Tags = ["social", "analytics", "posts"] - }, - new AgentSkill - { - Id = "recent-mentions", - Name = "Recent Mentions", - Description = "Get recent mentions and replies across all connected platforms.", - Tags = ["social", "mentions", "notifications"] - }, - new AgentSkill - { - Id = "follower-insights", - Name = "Follower Insights", - Description = "See who engages most with your content and their interaction patterns.", - Tags = ["social", "analytics", "followers"] - }, - new AgentSkill - { - Id = "platform-comparison", - Name = "Platform Comparison", - Description = "Compare engagement metrics across all connected social media platforms.", - Tags = ["social", "analytics", "comparison"] - }, - new AgentSkill - { - Id = "check-notifications", - Name = "Check Notifications", - Description = "Get unread notifications across all connected platforms.", - Tags = ["social", "notifications"] - }, - new AgentSkill - { - Id = "provider-status", - Name = "Provider Status", - Description = "Check connectivity and health of all configured social media providers.", - Tags = ["social", "status", "health"] - } - ]; - return Task.FromResult(initialCard); - } - - private static readonly List SkillDefinitions = - [ - new("engagement-summary", "Engagement Summary", "Get a summary of recent engagement across all connected social media platforms"), - new("top-posts", "Top Posts", "Get most-engaged posts ranked by total engagement"), - new("recent-mentions", "Recent Mentions", "Get recent mentions and replies across all connected platforms"), - new("follower-insights", "Follower Insights", "See who engages most with your content"), - new("platform-comparison", "Platform Comparison", "Compare engagement metrics across all connected platforms"), - new("check-notifications", "Check Notifications", "Get unread notifications across all connected platforms"), - new("provider-status", "Provider Status", "Check connectivity and health of all configured providers") - ]; - - private async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken ct) - { - var text = turnContext.Activity.Text?.Trim() ?? string.Empty; - - using var scope = _scopeFactory.CreateScope(); - - // Try LLM routing first, fall back to keyword matching - var router = scope.ServiceProvider.GetService(); - var skillId = router != null - ? await router.RouteAsync(text, SkillDefinitions, ct) ?? ExtractSkillId(text) - : ExtractSkillId(text); - - var result = skillId switch - { - "engagement-summary" => await HandleEngagementSummaryAsync(scope.ServiceProvider, ct), - "top-posts" => await HandleTopPostsAsync(scope.ServiceProvider, ct), - "recent-mentions" => await HandleRecentMentionsAsync(scope.ServiceProvider, ct), - "follower-insights" => await HandleFollowerInsightsAsync(scope.ServiceProvider, ct), - "platform-comparison" => await HandlePlatformComparisonAsync(scope.ServiceProvider, ct), - "check-notifications" => await HandleCheckNotificationsAsync(scope.ServiceProvider, ct), - "provider-status" => await HandleProviderStatusAsync(scope.ServiceProvider, ct), - _ => $"Unknown skill '{skillId}'. Available skills: engagement-summary, top-posts, recent-mentions, follower-insights, platform-comparison, check-notifications, provider-status" - }; - - var activity = new Activity - { - Text = result, - Type = ActivityTypes.EndOfConversation, - Code = EndOfConversationCodes.CompletedSuccessfully - }; - await turnContext.SendActivityAsync(activity, ct); - } - - private Task OnEndOfConversationAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken ct) - { - return Task.CompletedTask; - } - - private static readonly Dictionary SkillKeywords = new() - { - ["engagement-summary"] = ["engagement summary", "engagement-summary"], - ["top-posts"] = ["top posts", "top-posts", "most engaged", "best posts"], - ["recent-mentions"] = ["recent mentions", "recent-mentions", "mentions"], - ["follower-insights"] = ["follower insights", "follower-insights", "followers", "engagers"], - ["platform-comparison"] = ["platform comparison", "platform-comparison", "compare platforms", "compare engagement"], - ["check-notifications"] = ["check notifications", "check-notifications", "notifications", "unread"], - ["provider-status"] = ["provider status", "provider-status", "connectivity", "health check"] - }; - - private static string ExtractSkillId(string text) - { - var lower = text.ToLowerInvariant(); - - // Exact skill ID match first - foreach (var skill in SkillKeywords) - { - if (lower == skill.Key) - return skill.Key; - } - - // Keyword match (longer keywords first to avoid partial matches) - foreach (var skill in SkillKeywords) - { - foreach (var keyword in skill.Value) - { - if (lower.Contains(keyword)) - return skill.Key; - } - } - - return lower; - } - - private static async Task HandleEngagementSummaryAsync(IServiceProvider sp, CancellationToken ct) - { - var analytics = sp.GetRequiredService(); - var summary = await analytics.GetEngagementSummaryAsync(ct: ct); - return FormatJson(summary); - } - - private static async Task HandleTopPostsAsync(IServiceProvider sp, CancellationToken ct) - { - var analytics = sp.GetRequiredService(); - var posts = await analytics.GetTopPostsAsync(ct: ct); - return FormatJson(posts); - } - - private static async Task HandleRecentMentionsAsync(IServiceProvider sp, CancellationToken ct) - { - var analytics = sp.GetRequiredService(); - var mentions = await analytics.GetRecentMentionsAsync(ct: ct); - return FormatJson(mentions); - } - - private static async Task HandleFollowerInsightsAsync(IServiceProvider sp, CancellationToken ct) - { - var analytics = sp.GetRequiredService(); - var engagers = await analytics.GetTopEngagersAsync(ct: ct); - return FormatJson(engagers); - } - - private static async Task HandlePlatformComparisonAsync(IServiceProvider sp, CancellationToken ct) - { - var analytics = sp.GetRequiredService(); - var comparison = await analytics.GetPlatformComparisonAsync(ct: ct); - return FormatJson(comparison); - } - - private static async Task HandleCheckNotificationsAsync(IServiceProvider sp, CancellationToken ct) - { - var repository = sp.GetRequiredService(); - var unread = await repository.GetUnreadNotificationsAsync(ct: ct); - return FormatJson(unread); - } - - private static async Task HandleProviderStatusAsync(IServiceProvider sp, CancellationToken ct) - { - var providers = sp.GetServices(); - var statuses = new List(); - foreach (var provider in providers) - { - var connected = await provider.ValidateConnectionAsync(ct); - statuses.Add(new - { - provider.ProviderId, - provider.ProviderName, - Connected = connected - }); - } - return FormatJson(statuses); - } - - private static string FormatJson(object data) - { - return JsonSerializer.Serialize(data, JsonPretty); - } -} diff --git a/src/SocialAgent.Host/SocialAgentStubAgent.cs b/src/SocialAgent.Host/SocialAgentStubAgent.cs new file mode 100644 index 0000000..31e9d8b --- /dev/null +++ b/src/SocialAgent.Host/SocialAgentStubAgent.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace SocialAgent.Host; + +/// +/// Minimal AIAgent registered solely to satisfy the Microsoft Agent Framework's keyed-service +/// requirement. The actual A2A request handling is provided by , +/// which is registered as a keyed with the same name. The framework +/// only reads from this instance; none of the abstract members are ever invoked. +/// +internal sealed class SocialAgentStubAgent : AIAgent +{ + public const string AgentName = "social-agent"; + + public override string Name => AgentName; + + public override string Description => "Social media monitoring agent (custom A2A handler — AIAgent surface unused)."; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + throw new NotSupportedException("SocialAgentStubAgent has no AIAgent runtime — use the keyed IAgentHandler instead."); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException("SocialAgentStubAgent has no AIAgent runtime — use the keyed IAgentHandler instead."); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException("SocialAgentStubAgent has no AIAgent runtime — use the keyed IAgentHandler instead."); + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException("SocialAgentStubAgent has no AIAgent runtime — use the keyed IAgentHandler instead."); + + protected override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException("SocialAgentStubAgent has no AIAgent runtime — use the keyed IAgentHandler instead."); +}