diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9ec0dbee..fa36b723 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -299,7 +299,7 @@ jobs:
if: ${{ success() && env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@v5
with:
- files: ./artifacts/coverage/**/report/Cobertura.xml
+ files: ./artifacts/coverage/**/raw/**/coverage.cobertura.xml
disable_search: true
fail_ci_if_error: true
flags: ci
diff --git a/aevatar.slnx b/aevatar.slnx
index 26b52b2d..6ce68e96 100644
--- a/aevatar.slnx
+++ b/aevatar.slnx
@@ -2,7 +2,14 @@
+
+
+
+
+
+
+
@@ -23,7 +30,6 @@
-
@@ -99,6 +105,7 @@
+
diff --git a/agents/Aevatar.GAgents.ChatHistory/Aevatar.GAgents.ChatHistory.csproj b/agents/Aevatar.GAgents.ChatHistory/Aevatar.GAgents.ChatHistory.csproj
new file mode 100644
index 00000000..769c215f
--- /dev/null
+++ b/agents/Aevatar.GAgents.ChatHistory/Aevatar.GAgents.ChatHistory.csproj
@@ -0,0 +1,24 @@
+
+
+ net10.0
+ enable
+ enable
+ Aevatar.GAgents.ChatHistory
+ Aevatar.GAgents.ChatHistory
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
diff --git a/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs b/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs
new file mode 100644
index 00000000..31ba900c
--- /dev/null
+++ b/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs
@@ -0,0 +1,122 @@
+using Aevatar.Foundation.Abstractions;
+using Aevatar.Foundation.Abstractions.Attributes;
+using Aevatar.Foundation.Core;
+using Aevatar.Foundation.Core.EventSourcing;
+using Google.Protobuf;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Aevatar.GAgents.ChatHistory;
+
+///
+/// Per-conversation actor that holds all messages for a single conversation.
+/// Actor ID: chat-{scopeId}-{conversationId}.
+///
+/// When messages are replaced or the conversation is deleted, forwards
+/// the change to the via SendToAsync,
+/// ensuring transactional consistency between conversation and index actors.
+///
+public sealed class ChatConversationGAgent : GAgentBase
+{
+ /// Maximum messages retained per conversation.
+ internal const int MaxMessages = 500;
+
+ [EventHandler(EndpointName = "replaceMessages")]
+ public async Task HandleMessagesReplaced(MessagesReplacedEvent evt)
+ {
+ if (evt.Meta is null)
+ return;
+
+ // Trim to MaxMessages (keep newest)
+ var trimmed = TrimMessages(evt);
+
+ await PersistDomainEventAsync(trimmed);
+
+ // Forward index upsert to the index actor
+ if (!string.IsNullOrWhiteSpace(evt.ScopeId))
+ {
+ var indexActorId = IndexActorId(evt.ScopeId);
+ await EnsureIndexActorAsync(indexActorId);
+ var indexMeta = State.Meta?.Clone();
+ if (indexMeta is not null)
+ {
+ indexMeta.MessageCount = State.Messages.Count;
+ await SendToAsync(indexActorId, new ConversationUpsertedEvent { Meta = indexMeta });
+ }
+ }
+ }
+
+ [EventHandler(EndpointName = "deleteConversation")]
+ public async Task HandleConversationDeleted(ConversationDeletedEvent evt)
+ {
+ if (string.IsNullOrWhiteSpace(evt.ConversationId))
+ return;
+
+ // Only delete if there is state to delete
+ if (State.Meta is null && State.Messages.Count == 0)
+ return;
+
+ await PersistDomainEventAsync(evt);
+
+ // Forward index removal to the index actor
+ if (!string.IsNullOrWhiteSpace(evt.ScopeId))
+ {
+ var indexActorId = IndexActorId(evt.ScopeId);
+ await EnsureIndexActorAsync(indexActorId);
+ await SendToAsync(indexActorId, new ConversationRemovedEvent { ConversationId = evt.ConversationId });
+ }
+ }
+
+ protected override async Task OnActivateAsync(CancellationToken ct)
+ {
+ await base.OnActivateAsync(ct);
+ }
+
+ protected override ChatConversationState TransitionState(
+ ChatConversationState current, IMessage evt)
+ {
+ return StateTransitionMatcher
+ .Match(current, evt)
+ .On(ApplyMessagesReplaced)
+ .On(ApplyConversationDeleted)
+ .OrCurrent();
+ }
+
+ private static MessagesReplacedEvent TrimMessages(MessagesReplacedEvent evt)
+ {
+ if (evt.Messages.Count <= MaxMessages)
+ return evt;
+
+ var trimmed = evt.Clone();
+ var excess = trimmed.Messages.Count - MaxMessages;
+ for (var i = 0; i < excess; i++)
+ trimmed.Messages.RemoveAt(0);
+
+ if (trimmed.Meta is not null)
+ trimmed.Meta.MessageCount = trimmed.Messages.Count;
+
+ return trimmed;
+ }
+
+ private static ChatConversationState ApplyMessagesReplaced(
+ ChatConversationState state, MessagesReplacedEvent evt)
+ {
+ var next = new ChatConversationState { Meta = evt.Meta?.Clone() };
+ next.Messages.AddRange(evt.Messages);
+ return next;
+ }
+
+ private static ChatConversationState ApplyConversationDeleted(
+ ChatConversationState state, ConversationDeletedEvent evt)
+ {
+ return new ChatConversationState();
+ }
+
+ private async Task EnsureIndexActorAsync(string indexActorId)
+ {
+ var runtime = Services.GetRequiredService();
+ if (await runtime.GetAsync(indexActorId) is null)
+ await runtime.CreateAsync(indexActorId);
+ }
+
+ private static string IndexActorId(string scopeId) => $"chat-index-{scopeId}";
+}
diff --git a/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs b/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs
new file mode 100644
index 00000000..5ce65866
--- /dev/null
+++ b/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs
@@ -0,0 +1,81 @@
+using Aevatar.Foundation.Abstractions;
+using Aevatar.Foundation.Abstractions.Attributes;
+using Aevatar.Foundation.Core;
+using Aevatar.Foundation.Core.EventSourcing;
+using Google.Protobuf;
+
+namespace Aevatar.GAgents.ChatHistory;
+
+///
+/// Per-user index actor that holds conversation list and metadata.
+/// Actor ID: chat-index-{scopeId}.
+///
+public sealed class ChatHistoryIndexGAgent : GAgentBase
+{
+ [EventHandler(EndpointName = "upsertConversation")]
+ public async Task HandleConversationUpserted(ConversationUpsertedEvent evt)
+ {
+ if (evt.Meta is null || string.IsNullOrWhiteSpace(evt.Meta.Id))
+ return;
+
+ await PersistDomainEventAsync(evt);
+ }
+
+ [EventHandler(EndpointName = "removeConversation")]
+ public async Task HandleConversationRemoved(ConversationRemovedEvent evt)
+ {
+ if (string.IsNullOrWhiteSpace(evt.ConversationId))
+ return;
+
+ // Idempotent: skip if not present
+ var existing = State.Conversations.FirstOrDefault(c =>
+ string.Equals(c.Id, evt.ConversationId, StringComparison.Ordinal));
+ if (existing is null)
+ return;
+
+ await PersistDomainEventAsync(evt);
+ }
+
+ protected override async Task OnActivateAsync(CancellationToken ct)
+ {
+ await base.OnActivateAsync(ct);
+ }
+
+ protected override ChatHistoryIndexState TransitionState(
+ ChatHistoryIndexState current, IMessage evt)
+ {
+ return StateTransitionMatcher
+ .Match(current, evt)
+ .On(ApplyConversationUpserted)
+ .On(ApplyConversationRemoved)
+ .OrCurrent();
+ }
+
+ private static ChatHistoryIndexState ApplyConversationUpserted(
+ ChatHistoryIndexState state, ConversationUpsertedEvent evt)
+ {
+ var next = state.Clone();
+
+ var existing = next.Conversations.FirstOrDefault(c =>
+ string.Equals(c.Id, evt.Meta.Id, StringComparison.Ordinal));
+ if (existing is not null)
+ next.Conversations.Remove(existing);
+
+ next.Conversations.Add(evt.Meta.Clone());
+ return next;
+ }
+
+ private static ChatHistoryIndexState ApplyConversationRemoved(
+ ChatHistoryIndexState state, ConversationRemovedEvent evt)
+ {
+ var next = state.Clone();
+
+ var existing = next.Conversations.FirstOrDefault(c =>
+ string.Equals(c.Id, evt.ConversationId, StringComparison.Ordinal));
+ if (existing is not null)
+ next.Conversations.Remove(existing);
+
+ return next;
+ }
+
+}
diff --git a/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto b/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto
new file mode 100644
index 00000000..6cbca659
--- /dev/null
+++ b/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto
@@ -0,0 +1,64 @@
+syntax = "proto3";
+package aevatar.gagents.chathistory;
+option csharp_namespace = "Aevatar.GAgents.ChatHistory";
+
+// ─── Shared Types ───
+
+message StoredChatMessageProto {
+ string id = 1;
+ string role = 2;
+ string content = 3;
+ int64 timestamp = 4;
+ string status = 5;
+ string error = 6;
+ string thinking = 7;
+}
+
+message ConversationMetaProto {
+ string id = 1;
+ string title = 2;
+ string service_id = 3;
+ string service_kind = 4;
+ int64 created_at_ms = 5;
+ int64 updated_at_ms = 6;
+ int32 message_count = 7;
+ string llm_route = 8;
+ string llm_model = 9;
+}
+
+// ─── ChatConversationGAgent State ───
+
+message ChatConversationState {
+ repeated StoredChatMessageProto messages = 1;
+ ConversationMetaProto meta = 2;
+}
+
+// ─── ChatConversationGAgent Events ───
+
+message MessagesReplacedEvent {
+ repeated StoredChatMessageProto messages = 1;
+ ConversationMetaProto meta = 2;
+ string scope_id = 3;
+}
+
+message ConversationDeletedEvent {
+ string conversation_id = 1;
+ string scope_id = 2;
+}
+
+// ─── ChatHistoryIndexGAgent State ───
+
+message ChatHistoryIndexState {
+ repeated ConversationMetaProto conversations = 1;
+}
+
+// ─── ChatHistoryIndexGAgent Events ───
+
+message ConversationUpsertedEvent {
+ ConversationMetaProto meta = 1;
+}
+
+message ConversationRemovedEvent {
+ string conversation_id = 1;
+}
+
diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/Aevatar.GAgents.ConnectorCatalog.csproj b/agents/Aevatar.GAgents.ConnectorCatalog/Aevatar.GAgents.ConnectorCatalog.csproj
new file mode 100644
index 00000000..12e3dbaa
--- /dev/null
+++ b/agents/Aevatar.GAgents.ConnectorCatalog/Aevatar.GAgents.ConnectorCatalog.csproj
@@ -0,0 +1,24 @@
+
+
+ net10.0
+ enable
+ enable
+ Aevatar.GAgents.ConnectorCatalog
+ Aevatar.GAgents.ConnectorCatalog
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs b/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs
new file mode 100644
index 00000000..7c6d8123
--- /dev/null
+++ b/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs
@@ -0,0 +1,85 @@
+using Aevatar.Foundation.Abstractions;
+using Aevatar.Foundation.Abstractions.Attributes;
+using Aevatar.Foundation.Core;
+using Aevatar.Foundation.Core.EventSourcing;
+using Google.Protobuf;
+
+namespace Aevatar.GAgents.ConnectorCatalog;
+
+///
+/// Singleton actor that owns the connector catalog and draft.
+/// Replaces the chrono-storage backed ChronoStorageConnectorCatalogStore
+/// for remote persistence concerns.
+///
+/// Actor ID: connector-catalog (cluster-scoped singleton).
+///
+public sealed class ConnectorCatalogGAgent : GAgentBase
+{
+ [EventHandler(EndpointName = "saveCatalog")]
+ public async Task HandleCatalogSaved(ConnectorCatalogSavedEvent evt)
+ {
+ await PersistDomainEventAsync(evt);
+ }
+
+ [EventHandler(EndpointName = "saveDraft")]
+ public async Task HandleDraftSaved(ConnectorDraftSavedEvent evt)
+ {
+ await PersistDomainEventAsync(evt);
+ }
+
+ [EventHandler(EndpointName = "deleteDraft")]
+ public async Task HandleDraftDeleted(ConnectorDraftDeletedEvent evt)
+ {
+ // Idempotent: skip if no draft exists
+ if (State.Draft is null)
+ return;
+
+ await PersistDomainEventAsync(evt);
+ }
+
+ protected override async Task OnActivateAsync(CancellationToken ct)
+ {
+ await base.OnActivateAsync(ct);
+ }
+
+ protected override ConnectorCatalogState TransitionState(
+ ConnectorCatalogState current, IMessage evt)
+ {
+ return StateTransitionMatcher
+ .Match(current, evt)
+ .On(ApplyCatalogSaved)
+ .On(ApplyDraftSaved)
+ .On(ApplyDraftDeleted)
+ .OrCurrent();
+ }
+
+ private static ConnectorCatalogState ApplyCatalogSaved(
+ ConnectorCatalogState state, ConnectorCatalogSavedEvent evt)
+ {
+ var next = state.Clone();
+ next.Connectors.Clear();
+ next.Connectors.AddRange(evt.Connectors);
+ return next;
+ }
+
+ private static ConnectorCatalogState ApplyDraftSaved(
+ ConnectorCatalogState state, ConnectorDraftSavedEvent evt)
+ {
+ var next = state.Clone();
+ next.Draft = new ConnectorDraftEntry
+ {
+ Draft = evt.Draft?.Clone(),
+ UpdatedAtUtc = evt.UpdatedAtUtc,
+ };
+ return next;
+ }
+
+ private static ConnectorCatalogState ApplyDraftDeleted(
+ ConnectorCatalogState state, ConnectorDraftDeletedEvent _)
+ {
+ var next = state.Clone();
+ next.Draft = null;
+ return next;
+ }
+
+}
diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto b/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto
new file mode 100644
index 00000000..6fa2e3b3
--- /dev/null
+++ b/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto
@@ -0,0 +1,84 @@
+syntax = "proto3";
+package aevatar.gagents.connector_catalog;
+option csharp_namespace = "Aevatar.GAgents.ConnectorCatalog";
+
+import "google/protobuf/timestamp.proto";
+
+// ─── Sub-messages ───
+
+message ConnectorAuthEntry {
+ string type = 1;
+ string token_url = 2;
+ string client_id = 3;
+ string client_secret = 4;
+ string scope = 5;
+}
+
+message HttpConnectorConfigEntry {
+ string base_url = 1;
+ repeated string allowed_methods = 2;
+ repeated string allowed_paths = 3;
+ repeated string allowed_input_keys = 4;
+ map default_headers = 5;
+ ConnectorAuthEntry auth = 6;
+}
+
+message CliConnectorConfigEntry {
+ string command = 1;
+ repeated string fixed_arguments = 2;
+ repeated string allowed_operations = 3;
+ repeated string allowed_input_keys = 4;
+ string working_directory = 5;
+ map environment = 6;
+}
+
+message McpConnectorConfigEntry {
+ string server_name = 1;
+ string command = 2;
+ string url = 3;
+ repeated string arguments = 4;
+ map environment = 5;
+ map additional_headers = 6;
+ ConnectorAuthEntry auth = 7;
+ string default_tool = 8;
+ repeated string allowed_tools = 9;
+ repeated string allowed_input_keys = 10;
+}
+
+// ─── State ───
+
+message ConnectorDefinitionEntry {
+ string name = 1;
+ string type = 2;
+ bool enabled = 3;
+ int32 timeout_ms = 4;
+ int32 retry = 5;
+ HttpConnectorConfigEntry http = 6;
+ CliConnectorConfigEntry cli = 7;
+ McpConnectorConfigEntry mcp = 8;
+}
+
+message ConnectorDraftEntry {
+ ConnectorDefinitionEntry draft = 1;
+ google.protobuf.Timestamp updated_at_utc = 2;
+}
+
+message ConnectorCatalogState {
+ repeated ConnectorDefinitionEntry connectors = 1;
+ ConnectorDraftEntry draft = 2; // null when no draft exists
+}
+
+// ─── Events ───
+
+message ConnectorCatalogSavedEvent {
+ repeated ConnectorDefinitionEntry connectors = 1;
+}
+
+message ConnectorDraftSavedEvent {
+ ConnectorDefinitionEntry draft = 1;
+ google.protobuf.Timestamp updated_at_utc = 2;
+}
+
+message ConnectorDraftDeletedEvent {
+}
+
diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatActorStore.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatActorStore.cs
deleted file mode 100644
index 06822842..00000000
--- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatActorStore.cs
+++ /dev/null
@@ -1,85 +0,0 @@
-using System.Collections.Concurrent;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace Aevatar.GAgents.NyxidChat;
-
-///
-/// Manages NyxIdChat conversation actors.
-/// Phase 1: in-memory store. Phase 2: chrono-storage persistence.
-///
-public sealed class NyxIdChatActorStore
-{
- private static readonly JsonSerializerOptions JsonOptions = new()
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- WriteIndented = true,
- DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
- };
-
- private readonly ConcurrentDictionary> _store = new(StringComparer.OrdinalIgnoreCase);
-
- public Task> ListActorsAsync(string scopeId, CancellationToken ct = default)
- {
- var key = BuildKey(scopeId);
- var actors = _store.GetOrAdd(key, _ => []);
- IReadOnlyList result;
- lock (actors)
- {
- result = actors.ToList().AsReadOnly();
- }
- return Task.FromResult(result);
- }
-
- public Task CreateActorAsync(string scopeId, CancellationToken ct = default)
- {
- var key = BuildKey(scopeId);
- var entry = new ActorEntry
- {
- ActorId = NyxIdChatServiceDefaults.GenerateActorId(),
- CreatedAt = DateTimeOffset.UtcNow,
- };
- var actors = _store.GetOrAdd(key, _ => []);
- lock (actors)
- {
- actors.Add(entry);
- }
- return Task.FromResult(entry);
- }
-
- public Task DeleteActorAsync(string scopeId, string actorId, CancellationToken ct = default)
- {
- var key = BuildKey(scopeId);
- if (!_store.TryGetValue(key, out var actors))
- return Task.FromResult(false);
-
- bool removed;
- lock (actors)
- {
- removed = actors.RemoveAll(e =>
- string.Equals(e.ActorId, actorId, StringComparison.Ordinal)) > 0;
- }
- return Task.FromResult(removed);
- }
-
- /// Ensure an actor entry exists for the given scope + actorId (idempotent).
- public Task EnsureActorAsync(string scopeId, string actorId, CancellationToken ct = default)
- {
- var key = BuildKey(scopeId);
- var actors = _store.GetOrAdd(key, _ => []);
- lock (actors)
- {
- if (!actors.Exists(e => string.Equals(e.ActorId, actorId, StringComparison.Ordinal)))
- actors.Add(new ActorEntry { ActorId = actorId, CreatedAt = DateTimeOffset.UtcNow });
- }
- return Task.CompletedTask;
- }
-
- private static string BuildKey(string scopeId) => scopeId.Trim().ToLowerInvariant();
-
- public sealed class ActorEntry
- {
- public string ActorId { get; set; } = string.Empty;
- public DateTimeOffset CreatedAt { get; set; }
- }
-}
diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs
index 8dfe62ab..8e0ab2bf 100644
--- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs
+++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs
@@ -14,6 +14,7 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Aevatar.Studio.Application.Studio.Abstractions;
using Microsoft.Extensions.Logging;
namespace Aevatar.GAgents.NyxidChat;
@@ -76,21 +77,39 @@ public static IEndpointRouteBuilder MapNyxIdChatEndpoints(this IEndpointRouteBui
private static async Task HandleCreateConversationAsync(
HttpContext http,
string scopeId,
- [FromServices] NyxIdChatActorStore actorStore,
+ [FromServices] IGAgentActorStore actorStore,
CancellationToken ct)
{
- var entry = await actorStore.CreateActorAsync(scopeId, ct);
- return Results.Ok(new { actorId = entry.ActorId, createdAt = entry.CreatedAt });
+ var actorId = NyxIdChatServiceDefaults.GenerateActorId();
+ try
+ {
+ await actorStore.AddActorAsync(NyxIdChatServiceDefaults.GAgentTypeName, actorId, ct);
+ }
+ catch (InvalidOperationException)
+ {
+ // chrono-storage unavailable — actor still usable via runtime
+ }
+ return Results.Ok(new { actorId });
}
private static async Task HandleListConversationsAsync(
HttpContext http,
string scopeId,
- [FromServices] NyxIdChatActorStore actorStore,
+ [FromServices] IGAgentActorStore actorStore,
CancellationToken ct)
{
- var actors = await actorStore.ListActorsAsync(scopeId, ct);
- return Results.Ok(actors);
+ try
+ {
+ var groups = await actorStore.GetAsync(ct);
+ var group = groups.FirstOrDefault(g =>
+ string.Equals(g.GAgentType, NyxIdChatServiceDefaults.GAgentTypeName, StringComparison.Ordinal));
+ var actorIds = group?.ActorIds ?? [];
+ return Results.Ok(actorIds.Select(id => new { actorId = id }));
+ }
+ catch (InvalidOperationException)
+ {
+ return Results.Ok(Array.Empty