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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
17 changes: 10 additions & 7 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Microsoft Agents A2A -->
<PackageVersion Include="Microsoft.Agents.Hosting.AspNetCore.A2A.Preview" Version="1.5.45-beta" />
<!-- Microsoft Agent Framework — A2A 1.0 hosting -->
<PackageVersion Include="Microsoft.Agents.AI.Hosting.A2A.AspNetCore" Version="1.3.0-preview.260423.1" />
<!-- Google A2A .NET SDK (transitively required; we use it directly for AgentCard, IAgentHandler, MapWellKnownAgentCard) -->
<PackageVersion Include="A2A" Version="1.0.0-preview2" />
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />

<!-- ASP.NET Core & Hosting -->
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
Expand All @@ -20,11 +23,11 @@
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.OpenTelemetry" Version="4.2.0" />

<!-- OpenTelemetry -->
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
<!-- OpenTelemetry — pinned to 1.15.3 to clear NU1902 vulnerability advisories -->
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageVersion Include="Npgsql.OpenTelemetry" Version="10.0.2" />

<!-- Health Checks -->
Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 │ │ │
│ └──────┬───────┘ │ └─────┬─────┘ └──────┬───────┘ │ │
│ │ └───────┼───────────────┼─────────┘ │
│ ┌──────▼───────────────────▼───────────────▼─────────┐ │
Expand Down
1 change: 1 addition & 0 deletions deploy/k8s/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
7 changes: 6 additions & 1 deletion deploy/k8s/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ spec:
spec:
containers:
- name: social-agent
image: rockylhotka/socialagent:latest
image: rockylhotka/socialagent:1.3.4
ports:
- containerPort: 8080
env:
Expand All @@ -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:
Expand Down
71 changes: 54 additions & 17 deletions src/SocialAgent.Host/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -22,15 +21,14 @@
builder.Host.UseSerilog((context, configuration) => configuration
.ReadFrom.Configuration(context.Configuration));

// Agent infrastructure
builder.Services.AddSingleton<IStorage, MemoryStorage>();
builder.AddAgentApplicationOptions();

// Register the agent
builder.AddAgent<SocialAgentHandler>();

// 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<Microsoft.Agents.AI.AIAgent, SocialAgentStubAgent>(SocialAgentStubAgent.AgentName);
builder.Services.AddSingleton<SkillDispatcher>();
builder.Services.AddKeyedSingleton<IAgentHandler, SocialAgentA2AHandler>(SocialAgentStubAgent.AgentName);
builder.AddA2AServer(SocialAgentStubAgent.AgentName);

// Data layer
builder.Services.AddSocialAgentData(builder.Configuration);
Expand Down Expand Up @@ -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 { }
127 changes: 127 additions & 0 deletions src/SocialAgent.Host/SkillCatalog.cs
Original file line number Diff line number Diff line change
@@ -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<AgentSkill> 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<SkillRouter.SkillDefinition> 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<string, string[]> 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<string> 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;
}
}
Loading
Loading