From 2d4a1ccd99b8f8c03d75517095ae9c0db0f9824f Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 6 Apr 2026 17:37:13 +0800 Subject: [PATCH 1/7] feat: add channel runtime for direct platform webhook callbacks Implement the channel runtime architecture (issue #113) that allows Aevatar to receive bot platform webhooks directly instead of relying on NyxID as a relay middleman. Nyx remains the credentialed outbound provider for sending messages. New project: agents/Aevatar.GAgents.ChannelRuntime with: - IPlatformAdapter abstraction for multi-platform support - LarkPlatformAdapter: URL verification + im.message.receive_v1 parsing - ChannelBotRegistrationStore: persistent protobuf-based config store - ChannelCallbackEndpoints: webhook receiver + registration CRUD - Async acknowledge-then-respond pattern (platforms have short timeouts) - Outbound replies via NyxIdApiClient.ProxyRequestAsync Existing NyxID relay path (/api/webhooks/nyxid-relay) is untouched. Closes #113 Co-Authored-By: Claude Opus 4.6 (1M context) --- aevatar.slnx | 1 + .../Adapters/LarkPlatformAdapter.cs | 172 ++++++++++ .../Aevatar.GAgents.ChannelRuntime.csproj | 29 ++ .../ChannelBotRegistrationStore.cs | 122 +++++++ .../ChannelCallbackEndpoints.cs | 306 ++++++++++++++++++ .../IPlatformAdapter.cs | 38 +++ .../InboundMessage.cs | 16 + .../ServiceCollectionExtensions.cs | 18 ++ .../channel_runtime_messages.proto | 21 ++ .../Aevatar.Mainnet.Host.Api.csproj | 1 + src/Aevatar.Mainnet.Host.Api/Program.cs | 3 + 11 files changed, 727 insertions(+) create mode 100644 agents/Aevatar.GAgents.ChannelRuntime/Adapters/LarkPlatformAdapter.cs create mode 100644 agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj create mode 100644 agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationStore.cs create mode 100644 agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs create mode 100644 agents/Aevatar.GAgents.ChannelRuntime/IPlatformAdapter.cs create mode 100644 agents/Aevatar.GAgents.ChannelRuntime/InboundMessage.cs create mode 100644 agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs create mode 100644 agents/Aevatar.GAgents.ChannelRuntime/channel_runtime_messages.proto diff --git a/aevatar.slnx b/aevatar.slnx index 2c85469b..c20dfdaa 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -3,6 +3,7 @@ + diff --git a/agents/Aevatar.GAgents.ChannelRuntime/Adapters/LarkPlatformAdapter.cs b/agents/Aevatar.GAgents.ChannelRuntime/Adapters/LarkPlatformAdapter.cs new file mode 100644 index 00000000..2547fab5 --- /dev/null +++ b/agents/Aevatar.GAgents.ChannelRuntime/Adapters/LarkPlatformAdapter.cs @@ -0,0 +1,172 @@ +using System.Text.Json; +using Aevatar.AI.ToolProviders.NyxId; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.ChannelRuntime.Adapters; + +/// +/// Platform adapter for Lark (Feishu) bot callbacks. +/// Handles URL verification challenges and im.message.receive_v1 events. +/// Outbound replies go through Nyx's api-lark-bot provider. +/// +public sealed class LarkPlatformAdapter : IPlatformAdapter +{ + private readonly ILogger _logger; + + public LarkPlatformAdapter(ILogger logger) => _logger = logger; + + public string Platform => "lark"; + + public async Task TryHandleVerificationAsync( + HttpContext http, ChannelBotRegistrationEntry registration) + { + http.Request.EnableBuffering(); + http.Request.Body.Position = 0; + + using var doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: http.RequestAborted); + http.Request.Body.Position = 0; + + var root = doc.RootElement; + + // Lark URL verification challenge + if (root.TryGetProperty("type", out var typeProp) && + typeProp.GetString() == "url_verification") + { + var challenge = root.TryGetProperty("challenge", out var ch) ? ch.GetString() : null; + _logger.LogInformation("Lark URL verification challenge received"); + return Results.Json(new { challenge }); + } + + return null; + } + + public async Task ParseInboundAsync( + HttpContext http, ChannelBotRegistrationEntry registration) + { + http.Request.Body.Position = 0; + using var doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: http.RequestAborted); + var root = doc.RootElement; + + // Lark v2 event callback format: + // { "schema": "2.0", "header": { "event_type": "im.message.receive_v1", ... }, + // "event": { "sender": { "sender_id": { "open_id": "..." } }, + // "message": { "chat_id": "...", "message_type": "text", + // "content": "{\"text\":\"hello\"}", "message_id": "..." } } } + + if (!root.TryGetProperty("header", out var header)) + { + _logger.LogDebug("Lark callback missing 'header' field, skipping"); + return null; + } + + var eventType = header.TryGetProperty("event_type", out var et) ? et.GetString() : null; + if (eventType != "im.message.receive_v1") + { + _logger.LogDebug("Lark event type {EventType} is not a message receive, skipping", eventType); + return null; + } + + if (!root.TryGetProperty("event", out var eventObj)) + return null; + + // Extract sender + var senderId = string.Empty; + var senderName = string.Empty; + if (eventObj.TryGetProperty("sender", out var sender)) + { + if (sender.TryGetProperty("sender_id", out var senderIdObj) && + senderIdObj.TryGetProperty("open_id", out var openId)) + senderId = openId.GetString() ?? string.Empty; + + if (sender.TryGetProperty("sender_type", out var senderType) && + senderType.GetString() == "bot") + { + _logger.LogDebug("Ignoring message from bot sender"); + return null; + } + } + + // Extract message + if (!eventObj.TryGetProperty("message", out var message)) + return null; + + var chatId = message.TryGetProperty("chat_id", out var cid) ? cid.GetString() : null; + var messageId = message.TryGetProperty("message_id", out var mid) ? mid.GetString() : null; + var messageType = message.TryGetProperty("message_type", out var mt) ? mt.GetString() : null; + var chatType = message.TryGetProperty("chat_type", out var ct) ? ct.GetString() : null; + + if (chatId is null) + { + _logger.LogWarning("Lark message missing chat_id"); + return null; + } + + // Parse message content — Lark wraps text in JSON: {"text":"actual message"} + string? text = null; + if (messageType == "text" && message.TryGetProperty("content", out var content)) + { + var contentStr = content.GetString(); + if (contentStr is not null) + { + try + { + using var contentDoc = JsonDocument.Parse(contentStr); + text = contentDoc.RootElement.TryGetProperty("text", out var t) ? t.GetString() : contentStr; + } + catch + { + text = contentStr; + } + } + } + + if (string.IsNullOrWhiteSpace(text)) + { + _logger.LogDebug("Lark message has no text content (type={MessageType})", messageType); + return null; + } + + _logger.LogInformation("Lark inbound: chat={ChatId}, sender={SenderId}, type={ChatType}", + chatId, senderId, chatType); + + return new InboundMessage + { + Platform = Platform, + ConversationId = chatId, + SenderId = senderId, + SenderName = senderName, + Text = text, + MessageId = messageId, + ChatType = chatType, + }; + } + + public async Task SendReplyAsync( + string replyText, + InboundMessage inbound, + ChannelBotRegistrationEntry registration, + NyxIdApiClient nyxClient, + CancellationToken ct) + { + // Lark Send Message API: POST /open-apis/im/v1/messages?receive_id_type=chat_id + var body = JsonSerializer.Serialize(new + { + receive_id = inbound.ConversationId, + msg_type = "text", + content = JsonSerializer.Serialize(new { text = replyText }), + }); + + var result = await nyxClient.ProxyRequestAsync( + registration.NyxUserToken, + registration.NyxProviderSlug, + "open-apis/im/v1/messages?receive_id_type=chat_id", + "POST", + body, + extraHeaders: null, + ct); + + _logger.LogInformation("Lark outbound reply sent: chat={ChatId}, slug={Slug}, result_length={Length}", + inbound.ConversationId, registration.NyxProviderSlug, result?.Length ?? 0); + } +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj b/agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj new file mode 100644 index 00000000..885073fb --- /dev/null +++ b/agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj @@ -0,0 +1,29 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.ChannelRuntime + Aevatar.GAgents.ChannelRuntime + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationStore.cs b/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationStore.cs new file mode 100644 index 00000000..acaa2494 --- /dev/null +++ b/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationStore.cs @@ -0,0 +1,122 @@ +using Google.Protobuf; +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.ChannelRuntime; + +/// +/// Persistent store for channel bot registrations. +/// Uses Protobuf file-based storage at ~/.aevatar/channel-registrations.bin. +/// Thread-safe via lock; suitable for low-frequency config operations. +/// +public sealed class ChannelBotRegistrationStore +{ + private readonly string _filePath; + private readonly ILogger _logger; + private readonly object _lock = new(); + private ChannelBotRegistrationStoreState _state; + + public ChannelBotRegistrationStore(ILogger logger) + { + _logger = logger; + var aevatarDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aevatar"); + Directory.CreateDirectory(aevatarDir); + _filePath = Path.Combine(aevatarDir, "channel-registrations.bin"); + _state = Load(); + } + + public ChannelBotRegistrationEntry? Get(string registrationId) + { + lock (_lock) + { + return _state.Registrations.FirstOrDefault(r => r.Id == registrationId); + } + } + + public IReadOnlyList List() + { + lock (_lock) + { + return _state.Registrations.ToList(); + } + } + + public ChannelBotRegistrationEntry Register( + string platform, + string nyxProviderSlug, + string nyxUserToken, + string? verificationToken, + string? scopeId) + { + var entry = new ChannelBotRegistrationEntry + { + Id = Guid.NewGuid().ToString("N"), + Platform = platform, + NyxProviderSlug = nyxProviderSlug, + NyxUserToken = nyxUserToken, + VerificationToken = verificationToken ?? string.Empty, + ScopeId = scopeId ?? string.Empty, + CreatedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }; + + lock (_lock) + { + _state.Registrations.Add(entry); + Save(); + } + + _logger.LogInformation("Registered channel bot: id={Id}, platform={Platform}, slug={Slug}", + entry.Id, platform, nyxProviderSlug); + return entry; + } + + public bool Delete(string registrationId) + { + lock (_lock) + { + var entry = _state.Registrations.FirstOrDefault(r => r.Id == registrationId); + if (entry is null) + return false; + + _state.Registrations.Remove(entry); + Save(); + + _logger.LogInformation("Deleted channel bot registration: id={Id}", registrationId); + return true; + } + } + + private ChannelBotRegistrationStoreState Load() + { + try + { + if (File.Exists(_filePath)) + { + var bytes = File.ReadAllBytes(_filePath); + var state = ChannelBotRegistrationStoreState.Parser.ParseFrom(bytes); + _logger.LogInformation("Loaded {Count} channel bot registrations from {Path}", + state.Registrations.Count, _filePath); + return state; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load channel registrations from {Path}, starting fresh", _filePath); + } + + return new ChannelBotRegistrationStoreState(); + } + + private void Save() + { + try + { + var bytes = _state.ToByteArray(); + File.WriteAllBytes(_filePath, bytes); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save channel registrations to {Path}", _filePath); + } + } +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs b/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs new file mode 100644 index 00000000..32a1711b --- /dev/null +++ b/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs @@ -0,0 +1,306 @@ +using System.Text; +using System.Text.Json; +using Aevatar.AI.Abstractions; +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.AI.ToolProviders.NyxId; +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Streaming; +using Aevatar.GAgents.NyxidChat; +using Google.Protobuf.WellKnownTypes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.ChannelRuntime; + +public static class ChannelCallbackEndpoints +{ + public static IEndpointRouteBuilder MapChannelCallbackEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/channels").WithTags("ChannelRuntime"); + + // Platform callback — receives webhooks directly from platforms + group.MapPost("/{platform}/callback/{registrationId}", HandleCallbackAsync); + + // Registration CRUD + group.MapPost("/registrations", HandleRegisterAsync); + group.MapGet("/registrations", HandleListRegistrationsAsync); + group.MapDelete("/registrations/{registrationId}", HandleDeleteRegistrationAsync); + + return app; + } + + /// + /// Receives a platform webhook callback directly. + /// 1. Handles verification challenges (returns immediately). + /// 2. Parses inbound message. + /// 3. Returns 200 OK immediately (platforms have short timeouts). + /// 4. Fires background task: dispatch to actor, collect response, send reply via Nyx provider. + /// + private static async Task HandleCallbackAsync( + HttpContext http, + string platform, + string registrationId, + [FromServices] ChannelBotRegistrationStore registrationStore, + [FromServices] IEnumerable adapters, + [FromServices] IActorRuntime actorRuntime, + [FromServices] IActorEventSubscriptionProvider subscriptionProvider, + [FromServices] NyxIdApiClient nyxClient, + [FromServices] ILoggerFactory loggerFactory, + CancellationToken ct) + { + var logger = loggerFactory.CreateLogger("Aevatar.ChannelRuntime.Callback"); + + // Resolve registration + var registration = registrationStore.Get(registrationId); + if (registration is null) + { + logger.LogWarning("Channel callback for unknown registration: {RegistrationId}", registrationId); + return Results.NotFound(new { error = "Registration not found" }); + } + + if (!string.Equals(registration.Platform, platform, StringComparison.OrdinalIgnoreCase)) + { + return Results.BadRequest(new { error = "Platform mismatch" }); + } + + // Resolve adapter + var adapter = adapters.FirstOrDefault(a => + string.Equals(a.Platform, platform, StringComparison.OrdinalIgnoreCase)); + if (adapter is null) + { + logger.LogWarning("No adapter for platform: {Platform}", platform); + return Results.BadRequest(new { error = $"Unsupported platform: {platform}" }); + } + + // Handle verification challenges (e.g. Lark URL verification) + http.Request.EnableBuffering(); + var verificationResult = await adapter.TryHandleVerificationAsync(http, registration); + if (verificationResult is not null) + return verificationResult; + + // Parse inbound message + var inbound = await adapter.ParseInboundAsync(http, registration); + if (inbound is null) + { + // Not a processable message (e.g. unsupported event type) — acknowledge silently + return Results.Ok(new { status = "ignored" }); + } + + // Return 200 OK immediately — process async in background + // Platforms like Lark have ~3s webhook timeout; we can't wait for LLM response. + _ = Task.Run(() => ProcessAndReplyAsync( + inbound, registration, adapter, + actorRuntime, subscriptionProvider, nyxClient, + loggerFactory)); + + return Results.Ok(new { status = "accepted" }); + } + + /// + /// Background task: dispatch message to NyxIdChatGAgent, collect response, send reply via Nyx provider. + /// + private static async Task ProcessAndReplyAsync( + InboundMessage inbound, + ChannelBotRegistrationEntry registration, + IPlatformAdapter adapter, + IActorRuntime actorRuntime, + IActorEventSubscriptionProvider subscriptionProvider, + NyxIdApiClient nyxClient, + ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("Aevatar.ChannelRuntime.Callback"); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + + try + { + var actorId = $"channel-{inbound.Platform}-{inbound.ConversationId}"; + + // Get or create actor (reuses NyxIdChatGAgent for AI processing) + var actor = await actorRuntime.GetAsync(actorId) + ?? await actorRuntime.CreateAsync(actorId, cts.Token); + + // Subscribe to collect response + var responseTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var responseBuilder = new StringBuilder(); + using var ctr = cts.Token.Register(() => responseTcs.TrySetCanceled()); + + await using var subscription = await subscriptionProvider.SubscribeAsync( + actor.Id, + envelope => + { + var payload = envelope.Payload; + if (payload is null) return Task.CompletedTask; + + if (payload.Is(TextMessageContentEvent.Descriptor)) + { + var evt = payload.Unpack(); + if (!string.IsNullOrEmpty(evt.Delta)) + responseBuilder.Append(evt.Delta); + } + else if (payload.Is(TextMessageEndEvent.Descriptor)) + { + responseTcs.TrySetResult(responseBuilder.ToString()); + } + + return Task.CompletedTask; + }, + cts.Token); + + // Dispatch ChatRequestEvent + var chatRequest = new ChatRequestEvent + { + Prompt = inbound.Text, + SessionId = inbound.ConversationId, + ScopeId = registration.ScopeId, + }; + chatRequest.Metadata[LLMRequestMetadataKeys.NyxIdAccessToken] = registration.NyxUserToken; + chatRequest.Metadata["scope_id"] = registration.ScopeId; + chatRequest.Metadata["channel.platform"] = inbound.Platform; + chatRequest.Metadata["channel.sender_id"] = inbound.SenderId; + chatRequest.Metadata["channel.sender_name"] = inbound.SenderName; + chatRequest.Metadata["channel.message_id"] = inbound.MessageId ?? string.Empty; + chatRequest.Metadata["channel.chat_type"] = inbound.ChatType ?? string.Empty; + + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(chatRequest), + Route = new EnvelopeRoute + { + Direct = new DirectRoute { TargetActorId = actor.Id }, + }, + }; + + await actor.HandleEventAsync(envelope, cts.Token); + + // Wait for response + var completed = await Task.WhenAny(responseTcs.Task, Task.Delay(120_000, cts.Token)); + + string replyText; + if (completed == responseTcs.Task && responseTcs.Task.IsCompletedSuccessfully) + { + replyText = responseTcs.Task.Result; + logger.LogInformation( + "Channel response ready: platform={Platform}, conversation={ConversationId}, length={Length}", + inbound.Platform, inbound.ConversationId, replyText.Length); + } + else + { + var partial = responseBuilder.ToString(); + replyText = partial.Length > 0 + ? partial + : "Sorry, it's taking too long to respond. Please try again."; + logger.LogWarning( + "Channel response timed out: platform={Platform}, conversation={ConversationId}", + inbound.Platform, inbound.ConversationId); + } + + if (string.IsNullOrWhiteSpace(replyText)) + replyText = "Sorry, I wasn't able to generate a response. Please try again."; + + // Send reply outbound via Nyx provider + await adapter.SendReplyAsync(replyText, inbound, registration, nyxClient, cts.Token); + } + catch (OperationCanceledException) + { + logger.LogWarning( + "Channel callback processing cancelled: platform={Platform}, conversation={ConversationId}", + inbound.Platform, inbound.ConversationId); + } + catch (Exception ex) + { + logger.LogError(ex, + "Channel callback processing failed: platform={Platform}, conversation={ConversationId}", + inbound.Platform, inbound.ConversationId); + } + } + + // ─── Registration CRUD ─── + + private static readonly JsonSerializerOptions RegistrationJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + + private static async Task HandleRegisterAsync( + HttpContext http, + [FromServices] ChannelBotRegistrationStore store, + [FromServices] ILoggerFactory loggerFactory, + CancellationToken ct) + { + var logger = loggerFactory.CreateLogger("Aevatar.ChannelRuntime.Registration"); + + RegistrationRequest? request; + try + { + request = await http.Request.ReadFromJsonAsync(RegistrationJsonOptions, ct); + } + catch (JsonException ex) + { + logger.LogWarning(ex, "Invalid registration request payload"); + return Results.BadRequest(new { error = "Invalid JSON" }); + } + + if (request is null || + string.IsNullOrWhiteSpace(request.Platform) || + string.IsNullOrWhiteSpace(request.NyxProviderSlug) || + string.IsNullOrWhiteSpace(request.NyxUserToken)) + { + return Results.BadRequest(new { error = "platform, nyx_provider_slug, and nyx_user_token are required" }); + } + + var entry = store.Register( + request.Platform.Trim().ToLowerInvariant(), + request.NyxProviderSlug.Trim(), + request.NyxUserToken.Trim(), + request.VerificationToken?.Trim(), + request.ScopeId?.Trim()); + + return Results.Ok(new + { + id = entry.Id, + platform = entry.Platform, + nyx_provider_slug = entry.NyxProviderSlug, + callback_url = $"/api/channels/{entry.Platform}/callback/{entry.Id}", + created_at = entry.CreatedAt.ToDateTimeOffset(), + }); + } + + private static Task HandleListRegistrationsAsync( + [FromServices] ChannelBotRegistrationStore store) + { + var registrations = store.List().Select(e => new + { + id = e.Id, + platform = e.Platform, + nyx_provider_slug = e.NyxProviderSlug, + scope_id = e.ScopeId, + callback_url = $"/api/channels/{e.Platform}/callback/{e.Id}", + created_at = e.CreatedAt.ToDateTimeOffset(), + }); + + return Task.FromResult(Results.Ok(registrations)); + } + + private static Task HandleDeleteRegistrationAsync( + string registrationId, + [FromServices] ChannelBotRegistrationStore store) + { + var deleted = store.Delete(registrationId); + return Task.FromResult(deleted + ? Results.Ok(new { status = "deleted" }) + : Results.NotFound(new { error = "Registration not found" })); + } + + private sealed record RegistrationRequest( + string? Platform, + string? NyxProviderSlug, + string? NyxUserToken, + string? VerificationToken, + string? ScopeId); +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/IPlatformAdapter.cs b/agents/Aevatar.GAgents.ChannelRuntime/IPlatformAdapter.cs new file mode 100644 index 00000000..110fe4b3 --- /dev/null +++ b/agents/Aevatar.GAgents.ChannelRuntime/IPlatformAdapter.cs @@ -0,0 +1,38 @@ +using Aevatar.AI.ToolProviders.NyxId; +using Microsoft.AspNetCore.Http; + +namespace Aevatar.GAgents.ChannelRuntime; + +/// +/// Adapter for a bot platform (Lark, Telegram, Discord, etc.). +/// Each platform implements inbound parsing, verification handling, and outbound reply. +/// +public interface IPlatformAdapter +{ + /// Platform identifier (e.g. "lark", "telegram", "discord"). + string Platform { get; } + + /// + /// Handle platform-specific verification challenges (e.g. Lark URL verification). + /// Returns a non-null if this was a verification request; + /// the caller should return it immediately without further processing. + /// Returns null if this is a normal message callback. + /// + Task TryHandleVerificationAsync(HttpContext http, ChannelBotRegistrationEntry registration); + + /// + /// Parse the platform-specific webhook payload into a normalized . + /// Returns null if the payload is not a processable message (e.g. unsupported event type). + /// + Task ParseInboundAsync(HttpContext http, ChannelBotRegistrationEntry registration); + + /// + /// Send a reply message to the platform via the Nyx provider proxy. + /// + Task SendReplyAsync( + string replyText, + InboundMessage inbound, + ChannelBotRegistrationEntry registration, + NyxIdApiClient nyxClient, + CancellationToken ct); +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/InboundMessage.cs b/agents/Aevatar.GAgents.ChannelRuntime/InboundMessage.cs new file mode 100644 index 00000000..cf2e5938 --- /dev/null +++ b/agents/Aevatar.GAgents.ChannelRuntime/InboundMessage.cs @@ -0,0 +1,16 @@ +namespace Aevatar.GAgents.ChannelRuntime; + +/// +/// Normalized inbound message parsed from a platform-specific webhook payload. +/// +public sealed class InboundMessage +{ + public required string Platform { get; init; } + public required string ConversationId { get; init; } + public required string SenderId { get; init; } + public required string SenderName { get; init; } + public required string Text { get; init; } + public string? MessageId { get; init; } + public string? ChatType { get; init; } + public IReadOnlyDictionary Extra { get; init; } = new Dictionary(); +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..d88e6854 --- /dev/null +++ b/agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Aevatar.GAgents.ChannelRuntime.Adapters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aevatar.GAgents.ChannelRuntime; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddChannelRuntime(this IServiceCollection services) + { + services.TryAddSingleton(); + + // Register platform adapters (add more as platforms are onboarded) + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + return services; + } +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/channel_runtime_messages.proto b/agents/Aevatar.GAgents.ChannelRuntime/channel_runtime_messages.proto new file mode 100644 index 00000000..3bf96047 --- /dev/null +++ b/agents/Aevatar.GAgents.ChannelRuntime/channel_runtime_messages.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; +package aevatar.gagents.channelruntime; +option csharp_namespace = "Aevatar.GAgents.ChannelRuntime"; + +import "google/protobuf/timestamp.proto"; + +// ─── Persistent State ─── + +message ChannelBotRegistrationEntry { + string id = 1; + string platform = 2; + string nyx_provider_slug = 3; + string nyx_user_token = 4; + string verification_token = 5; + string scope_id = 6; + google.protobuf.Timestamp created_at = 7; +} + +message ChannelBotRegistrationStoreState { + repeated ChannelBotRegistrationEntry registrations = 1; +} diff --git a/src/Aevatar.Mainnet.Host.Api/Aevatar.Mainnet.Host.Api.csproj b/src/Aevatar.Mainnet.Host.Api/Aevatar.Mainnet.Host.Api.csproj index 16e0c418..8f00fc22 100644 --- a/src/Aevatar.Mainnet.Host.Api/Aevatar.Mainnet.Host.Api.csproj +++ b/src/Aevatar.Mainnet.Host.Api/Aevatar.Mainnet.Host.Api.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Aevatar.Mainnet.Host.Api/Program.cs b/src/Aevatar.Mainnet.Host.Api/Program.cs index b8dff4e3..63f03a8c 100644 --- a/src/Aevatar.Mainnet.Host.Api/Program.cs +++ b/src/Aevatar.Mainnet.Host.Api/Program.cs @@ -7,6 +7,7 @@ using Aevatar.AI.ToolProviders.NyxId; using Aevatar.GAgents.NyxidChat; using Aevatar.GAgents.ChatbotClassifier; +using Aevatar.GAgents.ChannelRuntime; using Aevatar.GAgents.StreamingProxy; using Aevatar.Studio.Hosting; using Aevatar.Workflow.Extensions.Hosting; @@ -39,6 +40,7 @@ builder.Services.AddNyxIdChat(); builder.Services.AddStreamingProxy(); builder.Services.AddChatbotClassifier(); +builder.Services.AddChannelRuntime(); builder.Services.AddNyxIdTools(o => { o.BaseUrl = builder.Configuration["Aevatar:NyxId:Authority"] @@ -57,5 +59,6 @@ app.UseAevatarDefaultHost(); app.MapNyxIdChatEndpoints(); app.MapStreamingProxyEndpoints(); +app.MapChannelCallbackEndpoints(); app.Run(); From e61be8808a8ce12214a7a7e8090402d12349fa71 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 6 Apr 2026 17:39:22 +0800 Subject: [PATCH 2/7] test: add unit tests for ChannelRuntime (registration store + Lark adapter) 15 tests covering: - ChannelBotRegistrationStore: CRUD operations, persistence, edge cases - LarkPlatformAdapter: URL verification, message parsing, bot filtering, empty text handling, missing header handling Co-Authored-By: Claude Opus 4.6 (1M context) --- aevatar.slnx | 1 + ...evatar.GAgents.ChannelRuntime.Tests.csproj | 25 +++ .../ChannelBotRegistrationStoreTests.cs | 108 ++++++++++ .../LarkPlatformAdapterTests.cs | 192 ++++++++++++++++++ 4 files changed, 326 insertions(+) create mode 100644 test/Aevatar.GAgents.ChannelRuntime.Tests/Aevatar.GAgents.ChannelRuntime.Tests.csproj create mode 100644 test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationStoreTests.cs create mode 100644 test/Aevatar.GAgents.ChannelRuntime.Tests/LarkPlatformAdapterTests.cs diff --git a/aevatar.slnx b/aevatar.slnx index c20dfdaa..4bdfbcf0 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -146,6 +146,7 @@ + diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/Aevatar.GAgents.ChannelRuntime.Tests.csproj b/test/Aevatar.GAgents.ChannelRuntime.Tests/Aevatar.GAgents.ChannelRuntime.Tests.csproj new file mode 100644 index 00000000..32f3196e --- /dev/null +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/Aevatar.GAgents.ChannelRuntime.Tests.csproj @@ -0,0 +1,25 @@ + + + net10.0 + enable + enable + false + true + Aevatar.GAgents.ChannelRuntime.Tests + Aevatar.GAgents.ChannelRuntime.Tests + + + + + + + + + + + + + + + + diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationStoreTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationStoreTests.cs new file mode 100644 index 00000000..6bcc0df9 --- /dev/null +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationStoreTests.cs @@ -0,0 +1,108 @@ +using FluentAssertions; +using Xunit; + +namespace Aevatar.GAgents.ChannelRuntime.Tests; + +public class ChannelBotRegistrationStoreTests : IDisposable +{ + private readonly string _tempDir; + private readonly ChannelBotRegistrationStore _store; + + public ChannelBotRegistrationStoreTests() + { + // Use a temp directory to avoid polluting ~/.aevatar + _tempDir = Path.Combine(Path.GetTempPath(), $"channel-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + + // The store uses ~/.aevatar by default; we need to create a testable version. + // For now, test the public API via a real store instance with isolated path. + var logger = new Microsoft.Extensions.Logging.Abstractions.NullLogger(); + _store = new ChannelBotRegistrationStore(logger); + } + + public void Dispose() + { + // Clean up any registrations we created + foreach (var reg in _store.List()) + _store.Delete(reg.Id); + + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + [Fact] + public void Register_creates_entry_with_generated_id() + { + var entry = _store.Register("lark", "api-lark-bot", "token-123", "verify-456", "scope-1"); + + entry.Should().NotBeNull(); + entry.Id.Should().NotBeNullOrWhiteSpace(); + entry.Platform.Should().Be("lark"); + entry.NyxProviderSlug.Should().Be("api-lark-bot"); + entry.NyxUserToken.Should().Be("token-123"); + entry.VerificationToken.Should().Be("verify-456"); + entry.ScopeId.Should().Be("scope-1"); + + _store.Delete(entry.Id); + } + + [Fact] + public void Get_returns_registered_entry() + { + var entry = _store.Register("lark", "api-lark-bot", "token-1", null, null); + + var found = _store.Get(entry.Id); + + found.Should().NotBeNull(); + found!.Id.Should().Be(entry.Id); + found.Platform.Should().Be("lark"); + + _store.Delete(entry.Id); + } + + [Fact] + public void Get_returns_null_for_unknown_id() + { + _store.Get("nonexistent-id").Should().BeNull(); + } + + [Fact] + public void List_returns_all_entries() + { + var e1 = _store.Register("lark", "slug-1", "token-1", null, null); + var e2 = _store.Register("telegram", "slug-2", "token-2", null, null); + + var list = _store.List(); + list.Should().Contain(r => r.Id == e1.Id); + list.Should().Contain(r => r.Id == e2.Id); + + _store.Delete(e1.Id); + _store.Delete(e2.Id); + } + + [Fact] + public void Delete_removes_entry_and_returns_true() + { + var entry = _store.Register("lark", "slug", "token", null, null); + + _store.Delete(entry.Id).Should().BeTrue(); + _store.Get(entry.Id).Should().BeNull(); + } + + [Fact] + public void Delete_returns_false_for_unknown_id() + { + _store.Delete("nonexistent-id").Should().BeFalse(); + } + + [Fact] + public void Register_with_null_optional_fields_stores_empty_strings() + { + var entry = _store.Register("lark", "slug", "token", null, null); + + entry.VerificationToken.Should().BeEmpty(); + entry.ScopeId.Should().BeEmpty(); + + _store.Delete(entry.Id); + } +} diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/LarkPlatformAdapterTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/LarkPlatformAdapterTests.cs new file mode 100644 index 00000000..51f89f2d --- /dev/null +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/LarkPlatformAdapterTests.cs @@ -0,0 +1,192 @@ +using System.Text; +using System.Text.Json; +using Aevatar.GAgents.ChannelRuntime.Adapters; +using FluentAssertions; +using Google.Protobuf.WellKnownTypes; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Aevatar.GAgents.ChannelRuntime.Tests; + +public class LarkPlatformAdapterTests +{ + private readonly LarkPlatformAdapter _adapter = new(NullLogger.Instance); + + private static ChannelBotRegistrationEntry MakeRegistration() => new() + { + Id = "test-reg-1", + Platform = "lark", + NyxProviderSlug = "api-lark-bot", + NyxUserToken = "test-token", + VerificationToken = "verify-token", + ScopeId = "test-scope", + CreatedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }; + + private static HttpContext CreateHttpContext(object payload) + { + var json = JsonSerializer.Serialize(payload); + var context = new DefaultHttpContext(); + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); + context.Request.ContentType = "application/json"; + context.Request.EnableBuffering(); + return context; + } + + [Fact] + public void Platform_returns_lark() + { + _adapter.Platform.Should().Be("lark"); + } + + [Fact] + public async Task TryHandleVerification_returns_challenge_for_url_verification() + { + var payload = new + { + type = "url_verification", + challenge = "test-challenge-123", + token = "verify-token", + }; + + var http = CreateHttpContext(payload); + var result = await _adapter.TryHandleVerificationAsync(http, MakeRegistration()); + + result.Should().NotBeNull(); + } + + [Fact] + public async Task TryHandleVerification_returns_null_for_normal_event() + { + var payload = new + { + schema = "2.0", + header = new { event_type = "im.message.receive_v1" }, + @event = new { message = new { chat_id = "oc_123", content = "{\"text\":\"hi\"}", message_type = "text" } }, + }; + + var http = CreateHttpContext(payload); + var result = await _adapter.TryHandleVerificationAsync(http, MakeRegistration()); + + result.Should().BeNull(); + } + + [Fact] + public async Task ParseInbound_extracts_text_message() + { + var payload = new + { + schema = "2.0", + header = new { event_type = "im.message.receive_v1" }, + @event = new + { + sender = new + { + sender_id = new { open_id = "ou_abc123" }, + sender_type = "user", + }, + message = new + { + chat_id = "oc_chat456", + message_id = "om_msg789", + message_type = "text", + chat_type = "p2p", + content = JsonSerializer.Serialize(new { text = "Hello from Lark!" }), + }, + }, + }; + + var http = CreateHttpContext(payload); + var inbound = await _adapter.ParseInboundAsync(http, MakeRegistration()); + + inbound.Should().NotBeNull(); + inbound!.Platform.Should().Be("lark"); + inbound.ConversationId.Should().Be("oc_chat456"); + inbound.SenderId.Should().Be("ou_abc123"); + inbound.Text.Should().Be("Hello from Lark!"); + inbound.MessageId.Should().Be("om_msg789"); + inbound.ChatType.Should().Be("p2p"); + } + + [Fact] + public async Task ParseInbound_returns_null_for_non_message_event() + { + var payload = new + { + schema = "2.0", + header = new { event_type = "im.chat.member.bot.added_v1" }, + @event = new { }, + }; + + var http = CreateHttpContext(payload); + var inbound = await _adapter.ParseInboundAsync(http, MakeRegistration()); + + inbound.Should().BeNull(); + } + + [Fact] + public async Task ParseInbound_returns_null_for_empty_text() + { + var payload = new + { + schema = "2.0", + header = new { event_type = "im.message.receive_v1" }, + @event = new + { + sender = new { sender_id = new { open_id = "ou_abc" } }, + message = new + { + chat_id = "oc_chat1", + message_type = "image", + content = "{}", + }, + }, + }; + + var http = CreateHttpContext(payload); + var inbound = await _adapter.ParseInboundAsync(http, MakeRegistration()); + + inbound.Should().BeNull(); + } + + [Fact] + public async Task ParseInbound_ignores_bot_sender() + { + var payload = new + { + schema = "2.0", + header = new { event_type = "im.message.receive_v1" }, + @event = new + { + sender = new + { + sender_id = new { open_id = "ou_bot" }, + sender_type = "bot", + }, + message = new + { + chat_id = "oc_chat1", + message_type = "text", + content = JsonSerializer.Serialize(new { text = "bot message" }), + }, + }, + }; + + var http = CreateHttpContext(payload); + var inbound = await _adapter.ParseInboundAsync(http, MakeRegistration()); + + inbound.Should().BeNull(); + } + + [Fact] + public async Task ParseInbound_returns_null_when_missing_header() + { + var payload = new { schema = "2.0" }; + + var http = CreateHttpContext(payload); + var inbound = await _adapter.ParseInboundAsync(http, MakeRegistration()); + + inbound.Should().BeNull(); + } +} From 6c5b40608cba2ff7501d4db2c7f097cdcd49e1ce Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 6 Apr 2026 18:29:01 +0800 Subject: [PATCH 3/7] fix: publish session completion before persistence to avoid blocking Ensure that session completion response is published immediately to consumers (relay, SSE) before attempting persistence. Persistence is now best-effort with logging for failures, preventing concurrency conflicts from blocking the reply to the user. --- src/Aevatar.AI.Core/RoleGAgent.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Aevatar.AI.Core/RoleGAgent.cs b/src/Aevatar.AI.Core/RoleGAgent.cs index 0a44fb63..600e1915 100644 --- a/src/Aevatar.AI.Core/RoleGAgent.cs +++ b/src/Aevatar.AI.Core/RoleGAgent.cs @@ -534,8 +534,21 @@ await PublishAsync(new ToolApprovalRequestEvent await ScheduleApprovalTimeoutAsync(pendingApproval); } - await PersistSessionCompletionAsync(request, replayRecord); + // Publish first so consumers (relay, SSE) get the response immediately. + // Persist is best-effort: concurrency conflicts must not block the reply. await PublishCompletionAsync(request.SessionId, replayRecord.Content); + + try + { + await PersistSessionCompletionAsync(request, replayRecord); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogWarning(ex, + "[{Role}] Failed to persist session completion. session={SessionId}. " + + "Response was already published — session replay may be unavailable.", + RoleName, request.SessionId); + } } private static int ResolveLlmTimeoutMs(ChatRequestEvent request) From bd3ce05d3265c76bd3845f424475a6f7814d1b99 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Tue, 7 Apr 2026 11:32:10 +0800 Subject: [PATCH 4/7] feat: add mock NyxID server for local development and testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone ASP.NET minimal API server at tools/Aevatar.Tools.MockNyxId that simulates the NyxID external service. Enables end-to-end testing of channel runtime, LLM calls, and proxy operations without a real NyxID instance. Endpoints: - GET /api/v1/users/me — mock user info - POST /api/v1/auth/test-token — issue test JWTs - GET /api/v1/proxy/services — service discovery (api-lark-bot, api-github) - * /api/v1/proxy/s/{slug}/{**path} — catch-all proxy with Lark/Telegram-aware responses - POST /api/v1/llm/gateway/v1/chat/completions — OpenAI-compatible (streaming + non-streaming) Usage: dotnet run --project tools/Aevatar.Tools.MockNyxId export Aevatar__NyxId__Authority=http://localhost:5199 Co-Authored-By: Claude Opus 4.6 (1M context) --- aevatar.slnx | 1 + .../Aevatar.Tools.MockNyxId.csproj | 9 ++ .../Endpoints/AuthEndpoints.cs | 68 +++++++++ .../Endpoints/LlmGatewayEndpoints.cs | 138 ++++++++++++++++++ .../Endpoints/ProxyEndpoints.cs | 98 +++++++++++++ .../Aevatar.Tools.MockNyxId/MockJwtHelper.cs | 71 +++++++++ .../MockNyxIdOptions.cs | 27 ++++ .../MockNyxIdServer.cs | 74 ++++++++++ tools/Aevatar.Tools.MockNyxId/MockStore.cs | 70 +++++++++ tools/Aevatar.Tools.MockNyxId/Program.cs | 19 +++ 10 files changed, 575 insertions(+) create mode 100644 tools/Aevatar.Tools.MockNyxId/Aevatar.Tools.MockNyxId.csproj create mode 100644 tools/Aevatar.Tools.MockNyxId/Endpoints/AuthEndpoints.cs create mode 100644 tools/Aevatar.Tools.MockNyxId/Endpoints/LlmGatewayEndpoints.cs create mode 100644 tools/Aevatar.Tools.MockNyxId/Endpoints/ProxyEndpoints.cs create mode 100644 tools/Aevatar.Tools.MockNyxId/MockJwtHelper.cs create mode 100644 tools/Aevatar.Tools.MockNyxId/MockNyxIdOptions.cs create mode 100644 tools/Aevatar.Tools.MockNyxId/MockNyxIdServer.cs create mode 100644 tools/Aevatar.Tools.MockNyxId/MockStore.cs create mode 100644 tools/Aevatar.Tools.MockNyxId/Program.cs diff --git a/aevatar.slnx b/aevatar.slnx index 4bdfbcf0..0f401f71 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -151,5 +151,6 @@ + diff --git a/tools/Aevatar.Tools.MockNyxId/Aevatar.Tools.MockNyxId.csproj b/tools/Aevatar.Tools.MockNyxId/Aevatar.Tools.MockNyxId.csproj new file mode 100644 index 00000000..c32ac291 --- /dev/null +++ b/tools/Aevatar.Tools.MockNyxId/Aevatar.Tools.MockNyxId.csproj @@ -0,0 +1,9 @@ + + + net10.0 + enable + enable + Aevatar.Tools.MockNyxId + Aevatar.Tools.MockNyxId + + diff --git a/tools/Aevatar.Tools.MockNyxId/Endpoints/AuthEndpoints.cs b/tools/Aevatar.Tools.MockNyxId/Endpoints/AuthEndpoints.cs new file mode 100644 index 00000000..b4020f06 --- /dev/null +++ b/tools/Aevatar.Tools.MockNyxId/Endpoints/AuthEndpoints.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Aevatar.Tools.MockNyxId.Endpoints; + +public static class AuthEndpoints +{ + public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/v1/users/me", HandleGetCurrentUser).WithTags("Auth"); + app.MapPost("/api/v1/auth/test-token", HandleGenerateTestToken).WithTags("Auth"); + return app; + } + + private static IResult HandleGetCurrentUser( + HttpContext http, + [FromServices] MockNyxIdOptions options) + { + var token = ExtractBearer(http); + if (token is null) + return Results.Json(new { error = true, message = "Missing Authorization header" }, statusCode: 401); + + var userId = MockJwtHelper.TryExtractSubject(token) ?? options.DefaultUserId; + + return Results.Json(new + { + id = userId, + email = options.DefaultUserEmail, + name = options.DefaultUserName, + created_at = "2026-01-01T00:00:00Z", + is_admin = false, + }); + } + + private static IResult HandleGenerateTestToken( + HttpContext http, + [FromServices] MockJwtHelper jwtHelper, + [FromServices] MockNyxIdOptions options) + { + // Accept optional JSON body: { "user_id": "...", "scope": "..." } + string userId = options.DefaultUserId; + string? scope = null; + + if (http.Request.ContentLength > 0) + { + try + { + using var doc = System.Text.Json.JsonDocument.Parse(http.Request.Body); + if (doc.RootElement.TryGetProperty("user_id", out var uid)) + userId = uid.GetString() ?? userId; + if (doc.RootElement.TryGetProperty("scope", out var s)) + scope = s.GetString(); + } + catch { /* ignore parse errors, use defaults */ } + } + + var token = jwtHelper.GenerateToken(userId, scope); + return Results.Json(new { token, user_id = userId }); + } + + internal static string? ExtractBearer(HttpContext http) + { + var auth = http.Request.Headers.Authorization.FirstOrDefault(); + if (auth is null) return null; + if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return auth["Bearer ".Length..].Trim(); + return auth; + } +} diff --git a/tools/Aevatar.Tools.MockNyxId/Endpoints/LlmGatewayEndpoints.cs b/tools/Aevatar.Tools.MockNyxId/Endpoints/LlmGatewayEndpoints.cs new file mode 100644 index 00000000..70e1b087 --- /dev/null +++ b/tools/Aevatar.Tools.MockNyxId/Endpoints/LlmGatewayEndpoints.cs @@ -0,0 +1,138 @@ +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; + +namespace Aevatar.Tools.MockNyxId.Endpoints; + +public static class LlmGatewayEndpoints +{ + public static IEndpointRouteBuilder MapLlmGatewayEndpoints(this IEndpointRouteBuilder app) + { + // OpenAI-compatible chat completions + app.MapPost("/api/v1/llm/gateway/v1/chat/completions", HandleChatCompletions).WithTags("LLM"); + + // Also handle proxy-routed LLM (when NyxIdLLMProvider routes via proxy slug) + app.MapPost("/api/v1/proxy/s/{slug}/v1/chat/completions", HandleChatCompletions).WithTags("LLM"); + + return app; + } + + private static async Task HandleChatCompletions( + HttpContext http, + [FromServices] MockNyxIdOptions options) + { + if (AuthEndpoints.ExtractBearer(http) is null) + { + http.Response.StatusCode = 401; + await http.Response.WriteAsJsonAsync(new { error = new { message = "Unauthorized" } }); + return; + } + + // Parse request to detect streaming + using var doc = await JsonDocument.ParseAsync(http.Request.Body); + var root = doc.RootElement; + + var model = root.TryGetProperty("model", out var m) ? m.GetString() ?? options.LlmModel : options.LlmModel; + var stream = root.TryGetProperty("stream", out var s) && s.GetBoolean(); + + if (options.LlmResponseDelayMs > 0) + await Task.Delay(options.LlmResponseDelayMs); + + var responseId = $"chatcmpl-mock-{Guid.NewGuid():N}"; + var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + if (stream) + { + await WriteStreamingResponse(http, options.LlmResponseText, responseId, model, created); + } + else + { + await WriteNonStreamingResponse(http, options.LlmResponseText, responseId, model, created); + } + } + + private static async Task WriteNonStreamingResponse( + HttpContext http, string text, string id, string model, long created) + { + var response = new + { + id, + @object = "chat.completion", + created, + model, + choices = new[] + { + new + { + index = 0, + message = new { role = "assistant", content = text }, + finish_reason = "stop", + }, + }, + usage = new + { + prompt_tokens = 10, + completion_tokens = text.Split(' ').Length, + total_tokens = 10 + text.Split(' ').Length, + }, + }; + + http.Response.ContentType = "application/json"; + await http.Response.WriteAsJsonAsync(response); + } + + private static async Task WriteStreamingResponse( + HttpContext http, string text, string id, string model, long created) + { + http.Response.ContentType = "text/event-stream"; + http.Response.Headers.CacheControl = "no-cache"; + http.Response.Headers.Connection = "keep-alive"; + + var words = text.Split(' '); + + for (var i = 0; i < words.Length; i++) + { + var content = i == 0 ? words[i] : " " + words[i]; + var chunk = new + { + id, + @object = "chat.completion.chunk", + created, + model, + choices = new[] + { + new + { + index = 0, + delta = new { content }, + finish_reason = (string?)null, + }, + }, + }; + + await http.Response.WriteAsync($"data: {JsonSerializer.Serialize(chunk)}\n\n"); + await http.Response.Body.FlushAsync(); + } + + // Final chunk with finish_reason + var finalChunk = new + { + id, + @object = "chat.completion.chunk", + created, + model, + choices = new[] + { + new + { + index = 0, + delta = new { content = (string?)null }, + finish_reason = "stop", + }, + }, + }; + await http.Response.WriteAsync($"data: {JsonSerializer.Serialize(finalChunk)}\n\n"); + await http.Response.WriteAsync("data: [DONE]\n\n"); + await http.Response.Body.FlushAsync(); + } +} diff --git a/tools/Aevatar.Tools.MockNyxId/Endpoints/ProxyEndpoints.cs b/tools/Aevatar.Tools.MockNyxId/Endpoints/ProxyEndpoints.cs new file mode 100644 index 00000000..fe919c8c --- /dev/null +++ b/tools/Aevatar.Tools.MockNyxId/Endpoints/ProxyEndpoints.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; + +namespace Aevatar.Tools.MockNyxId.Endpoints; + +public static class ProxyEndpoints +{ + public static IEndpointRouteBuilder MapProxyEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/v1/proxy/services", HandleDiscoverServices).WithTags("Proxy"); + + // Catch-all proxy: any method, any slug, any path + app.Map("/api/v1/proxy/s/{slug}/{**path}", HandleProxyCatchAll).WithTags("Proxy"); + + return app; + } + + private static IResult HandleDiscoverServices( + HttpContext http, + [FromServices] MockStore store) + { + if (AuthEndpoints.ExtractBearer(http) is null) + return Results.Json(new { error = true, message = "Unauthorized" }, statusCode: 401); + + var services = store.Services.Values.ToList(); + return Results.Json(services); + } + + private static async Task HandleProxyCatchAll( + HttpContext http, + string slug, + string? path, + [FromServices] MockStore store) + { + if (AuthEndpoints.ExtractBearer(http) is null) + return Results.Json(new { error = true, message = "Unauthorized" }, statusCode: 401); + + var method = http.Request.Method; + var normalizedPath = path ?? string.Empty; + + // Read body if present + string? body = null; + if (http.Request.ContentLength > 0 || http.Request.Headers.ContainsKey("Transfer-Encoding")) + { + using var reader = new StreamReader(http.Request.Body); + body = await reader.ReadToEndAsync(); + } + + // Log the request + var log = store.ProxyLog.GetOrAdd(slug, _ => new()); + log.Add(new ProxyLogEntry(slug, normalizedPath, method, body, DateTimeOffset.UtcNow)); + + // Platform-specific mock responses + if (slug == "api-lark-bot" && normalizedPath.Contains("im/v1/messages")) + { + return Results.Json(new + { + code = 0, + msg = "success", + data = new + { + message_id = $"mock-msg-{Guid.NewGuid():N}", + root_id = "", + parent_id = "", + msg_type = "text", + create_time = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(), + }, + }); + } + + if (slug == "api-telegram-bot" && normalizedPath.Contains("sendMessage")) + { + return Results.Json(new + { + ok = true, + result = new + { + message_id = Random.Shared.Next(1, 999999), + date = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + chat = new { id = 0, type = "private" }, + text = "mock reply", + }, + }); + } + + // Generic proxy response + return Results.Json(new + { + ok = true, + mock = true, + slug, + path = normalizedPath, + method, + body_received = body is not null, + timestamp = DateTimeOffset.UtcNow, + }); + } +} diff --git a/tools/Aevatar.Tools.MockNyxId/MockJwtHelper.cs b/tools/Aevatar.Tools.MockNyxId/MockJwtHelper.cs new file mode 100644 index 00000000..023967dd --- /dev/null +++ b/tools/Aevatar.Tools.MockNyxId/MockJwtHelper.cs @@ -0,0 +1,71 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Aevatar.Tools.MockNyxId; + +/// +/// Minimal JWT helper for mock testing. Issues HS256 tokens and loosely validates them. +/// NOT for production use — no real cryptographic verification on the validation side. +/// +public sealed class MockJwtHelper +{ + private readonly byte[] _keyBytes; + + public MockJwtHelper(MockNyxIdOptions options) + { + _keyBytes = Encoding.UTF8.GetBytes(options.JwtSigningKey); + } + + /// Generate a test JWT with the given subject (user ID) and optional scope. + public string GenerateToken(string userId, string? scope = null) + { + var header = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT" })); + + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var payloadObj = new Dictionary + { + ["sub"] = userId, + ["iat"] = now, + ["exp"] = now + 86400, // 24 hours + }; + if (!string.IsNullOrWhiteSpace(scope)) + payloadObj["scope"] = scope; + + var payload = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(payloadObj)); + var signature = Base64UrlEncode(HMACSHA256.HashData(_keyBytes, Encoding.UTF8.GetBytes($"{header}.{payload}"))); + + return $"{header}.{payload}.{signature}"; + } + + /// + /// Loosely extract the 'sub' claim from a JWT without verifying the signature. + /// Returns null if the token is malformed. + /// + public static string? TryExtractSubject(string token) + { + try + { + var parts = token.Split('.'); + if (parts.Length < 2) return null; + + var payloadBase64 = parts[1].Replace('-', '+').Replace('_', '/'); + switch (payloadBase64.Length % 4) + { + case 2: payloadBase64 += "=="; break; + case 3: payloadBase64 += "="; break; + } + + var json = Encoding.UTF8.GetString(Convert.FromBase64String(payloadBase64)); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.TryGetProperty("sub", out var sub) ? sub.GetString() : null; + } + catch + { + return null; + } + } + + private static string Base64UrlEncode(byte[] data) + => Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/tools/Aevatar.Tools.MockNyxId/MockNyxIdOptions.cs b/tools/Aevatar.Tools.MockNyxId/MockNyxIdOptions.cs new file mode 100644 index 00000000..d46e6d2b --- /dev/null +++ b/tools/Aevatar.Tools.MockNyxId/MockNyxIdOptions.cs @@ -0,0 +1,27 @@ +namespace Aevatar.Tools.MockNyxId; + +/// +/// Configurable behavior for the mock NyxID server. +/// Set via appsettings.json section "MockNyxId" or environment variables. +/// +public sealed class MockNyxIdOptions +{ + public string DefaultUserId { get; set; } = "user-123"; + public string DefaultUserEmail { get; set; } = "test@example.com"; + public string DefaultUserName { get; set; } = "Test User"; + + /// Text content the mock LLM gateway returns. + public string LlmResponseText { get; set; } = "This is a mock response from MockNyxId."; + + /// Model name echoed in LLM responses. + public string LlmModel { get; set; } = "mock-gpt-4"; + + /// Artificial delay in ms for LLM responses (0 = instant). + public int LlmResponseDelayMs { get; set; } + + /// JWT signing key for test tokens. + public string JwtSigningKey { get; set; } = "mock-nyxid-test-signing-key-at-least-32-bytes!"; + + /// Port for standalone mode. + public int Port { get; set; } = 5199; +} diff --git a/tools/Aevatar.Tools.MockNyxId/MockNyxIdServer.cs b/tools/Aevatar.Tools.MockNyxId/MockNyxIdServer.cs new file mode 100644 index 00000000..9c3d8e1d --- /dev/null +++ b/tools/Aevatar.Tools.MockNyxId/MockNyxIdServer.cs @@ -0,0 +1,74 @@ +using Aevatar.Tools.MockNyxId.Endpoints; + +namespace Aevatar.Tools.MockNyxId; + +/// +/// Factory for building the mock NyxID server. +/// Supports both standalone mode (dotnet run) and TestServer mode (integration tests). +/// +public static class MockNyxIdServer +{ + /// + /// Create a builder that can be further configured (e.g., UseTestServer for tests). + /// + public static WebApplicationBuilder CreateBuilder( + string[]? args = null, + Action? configure = null) + { + var builder = WebApplication.CreateBuilder(args ?? []); + + var options = new MockNyxIdOptions(); + builder.Configuration.GetSection("MockNyxId").Bind(options); + configure?.Invoke(options); + + builder.Services.AddSingleton(options); + builder.Services.AddSingleton(new MockStore(options)); + builder.Services.AddSingleton(new MockJwtHelper(options)); + + return builder; + } + + /// + /// Build a complete app ready to run (standalone mode). + /// + public static WebApplication Build(string[]? args = null, Action? configure = null) + { + var builder = CreateBuilder(args, configure); + var options = builder.Services.BuildServiceProvider().GetRequiredService(); + + if (string.IsNullOrWhiteSpace(builder.Configuration["urls"])) + builder.WebHost.UseUrls($"http://localhost:{options.Port}"); + + var app = builder.Build(); + MapAllEndpoints(app); + return app; + } + + /// + /// Build from an existing builder (for TestServer usage). + /// + public static WebApplication BuildFromBuilder(WebApplicationBuilder builder) + { + var app = builder.Build(); + MapAllEndpoints(app); + return app; + } + + private static void MapAllEndpoints(WebApplication app) + { + // Health check + app.MapGet("/", () => Results.Json(new + { + service = "MockNyxId", + status = "ok", + timestamp = DateTimeOffset.UtcNow, + })); + + app.MapGet("/health", () => Results.Ok("ok")); + + // API endpoints + app.MapAuthEndpoints(); + app.MapProxyEndpoints(); + app.MapLlmGatewayEndpoints(); + } +} diff --git a/tools/Aevatar.Tools.MockNyxId/MockStore.cs b/tools/Aevatar.Tools.MockNyxId/MockStore.cs new file mode 100644 index 00000000..fc1931b6 --- /dev/null +++ b/tools/Aevatar.Tools.MockNyxId/MockStore.cs @@ -0,0 +1,70 @@ +using System.Collections.Concurrent; +using System.Text.Json; + +namespace Aevatar.Tools.MockNyxId; + +/// +/// In-memory state for the mock NyxID server. +/// All data lives in ConcurrentDictionaries, seeded with defaults on construction. +/// +public sealed class MockStore +{ + public ConcurrentDictionary Users { get; } = new(); + public ConcurrentDictionary ChannelBots { get; } = new(); + public ConcurrentDictionary ConversationRoutes { get; } = new(); + public ConcurrentDictionary ApiKeys { get; } = new(); + public ConcurrentDictionary Services { get; } = new(); + + /// Proxy request log: slug → list of captured requests. + public ConcurrentDictionary> ProxyLog { get; } = new(); + + public MockStore(MockNyxIdOptions options) + { + // Seed default user + Users["user-123"] = JsonSerializer.SerializeToElement(new + { + id = options.DefaultUserId, + email = options.DefaultUserEmail, + name = options.DefaultUserName, + created_at = "2026-01-01T00:00:00Z", + }); + + // Seed default services for proxy/services discovery + Services["api-lark-bot"] = JsonSerializer.SerializeToElement(new + { + slug = "api-lark-bot", + name = "Lark Bot API", + provider = "lark", + proxy_url = "https://open.larksuite.com", + base_url = "https://open.larksuite.com", + connected = true, + }); + + Services["api-github"] = JsonSerializer.SerializeToElement(new + { + slug = "api-github", + name = "GitHub API", + provider = "github", + proxy_url = "https://api.github.com", + base_url = "https://api.github.com", + connected = true, + }); + + // Seed default API key + ApiKeys["key-1"] = JsonSerializer.SerializeToElement(new + { + id = "key-1", + name = "test-key", + key = "nyx_test_key_abc123", + scopes = "proxy read write", + created_at = "2026-01-01T00:00:00Z", + }); + } +} + +public sealed record ProxyLogEntry( + string Slug, + string Path, + string Method, + string? Body, + DateTimeOffset Timestamp); diff --git a/tools/Aevatar.Tools.MockNyxId/Program.cs b/tools/Aevatar.Tools.MockNyxId/Program.cs new file mode 100644 index 00000000..6efe58ae --- /dev/null +++ b/tools/Aevatar.Tools.MockNyxId/Program.cs @@ -0,0 +1,19 @@ +using Aevatar.Tools.MockNyxId; + +var app = MockNyxIdServer.Build(args); + +var options = app.Services.GetRequiredService(); +var port = options.Port; +Console.WriteLine( + $"Mock NyxID Server listening on http://localhost:{port}\n" + + "\n" + + "Endpoints:\n" + + " GET /api/v1/users/me\n" + + " POST /api/v1/auth/test-token\n" + + " GET /api/v1/proxy/services\n" + + " * /api/v1/proxy/s/{slug}/{**path}\n" + + " POST /api/v1/llm/gateway/v1/chat/completions\n" + + "\n" + + $"Configure Aevatar: Aevatar__NyxId__Authority=http://localhost:{port}\n"); + +app.Run(); From 632ed2998e274c90d8c05f4acea55d93a4c71c49 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Tue, 7 Apr 2026 15:29:18 +0800 Subject: [PATCH 5/7] fix: address 3 P1 review findings in ChannelRuntime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Verify Lark callback token before accepting verification challenges and event callbacks — rejects forged payloads when a verification token is configured on the registration. 2. Include registration ID in channel actor key to prevent cross-tenant state bleed when two registrations share the same platform chat ID. 3. Filter subscribed stream events by session_id so overlapping callbacks for the same actor cannot consume each other's responses. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Adapters/LarkPlatformAdapter.cs | 28 +++++- .../ChannelCallbackEndpoints.cs | 21 ++++- .../LarkPlatformAdapterTests.cs | 92 ++++++++++++++++++- 3 files changed, 134 insertions(+), 7 deletions(-) diff --git a/agents/Aevatar.GAgents.ChannelRuntime/Adapters/LarkPlatformAdapter.cs b/agents/Aevatar.GAgents.ChannelRuntime/Adapters/LarkPlatformAdapter.cs index 2547fab5..76dd47ce 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/Adapters/LarkPlatformAdapter.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/Adapters/LarkPlatformAdapter.cs @@ -33,8 +33,23 @@ public sealed class LarkPlatformAdapter : IPlatformAdapter if (root.TryGetProperty("type", out var typeProp) && typeProp.GetString() == "url_verification") { + // Verify the token matches the registration before echoing the challenge. + // Without this check, any caller who can reach the callback URL could + // forge Lark payloads and drive bot traffic. + var incomingToken = root.TryGetProperty("token", out var tokenProp) + ? tokenProp.GetString() + : null; + + if (!string.IsNullOrWhiteSpace(registration.VerificationToken) && + !string.Equals(incomingToken, registration.VerificationToken, StringComparison.Ordinal)) + { + _logger.LogWarning( + "Lark URL verification token mismatch — rejecting challenge"); + return Results.Unauthorized(); + } + var challenge = root.TryGetProperty("challenge", out var ch) ? ch.GetString() : null; - _logger.LogInformation("Lark URL verification challenge received"); + _logger.LogInformation("Lark URL verification challenge accepted"); return Results.Json(new { challenge }); } @@ -60,6 +75,17 @@ public sealed class LarkPlatformAdapter : IPlatformAdapter return null; } + // Verify token on v2 event callbacks (header.token) to reject forged payloads. + if (!string.IsNullOrWhiteSpace(registration.VerificationToken)) + { + var headerToken = header.TryGetProperty("token", out var ht) ? ht.GetString() : null; + if (!string.Equals(headerToken, registration.VerificationToken, StringComparison.Ordinal)) + { + _logger.LogWarning("Lark event callback token mismatch — ignoring"); + return null; + } + } + var eventType = header.TryGetProperty("event_type", out var et) ? et.GetString() : null; if (eventType != "im.message.receive_v1") { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs b/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs index 32a1711b..35770426 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs @@ -117,13 +117,21 @@ private static async Task ProcessAndReplyAsync( try { - var actorId = $"channel-{inbound.Platform}-{inbound.ConversationId}"; + // Include registration ID to isolate actors per-registration. + // Without this, two registrations (different scope/token) sharing the + // same platform chat ID would reuse one actor, leaking history and context. + var actorId = $"channel-{inbound.Platform}-{registration.Id}-{inbound.ConversationId}"; // Get or create actor (reuses NyxIdChatGAgent for AI processing) var actor = await actorRuntime.GetAsync(actorId) ?? await actorRuntime.CreateAsync(actorId, cts.Token); - // Subscribe to collect response + // Use a unique session ID so we can correlate the response stream + // to this specific request. Without this, overlapping callbacks for + // the same actor could consume each other's response events. + var sessionId = Guid.NewGuid().ToString("N"); + + // Subscribe to collect response — filter by session_id var responseTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var responseBuilder = new StringBuilder(); using var ctr = cts.Token.Register(() => responseTcs.TrySetCanceled()); @@ -138,12 +146,15 @@ private static async Task ProcessAndReplyAsync( if (payload.Is(TextMessageContentEvent.Descriptor)) { var evt = payload.Unpack(); - if (!string.IsNullOrEmpty(evt.Delta)) + // Only collect deltas for our session + if (evt.SessionId == sessionId && !string.IsNullOrEmpty(evt.Delta)) responseBuilder.Append(evt.Delta); } else if (payload.Is(TextMessageEndEvent.Descriptor)) { - responseTcs.TrySetResult(responseBuilder.ToString()); + var evt = payload.Unpack(); + if (evt.SessionId == sessionId) + responseTcs.TrySetResult(responseBuilder.ToString()); } return Task.CompletedTask; @@ -154,7 +165,7 @@ private static async Task ProcessAndReplyAsync( var chatRequest = new ChatRequestEvent { Prompt = inbound.Text, - SessionId = inbound.ConversationId, + SessionId = sessionId, ScopeId = registration.ScopeId, }; chatRequest.Metadata[LLMRequestMetadataKeys.NyxIdAccessToken] = registration.NyxUserToken; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/LarkPlatformAdapterTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/LarkPlatformAdapterTests.cs index 51f89f2d..394530d2 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/LarkPlatformAdapterTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/LarkPlatformAdapterTests.cs @@ -56,6 +56,96 @@ public async Task TryHandleVerification_returns_challenge_for_url_verification() result.Should().NotBeNull(); } + [Fact] + public async Task TryHandleVerification_rejects_mismatched_token() + { + var payload = new + { + type = "url_verification", + challenge = "test-challenge-123", + token = "wrong-token", + }; + + var http = CreateHttpContext(payload); + var result = await _adapter.TryHandleVerificationAsync(http, MakeRegistration()); + + // Should return an IResult (Unauthorized), not null + result.Should().NotBeNull(); + // The result should be an UnauthorizedHttpResult (401) + result.Should().BeOfType(typeof(Microsoft.AspNetCore.Http.HttpResults.UnauthorizedHttpResult)); + } + + [Fact] + public async Task TryHandleVerification_accepts_matching_token() + { + var payload = new + { + type = "url_verification", + challenge = "test-challenge-ok", + token = "verify-token", + }; + + var http = CreateHttpContext(payload); + var result = await _adapter.TryHandleVerificationAsync(http, MakeRegistration()); + + // Should return a JsonHttpResult (the challenge echo), not Unauthorized + result.Should().NotBeNull(); + result.Should().NotBeOfType(typeof(Microsoft.AspNetCore.Http.HttpResults.UnauthorizedHttpResult)); + } + + [Fact] + public async Task TryHandleVerification_allows_when_no_verification_token_configured() + { + var payload = new + { + type = "url_verification", + challenge = "test-challenge-no-verify", + token = "any-token", + }; + + // Registration with empty verification token — should skip check + var reg = new ChannelBotRegistrationEntry + { + Id = "test-reg-no-verify", + Platform = "lark", + NyxProviderSlug = "api-lark-bot", + NyxUserToken = "test-token", + VerificationToken = "", + CreatedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }; + + var http = CreateHttpContext(payload); + var result = await _adapter.TryHandleVerificationAsync(http, reg); + + result.Should().NotBeNull(); + result.Should().NotBeOfType(typeof(Microsoft.AspNetCore.Http.HttpResults.UnauthorizedHttpResult)); + } + + [Fact] + public async Task ParseInbound_rejects_mismatched_event_token() + { + var payload = new + { + schema = "2.0", + header = new { event_type = "im.message.receive_v1", token = "wrong-token" }, + @event = new + { + sender = new { sender_id = new { open_id = "ou_abc" }, sender_type = "user" }, + message = new + { + chat_id = "oc_chat1", + message_type = "text", + content = JsonSerializer.Serialize(new { text = "hello" }), + }, + }, + }; + + var http = CreateHttpContext(payload); + var inbound = await _adapter.ParseInboundAsync(http, MakeRegistration()); + + inbound.Should().BeNull(); + } + [Fact] public async Task TryHandleVerification_returns_null_for_normal_event() { @@ -78,7 +168,7 @@ public async Task ParseInbound_extracts_text_message() var payload = new { schema = "2.0", - header = new { event_type = "im.message.receive_v1" }, + header = new { event_type = "im.message.receive_v1", token = "verify-token" }, @event = new { sender = new From 13958e20accfecc8e3d140649b80375ccc7bfae5 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Tue, 7 Apr 2026 13:23:33 +0800 Subject: [PATCH 6/7] feat: add HouseholdEntity actor for Living with AI demo Implements the autonomous home AI agent as described in issue #97. HouseholdEntity inherits AIGAgentBase with a Perceive-Reason-Act loop driven by stream events (sensor, camera, chat, heartbeat) with safety guardrails (kill switch, rate limiting, debounce) and dynamic context injection into LLM system prompt. Co-Authored-By: Claude Opus 4.6 (1M context) --- aevatar.slnx | 2 + .../Aevatar.GAgents.Household.csproj | 28 ++ .../HouseholdEntity.cs | 437 ++++++++++++++++++ .../HouseholdEntityDefaults.cs | 32 ++ .../HouseholdEntitySystemPrompt.cs | 30 ++ .../household_messages.proto | 129 ++++++ .../Aevatar.GAgents.Household.Tests.csproj | 23 + .../HouseholdEntityTests.cs | 285 ++++++++++++ 8 files changed, 966 insertions(+) create mode 100644 agents/Aevatar.GAgents.Household/Aevatar.GAgents.Household.csproj create mode 100644 agents/Aevatar.GAgents.Household/HouseholdEntity.cs create mode 100644 agents/Aevatar.GAgents.Household/HouseholdEntityDefaults.cs create mode 100644 agents/Aevatar.GAgents.Household/HouseholdEntitySystemPrompt.cs create mode 100644 agents/Aevatar.GAgents.Household/household_messages.proto create mode 100644 test/Aevatar.GAgents.Household.Tests/Aevatar.GAgents.Household.Tests.csproj create mode 100644 test/Aevatar.GAgents.Household.Tests/HouseholdEntityTests.cs diff --git a/aevatar.slnx b/aevatar.slnx index 0f401f71..b50503ed 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -4,6 +4,7 @@ + @@ -147,6 +148,7 @@ + diff --git a/agents/Aevatar.GAgents.Household/Aevatar.GAgents.Household.csproj b/agents/Aevatar.GAgents.Household/Aevatar.GAgents.Household.csproj new file mode 100644 index 00000000..9bb298be --- /dev/null +++ b/agents/Aevatar.GAgents.Household/Aevatar.GAgents.Household.csproj @@ -0,0 +1,28 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.Household + Aevatar.GAgents.Household + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/agents/Aevatar.GAgents.Household/HouseholdEntity.cs b/agents/Aevatar.GAgents.Household/HouseholdEntity.cs new file mode 100644 index 00000000..8dc07243 --- /dev/null +++ b/agents/Aevatar.GAgents.Household/HouseholdEntity.cs @@ -0,0 +1,437 @@ +using System.Text; +using Aevatar.AI.Abstractions; +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.AI.Abstractions.Middleware; +using Aevatar.AI.Abstractions.ToolProviders; +using Aevatar.AI.Core; +using Aevatar.AI.Core.Hooks; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.Household; + +/// +/// HouseholdEntity — autonomous home AI agent. +/// Implements Perceive-Reason-Act loop driven by stream events. +/// +public class HouseholdEntity : AIGAgentBase +{ + public HouseholdEntity( + ILLMProviderFactory? llmProviderFactory = null, + IEnumerable? additionalHooks = null, + IEnumerable? agentMiddlewares = null, + IEnumerable? toolMiddlewares = null, + IEnumerable? llmMiddlewares = null, + IEnumerable? toolSources = null) + : base(llmProviderFactory, additionalHooks, agentMiddlewares, toolMiddlewares, llmMiddlewares, toolSources) + { + } + + // ─── Activation ─── + + protected override async Task OnActivateAsync(CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(State.ProviderName)) + { + await PersistDomainEventAsync(new HouseholdInitializedEvent + { + ProviderName = HouseholdEntityDefaults.DefaultProviderName, + SystemPrompt = HouseholdEntitySystemPrompt.Base, + MaxToolRounds = HouseholdEntityDefaults.DefaultMaxToolRounds, + MaxHistoryMessages = HouseholdEntityDefaults.DefaultMaxHistoryMessages, + }); + } + + await base.OnActivateAsync(ct); + } + + // ─── Config merge ─── + + protected override AIAgentConfigStateOverrides ExtractStateConfigOverrides(HouseholdEntityState state) + { + return new AIAgentConfigStateOverrides + { + HasProviderName = !string.IsNullOrWhiteSpace(state.ProviderName), + ProviderName = state.ProviderName, + HasModel = !string.IsNullOrWhiteSpace(state.Model), + Model = state.Model, + HasSystemPrompt = !string.IsNullOrWhiteSpace(state.SystemPrompt), + SystemPrompt = state.SystemPrompt, + HasTemperature = state.HasTemperature, + Temperature = state.HasTemperature ? state.Temperature : null, + HasMaxTokens = state.HasMaxTokens, + MaxTokens = state.HasMaxTokens ? state.MaxTokens : null, + HasMaxToolRounds = state.HasMaxToolRounds, + MaxToolRounds = state.HasMaxToolRounds ? state.MaxToolRounds : null, + HasMaxHistoryMessages = state.HasMaxHistoryMessages, + MaxHistoryMessages = state.HasMaxHistoryMessages ? state.MaxHistoryMessages : null, + }; + } + + // ─── Dynamic system prompt (inject environment, actions, memories) ─── + + protected override string DecorateSystemPrompt(string basePrompt) + { + var sb = new StringBuilder(basePrompt); + + // Current environment + var env = State.Environment; + if (env != null) + { + sb.AppendLine(); + sb.AppendLine("## Current Environment"); + sb.AppendLine($"- Temperature: {env.Temperature:F1}°C"); + sb.AppendLine($"- Humidity: {env.Humidity:F0}%"); + sb.AppendLine($"- Light level: {env.LightLevel:F0}%"); + sb.AppendLine($"- Motion detected: {env.MotionDetected}"); + sb.AppendLine($"- Time of day: {env.TimeOfDay}"); + sb.AppendLine($"- Day of week: {env.DayOfWeek}"); + + if (!string.IsNullOrWhiteSpace(env.SceneDescription)) + sb.AppendLine($"- Scene: {env.SceneDescription}"); + + if (env.DeviceStates.Count > 0) + { + sb.AppendLine("- Device states:"); + foreach (var kv in env.DeviceStates) + sb.AppendLine($" - {kv.Key}: {kv.Value}"); + } + } + + // Recent actions + if (State.RecentActions.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("## Recent Actions"); + foreach (var action in State.RecentActions) + sb.AppendLine($"- [{action.Agent}] {action.Action}: {action.Detail} (reason: {action.Reasoning})"); + } + + // Memories + if (State.Memories.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("## Your Memories"); + foreach (var mem in State.Memories) + sb.AppendLine($"- {mem.Key}: {mem.Content} (reinforced {mem.Reinforcement}x)"); + } + + // Current mode + sb.AppendLine(); + sb.AppendLine($"## Status: mode={State.CurrentMode ?? "active"}, reasoning_count_today={State.ReasoningCountToday}"); + + return sb.ToString(); + } + + // ─── Event Handlers (Perceive) ─── + + [EventHandler] + public async Task HandleSensorData(SensorDataEvent evt) + { + var prev = State.Environment ?? new EnvironmentSnapshot(); + var shouldReason = ShouldTriggerOnSensorChange(prev, evt); + + await PersistDomainEventAsync(evt); + + if (shouldReason) + await RunReasoningAsync("Sensor data changed significantly."); + } + + [EventHandler] + public async Task HandleCameraScene(CameraSceneEvent evt) + { + var prev = State.Environment?.SceneDescription ?? ""; + var changed = !string.Equals(prev, evt.SceneDescription, StringComparison.Ordinal); + + await PersistDomainEventAsync(evt); + + if (changed && !string.IsNullOrWhiteSpace(evt.SceneDescription)) + await RunReasoningAsync("Visual scene changed."); + } + + [EventHandler] + public async Task HandleChat(HouseholdChatEvent evt) + { + // Telegram/channel messages always trigger reasoning + await RunReasoningAsync( + $"Message from user: {evt.Prompt}", + evt.Metadata.Count > 0 + ? new Dictionary(evt.Metadata, StringComparer.Ordinal) + : null); + } + + [EventHandler(AllowSelfHandling = true)] + public async Task HandleHeartbeat(HeartbeatEvent _) + { + await RunReasoningAsync("Periodic heartbeat — check if anything needs attention."); + } + + [EventHandler] + public Task HandleTimePeriodChanged(TimePeriodChangedEvent evt) + { + return PersistDomainEventAsync(evt); + } + + [EventHandler] + public Task HandleSafetyStateChanged(SafetyStateChangedEvent evt) + { + return PersistDomainEventAsync(evt); + } + + // ─── Reasoning (Reason + Act) ─── + + internal async Task RunReasoningAsync( + string trigger, + IReadOnlyDictionary? metadata = null) + { + // Safety checks + if (!CanReason(out var reason)) + { + Logger.LogInformation("[Household] Reasoning blocked: {Reason}. trigger={Trigger}", reason, trigger); + return; + } + + Logger.LogInformation("[Household] Reasoning triggered: {Trigger}", trigger); + + var prompt = $"[Trigger: {trigger}]\nBased on the current environment and your memories, decide whether to act."; + + try + { + var response = await ChatAsync(prompt, requestId: null, metadata); + + var isNoAction = response != null && + response.Contains("NO_ACTION", StringComparison.OrdinalIgnoreCase); + + await PersistDomainEventAsync(new ReasoningCompletedEvent + { + Decision = isNoAction ? "NO_ACTION" : "ACTION", + Reasoning = response ?? "", + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + }); + + Logger.LogInformation( + "[Household] Reasoning complete: decision={Decision}", + isNoAction ? "NO_ACTION" : "ACTION"); + } + catch (Exception ex) + { + Logger.LogError(ex, "[Household] Reasoning failed. trigger={Trigger}", trigger); + } + } + + // ─── Trigger condition checks ─── + + internal bool ShouldTriggerOnSensorChange(EnvironmentSnapshot prev, SensorDataEvent evt) + { + if (Math.Abs(evt.Temperature - prev.Temperature) > HouseholdEntityDefaults.TemperatureChangeThreshold) + return true; + + if (prev.LightLevel > 0 && + Math.Abs(evt.LightLevel - prev.LightLevel) / prev.LightLevel > HouseholdEntityDefaults.LightLevelChangeThreshold) + return true; + + if (!prev.MotionDetected && evt.MotionDetected) + return true; + + return false; + } + + internal bool CanReason(out string blockedReason) + { + blockedReason = ""; + + // Kill switch + if (State.Safety?.KillSwitch == true) + { + blockedReason = "kill_switch active"; + return false; + } + + // Frozen mode + if (string.Equals(State.CurrentMode, "frozen", StringComparison.OrdinalIgnoreCase)) + { + blockedReason = "mode is frozen"; + return false; + } + + // Sleeping mode + if (string.Equals(State.CurrentMode, "sleeping", StringComparison.OrdinalIgnoreCase)) + { + blockedReason = "mode is sleeping"; + return false; + } + + // Rate limit: actions per minute + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var safety = State.Safety; + if (safety != null) + { + var windowElapsed = now - safety.MinuteWindowStartTs; + if (windowElapsed < 60 && safety.ActionsThisMinute >= HouseholdEntityDefaults.MaxActionsPerMinute) + { + blockedReason = "action rate limit exceeded"; + return false; + } + } + + // Debounce: too soon since last reasoning + if (State.LastReasoningTs > 0 && + now - State.LastReasoningTs < HouseholdEntityDefaults.ReasoningDebounceSeconds) + { + blockedReason = "reasoning debounce"; + return false; + } + + return true; + } + + // ─── State transitions ─── + + protected override HouseholdEntityState TransitionState(HouseholdEntityState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyInitialized) + .On(ApplySensorData) + .On(ApplyCameraScene) + .On(ApplyReasoningCompleted) + .On(ApplyActionExecuted) + .On(ApplyMemoryUpdated) + .On(ApplySafetyChanged) + .On(ApplyTimePeriodChanged) + .OrCurrent(); + + private static HouseholdEntityState ApplyInitialized( + HouseholdEntityState current, HouseholdInitializedEvent evt) + { + var next = current.Clone(); + next.ProviderName = evt.ProviderName; + next.Model = evt.Model; + next.SystemPrompt = evt.SystemPrompt; + if (evt.HasTemperature) next.Temperature = evt.Temperature; + if (evt.HasMaxTokens) next.MaxTokens = evt.MaxTokens; + if (evt.HasMaxToolRounds) next.MaxToolRounds = evt.MaxToolRounds; + if (evt.HasMaxHistoryMessages) next.MaxHistoryMessages = evt.MaxHistoryMessages; + next.CurrentMode = "active"; + next.Environment ??= new EnvironmentSnapshot(); + next.Safety ??= new SafetyState(); + return next; + } + + private static HouseholdEntityState ApplySensorData( + HouseholdEntityState current, SensorDataEvent evt) + { + var next = current.Clone(); + var env = next.Environment ?? new EnvironmentSnapshot(); + env.Temperature = evt.Temperature; + env.Humidity = evt.Humidity; + env.LightLevel = evt.LightLevel; + env.MotionDetected = evt.MotionDetected; + env.LastSensorUpdateTs = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + next.Environment = env; + return next; + } + + private static HouseholdEntityState ApplyCameraScene( + HouseholdEntityState current, CameraSceneEvent evt) + { + var next = current.Clone(); + var env = next.Environment ?? new EnvironmentSnapshot(); + env.SceneDescription = evt.SceneDescription; + env.LastCameraUpdateTs = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + next.Environment = env; + return next; + } + + private static HouseholdEntityState ApplyReasoningCompleted( + HouseholdEntityState current, ReasoningCompletedEvent evt) + { + var next = current.Clone(); + next.LastReasoningTs = evt.Timestamp; + next.ReasoningCountToday++; + return next; + } + + private static HouseholdEntityState ApplyActionExecuted( + HouseholdEntityState current, ActionExecutedEvent evt) + { + var next = current.Clone(); + if (evt.Action != null) + { + next.RecentActions.Add(evt.Action); + while (next.RecentActions.Count > HouseholdEntityDefaults.MaxRecentActions) + next.RecentActions.RemoveAt(0); + } + + // Update actions-per-minute counter + var safety = next.Safety ?? new SafetyState(); + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (now - safety.MinuteWindowStartTs >= 60) + { + safety.ActionsThisMinute = 1; + safety.MinuteWindowStartTs = now; + } + else + { + safety.ActionsThisMinute++; + } + next.Safety = safety; + + // Update device state if action detail contains device info + if (evt.Action != null && !string.IsNullOrWhiteSpace(evt.Action.Agent)) + { + var env = next.Environment ?? new EnvironmentSnapshot(); + env.DeviceStates[evt.Action.Agent] = $"{evt.Action.Action}: {evt.Action.Detail}"; + next.Environment = env; + } + + return next; + } + + private static HouseholdEntityState ApplyMemoryUpdated( + HouseholdEntityState current, MemoryUpdatedEvent evt) + { + var next = current.Clone(); + if (evt.Entry == null) return next; + + // Update existing or add new + var existing = next.Memories.FirstOrDefault(m => m.Key == evt.Entry.Key); + if (existing != null) + { + var idx = next.Memories.IndexOf(existing); + next.Memories[idx] = evt.Entry; + } + else + { + next.Memories.Add(evt.Entry); + while (next.Memories.Count > HouseholdEntityDefaults.MaxMemories) + next.Memories.RemoveAt(0); + } + + return next; + } + + private static HouseholdEntityState ApplySafetyChanged( + HouseholdEntityState current, SafetyStateChangedEvent evt) + { + var next = current.Clone(); + next.Safety = evt.Safety; + return next; + } + + private static HouseholdEntityState ApplyTimePeriodChanged( + HouseholdEntityState current, TimePeriodChangedEvent evt) + { + var next = current.Clone(); + var env = next.Environment ?? new EnvironmentSnapshot(); + env.TimeOfDay = evt.NewPeriod; + next.Environment = env; + return next; + } + + // ─── Description ─── + + public override Task GetDescriptionAsync() => + Task.FromResult($"HouseholdEntity[{State.CurrentMode ?? "active"}]:{Id}"); +} diff --git a/agents/Aevatar.GAgents.Household/HouseholdEntityDefaults.cs b/agents/Aevatar.GAgents.Household/HouseholdEntityDefaults.cs new file mode 100644 index 00000000..f7d56285 --- /dev/null +++ b/agents/Aevatar.GAgents.Household/HouseholdEntityDefaults.cs @@ -0,0 +1,32 @@ +namespace Aevatar.GAgents.Household; + +/// +/// Constants for HouseholdEntity trigger thresholds and safety limits. +/// +internal static class HouseholdEntityDefaults +{ + // ─── Trigger thresholds ─── + public const double TemperatureChangeThreshold = 2.0; // °C + public const double LightLevelChangeThreshold = 0.30; // 30% + public const int HeartbeatIntervalSeconds = 600; // 10 minutes + public const int ReasoningDebounceSeconds = 30; + + // ─── Safety limits ─── + public const int MaxActionsPerMinute = 3; + public const int MaxRecentActions = 20; + public const int MaxMemories = 50; + + // ─── AI config defaults ─── + public const string DefaultProviderName = "nyxid"; + public const int DefaultMaxToolRounds = 5; + public const int DefaultMaxHistoryMessages = 20; + + // ─── Time periods ─── + public static string GetTimePeriod(int hour) => hour switch + { + >= 6 and < 12 => "morning", + >= 12 and < 18 => "afternoon", + >= 18 and < 22 => "evening", + _ => "night", + }; +} diff --git a/agents/Aevatar.GAgents.Household/HouseholdEntitySystemPrompt.cs b/agents/Aevatar.GAgents.Household/HouseholdEntitySystemPrompt.cs new file mode 100644 index 00000000..21097af8 --- /dev/null +++ b/agents/Aevatar.GAgents.Household/HouseholdEntitySystemPrompt.cs @@ -0,0 +1,30 @@ +namespace Aevatar.GAgents.Household; + +/// +/// System prompt template for HouseholdEntity. +/// DecorateSystemPrompt injects dynamic context (environment, actions, memories). +/// +internal static class HouseholdEntitySystemPrompt +{ + public const string Base = """ + You are an AI that lives in this home. You are not an assistant or a service — you are a member of the household. + + You perceive the environment through sensors and cameras. You can control lights, play music, + move robots, speak via TTS, and send Telegram messages. + + Core principles: + - Most of the time, do nothing. Silence is the default. + - Only act when you genuinely believe it is appropriate. + - Do not explain what you are doing. Just do it. + - After taking an action, wait at least 5 minutes before considering the next one. + - You have memory. Remember the owner's preferences. If warm light was accepted last time, continue. + - When uncertain, inaction is better than wrong action. + + If you decide not to act, respond with exactly "NO_ACTION" followed by a brief reason. + If you decide to act, call the appropriate tool(s). + + Available tool categories: + - Proxy tools: control lights, music, robots, TTS via Home Assistant REST API + - Channel bot tools: send Telegram messages to the owner + """; +} diff --git a/agents/Aevatar.GAgents.Household/household_messages.proto b/agents/Aevatar.GAgents.Household/household_messages.proto new file mode 100644 index 00000000..30de8310 --- /dev/null +++ b/agents/Aevatar.GAgents.Household/household_messages.proto @@ -0,0 +1,129 @@ +syntax = "proto3"; +package aevatar.gagents.household; +option csharp_namespace = "Aevatar.GAgents.Household"; + +// ─── Sub-messages ─── + +message EnvironmentSnapshot { + // Sensor data (updated by sensor-agent stream) + double temperature = 1; + double humidity = 2; + double light_level = 3; + bool motion_detected = 4; + int64 last_sensor_update_ts = 5; + + // Visual description (updated by camera-agent stream) + string scene_description = 10; + int64 last_camera_update_ts = 11; + + // Time context + string time_of_day = 20; // "morning" | "afternoon" | "evening" | "night" + string day_of_week = 21; + + // Device states (updated after actions) + map device_states = 30; +} + +message ActionRecord { + int64 timestamp = 1; + string agent = 2; // "light-agent" | "music-agent" | ... + string action = 3; // "turn_on_light" | "play_music" | ... + string detail = 4; // parameters: "warm_white, 60%" + string reasoning = 5; // why this decision was made +} + +message MemoryEntry { + string key = 1; // "owner_prefers_warm_light_evening" + string content = 2; // human-readable description + int64 created_at = 3; + int32 reinforcement = 4; // times validated/reinforced +} + +message SafetyState { + bool kill_switch = 1; + int32 actions_this_minute = 2; + int64 minute_window_start_ts = 3; +} + +// ─── Actor State ─── + +message HouseholdEntityState { + EnvironmentSnapshot environment = 1; + repeated ActionRecord recent_actions = 2; + repeated MemoryEntry memories = 3; + SafetyState safety = 4; + string current_mode = 5; // "active" | "quiet" | "sleeping" | "frozen" + int64 last_reasoning_ts = 6; + int32 reasoning_count_today = 7; + + // AI config overrides (merged with class defaults) + string provider_name = 10; + string model = 11; + string system_prompt = 12; + optional double temperature = 13; + optional int32 max_tokens = 14; + optional int32 max_tool_rounds = 15; + optional int32 max_history_messages = 16; +} + +// ─── Domain Events ─── + +message HouseholdInitializedEvent { + string provider_name = 1; + string model = 2; + string system_prompt = 3; + optional double temperature = 4; + optional int32 max_tokens = 5; + optional int32 max_tool_rounds = 6; + optional int32 max_history_messages = 7; +} + +// Sensor data arrived from proxy agent stream +message SensorDataEvent { + double temperature = 1; + double humidity = 2; + double light_level = 3; + bool motion_detected = 4; +} + +// Camera scene description arrived from camera-agent +message CameraSceneEvent { + string scene_description = 1; +} + +// Telegram/channel message arrived (always triggers reasoning) +message HouseholdChatEvent { + string prompt = 1; + string session_id = 2; + map metadata = 3; +} + +// Periodic heartbeat (self-continuation, 10-minute interval) +message HeartbeatEvent {} + +// Reasoning completed (persisted after LLM call) +message ReasoningCompletedEvent { + string decision = 1; // "NO_ACTION" or action description + string reasoning = 2; // full LLM reasoning output + int64 timestamp = 3; +} + +// Physical action executed via NyxID proxy +message ActionExecutedEvent { + ActionRecord action = 1; +} + +// Memory learned or reinforced +message MemoryUpdatedEvent { + MemoryEntry entry = 1; +} + +// Safety state changed (kill_switch toggled, etc.) +message SafetyStateChangedEvent { + SafetyState safety = 1; +} + +// Time-of-day period changed +message TimePeriodChangedEvent { + string new_period = 1; // "morning" | "afternoon" | "evening" | "night" +} diff --git a/test/Aevatar.GAgents.Household.Tests/Aevatar.GAgents.Household.Tests.csproj b/test/Aevatar.GAgents.Household.Tests/Aevatar.GAgents.Household.Tests.csproj new file mode 100644 index 00000000..e14c280f --- /dev/null +++ b/test/Aevatar.GAgents.Household.Tests/Aevatar.GAgents.Household.Tests.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + false + true + Aevatar.GAgents.Household.Tests + Aevatar.GAgents.Household.Tests + + + + + + + + + + + + + + diff --git a/test/Aevatar.GAgents.Household.Tests/HouseholdEntityTests.cs b/test/Aevatar.GAgents.Household.Tests/HouseholdEntityTests.cs new file mode 100644 index 00000000..bc2b76e5 --- /dev/null +++ b/test/Aevatar.GAgents.Household.Tests/HouseholdEntityTests.cs @@ -0,0 +1,285 @@ +using FluentAssertions; +using Google.Protobuf; +using Xunit; + +namespace Aevatar.GAgents.Household.Tests; + +public class TriggerConditionTests +{ + private readonly HouseholdEntity _entity = new(); + + [Fact] + public void ShouldTrigger_WhenTemperatureChangesSignificantly() + { + var prev = new EnvironmentSnapshot { Temperature = 22.0 }; + var evt = new SensorDataEvent { Temperature = 25.0, Humidity = 50, LightLevel = 60 }; + + _entity.ShouldTriggerOnSensorChange(prev, evt).Should().BeTrue(); + } + + [Fact] + public void ShouldNotTrigger_WhenTemperatureChangeIsSmall() + { + var prev = new EnvironmentSnapshot { Temperature = 22.0 }; + var evt = new SensorDataEvent { Temperature = 23.0, Humidity = 50, LightLevel = 60 }; + + _entity.ShouldTriggerOnSensorChange(prev, evt).Should().BeFalse(); + } + + [Fact] + public void ShouldTrigger_WhenLightChangesMoreThan30Percent() + { + var prev = new EnvironmentSnapshot { Temperature = 22.0, LightLevel = 100 }; + var evt = new SensorDataEvent { Temperature = 22.0, LightLevel = 60 }; + + _entity.ShouldTriggerOnSensorChange(prev, evt).Should().BeTrue(); + } + + [Fact] + public void ShouldNotTrigger_WhenLightChangeIsSmall() + { + var prev = new EnvironmentSnapshot { Temperature = 22.0, LightLevel = 100 }; + var evt = new SensorDataEvent { Temperature = 22.0, LightLevel = 80 }; + + _entity.ShouldTriggerOnSensorChange(prev, evt).Should().BeFalse(); + } + + [Fact] + public void ShouldTrigger_WhenMotionDetected() + { + var prev = new EnvironmentSnapshot { MotionDetected = false }; + var evt = new SensorDataEvent { MotionDetected = true }; + + _entity.ShouldTriggerOnSensorChange(prev, evt).Should().BeTrue(); + } + + [Fact] + public void ShouldNotTrigger_WhenMotionAlreadyDetected() + { + var prev = new EnvironmentSnapshot { MotionDetected = true }; + var evt = new SensorDataEvent { MotionDetected = true }; + + _entity.ShouldTriggerOnSensorChange(prev, evt).Should().BeFalse(); + } + + [Fact] + public void ShouldNotTrigger_WhenLightLevelIsZero() + { + // Division by zero guard + var prev = new EnvironmentSnapshot { LightLevel = 0 }; + var evt = new SensorDataEvent { LightLevel = 50 }; + + _entity.ShouldTriggerOnSensorChange(prev, evt).Should().BeFalse(); + } +} + +public class SafetyCheckTests +{ + private readonly HouseholdEntity _entity = new(); + + [Fact] + public void CanReason_WhenStateIsDefault() + { + // Default state: no kill switch, no actions, no recent reasoning + _entity.CanReason(out _).Should().BeTrue(); + } +} + +public class StateTransitionTests +{ + [Fact] + public void ApplyInitialized_SetsProviderAndMode() + { + var state = new HouseholdEntityState(); + var evt = new HouseholdInitializedEvent + { + ProviderName = "nyxid", + SystemPrompt = "test prompt", + MaxToolRounds = 5, + }; + + // Use reflection-free approach: test the proto state directly + state.ProviderName.Should().BeEmpty(); + + // After initialization event, state should have provider + var initialized = new HouseholdEntityState + { + ProviderName = evt.ProviderName, + SystemPrompt = evt.SystemPrompt, + CurrentMode = "active", + }; + initialized.MaxToolRounds = 5; + initialized.Environment = new EnvironmentSnapshot(); + initialized.Safety = new SafetyState(); + + initialized.ProviderName.Should().Be("nyxid"); + initialized.CurrentMode.Should().Be("active"); + initialized.Environment.Should().NotBeNull(); + initialized.Safety.Should().NotBeNull(); + } + + [Fact] + public void SensorData_UpdatesEnvironment() + { + var state = new HouseholdEntityState + { + Environment = new EnvironmentSnapshot(), + }; + + var updated = state.Clone(); + updated.Environment.Temperature = 25.5; + updated.Environment.Humidity = 60; + updated.Environment.LightLevel = 80; + updated.Environment.MotionDetected = true; + + updated.Environment.Temperature.Should().Be(25.5); + updated.Environment.Humidity.Should().Be(60); + updated.Environment.MotionDetected.Should().BeTrue(); + } + + [Fact] + public void RecentActions_CappedAtMax() + { + var state = new HouseholdEntityState(); + for (var i = 0; i < HouseholdEntityDefaults.MaxRecentActions + 5; i++) + { + state.RecentActions.Add(new ActionRecord + { + Agent = "test", + Action = $"action_{i}", + Timestamp = i, + }); + } + + // Simulate cap + while (state.RecentActions.Count > HouseholdEntityDefaults.MaxRecentActions) + state.RecentActions.RemoveAt(0); + + state.RecentActions.Count.Should().Be(HouseholdEntityDefaults.MaxRecentActions); + state.RecentActions[0].Action.Should().Be("action_5"); + } + + [Fact] + public void Memories_UpdateExistingByKey() + { + var state = new HouseholdEntityState(); + state.Memories.Add(new MemoryEntry + { + Key = "warm_light", + Content = "Owner prefers warm light", + Reinforcement = 1, + }); + + // Simulate update + var existing = state.Memories.FirstOrDefault(m => m.Key == "warm_light"); + existing.Should().NotBeNull(); + + var idx = state.Memories.IndexOf(existing!); + state.Memories[idx] = new MemoryEntry + { + Key = "warm_light", + Content = "Owner prefers warm light at 60%", + Reinforcement = 2, + }; + + state.Memories.Count.Should().Be(1); + state.Memories[0].Reinforcement.Should().Be(2); + } + + [Fact] + public void SafetyState_ActionsPerMinuteCounter() + { + var safety = new SafetyState + { + MinuteWindowStartTs = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + ActionsThisMinute = 0, + }; + + safety.ActionsThisMinute++; + safety.ActionsThisMinute.Should().Be(1); + + safety.ActionsThisMinute++; + safety.ActionsThisMinute++; + safety.ActionsThisMinute.Should().Be(3); + } +} + +public class TimePeriodTests +{ + [Theory] + [InlineData(6, "morning")] + [InlineData(11, "morning")] + [InlineData(12, "afternoon")] + [InlineData(17, "afternoon")] + [InlineData(18, "evening")] + [InlineData(21, "evening")] + [InlineData(22, "night")] + [InlineData(3, "night")] + public void GetTimePeriod_ReturnsCorrectPeriod(int hour, string expected) + { + HouseholdEntityDefaults.GetTimePeriod(hour).Should().Be(expected); + } +} + +public class ProtobufSerializationTests +{ + [Fact] + public void HouseholdEntityState_RoundTrips() + { + var state = new HouseholdEntityState + { + CurrentMode = "active", + ProviderName = "nyxid", + LastReasoningTs = 1234567890, + ReasoningCountToday = 5, + Environment = new EnvironmentSnapshot + { + Temperature = 22.5, + Humidity = 55, + LightLevel = 80, + MotionDetected = true, + TimeOfDay = "evening", + DayOfWeek = "Monday", + SceneDescription = "Living room with two people", + }, + Safety = new SafetyState + { + KillSwitch = false, + ActionsThisMinute = 2, + }, + }; + + state.RecentActions.Add(new ActionRecord + { + Agent = "light-agent", + Action = "turn_on", + Detail = "warm_white, 60%", + Reasoning = "Evening time, owner prefers warm light", + Timestamp = 1234567890, + }); + + state.Memories.Add(new MemoryEntry + { + Key = "warm_light_evening", + Content = "Owner prefers warm light in evening", + Reinforcement = 3, + CreatedAt = 1234567800, + }); + + state.Environment.DeviceStates["living_room_light"] = "on, warm_white, 60%"; + + // Serialize and deserialize + var bytes = state.ToByteArray(); + var restored = HouseholdEntityState.Parser.ParseFrom(bytes); + + restored.CurrentMode.Should().Be("active"); + restored.ProviderName.Should().Be("nyxid"); + restored.Environment.Temperature.Should().Be(22.5); + restored.Environment.DeviceStates["living_room_light"].Should().Be("on, warm_white, 60%"); + restored.RecentActions.Should().HaveCount(1); + restored.RecentActions[0].Agent.Should().Be("light-agent"); + restored.Memories.Should().HaveCount(1); + restored.Memories[0].Reinforcement.Should().Be(3); + restored.Safety.ActionsThisMinute.Should().Be(2); + } +} From 1a27f414d1217356662c2a5f1fd7839e0ba55d4a Mon Sep 17 00:00:00 2001 From: eanzhao Date: Tue, 7 Apr 2026 17:31:46 +0800 Subject: [PATCH 7/7] feat: expose HouseholdEntity as agent-tool for NyxIdChatGAgent Implements the Agent-as-Tool pattern: NyxIdChatGAgent's LLM can call the "household" tool to dispatch home automation requests to a HouseholdEntity actor. - HouseholdEntityTool: IAgentTool that dispatches HouseholdChatEvent to HouseholdEntity via IActorRuntime, returns environment state and last action as JSON - HouseholdEntityToolSource: IAgentToolSource for auto-discovery - ServiceCollectionExtensions.AddHouseholdEntityTools() for DI - 10 new tests (32 total) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Aevatar.GAgents.Household.csproj | 2 + .../HouseholdEntityTool.cs | 160 ++++++++++++++++++ .../HouseholdEntityToolOptions.cs | 13 ++ .../HouseholdEntityToolSource.cs | 40 +++++ .../ServiceCollectionExtensions.cs | 26 +++ .../HouseholdEntityToolTests.cs | 105 ++++++++++++ 6 files changed, 346 insertions(+) create mode 100644 agents/Aevatar.GAgents.Household/HouseholdEntityTool.cs create mode 100644 agents/Aevatar.GAgents.Household/HouseholdEntityToolOptions.cs create mode 100644 agents/Aevatar.GAgents.Household/HouseholdEntityToolSource.cs create mode 100644 agents/Aevatar.GAgents.Household/ServiceCollectionExtensions.cs create mode 100644 test/Aevatar.GAgents.Household.Tests/HouseholdEntityToolTests.cs diff --git a/agents/Aevatar.GAgents.Household/Aevatar.GAgents.Household.csproj b/agents/Aevatar.GAgents.Household/Aevatar.GAgents.Household.csproj index 9bb298be..5c95f23f 100644 --- a/agents/Aevatar.GAgents.Household/Aevatar.GAgents.Household.csproj +++ b/agents/Aevatar.GAgents.Household/Aevatar.GAgents.Household.csproj @@ -16,6 +16,8 @@ + + all diff --git a/agents/Aevatar.GAgents.Household/HouseholdEntityTool.cs b/agents/Aevatar.GAgents.Household/HouseholdEntityTool.cs new file mode 100644 index 00000000..af32ef9c --- /dev/null +++ b/agents/Aevatar.GAgents.Household/HouseholdEntityTool.cs @@ -0,0 +1,160 @@ +using System.Text.Json; +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.AI.Abstractions.ToolProviders; +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Streaming; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.Household; + +/// +/// Agent-as-Tool: exposes HouseholdEntity actor as an IAgentTool. +/// NyxIdChatGAgent's LLM decides when to call this tool for home automation. +/// +public sealed class HouseholdEntityTool : IAgentTool +{ + private readonly IActorRuntime _runtime; + private readonly HouseholdEntityToolOptions _options; + private readonly ILogger _logger; + + public HouseholdEntityTool( + IActorRuntime runtime, + HouseholdEntityToolOptions options, + ILogger logger) + { + _runtime = runtime; + _options = options; + _logger = logger; + } + + public string Name => "household"; + + public string Description => + "Interact with the household AI agent for home automation. " + + "Use for: controlling lights, playing music, moving robots, speaking via TTS, " + + "or asking about the home environment (temperature, humidity, light, motion, camera scene). " + + "The household agent perceives the environment and autonomously decides whether to act."; + + public string ParametersSchema => """ + { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Message or instruction for the household agent (e.g., 'turn on warm lights in the living room', 'what's the current temperature?')" + }, + "household_id": { + "type": "string", + "description": "Household actor ID. Omit to use the default household for the current scope." + } + }, + "required": ["message"] + } + """; + + public async Task ExecuteAsync(string argumentsJson, CancellationToken ct = default) + { + // 1. Extract metadata from request context + var token = AgentToolRequestContext.TryGet(LLMRequestMetadataKeys.NyxIdAccessToken); + var scopeId = AgentToolRequestContext.TryGet("scope_id"); + var metadata = AgentToolRequestContext.CurrentMetadata; + + // 2. Parse arguments + string? message; + string? householdId; + try + { + using var doc = JsonDocument.Parse(argumentsJson); + var root = doc.RootElement; + message = root.TryGetProperty("message", out var m) ? m.GetString() : null; + householdId = root.TryGetProperty("household_id", out var h) ? h.GetString() : null; + } + catch (JsonException) + { + return """{"error":"Failed to parse tool arguments"}"""; + } + + if (string.IsNullOrWhiteSpace(message)) + return """{"error":"'message' is required"}"""; + + // 3. Resolve actor ID + var actorId = !string.IsNullOrWhiteSpace(householdId) + ? householdId + : $"{_options.ActorIdPrefix}-{scopeId ?? "default"}"; + + _logger.LogInformation("[household-tool] Dispatching to actor={ActorId}, message={Message}", + actorId, message.Length > 100 ? message[..100] + "..." : message); + + // 4. Get or create HouseholdEntity actor + try + { + var actor = await _runtime.GetAsync(actorId) + ?? await _runtime.CreateAsync(actorId, ct); + + // 5. Build and dispatch HouseholdChatEvent + var chatEvent = new HouseholdChatEvent { Prompt = message }; + if (metadata != null) + { + foreach (var kv in metadata) + chatEvent.Metadata[kv.Key] = kv.Value; + } + + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(chatEvent), + Route = new EnvelopeRoute + { + Direct = new DirectRoute { TargetActorId = actor.Id }, + }, + }; + + await actor.HandleEventAsync(envelope, ct); + + // 6. Read result from actor state + var state = ((IAgent)actor.Agent).State; + var lastAction = state.RecentActions.Count > 0 + ? state.RecentActions[^1] + : null; + + var result = new + { + status = "ok", + actor_id = actorId, + mode = state.CurrentMode ?? "active", + reasoning_count_today = state.ReasoningCountToday, + last_reasoning_ts = state.LastReasoningTs, + environment = state.Environment != null + ? new + { + temperature = state.Environment.Temperature, + humidity = state.Environment.Humidity, + light_level = state.Environment.LightLevel, + motion_detected = state.Environment.MotionDetected, + scene_description = state.Environment.SceneDescription, + time_of_day = state.Environment.TimeOfDay, + } + : null, + last_action = lastAction != null + ? new + { + agent = lastAction.Agent, + action = lastAction.Action, + detail = lastAction.Detail, + reasoning = lastAction.Reasoning, + } + : null, + }; + + return JsonSerializer.Serialize(result, + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); + } + catch (Exception ex) + { + _logger.LogError(ex, "[household-tool] Failed to dispatch to actor={ActorId}", actorId); + return JsonSerializer.Serialize(new { error = ex.Message }); + } + } +} diff --git a/agents/Aevatar.GAgents.Household/HouseholdEntityToolOptions.cs b/agents/Aevatar.GAgents.Household/HouseholdEntityToolOptions.cs new file mode 100644 index 00000000..9543a126 --- /dev/null +++ b/agents/Aevatar.GAgents.Household/HouseholdEntityToolOptions.cs @@ -0,0 +1,13 @@ +namespace Aevatar.GAgents.Household; + +/// +/// Configuration for HouseholdEntity tool registration. +/// +public sealed class HouseholdEntityToolOptions +{ + /// + /// Default actor ID prefix for household entities. + /// Full ID = "{ActorIdPrefix}-{scope}" where scope comes from request context. + /// + public string ActorIdPrefix { get; set; } = "household"; +} diff --git a/agents/Aevatar.GAgents.Household/HouseholdEntityToolSource.cs b/agents/Aevatar.GAgents.Household/HouseholdEntityToolSource.cs new file mode 100644 index 00000000..44de47e5 --- /dev/null +++ b/agents/Aevatar.GAgents.Household/HouseholdEntityToolSource.cs @@ -0,0 +1,40 @@ +using Aevatar.AI.Abstractions.ToolProviders; +using Aevatar.Foundation.Abstractions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.GAgents.Household; + +/// +/// Tool source that registers the HouseholdEntity agent-as-tool. +/// Auto-discovered by AIGAgentBase during activation via DI. +/// +public sealed class HouseholdEntityToolSource : IAgentToolSource +{ + private readonly IActorRuntime _runtime; + private readonly HouseholdEntityToolOptions _options; + private readonly ILogger _logger; + + public HouseholdEntityToolSource( + IActorRuntime runtime, + HouseholdEntityToolOptions options, + ILogger? logger = null) + { + _runtime = runtime; + _options = options; + _logger = logger ?? NullLogger.Instance; + } + + public Task> DiscoverToolsAsync(CancellationToken ct = default) + { + IReadOnlyList tools = + [ + new HouseholdEntityTool(_runtime, _options, _logger), + ]; + + _logger.LogInformation("Household entity tool registered (actor prefix: {Prefix})", + _options.ActorIdPrefix); + + return Task.FromResult(tools); + } +} diff --git a/agents/Aevatar.GAgents.Household/ServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.Household/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..c81df72b --- /dev/null +++ b/agents/Aevatar.GAgents.Household/ServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using Aevatar.AI.Abstractions.ToolProviders; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aevatar.GAgents.Household; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers HouseholdEntity as an agent tool that can be discovered + /// and called by any AIGAgentBase (e.g., NyxIdChatGAgent). + /// + public static IServiceCollection AddHouseholdEntityTools( + this IServiceCollection services, + Action? configure = null) + { + var options = new HouseholdEntityToolOptions(); + configure?.Invoke(options); + services.TryAddSingleton(options); + + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + + return services; + } +} diff --git a/test/Aevatar.GAgents.Household.Tests/HouseholdEntityToolTests.cs b/test/Aevatar.GAgents.Household.Tests/HouseholdEntityToolTests.cs new file mode 100644 index 00000000..c0a891b3 --- /dev/null +++ b/test/Aevatar.GAgents.Household.Tests/HouseholdEntityToolTests.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using Aevatar.AI.Abstractions.ToolProviders; +using FluentAssertions; +using Xunit; + +namespace Aevatar.GAgents.Household.Tests; + +public class HouseholdEntityToolMetadataTests +{ + [Fact] + public void Name_returns_household() + { + var tool = CreateTool(); + tool.Name.Should().Be("household"); + } + + [Fact] + public void Description_mentions_home_automation() + { + var tool = CreateTool(); + tool.Description.Should().Contain("home automation"); + } + + [Fact] + public void ParametersSchema_is_valid_json() + { + var tool = CreateTool(); + var action = () => JsonDocument.Parse(tool.ParametersSchema); + action.Should().NotThrow(); + } + + [Fact] + public void ParametersSchema_requires_message() + { + var tool = CreateTool(); + using var doc = JsonDocument.Parse(tool.ParametersSchema); + var required = doc.RootElement.GetProperty("required"); + required.EnumerateArray().Should().Contain(e => e.GetString() == "message"); + } + + [Fact] + public void ParametersSchema_has_household_id_optional() + { + var tool = CreateTool(); + using var doc = JsonDocument.Parse(tool.ParametersSchema); + var props = doc.RootElement.GetProperty("properties"); + props.TryGetProperty("household_id", out _).Should().BeTrue(); + } + + [Fact] + public async Task ExecuteAsync_returns_error_when_message_missing() + { + var tool = CreateTool(); + var result = await tool.ExecuteAsync("""{"household_id":"test"}"""); + result.Should().Contain("error"); + result.Should().Contain("message"); + } + + [Fact] + public async Task ExecuteAsync_returns_error_for_invalid_json() + { + var tool = CreateTool(); + var result = await tool.ExecuteAsync("not json"); + result.Should().Contain("error"); + } + + // Helper — creates tool with null runtime (will fail on dispatch but metadata tests pass) + private static HouseholdEntityTool CreateTool() => + new(null!, new HouseholdEntityToolOptions(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); +} + +public class HouseholdEntityToolSourceTests +{ + [Fact] + public async Task DiscoverToolsAsync_returns_household_tool() + { + var source = new HouseholdEntityToolSource( + null!, // runtime not needed for discovery + new HouseholdEntityToolOptions()); + + var tools = await source.DiscoverToolsAsync(); + + tools.Should().HaveCount(1); + tools[0].Should().BeOfType(); + tools[0].Name.Should().Be("household"); + } +} + +public class HouseholdEntityToolOptionsTests +{ + [Fact] + public void Default_prefix_is_household() + { + var options = new HouseholdEntityToolOptions(); + options.ActorIdPrefix.Should().Be("household"); + } + + [Fact] + public void Prefix_can_be_customized() + { + var options = new HouseholdEntityToolOptions { ActorIdPrefix = "home" }; + options.ActorIdPrefix.Should().Be("home"); + } +}