From 9b2deebb18c3ff5088807b140f3bf989678af0f8 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Mon, 4 May 2026 01:42:19 -0500 Subject: [PATCH 1/6] feat: migrate A2A protocol from 0.3 to 1.0-preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap from Microsoft.Agents.Hosting.AspNetCore.A2A.Preview (M365 Agents SDK lineage, A2A 0.3) to the Microsoft Agent Framework (Microsoft.Agents.AI.Hosting.A2A.AspNetCore) plus the upstream A2A .NET SDK (A2A, A2A.AspNetCore), which speak A2A 1.0. The previous AgentApplication-derived handler does not exist in the new framework, which models agents as AIAgent + tools. Since SocialAgent's seven skills are deterministic JSON returners (not LLM tool calls), the migration uses a custom A2A.IAgentHandler keyed under the agent name — this bypasses the framework's default LLM-driven A2AAgentHandler and preserves the existing dispatch semantics. A minimal AIAgent stub (SocialAgentStubAgent) is registered alongside it solely as a name carrier required by AddA2AServer; none of its abstract members are ever invoked. Endpoint surface changes: - Discovery moves from /.well-known/agent.json (auto-emitted) to /.well-known/agent-card.json (A2A 1.0 spec path), built explicitly from SkillCatalog with ProtocolVersion="1.0". - /a2a now serves both JSON-RPC (POST /a2a) and HTTP+JSON (e.g. POST /a2a/message:send, GET /a2a/tasks/{id}) — clients pick either. - ApiKey auth is reapplied via AuthorizationPolicyBuilder against the mapped JSON-RPC and HTTP+JSON endpoints in non-Development env; agent card stays anonymous so consumers can discover capabilities before authenticating. Skill code is unchanged behaviorally — extracted from the old SocialAgentHandler into SkillCatalog (metadata + keyword routing), SkillDispatcher (dispatch + analytics calls), and SocialAgentA2AHandler (A2A bridge). LLM-assisted routing via SkillRouter remains optional and falls back to keyword matching when LLM__Low is unconfigured. Also bumps OpenTelemetry packages to 1.15.x patched releases to clear pre-existing NU1902 vulnerability advisories that were blocking restore. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 16 +- Directory.Packages.props | 17 +- README.md | 16 +- src/SocialAgent.Host/Program.cs | 67 +++-- src/SocialAgent.Host/SkillCatalog.cs | 105 ++++++++ src/SocialAgent.Host/SkillDispatcher.cs | 99 ++++++++ src/SocialAgent.Host/SocialAgent.Host.csproj | 4 +- src/SocialAgent.Host/SocialAgentA2AHandler.cs | 39 +++ src/SocialAgent.Host/SocialAgentHandler.cs | 238 ------------------ src/SocialAgent.Host/SocialAgentStubAgent.cs | 35 +++ 10 files changed, 359 insertions(+), 277 deletions(-) create mode 100644 src/SocialAgent.Host/SkillCatalog.cs create mode 100644 src/SocialAgent.Host/SkillDispatcher.cs create mode 100644 src/SocialAgent.Host/SocialAgentA2AHandler.cs delete mode 100644 src/SocialAgent.Host/SocialAgentHandler.cs create mode 100644 src/SocialAgent.Host/SocialAgentStubAgent.cs 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/src/SocialAgent.Host/Program.cs b/src/SocialAgent.Host/Program.cs index 81b8974..5dbd6c3 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,46 @@ app.UseAuthentication(); app.UseAuthorization(); -// Map A2A endpoints (no auth required for development) -app.MapA2AEndpoints(requireAuth: !app.Environment.IsDevelopment()); +// Build the agent card. Path/URL is host-relative; clients consume it from /.well-known/agent-card.json. +var agentVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "1.0.0"; +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 = "/a2a", ProtocolBinding = "JSONRPC", ProtocolVersion = "1.0" }, + new AgentInterface { Url = "/a2a", 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..c280e92 --- /dev/null +++ b/src/SocialAgent.Host/SkillCatalog.cs @@ -0,0 +1,105 @@ +using A2A; +using SocialAgent.Host.Routing; + +namespace SocialAgent.Host; + +internal static class SkillCatalog +{ + public static IReadOnlyList AgentCardSkills { get; } = + [ + new() + { + 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() + { + 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() + { + Id = "recent-mentions", + Name = "Recent Mentions", + Description = "Get recent mentions and replies across all connected platforms.", + Tags = ["social", "mentions", "notifications"] + }, + new() + { + Id = "follower-insights", + Name = "Follower Insights", + Description = "See who engages most with your content and their interaction patterns.", + Tags = ["social", "analytics", "followers"] + }, + new() + { + Id = "platform-comparison", + Name = "Platform Comparison", + Description = "Compare engagement metrics across all connected social media platforms.", + Tags = ["social", "analytics", "comparison"] + }, + new() + { + Id = "check-notifications", + Name = "Check Notifications", + Description = "Get unread notifications across all connected platforms.", + Tags = ["social", "notifications"] + }, + new() + { + Id = "provider-status", + Name = "Provider Status", + Description = "Check connectivity and health of all configured social media providers.", + 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"] + }; + + 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..9c5af6a --- /dev/null +++ b/src/SocialAgent.Host/SkillDispatcher.cs @@ -0,0 +1,99 @@ +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 userText, CancellationToken ct) + { + var text = userText?.Trim() ?? string.Empty; + + using var scope = scopeFactory.CreateScope(); + + var router = scope.ServiceProvider.GetService(); + var skillId = router != null + ? await router.RouteAsync(text, SkillCatalog.RouterDefinitions, ct) ?? SkillCatalog.MatchSkillByKeywords(text) + : SkillCatalog.MatchSkillByKeywords(text); + + logger.LogInformation("Dispatching skill {SkillId} for input \"{Input}\"", skillId, text); + + return 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" + }; + } + + 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) => JsonSerializer.Serialize(data, JsonPretty); +} diff --git a/src/SocialAgent.Host/SocialAgent.Host.csproj b/src/SocialAgent.Host/SocialAgent.Host.csproj index 67c1675..ac94685 100644 --- a/src/SocialAgent.Host/SocialAgent.Host.csproj +++ b/src/SocialAgent.Host/SocialAgent.Host.csproj @@ -4,7 +4,9 @@ socialagent-host-001 - + + + 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..e5255ab --- /dev/null +++ b/src/SocialAgent.Host/SocialAgentA2AHandler.cs @@ -0,0 +1,39 @@ +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; + + string responseText; + try + { + responseText = await dispatcher.DispatchAsync(userText, 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); + } +} 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."); +} From 1d7dd55c483846922f5207316752c3b63b329d36 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Mon, 4 May 2026 11:08:33 -0500 Subject: [PATCH 2/6] chore: bump version to 1.3.0 and pin deployment image tag Sets 1.3.0 on the host project so the agent card advertises 1.3.0 instead of the default 1.0.0.0, and pins the k8s deployment image to rockylhotka/socialagent:1.3.0 (was :latest) so rollout state is explicit and rollback is a single-line change. Image rockylhotka/socialagent:1.3.0 has been built and pushed; the :latest tag has been updated to point at the same digest. Smoke-tested on the rockbot cluster: agent card reports version 1.3.0.0 and both JSONRPC and HTTPJSON interfaces report protocolVersion 1.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy/k8s/deployment.yaml | 2 +- src/SocialAgent.Host/SocialAgent.Host.csproj | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml index 1e73f2b..6319359 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.0 ports: - containerPort: 8080 env: diff --git a/src/SocialAgent.Host/SocialAgent.Host.csproj b/src/SocialAgent.Host/SocialAgent.Host.csproj index ac94685..0008bc7 100644 --- a/src/SocialAgent.Host/SocialAgent.Host.csproj +++ b/src/SocialAgent.Host/SocialAgent.Host.csproj @@ -2,6 +2,7 @@ SocialAgent.Host socialagent-host-001 + 1.3.0 From fd9175776f2963cf3d97c5e23eae5a7f2af52667 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Mon, 4 May 2026 12:25:19 -0500 Subject: [PATCH 3/6] fix: emit absolute URLs in agent card supportedInterfaces (1.3.1) The A2A v1.0 spec example shows supportedInterfaces[].url as absolute URLs (e.g. https://georoute-agent.example.com/a2a/v1) so cross-host clients can resolve the endpoint without depending on the discovery URL's host. We were emitting bare paths ("/a2a"), which works only when the client happens to resolve relative to the card URL. Adds a SocialAgent:PublicBaseUrl configuration value. When set, both JSONRPC and HTTPJSON interface URLs in the card are prefixed with it. When unset (e.g. local dev), the card falls back to the relative "/a2a" path so dotnet run still works without configuration. The k8s ConfigMap now sets PublicBaseUrl to the in-cluster service DNS (http://social-agent.rockbot.svc.cluster.local), wired into the deployment via SocialAgent__PublicBaseUrl. Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy/k8s/configmap.yaml | 1 + deploy/k8s/deployment.yaml | 7 ++++++- src/SocialAgent.Host/Program.cs | 10 +++++++--- src/SocialAgent.Host/SocialAgent.Host.csproj | 2 +- 4 files changed, 15 insertions(+), 5 deletions(-) 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 6319359..497fbfa 100644 --- a/deploy/k8s/deployment.yaml +++ b/deploy/k8s/deployment.yaml @@ -17,7 +17,7 @@ spec: spec: containers: - name: social-agent - image: rockylhotka/socialagent:1.3.0 + image: rockylhotka/socialagent:1.3.1 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 5dbd6c3..c2c1e66 100644 --- a/src/SocialAgent.Host/Program.cs +++ b/src/SocialAgent.Host/Program.cs @@ -74,8 +74,12 @@ app.UseAuthentication(); app.UseAuthorization(); -// Build the agent card. Path/URL is host-relative; clients consume it from /.well-known/agent-card.json. +// 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", @@ -88,8 +92,8 @@ DefaultOutputModes = ["text"], SupportedInterfaces = [ - new AgentInterface { Url = "/a2a", ProtocolBinding = "JSONRPC", ProtocolVersion = "1.0" }, - new AgentInterface { Url = "/a2a", ProtocolBinding = "HTTPJSON", ProtocolVersion = "1.0" } + new AgentInterface { Url = a2aInterfaceUrl, ProtocolBinding = "JSONRPC", ProtocolVersion = "1.0" }, + new AgentInterface { Url = a2aInterfaceUrl, ProtocolBinding = "HTTPJSON", ProtocolVersion = "1.0" } ] }; diff --git a/src/SocialAgent.Host/SocialAgent.Host.csproj b/src/SocialAgent.Host/SocialAgent.Host.csproj index 0008bc7..03d3c41 100644 --- a/src/SocialAgent.Host/SocialAgent.Host.csproj +++ b/src/SocialAgent.Host/SocialAgent.Host.csproj @@ -2,7 +2,7 @@ SocialAgent.Host socialagent-host-001 - 1.3.0 + 1.3.1 From ae4855947107375aeb8d460020b226dabefe4e84 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Mon, 4 May 2026 12:36:21 -0500 Subject: [PATCH 4/6] fix: route by message.metadata.skill before falling back to text (1.3.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatcher was reading only context.UserText and routing via the optional LLM SkillRouter (or keyword matcher), so when a client put the skill discriminator in message metadata — which RockBot does in its v1 SDK send-request builder (BuildV1SendRequest), per the v0.3-compatible convention — the metadata was ignored and the LLM was free to pick whatever skill it thought matched the body text. In production, with LLM__Low configured, the LLM was returning provider-status for both recent-mentions and other requests, causing both to hand back identical provider-status JSON. SocialAgentA2AHandler now reads "skill" (or "skillId") from message.metadata and request-level metadata. If the value is a known skill ID, it dispatches directly and skips routing entirely. Falls back to the existing LLM/keyword routing only when metadata is absent or unrecognized, so dotnet run -style ad-hoc text invocation still works. Smoke-tested in cluster: three skills with metadata.skill set to provider-status, recent-mentions, engagement-summary now return three distinct payload shapes. Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy/k8s/deployment.yaml | 2 +- src/SocialAgent.Host/SkillCatalog.cs | 13 ++++++++++ src/SocialAgent.Host/SkillDispatcher.cs | 22 +++++++++++------ src/SocialAgent.Host/SocialAgent.Host.csproj | 2 +- src/SocialAgent.Host/SocialAgentA2AHandler.cs | 24 ++++++++++++++++++- 5 files changed, 53 insertions(+), 10 deletions(-) diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml index 497fbfa..d7e682e 100644 --- a/deploy/k8s/deployment.yaml +++ b/deploy/k8s/deployment.yaml @@ -17,7 +17,7 @@ spec: spec: containers: - name: social-agent - image: rockylhotka/socialagent:1.3.1 + image: rockylhotka/socialagent:1.3.2 ports: - containerPort: 8080 env: diff --git a/src/SocialAgent.Host/SkillCatalog.cs b/src/SocialAgent.Host/SkillCatalog.cs index c280e92..7517627 100644 --- a/src/SocialAgent.Host/SkillCatalog.cs +++ b/src/SocialAgent.Host/SkillCatalog.cs @@ -81,6 +81,19 @@ internal static class SkillCatalog ["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(); diff --git a/src/SocialAgent.Host/SkillDispatcher.cs b/src/SocialAgent.Host/SkillDispatcher.cs index 9c5af6a..0d44edd 100644 --- a/src/SocialAgent.Host/SkillDispatcher.cs +++ b/src/SocialAgent.Host/SkillDispatcher.cs @@ -10,18 +10,26 @@ internal sealed class SkillDispatcher(IServiceScopeFactory scopeFactory, ILogger { private static readonly JsonSerializerOptions JsonPretty = new() { WriteIndented = true }; - public async Task DispatchAsync(string userText, CancellationToken ct) + public async Task DispatchAsync(string? explicitSkillId, string userText, CancellationToken ct) { var text = userText?.Trim() ?? string.Empty; using var scope = scopeFactory.CreateScope(); - var router = scope.ServiceProvider.GetService(); - var skillId = router != null - ? await router.RouteAsync(text, SkillCatalog.RouterDefinitions, ct) ?? SkillCatalog.MatchSkillByKeywords(text) - : SkillCatalog.MatchSkillByKeywords(text); - - logger.LogInformation("Dispatching skill {SkillId} for input \"{Input}\"", skillId, text); + string skillId; + if (!string.IsNullOrWhiteSpace(explicitSkillId) && SkillCatalog.IsKnownSkill(explicitSkillId)) + { + skillId = explicitSkillId; + logger.LogInformation("Dispatching skill {SkillId} (from request metadata)", skillId); + } + 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}\")", skillId, text); + } return skillId switch { diff --git a/src/SocialAgent.Host/SocialAgent.Host.csproj b/src/SocialAgent.Host/SocialAgent.Host.csproj index 03d3c41..2679860 100644 --- a/src/SocialAgent.Host/SocialAgent.Host.csproj +++ b/src/SocialAgent.Host/SocialAgent.Host.csproj @@ -2,7 +2,7 @@ SocialAgent.Host socialagent-host-001 - 1.3.1 + 1.3.2 diff --git a/src/SocialAgent.Host/SocialAgentA2AHandler.cs b/src/SocialAgent.Host/SocialAgentA2AHandler.cs index e5255ab..65c0de7 100644 --- a/src/SocialAgent.Host/SocialAgentA2AHandler.cs +++ b/src/SocialAgent.Host/SocialAgentA2AHandler.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using A2A; namespace SocialAgent.Host; @@ -10,11 +11,12 @@ public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueu ArgumentNullException.ThrowIfNull(eventQueue); var userText = context.UserText ?? string.Empty; + var explicitSkillId = ReadSkillIdFromMetadata(context); string responseText; try { - responseText = await dispatcher.DispatchAsync(userText, cancellationToken); + responseText = await dispatcher.DispatchAsync(explicitSkillId, userText, cancellationToken); } catch (OperationCanceledException) { @@ -36,4 +38,24 @@ public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueu 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; + } } From 2c8d0ea249bc3b01af6d6a8272e8306bc37a4c75 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Mon, 4 May 2026 12:48:06 -0500 Subject: [PATCH 5/6] feat: per-skill parameters via message.metadata (1.3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills previously took no parameters from the A2A request, so any client-side filtering (e.g. \"recent-mentions for Bluesky only\") was silently ignored — the dispatcher always called the analytics methods with their defaults, returning the all-platforms union. The analytics layer already supported providerId/count/since arguments; this PR wires them through. Convention: parameters arrive as flat keys on message.metadata, alongside metadata.skill. Recognized keys per skill: engagement-summary: providerId, since top-posts: providerId, count, since recent-mentions: providerId, count follower-insights: providerId, count, since platform-comparison: since check-notifications: providerId provider-status: (none) Unknown keys are ignored. Strings, numbers, and ISO-8601 timestamps are all accepted; the dispatcher coerces strings to int/date where needed. Agent card skill descriptions updated to document the per-skill keys so v1 clients can discover them via /.well-known/agent-card.json. Smoke-tested in cluster: recent-mentions, no filter: 4 items, providers: mastodon recent-mentions, providerId=mastodon: 4 items, providers: mastodon recent-mentions, providerId=bluesky: 0 items (no native Bluesky mentions in the polled window) The filter is honored — the empty Bluesky result is real, not a bug. Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy/k8s/deployment.yaml | 2 +- src/SocialAgent.Host/SkillCatalog.cs | 25 ++++-- src/SocialAgent.Host/SkillDispatcher.cs | 76 ++++++++++++++----- src/SocialAgent.Host/SocialAgent.Host.csproj | 2 +- src/SocialAgent.Host/SocialAgentA2AHandler.cs | 3 +- 5 files changed, 78 insertions(+), 30 deletions(-) diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml index d7e682e..90b0389 100644 --- a/deploy/k8s/deployment.yaml +++ b/deploy/k8s/deployment.yaml @@ -17,7 +17,7 @@ spec: spec: containers: - name: social-agent - image: rockylhotka/socialagent:1.3.2 + image: rockylhotka/socialagent:1.3.3 ports: - containerPort: 8080 env: diff --git a/src/SocialAgent.Host/SkillCatalog.cs b/src/SocialAgent.Host/SkillCatalog.cs index 7517627..574af26 100644 --- a/src/SocialAgent.Host/SkillCatalog.cs +++ b/src/SocialAgent.Host/SkillCatalog.cs @@ -5,56 +5,65 @@ 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 across all connected social media platforms. " + - "Returns total likes, reposts, replies, mentions, new followers, and averages per post.", + 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 over a configurable time period, ranked by total engagement (likes + reposts + replies).", + 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 across all connected platforms.", + 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.", + 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.", + 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 across all connected platforms.", + 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.", + Description = "Check connectivity and health of all configured social media providers. Takes no parameters.", Tags = ["social", "status", "health"] } ]; diff --git a/src/SocialAgent.Host/SkillDispatcher.cs b/src/SocialAgent.Host/SkillDispatcher.cs index 0d44edd..8727e7e 100644 --- a/src/SocialAgent.Host/SkillDispatcher.cs +++ b/src/SocialAgent.Host/SkillDispatcher.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; using SocialAgent.Core.Analytics; using SocialAgent.Core.Providers; @@ -10,7 +11,11 @@ internal sealed class SkillDispatcher(IServiceScopeFactory scopeFactory, ILogger { private static readonly JsonSerializerOptions JsonPretty = new() { WriteIndented = true }; - public async Task DispatchAsync(string? explicitSkillId, string userText, CancellationToken ct) + public async Task DispatchAsync( + string? explicitSkillId, + string userText, + IReadOnlyDictionary? parameters, + CancellationToken ct) { var text = userText?.Trim() ?? string.Empty; @@ -31,58 +36,60 @@ public async Task DispatchAsync(string? explicitSkillId, string userText logger.LogInformation("Dispatching skill {SkillId} (routed from text \"{Input}\")", skillId, text); } + var p = new SkillParameters(parameters); + return 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), + "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, CancellationToken ct) + private static async Task HandleEngagementSummaryAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct) { var analytics = sp.GetRequiredService(); - var summary = await analytics.GetEngagementSummaryAsync(ct: ct); + var summary = await analytics.GetEngagementSummaryAsync(p.ProviderId, p.Since, ct); return FormatJson(summary); } - private static async Task HandleTopPostsAsync(IServiceProvider sp, CancellationToken ct) + private static async Task HandleTopPostsAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct) { var analytics = sp.GetRequiredService(); - var posts = await analytics.GetTopPostsAsync(ct: ct); + var posts = await analytics.GetTopPostsAsync(p.Count ?? 10, p.ProviderId, p.Since, ct); return FormatJson(posts); } - private static async Task HandleRecentMentionsAsync(IServiceProvider sp, CancellationToken ct) + private static async Task HandleRecentMentionsAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct) { var analytics = sp.GetRequiredService(); - var mentions = await analytics.GetRecentMentionsAsync(ct: ct); + var mentions = await analytics.GetRecentMentionsAsync(p.Count ?? 20, p.ProviderId, ct); return FormatJson(mentions); } - private static async Task HandleFollowerInsightsAsync(IServiceProvider sp, CancellationToken ct) + private static async Task HandleFollowerInsightsAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct) { var analytics = sp.GetRequiredService(); - var engagers = await analytics.GetTopEngagersAsync(ct: ct); + var engagers = await analytics.GetTopEngagersAsync(p.Count ?? 10, p.ProviderId, p.Since, ct); return FormatJson(engagers); } - private static async Task HandlePlatformComparisonAsync(IServiceProvider sp, CancellationToken ct) + private static async Task HandlePlatformComparisonAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct) { var analytics = sp.GetRequiredService(); - var comparison = await analytics.GetPlatformComparisonAsync(ct: ct); + var comparison = await analytics.GetPlatformComparisonAsync(p.Since, ct); return FormatJson(comparison); } - private static async Task HandleCheckNotificationsAsync(IServiceProvider sp, CancellationToken ct) + private static async Task HandleCheckNotificationsAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct) { var repository = sp.GetRequiredService(); - var unread = await repository.GetUnreadNotificationsAsync(ct: ct); + var unread = await repository.GetUnreadNotificationsAsync(p.ProviderId, ct); return FormatJson(unread); } @@ -104,4 +111,35 @@ private static async Task HandleProviderStatusAsync(IServiceProvider sp, } private static string FormatJson(object data) => JsonSerializer.Serialize(data, JsonPretty); + + 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 2679860..e173cc3 100644 --- a/src/SocialAgent.Host/SocialAgent.Host.csproj +++ b/src/SocialAgent.Host/SocialAgent.Host.csproj @@ -2,7 +2,7 @@ SocialAgent.Host socialagent-host-001 - 1.3.2 + 1.3.3 diff --git a/src/SocialAgent.Host/SocialAgentA2AHandler.cs b/src/SocialAgent.Host/SocialAgentA2AHandler.cs index 65c0de7..0228b49 100644 --- a/src/SocialAgent.Host/SocialAgentA2AHandler.cs +++ b/src/SocialAgent.Host/SocialAgentA2AHandler.cs @@ -12,11 +12,12 @@ public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueu var userText = context.UserText ?? string.Empty; var explicitSkillId = ReadSkillIdFromMetadata(context); + var parameters = context.Message?.Metadata; string responseText; try { - responseText = await dispatcher.DispatchAsync(explicitSkillId, userText, cancellationToken); + responseText = await dispatcher.DispatchAsync(explicitSkillId, userText, parameters, cancellationToken); } catch (OperationCanceledException) { From e408529368f0396256645fa1c9b5e78e08c1e544 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Mon, 4 May 2026 13:27:11 -0500 Subject: [PATCH 6/6] chore: log per-skill parameters in dispatcher (1.3.4) The 1.3.3 metadata-parameter forwarding was hard to verify from logs: the dispatcher logged only the skill ID, so when RockBot sent a recent-mentions request with providerId=bluesky there was no way to tell from the log whether the parameter arrived, was forwarded, or was silently dropped. Adds a parameters={...} field to the dispatch log line, summarizing recognized keys (providerId, count, since) from message.metadata. Unrecognized keys are intentionally omitted so the log stays compact. Format: parameters={providerId=bluesky, count=25} or parameters={} when no recognized keys are present. Verified in cluster: a recent-mentions request with metadata {providerId: \"bluesky\", count: 25} now produces: Dispatching skill recent-mentions (from request metadata) parameters={providerId=bluesky, count=25} Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy/k8s/deployment.yaml | 2 +- src/SocialAgent.Host/SkillDispatcher.cs | 22 ++++++++++++++++++-- src/SocialAgent.Host/SocialAgent.Host.csproj | 2 +- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml index 90b0389..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:1.3.3 + image: rockylhotka/socialagent:1.3.4 ports: - containerPort: 8080 env: diff --git a/src/SocialAgent.Host/SkillDispatcher.cs b/src/SocialAgent.Host/SkillDispatcher.cs index 8727e7e..30f30d2 100644 --- a/src/SocialAgent.Host/SkillDispatcher.cs +++ b/src/SocialAgent.Host/SkillDispatcher.cs @@ -22,10 +22,11 @@ public async Task DispatchAsync( 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)", skillId); + logger.LogInformation("Dispatching skill {SkillId} (from request metadata) parameters={Parameters}", skillId, parameterSummary); } else { @@ -33,7 +34,7 @@ public async Task DispatchAsync( 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}\")", skillId, text); + logger.LogInformation("Dispatching skill {SkillId} (routed from text \"{Input}\") parameters={Parameters}", skillId, text, parameterSummary); } var p = new SkillParameters(parameters); @@ -112,6 +113,23 @@ private static async Task HandleProviderStatusAsync(IServiceProvider sp, 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; diff --git a/src/SocialAgent.Host/SocialAgent.Host.csproj b/src/SocialAgent.Host/SocialAgent.Host.csproj index e173cc3..fd48f64 100644 --- a/src/SocialAgent.Host/SocialAgent.Host.csproj +++ b/src/SocialAgent.Host/SocialAgent.Host.csproj @@ -2,7 +2,7 @@ SocialAgent.Host socialagent-host-001 - 1.3.3 + 1.3.4