diff --git a/.gitignore b/.gitignore index 874e256ec..83f3dc2dc 100644 --- a/.gitignore +++ b/.gitignore @@ -494,6 +494,7 @@ docs/agents-working-space/* .tools/* .plan/ .claude/ralph-loop.local.md +.claude/scheduled_tasks.lock # Docs triage working directory docs/.triage/ diff --git a/aevatar.agents.slnf b/aevatar.agents.slnf new file mode 100644 index 000000000..98d45a794 --- /dev/null +++ b/aevatar.agents.slnf @@ -0,0 +1,10 @@ +{ + "solution": { + "path": "aevatar.slnx", + "projects": [ + "agents\\Aevatar.GAgents.Authoring.Lark\\Aevatar.GAgents.Authoring.Lark.csproj", + "agents\\Aevatar.GAgents.Scheduled\\Aevatar.GAgents.Scheduled.csproj", + "agents\\Aevatar.GAgents.Device\\Aevatar.GAgents.Device.csproj" + ] + } +} diff --git a/aevatar.ai.slnf b/aevatar.ai.slnf index fae376ae1..b4bc43411 100644 --- a/aevatar.ai.slnf +++ b/aevatar.ai.slnf @@ -9,6 +9,8 @@ "src\\Aevatar.AI.LLMProviders.Tornado\\Aevatar.AI.LLMProviders.Tornado.csproj", "src\\Aevatar.AI.ToolProviders.MCP\\Aevatar.AI.ToolProviders.MCP.csproj", "src\\Aevatar.AI.ToolProviders.Skills\\Aevatar.AI.ToolProviders.Skills.csproj", + "src\\Aevatar.AI.ToolProviders.AgentCatalog\\Aevatar.AI.ToolProviders.AgentCatalog.csproj", + "src\\Aevatar.AI.ToolProviders.ChannelAdmin\\Aevatar.AI.ToolProviders.ChannelAdmin.csproj", "test\\Aevatar.AI.Tests\\Aevatar.AI.Tests.csproj" ] } diff --git a/aevatar.channels.slnf b/aevatar.channels.slnf index a2be05829..540b84a16 100644 --- a/aevatar.channels.slnf +++ b/aevatar.channels.slnf @@ -5,10 +5,8 @@ "agents\\Aevatar.GAgents.Channel.Abstractions\\Aevatar.GAgents.Channel.Abstractions.csproj", "agents\\channels\\Aevatar.GAgents.Channel.NyxIdRelay\\Aevatar.GAgents.Channel.NyxIdRelay.csproj", "agents\\Aevatar.GAgents.Channel.Runtime\\Aevatar.GAgents.Channel.Runtime.csproj", - "agents\\Aevatar.GAgents.ChannelRuntime\\Aevatar.GAgents.ChannelRuntime.csproj", "test\\Aevatar.GAgents.Channel.Testing\\Aevatar.GAgents.Channel.Testing.csproj", - "test\\Aevatar.GAgents.Channel.Protocol.Tests\\Aevatar.GAgents.Channel.Protocol.Tests.csproj", - "test\\Aevatar.GAgents.ChannelRuntime.Tests\\Aevatar.GAgents.ChannelRuntime.Tests.csproj" + "test\\Aevatar.GAgents.Channel.Protocol.Tests\\Aevatar.GAgents.Channel.Protocol.Tests.csproj" ] } } diff --git a/aevatar.slnx b/aevatar.slnx index ae5249b3f..caaff528f 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -15,7 +15,9 @@ - + + + @@ -43,7 +45,9 @@ + + diff --git a/agents/Aevatar.GAgents.Authoring.Lark/Aevatar.GAgents.Authoring.Lark.csproj b/agents/Aevatar.GAgents.Authoring.Lark/Aevatar.GAgents.Authoring.Lark.csproj new file mode 100644 index 000000000..4cf5d438f --- /dev/null +++ b/agents/Aevatar.GAgents.Authoring.Lark/Aevatar.GAgents.Authoring.Lark.csproj @@ -0,0 +1,30 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.Authoring.Lark + Aevatar.GAgents.Authoring.Lark + + + + + + + + + + + + + + + + + + + + + + + diff --git a/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderCardContent.cs b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardContent.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderCardContent.cs rename to agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardContent.cs index 2d4d6159d..0bb047709 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderCardContent.cs +++ b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardContent.cs @@ -1,14 +1,15 @@ using System.Text.Json; using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Scheduled; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Authoring.Lark; /// /// Builds channel-neutral payloads for the Day One agent builder flow. /// Actions and CardBlocks let the platform composer render native interactive cards instead of /// bouncing a pre-serialized JSON blob through a plain-text fallback. /// -internal static class AgentBuilderCardContent +public static class AgentBuilderCardContent { private const string DailyReportAction = "create_daily_report"; private const string SocialMediaAction = "create_social_media"; diff --git a/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderCardFlow.cs b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardFlow.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderCardFlow.cs rename to agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardFlow.cs index adbc1f23a..4e47f8aa3 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderCardFlow.cs +++ b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardFlow.cs @@ -1,11 +1,13 @@ using System.Globalization; using System.Text.Json; using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.Runtime; +using Aevatar.GAgents.Scheduled; using Aevatar.Studio.Application.Studio.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Authoring.Lark; -internal static class AgentBuilderCardFlow +public static class AgentBuilderCardFlow { private const string PrivateChatType = "p2p"; private const string CardActionChatType = "card_action"; @@ -1255,13 +1257,13 @@ private static IReadOnlyList ReadAgentList(JsonElement root) } } -internal sealed record AgentListCardItem( +public sealed record AgentListCardItem( string AgentId, string Template, string Status, string NextRun); -internal sealed record AgentBuilderFlowDecision( +public sealed record AgentBuilderFlowDecision( bool RequiresToolExecution, string ReplyPayload, string? ToolArgumentsJson, diff --git a/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTemplates.cs b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderTemplates.cs similarity index 97% rename from agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTemplates.cs rename to agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderTemplates.cs index 9a9d63289..0bd7c333d 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTemplates.cs +++ b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderTemplates.cs @@ -1,8 +1,8 @@ using System.Text; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Authoring.Lark; -internal static class AgentBuilderTemplates +public static class AgentBuilderTemplates { public static IReadOnlyList ListTemplates() => [ @@ -218,14 +218,14 @@ private static string SanitizeSegment(string value) } } -internal sealed record DailyReportTemplateSpec( +public sealed record DailyReportTemplateSpec( string TemplateName, string SkillName, string SkillContent, string ExecutionPrompt, IReadOnlyList RequiredServiceSlugs); -internal sealed record SocialMediaTemplateSpec( +public sealed record SocialMediaTemplateSpec( string WorkflowId, string WorkflowName, string DisplayName, diff --git a/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderTool.cs similarity index 89% rename from agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs rename to agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderTool.cs index 462922d44..b418e3436 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs +++ b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderTool.cs @@ -6,6 +6,9 @@ using Aevatar.GAgentService.Abstractions; using Aevatar.GAgentService.Abstractions.Ports; using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.Channel.Runtime; +using Aevatar.GAgents.Platform.Lark; +using Aevatar.GAgents.Scheduled; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Workflow.Application.Abstractions.Runs; using Google.Protobuf; @@ -13,7 +16,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Authoring.Lark; public sealed class AgentBuilderTool : IAgentTool { @@ -131,22 +134,25 @@ public async Task ExecuteAsync(string argumentsJson, CancellationToken c return JsonSerializer.Serialize(new { templates = AgentBuilderTemplates.ListTemplates() }); var queryPort = _serviceProvider.GetService(); - var actorRuntime = _serviceProvider.GetService(); var nyxClient = _serviceProvider.GetService(); - if (queryPort is null || actorRuntime is null || nyxClient is null) + var skillRunnerPort = _serviceProvider.GetService(); + var workflowAgentPort = _serviceProvider.GetService(); + var catalogCommandPort = _serviceProvider.GetService(); + if (queryPort is null || nyxClient is null || + skillRunnerPort is null || workflowAgentPort is null || catalogCommandPort is null) { return """{"error":"Agent builder runtime not available. Required services are not registered in DI."}"""; } return action switch { - "create_agent" => await CreateAgentAsync(args, queryPort, actorRuntime, nyxClient, token, ct), + "create_agent" => await CreateAgentAsync(args, queryPort, skillRunnerPort, workflowAgentPort, nyxClient, token, ct), "list_agents" => await ListAgentsAsync(args, queryPort, nyxClient, token, ct), "agent_status" => await GetAgentStatusAsync(args, queryPort, ct), - "run_agent" => await RunAgentAsync(args, queryPort, actorRuntime, ct), - "disable_agent" => await DisableAgentAsync(args, queryPort, actorRuntime, ct), - "enable_agent" => await EnableAgentAsync(args, queryPort, actorRuntime, ct), - "delete_agent" => await DeleteAgentAsync(args, queryPort, actorRuntime, nyxClient, token, ct), + "run_agent" => await RunAgentAsync(args, queryPort, skillRunnerPort, workflowAgentPort, ct), + "disable_agent" => await DisableAgentAsync(args, queryPort, skillRunnerPort, workflowAgentPort, ct), + "enable_agent" => await EnableAgentAsync(args, queryPort, skillRunnerPort, workflowAgentPort, ct), + "delete_agent" => await DeleteAgentAsync(args, queryPort, catalogCommandPort, skillRunnerPort, workflowAgentPort, nyxClient, token, ct), _ => JsonSerializer.Serialize(new { error = $"Unsupported action '{action}'" }), }; } @@ -154,7 +160,8 @@ public async Task ExecuteAsync(string argumentsJson, CancellationToken c private async Task CreateAgentAsync( BuilderArgs args, IUserAgentCatalogQueryPort queryPort, - IActorRuntime actorRuntime, + ISkillRunnerCommandPort skillRunnerPort, + IWorkflowAgentCommandPort workflowAgentPort, NyxIdApiClient nyxClient, string token, CancellationToken ct) @@ -169,8 +176,8 @@ private async Task CreateAgentAsync( var template = (args.Str("template") ?? string.Empty).Trim(); return template.ToLowerInvariant() switch { - "daily_report" => await CreateDailyReportAgentAsync(args, queryPort, actorRuntime, nyxClient, token, ct), - "social_media" => await CreateSocialMediaAgentAsync(args, queryPort, actorRuntime, nyxClient, token, ct), + "daily_report" => await CreateDailyReportAgentAsync(args, queryPort, skillRunnerPort, nyxClient, token, ct), + "social_media" => await CreateSocialMediaAgentAsync(args, queryPort, workflowAgentPort, nyxClient, token, ct), _ => JsonSerializer.Serialize(new { error = $"Unsupported template '{template}'. Supported templates: daily_report, social_media." }), }; } @@ -178,7 +185,7 @@ private async Task CreateAgentAsync( private async Task CreateDailyReportAgentAsync( BuilderArgs args, IUserAgentCatalogQueryPort queryPort, - IActorRuntime actorRuntime, + ISkillRunnerCommandPort skillRunnerPort, NyxIdApiClient nyxClient, string token, CancellationToken ct) @@ -267,16 +274,8 @@ private async Task CreateDailyReportAgentAsync( return preflight; } - var actor = await actorRuntime.GetAsync(agentId) - ?? await actorRuntime.CreateAsync(agentId, ct); - var versionBefore = await queryPort.GetStateVersionAsync(agentId, ct) ?? -1; - // Prime the projection scope BEFORE dispatch — see DeleteAgentAsync for - // the rationale. A late prime can't recover an event the projector - // already missed. - await EnsureUserAgentCatalogProjectionAsync(ct); - var deliveryTarget = ResolveDeliveryTarget(conversationId, agentId); var initialize = new InitializeSkillRunnerCommand { @@ -305,13 +304,8 @@ private async Task CreateDailyReportAgentAsync( }, }; - await actor.HandleEventAsync(BuildDirectEnvelope(actor.Id, initialize), ct); - var runImmediatelyRequested = args.Bool("run_immediately") == true; - if (runImmediatelyRequested) - await actor.HandleEventAsync( - BuildDirectEnvelope(actor.Id, new TriggerSkillRunnerExecutionCommand { Reason = "create_agent" }), - ct); + await skillRunnerPort.InitializeAsync(agentId, initialize, runImmediatelyRequested, ct); var confirmed = await WaitForCreatedAgentAsync( queryPort, @@ -348,7 +342,7 @@ await actor.HandleEventAsync( private async Task CreateSocialMediaAgentAsync( BuilderArgs args, IUserAgentCatalogQueryPort queryPort, - IActorRuntime actorRuntime, + IWorkflowAgentCommandPort workflowAgentPort, NyxIdApiClient nyxClient, string token, CancellationToken ct) @@ -418,16 +412,8 @@ private async Task CreateSocialMediaAgentAsync( templateSpec.DisplayName), ct); - var actor = await actorRuntime.GetAsync(agentId) - ?? await actorRuntime.CreateAsync(agentId, ct); - var versionBefore = await queryPort.GetStateVersionAsync(agentId, ct) ?? -1; - // Prime the projection scope BEFORE dispatch — see DeleteAgentAsync for - // the rationale. A late prime can't recover an event the projector - // already missed. - await EnsureUserAgentCatalogProjectionAsync(ct); - var deliveryTarget = ResolveDeliveryTarget(conversationId, agentId); var initialize = new InitializeWorkflowAgentCommand { @@ -450,7 +436,11 @@ private async Task CreateSocialMediaAgentAsync( LarkReceiveIdTypeFallback = deliveryTarget.Fallback?.ReceiveIdType ?? string.Empty, }; - await actor.HandleEventAsync(BuildDirectEnvelope(actor.Id, initialize), ct); + // Initialize via the workflow-agent command port; observation lives in + // the polling loop below since it crosses actors (Workflow → catalog). + // We split run-immediately into a follow-up TriggerAsync so the trigger + // fires only after the catalog projection confirms creation. + await workflowAgentPort.InitializeAsync(agentId, initialize, runImmediately: false, ct); var confirmed = await WaitForCreatedAgentAsync( queryPort, @@ -463,9 +453,7 @@ private async Task CreateSocialMediaAgentAsync( if (args.Bool("run_immediately") == true && confirmed) { - await actor.HandleEventAsync( - BuildDirectEnvelope(actor.Id, new TriggerWorkflowAgentExecutionCommand { Reason = "create_agent" }), - ct); + await workflowAgentPort.TriggerAsync(agentId, "create_agent", revisionFeedback: null, ct); } return JsonSerializer.Serialize(new @@ -519,7 +507,9 @@ private async Task GetAgentStatusAsync( private async Task DeleteAgentAsync( BuilderArgs args, IUserAgentCatalogQueryPort queryPort, - IActorRuntime actorRuntime, + IUserAgentCatalogCommandPort catalogCommandPort, + ISkillRunnerCommandPort skillRunnerPort, + IWorkflowAgentCommandPort workflowAgentPort, NyxIdApiClient nyxClient, string token, CancellationToken ct) @@ -543,38 +533,21 @@ private async Task DeleteAgentAsync( }); } - // Capture the read-model version before issuing tombstone so the wait can - // distinguish "projection caught up" from "projector did not run yet". - var versionBefore = await queryPort.GetStateVersionAsync(entry.AgentId, ct) ?? -1; - - // Prime the projection scope BEFORE any dispatch. If we primed after - // HandleEventAsync, an idle-deactivated projection grain would have - // already missed the published event and a late activation could not - // recover it (the activation contract is "be alive when the event - // arrives", not "replay missed events"). Activating up front costs at - // most one extra warm-grain round trip. - await EnsureUserAgentCatalogProjectionAsync(ct); - - var disableResult = await DispatchAgentLifecycleAsync(entry, actorRuntime, "delete_agent", LifecycleAction.Disable, null, ct); + // Disable via the typed lifecycle port (dispatch + projection priming + // happen there); skip if the agent type isn't managed. + var disableResult = await TryDispatchLifecycleAsync( + entry, "delete_agent", LifecycleAction.Disable, revisionFeedback: null, + skillRunnerPort, workflowAgentPort, ct); if (disableResult.error != null) return disableResult.error; if (!string.IsNullOrWhiteSpace(entry.ApiKeyId)) await nyxClient.DeleteApiKeyAsync(token, entry.ApiKeyId, ct); - var registryActor = await actorRuntime.GetAsync(UserAgentCatalogGAgent.WellKnownId) - ?? await actorRuntime.CreateAsync(UserAgentCatalogGAgent.WellKnownId, ct); - await registryActor.HandleEventAsync( - BuildDirectEnvelope(registryActor.Id, new UserAgentCatalogTombstoneCommand { AgentId = entry.AgentId }), - ct); - - var deleted = await WaitForTombstoneReflectedAsync( - queryPort, - entry.AgentId, - versionBefore, - ct, - _projectionWaitAttempts, - _projectionWaitDelayMilliseconds); + // Tombstone via UserAgentCatalogCommandPort; port owns priming + + // version observation and returns an honest accepted/observed status. + var tombstoneResult = await catalogCommandPort.TombstoneAsync(entry.AgentId, ct); + var deleted = tombstoneResult.Outcome == CatalogCommandOutcome.Observed; var ownerFilter = !string.IsNullOrWhiteSpace(entry.OwnerNyxUserId) ? entry.OwnerNyxUserId @@ -609,7 +582,8 @@ await registryActor.HandleEventAsync( private async Task RunAgentAsync( BuilderArgs args, IUserAgentCatalogQueryPort queryPort, - IActorRuntime actorRuntime, + ISkillRunnerCommandPort skillRunnerPort, + IWorkflowAgentCommandPort workflowAgentPort, CancellationToken ct) { var agentId = args.Str("agent_id"); @@ -628,7 +602,7 @@ private async Task RunAgentAsync( return JsonSerializer.Serialize(new { error = $"Agent '{entry.AgentId}' is disabled. Enable it before running." }); var revisionFeedback = NormalizeOptional(args.Str("revision_feedback")); - var dispatch = await DispatchAgentLifecycleAsync(entry, actorRuntime, "run_agent", LifecycleAction.Run, revisionFeedback, ct); + var dispatch = await TryDispatchLifecycleAsync(entry, "run_agent", LifecycleAction.Run, revisionFeedback, skillRunnerPort, workflowAgentPort, ct); if (dispatch.error != null) return dispatch.error; @@ -646,7 +620,8 @@ private async Task RunAgentAsync( private async Task DisableAgentAsync( BuilderArgs args, IUserAgentCatalogQueryPort queryPort, - IActorRuntime actorRuntime, + ISkillRunnerCommandPort skillRunnerPort, + IWorkflowAgentCommandPort workflowAgentPort, CancellationToken ct) { var entry = await RequireManagedAgentAsync(args, queryPort, "disable_agent", ct); @@ -664,12 +639,7 @@ private async Task DisableAgentAsync( // against a fast projection that already advanced the version. var versionBefore = await queryPort.GetStateVersionAsync(entry.value.AgentId, ct) ?? -1; - // Prime the projection scope BEFORE dispatch — see DeleteAgentAsync for - // the rationale. A late prime can't recover an event the projector - // already missed. - await EnsureUserAgentCatalogProjectionAsync(ct); - - var dispatch = await DispatchAgentLifecycleAsync(entry.value, actorRuntime, "disable_agent", LifecycleAction.Disable, null, ct); + var dispatch = await TryDispatchLifecycleAsync(entry.value, "disable_agent", LifecycleAction.Disable, null, skillRunnerPort, workflowAgentPort, ct); if (dispatch.error != null) return dispatch.error; @@ -687,7 +657,8 @@ private async Task DisableAgentAsync( private async Task EnableAgentAsync( BuilderArgs args, IUserAgentCatalogQueryPort queryPort, - IActorRuntime actorRuntime, + ISkillRunnerCommandPort skillRunnerPort, + IWorkflowAgentCommandPort workflowAgentPort, CancellationToken ct) { var entry = await RequireManagedAgentAsync(args, queryPort, "enable_agent", ct); @@ -702,12 +673,7 @@ private async Task EnableAgentAsync( // any dispatch) and not inside WaitForAgentStatusAsync. var versionBefore = await queryPort.GetStateVersionAsync(entry.value.AgentId, ct) ?? -1; - // Prime the projection scope BEFORE dispatch — see DeleteAgentAsync for - // the rationale. A late prime can't recover an event the projector - // already missed. - await EnsureUserAgentCatalogProjectionAsync(ct); - - var dispatch = await DispatchAgentLifecycleAsync(entry.value, actorRuntime, "enable_agent", LifecycleAction.Enable, null, ct); + var dispatch = await TryDispatchLifecycleAsync(entry.value, "enable_agent", LifecycleAction.Enable, null, skillRunnerPort, workflowAgentPort, ct); if (dispatch.error != null) return dispatch.error; @@ -719,20 +685,6 @@ private async Task EnableAgentAsync( return SerializeAgentStatus(entry.value, "Enable submitted. Run /agent-status in a few seconds to confirm the agent is running."); } - private static EventEnvelope BuildDirectEnvelope(string targetActorId, IMessage payload) - { - return new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(payload), - Route = new EnvelopeRoute - { - Direct = new DirectRoute { TargetActorId = targetActorId }, - }, - }; - } - /// /// Builds the JSON body for POST /api/v1/api-keys when the agent-builder mints a /// scoped child key for a new agent. Pins allow_all_services = false alongside the @@ -867,14 +819,6 @@ private async Task WaitForCreatedAgentAsync( return false; } - private async Task EnsureUserAgentCatalogProjectionAsync(CancellationToken ct) - { - var projectionPort = _serviceProvider.GetService(); - if (projectionPort is null) - return; - - await projectionPort.EnsureProjectionForActorAsync(UserAgentCatalogGAgent.WellKnownId, ct); - } private async Task<(bool Confirmed, UserAgentCatalogEntry? Entry)> WaitForAgentStatusAsync( IUserAgentCatalogQueryPort queryPort, @@ -921,88 +865,50 @@ private async Task EnsureUserAgentCatalogProjectionAsync(CancellationToken ct) return (Confirmed: false, Entry: null); } - /// - /// Polls the read model until the agent's tombstoned state is reflected as a - /// document deletion. The read-model contract guarantees that a tombstoned - /// entry causes to dispatch - /// DeleteAsync; document absence is therefore the authoritative signal. - /// - private static async Task WaitForTombstoneReflectedAsync( - IUserAgentCatalogQueryPort queryPort, - string agentId, - long versionBefore, - CancellationToken ct, - int maxAttempts = ProjectionWaitDefaults.Attempts, - int delayMilliseconds = ProjectionWaitDefaults.DelayMilliseconds) - { - for (var attempt = 0; attempt < maxAttempts; attempt++) - { - if (attempt > 0) - await Task.Delay(delayMilliseconds, ct); - - // GetStateVersionAsync reads the same document; if it is null the - // document has been deleted by the projector. - var versionAfter = await queryPort.GetStateVersionAsync(agentId, ct); - if (versionAfter == null) - return true; - - if (versionAfter.Value <= versionBefore) - continue; - - // Version advanced (a fresh state event reached the projector) but the - // document still exists; if it is the tombstoned entry the projector - // would have deleted it on the same advance, so a non-null entry means - // either an interleaving upsert or a stale read replica - keep waiting. - if (await queryPort.GetAsync(agentId, ct) == null) - return true; - } - - return false; - } - - private async Task<(bool success, string? error)> DispatchAgentLifecycleAsync( + private static async Task<(bool success, string? error)> TryDispatchLifecycleAsync( UserAgentCatalogEntry entry, - IActorRuntime actorRuntime, string reason, LifecycleAction action, string? revisionFeedback, + ISkillRunnerCommandPort skillRunnerPort, + IWorkflowAgentCommandPort workflowAgentPort, CancellationToken ct) { if (string.Equals(entry.AgentType, SkillRunnerDefaults.AgentType, StringComparison.Ordinal)) { - var actor = await actorRuntime.GetAsync(entry.AgentId) - ?? await actorRuntime.CreateAsync(entry.AgentId, ct); - - IMessage payload = action switch + switch (action) { - LifecycleAction.Run => new TriggerSkillRunnerExecutionCommand { Reason = reason }, - LifecycleAction.Disable => new DisableSkillRunnerCommand { Reason = reason }, - LifecycleAction.Enable => new EnableSkillRunnerCommand { Reason = reason }, - _ => throw new ArgumentOutOfRangeException(nameof(action), action, null), - }; - - await actor.HandleEventAsync(BuildDirectEnvelope(actor.Id, payload), ct); + case LifecycleAction.Run: + await skillRunnerPort.TriggerAsync(entry.AgentId, reason, ct); + break; + case LifecycleAction.Disable: + await skillRunnerPort.DisableAsync(entry.AgentId, reason, ct); + break; + case LifecycleAction.Enable: + await skillRunnerPort.EnableAsync(entry.AgentId, reason, ct); + break; + default: + throw new ArgumentOutOfRangeException(nameof(action), action, null); + } return (true, null); } if (string.Equals(entry.AgentType, WorkflowAgentDefaults.AgentType, StringComparison.Ordinal)) { - var actor = await actorRuntime.GetAsync(entry.AgentId) - ?? await actorRuntime.CreateAsync(entry.AgentId, ct); - - IMessage payload = action switch + switch (action) { - LifecycleAction.Run => new TriggerWorkflowAgentExecutionCommand - { - Reason = reason, - RevisionFeedback = revisionFeedback?.Trim() ?? string.Empty, - }, - LifecycleAction.Disable => new DisableWorkflowAgentCommand { Reason = reason }, - LifecycleAction.Enable => new EnableWorkflowAgentCommand { Reason = reason }, - _ => throw new ArgumentOutOfRangeException(nameof(action), action, null), - }; - - await actor.HandleEventAsync(BuildDirectEnvelope(actor.Id, payload), ct); + case LifecycleAction.Run: + await workflowAgentPort.TriggerAsync(entry.AgentId, reason, revisionFeedback?.Trim(), ct); + break; + case LifecycleAction.Disable: + await workflowAgentPort.DisableAsync(entry.AgentId, reason, ct); + break; + case LifecycleAction.Enable: + await workflowAgentPort.EnableAsync(entry.AgentId, reason, ct); + break; + default: + throw new ArgumentOutOfRangeException(nameof(action), action, null); + } return (true, null); } diff --git a/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderToolSource.cs b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderToolSource.cs similarity index 93% rename from agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderToolSource.cs rename to agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderToolSource.cs index 6d5e89b9b..6874d9f00 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderToolSource.cs +++ b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderToolSource.cs @@ -1,6 +1,6 @@ using Aevatar.AI.Abstractions.ToolProviders; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Authoring.Lark; public sealed class AgentBuilderToolSource : IAgentToolSource { diff --git a/agents/Aevatar.GAgents.Authoring.Lark/DependencyInjection/AuthoringServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.Authoring.Lark/DependencyInjection/AuthoringServiceCollectionExtensions.cs new file mode 100644 index 000000000..c70a35dd3 --- /dev/null +++ b/agents/Aevatar.GAgents.Authoring.Lark/DependencyInjection/AuthoringServiceCollectionExtensions.cs @@ -0,0 +1,34 @@ +using Aevatar.AI.Abstractions.ToolProviders; +using Aevatar.Foundation.Abstractions.HumanInteraction; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aevatar.GAgents.Authoring.Lark; + +/// +/// DI registration entry point for the Lark-specific AgentBuilder authoring package. +/// +/// +/// Both the package and the registration are intentionally Lark-specific (RFC §9.4 option a): +/// is the Lark interactive-card implementation of +/// , and the AgentBuilder card flows are hard-coded to Lark +/// `p2p` / `card_action` semantics. Hosts that compose this package opt into Lark behavior by +/// name; hosts targeting other channels must not call . +/// +public static class AuthoringServiceCollectionExtensions +{ + /// + /// Replaces the default with the Feishu-card + /// implementation and registers the AgentBuilder tool source so LLM turns can author + /// new agents through interactive Lark cards. + /// + public static IServiceCollection AddLarkAgentAuthoring(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.Replace(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + return services; + } +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/FeishuCardHumanInteractionPort.cs b/agents/Aevatar.GAgents.Authoring.Lark/FeishuCardHumanInteractionPort.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/FeishuCardHumanInteractionPort.cs rename to agents/Aevatar.GAgents.Authoring.Lark/FeishuCardHumanInteractionPort.cs index e3b5e3fb9..214a5a362 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/FeishuCardHumanInteractionPort.cs +++ b/agents/Aevatar.GAgents.Authoring.Lark/FeishuCardHumanInteractionPort.cs @@ -4,9 +4,10 @@ using Aevatar.Foundation.Abstractions.HumanInteraction; using Aevatar.GAgents.Channel.Abstractions; using Aevatar.GAgents.Platform.Lark; +using Aevatar.GAgents.Scheduled; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Authoring.Lark; /// /// Delivers workflow human-interaction suspensions and resolutions as Lark interactive cards diff --git a/agents/Aevatar.GAgents.ChannelRuntime/NyxRelayAgentBuilderFlow.cs b/agents/Aevatar.GAgents.Authoring.Lark/NyxRelayAgentBuilderFlow.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/NyxRelayAgentBuilderFlow.cs rename to agents/Aevatar.GAgents.Authoring.Lark/NyxRelayAgentBuilderFlow.cs index f500344e7..50ed5deaf 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/NyxRelayAgentBuilderFlow.cs +++ b/agents/Aevatar.GAgents.Authoring.Lark/NyxRelayAgentBuilderFlow.cs @@ -2,10 +2,12 @@ using System.Text; using System.Text.Json; using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.Runtime; +using Aevatar.GAgents.Scheduled; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Authoring.Lark; -internal static class NyxRelayAgentBuilderFlow +public static class NyxRelayAgentBuilderFlow { private const string PrivateChatType = "p2p"; private const string DailyCommand = "/daily"; diff --git a/agents/Aevatar.GAgents.Channel.Runtime/Aevatar.GAgents.Channel.Runtime.csproj b/agents/Aevatar.GAgents.Channel.Runtime/Aevatar.GAgents.Channel.Runtime.csproj index 5379f7b1c..6b7d65843 100644 --- a/agents/Aevatar.GAgents.Channel.Runtime/Aevatar.GAgents.Channel.Runtime.csproj +++ b/agents/Aevatar.GAgents.Channel.Runtime/Aevatar.GAgents.Channel.Runtime.csproj @@ -6,8 +6,20 @@ Aevatar.GAgents.Channel.Runtime Aevatar.GAgents.Channel.Runtime + + + + + + + + + + + + @@ -17,8 +29,11 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + + { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationDocumentMetadataProvider.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationDocumentMetadataProvider.cs similarity index 93% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationDocumentMetadataProvider.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationDocumentMetadataProvider.cs index 7870796a5..6111fb6d4 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationDocumentMetadataProvider.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationDocumentMetadataProvider.cs @@ -1,6 +1,6 @@ using Aevatar.CQRS.Projection.Stores.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; public sealed class ChannelBotRegistrationDocumentMetadataProvider : IProjectionDocumentMetadataProvider diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationGAgent.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationGAgent.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationGAgent.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationGAgent.cs index 14b4f04f4..fa20b145e 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationGAgent.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationGAgent.cs @@ -5,7 +5,7 @@ using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; /// /// Actor-backed channel bot registration store. diff --git a/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationLegacyAliases.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationLegacyAliases.cs new file mode 100644 index 000000000..fb0bcb57f --- /dev/null +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationLegacyAliases.cs @@ -0,0 +1,61 @@ +using Aevatar.Foundation.Abstractions.Compatibility; + +namespace Aevatar.GAgents.Channel.Runtime; + +internal static class ChannelBotRegistrationLegacyAliases +{ + private const string ProtoPrefix = "aevatar.gagents.channelruntime."; + private const string ClrPrefix = "Aevatar.GAgents.ChannelRuntime."; + + internal const string EntryProto = ProtoPrefix + "ChannelBotRegistrationEntry"; + internal const string StoreStateProto = ProtoPrefix + "ChannelBotRegistrationStoreState"; + internal const string DocumentProto = ProtoPrefix + "ChannelBotRegistrationDocument"; + internal const string RegisteredEventProto = ProtoPrefix + "ChannelBotRegisteredEvent"; + internal const string UnregisteredEventProto = ProtoPrefix + "ChannelBotUnregisteredEvent"; + internal const string RegisterCommandProto = ProtoPrefix + "ChannelBotRegisterCommand"; + internal const string UnregisterCommandProto = ProtoPrefix + "ChannelBotUnregisterCommand"; + internal const string RebuildProjectionCommandProto = ProtoPrefix + "ChannelBotRebuildProjectionCommand"; + internal const string CompactTombstonesCommandProto = ProtoPrefix + "ChannelBotCompactTombstonesCommand"; + internal const string ProjectionRebuildRequestedEventProto = ProtoPrefix + "ChannelBotProjectionRebuildRequestedEvent"; + internal const string TombstonesCompactedEventProto = ProtoPrefix + "ChannelBotTombstonesCompactedEvent"; + internal const string ChannelInboundEventProto = ProtoPrefix + "ChannelInboundEvent"; + + internal const string StoreStateClr = ClrPrefix + "ChannelBotRegistrationStoreState"; +} + +[LegacyProtoFullName(ChannelBotRegistrationLegacyAliases.EntryProto)] +public sealed partial class ChannelBotRegistrationEntry; + +[LegacyProtoFullName(ChannelBotRegistrationLegacyAliases.StoreStateProto)] +[LegacyClrTypeName(ChannelBotRegistrationLegacyAliases.StoreStateClr)] +public sealed partial class ChannelBotRegistrationStoreState; + +[LegacyProtoFullName(ChannelBotRegistrationLegacyAliases.DocumentProto)] +public sealed partial class ChannelBotRegistrationDocument; + +[LegacyProtoFullName(ChannelBotRegistrationLegacyAliases.RegisteredEventProto)] +public sealed partial class ChannelBotRegisteredEvent; + +[LegacyProtoFullName(ChannelBotRegistrationLegacyAliases.UnregisteredEventProto)] +public sealed partial class ChannelBotUnregisteredEvent; + +[LegacyProtoFullName(ChannelBotRegistrationLegacyAliases.RegisterCommandProto)] +public sealed partial class ChannelBotRegisterCommand; + +[LegacyProtoFullName(ChannelBotRegistrationLegacyAliases.UnregisterCommandProto)] +public sealed partial class ChannelBotUnregisterCommand; + +[LegacyProtoFullName(ChannelBotRegistrationLegacyAliases.RebuildProjectionCommandProto)] +public sealed partial class ChannelBotRebuildProjectionCommand; + +[LegacyProtoFullName(ChannelBotRegistrationLegacyAliases.CompactTombstonesCommandProto)] +public sealed partial class ChannelBotCompactTombstonesCommand; + +[LegacyProtoFullName(ChannelBotRegistrationLegacyAliases.ProjectionRebuildRequestedEventProto)] +public sealed partial class ChannelBotProjectionRebuildRequestedEvent; + +[LegacyProtoFullName(ChannelBotRegistrationLegacyAliases.TombstonesCompactedEventProto)] +public sealed partial class ChannelBotTombstonesCompactedEvent; + +[LegacyProtoFullName(ChannelBotRegistrationLegacyAliases.ChannelInboundEventProto)] +public sealed partial class ChannelInboundEvent; diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationMaterializationContext.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationMaterializationContext.cs similarity index 86% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationMaterializationContext.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationMaterializationContext.cs index 1913914f5..e23c95722 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationMaterializationContext.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationMaterializationContext.cs @@ -1,6 +1,6 @@ using Aevatar.CQRS.Projection.Core.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; public sealed class ChannelBotRegistrationMaterializationContext : IProjectionMaterializationContext diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationMaterializationRuntimeLease.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationMaterializationRuntimeLease.cs similarity index 93% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationMaterializationRuntimeLease.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationMaterializationRuntimeLease.cs index b72bc516f..7b4ce22d3 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationMaterializationRuntimeLease.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationMaterializationRuntimeLease.cs @@ -1,7 +1,7 @@ using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Core.Orchestration; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; public sealed class ChannelBotRegistrationMaterializationRuntimeLease : ProjectionRuntimeLeaseBase, diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationProjectionPort.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationProjectionPort.cs similarity index 96% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationProjectionPort.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationProjectionPort.cs index 902b29fd8..d304dd18d 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationProjectionPort.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationProjectionPort.cs @@ -1,7 +1,7 @@ using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Core.Orchestration; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; /// /// Projection port that activates the materialization scope for the diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationProjector.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationProjector.cs similarity index 98% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationProjector.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationProjector.cs index 28393b35c..f6112b70b 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationProjector.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationProjector.cs @@ -4,7 +4,7 @@ using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.Channel.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; /// /// Materializes into per-entry diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationQueryPort.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationQueryPort.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationQueryPort.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationQueryPort.cs index 6a6667ab0..6d4a28a4b 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationQueryPort.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationQueryPort.cs @@ -1,6 +1,6 @@ using Aevatar.CQRS.Projection.Stores.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; public sealed class ChannelBotRegistrationQueryPort : IChannelBotRegistrationQueryPort , IChannelBotRegistrationQueryByNyxIdentityPort diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationRuntimeQueryPort.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationRuntimeQueryPort.cs similarity index 92% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationRuntimeQueryPort.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationRuntimeQueryPort.cs index 8ee5dcfac..4c75554b2 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationRuntimeQueryPort.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationRuntimeQueryPort.cs @@ -1,4 +1,4 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; public sealed class ChannelBotRegistrationRuntimeQueryPort : IChannelBotRegistrationRuntimeQueryPort { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationStartupService.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationStartupService.cs similarity index 98% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationStartupService.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationStartupService.cs index b7b2e491f..30e11cb9a 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationStartupService.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationStartupService.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; /// /// Activates the projection scope for the channel bot registration store diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationStoreCommands.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationStoreCommands.cs similarity index 96% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationStoreCommands.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationStoreCommands.cs index 316ec67c6..9a11d5059 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationStoreCommands.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationStoreCommands.cs @@ -2,9 +2,9 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; -internal static class ChannelBotRegistrationStoreCommands +public static class ChannelBotRegistrationStoreCommands { private const string PublisherActorId = "channel-runtime.registration-store"; diff --git a/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationTombstoneCompactionTarget.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationTombstoneCompactionTarget.cs new file mode 100644 index 000000000..2dfcc4bf7 --- /dev/null +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelBotRegistrationTombstoneCompactionTarget.cs @@ -0,0 +1,23 @@ +using Aevatar.Foundation.Abstractions; +using Google.Protobuf; + +namespace Aevatar.GAgents.Channel.Runtime; + +internal sealed class ChannelBotRegistrationTombstoneCompactionTarget : ITombstoneCompactionTarget +{ + public string ActorId => ChannelBotRegistrationGAgent.WellKnownId; + public string ProjectionKind => ChannelBotRegistrationProjectionPort.ProjectionKind; + public string TargetName => "channel bot registration"; + + public async Task EnsureActorAsync(IActorRuntime actorRuntime, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(actorRuntime); + _ = await actorRuntime.GetAsync(ChannelBotRegistrationGAgent.WellKnownId) + ?? await actorRuntime.CreateAsync( + ChannelBotRegistrationGAgent.WellKnownId, + ct); + } + + public IMessage CreateCommand(long safeStateVersion) => + new ChannelBotCompactTombstonesCommand { SafeStateVersion = safeStateVersion }; +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelMetadataKeys.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelMetadataKeys.cs similarity index 97% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelMetadataKeys.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ChannelMetadataKeys.cs index 0ea1fac3b..8ac0d6ea3 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelMetadataKeys.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelMetadataKeys.cs @@ -1,4 +1,4 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; /// /// Typed metadata keys for channel runtime context. diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelRuntimeDiagnostics.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelRuntimeDiagnostics.cs similarity index 98% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelRuntimeDiagnostics.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ChannelRuntimeDiagnostics.cs index 71b6a9178..0dbd6b4f1 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelRuntimeDiagnostics.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelRuntimeDiagnostics.cs @@ -1,6 +1,6 @@ using System.Collections.Immutable; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; public interface IChannelRuntimeDiagnostics { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelRuntimeTombstoneCompactionOptions.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelRuntimeTombstoneCompactionOptions.cs similarity index 76% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelRuntimeTombstoneCompactionOptions.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ChannelRuntimeTombstoneCompactionOptions.cs index eef67a188..ceb01abaf 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelRuntimeTombstoneCompactionOptions.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelRuntimeTombstoneCompactionOptions.cs @@ -1,4 +1,4 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; public sealed class ChannelRuntimeTombstoneCompactionOptions { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelRuntimeTombstoneCompactionService.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelRuntimeTombstoneCompactionService.cs similarity index 97% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelRuntimeTombstoneCompactionService.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ChannelRuntimeTombstoneCompactionService.cs index f2a02edfb..4f1089ef1 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelRuntimeTombstoneCompactionService.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelRuntimeTombstoneCompactionService.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; public sealed class ChannelRuntimeTombstoneCompactionService : BackgroundService { diff --git a/agents/Aevatar.GAgents.Channel.Runtime/ChannelRuntimeTombstoneCompactor.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelRuntimeTombstoneCompactor.cs new file mode 100644 index 000000000..55f5f9db4 --- /dev/null +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelRuntimeTombstoneCompactor.cs @@ -0,0 +1,79 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.Foundation.Abstractions; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.Channel.Runtime; + +public sealed class ChannelRuntimeTombstoneCompactor +{ + // Stable publisher id for envelope routing — keeps compactor traffic + // distinguishable from end-user / tool dispatches in tracing + log fields. + private const string PublisherActorId = "channel-runtime.tombstone-compactor"; + + private readonly IProjectionScopeWatermarkQueryPort _watermarkQueryPort; + private readonly IActorRuntime _actorRuntime; + private readonly IActorDispatchPort _actorDispatchPort; + private readonly IEnumerable _targets; + private readonly ILogger _logger; + + public ChannelRuntimeTombstoneCompactor( + IProjectionScopeWatermarkQueryPort watermarkQueryPort, + IActorRuntime actorRuntime, + IActorDispatchPort actorDispatchPort, + IEnumerable targets, + ILogger logger) + { + _watermarkQueryPort = watermarkQueryPort ?? throw new ArgumentNullException(nameof(watermarkQueryPort)); + _actorRuntime = actorRuntime ?? throw new ArgumentNullException(nameof(actorRuntime)); + _actorDispatchPort = actorDispatchPort ?? throw new ArgumentNullException(nameof(actorDispatchPort)); + _targets = targets ?? throw new ArgumentNullException(nameof(targets)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task RunOnceAsync(CancellationToken ct = default) + { + foreach (var target in _targets) + { + try + { + await CompactAsync(target, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, + "Tombstone compaction failed for {TargetName}: actorId={ActorId}. Continuing with remaining targets.", + target.TargetName, + target.ActorId); + } + } + } + + private async Task CompactAsync(ITombstoneCompactionTarget target, CancellationToken ct) + { + var safeVersion = await _watermarkQueryPort.GetLastSuccessfulVersionAsync( + new ProjectionRuntimeScopeKey(target.ActorId, target.ProjectionKind, ProjectionRuntimeMode.DurableMaterialization), + ct); + if (!safeVersion.HasValue || safeVersion.Value <= 0) + return; + + // Lifecycle only — the compactor does not own message delivery. + await target.EnsureActorAsync(_actorRuntime, ct); + + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(target.CreateCommand(safeVersion.Value)), + Route = EnvelopeRouteSemantics.CreateDirect(PublisherActorId, target.ActorId), + }; + + await _actorDispatchPort.DispatchAsync(target.ActorId, envelope, ct); + + _logger.LogDebug( + "Dispatched tombstone compaction for {TargetName}: actorId={ActorId} safeStateVersion={SafeStateVersion}", + target.TargetName, + target.ActorId, + safeVersion.Value); + } +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelTextCommandParser.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelTextCommandParser.cs similarity index 95% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelTextCommandParser.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ChannelTextCommandParser.cs index 441d29885..840386432 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelTextCommandParser.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelTextCommandParser.cs @@ -1,6 +1,6 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; -internal static class ChannelTextCommandParser +public static class ChannelTextCommandParser { public static IReadOnlyList Tokenize(string? text) { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelUserConfigScope.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelUserConfigScope.cs similarity index 95% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelUserConfigScope.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ChannelUserConfigScope.cs index 2223ee074..89cbcd242 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelUserConfigScope.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelUserConfigScope.cs @@ -1,4 +1,4 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; /// /// Composes the per-end-user scope id used by UserConfigGAgent for @@ -12,7 +12,7 @@ namespace Aevatar.GAgents.ChannelRuntime; /// intact for downstream tools that legitimately need NyxID tenant scope /// (binding store, service invocation, etc.). /// -internal static class ChannelUserConfigScope +public static class ChannelUserConfigScope { private const string DefaultScope = "default"; private const string DefaultPlatform = "channel"; diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ConversationDispatchMiddleware.cs b/agents/Aevatar.GAgents.Channel.Runtime/ConversationDispatchMiddleware.cs similarity index 97% rename from agents/Aevatar.GAgents.ChannelRuntime/ConversationDispatchMiddleware.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ConversationDispatchMiddleware.cs index b1de2a8dd..a2bb23606 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ConversationDispatchMiddleware.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ConversationDispatchMiddleware.cs @@ -3,7 +3,7 @@ using Aevatar.GAgents.Channel.Runtime; using Google.Protobuf.WellKnownTypes; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; internal sealed class ConversationDispatchMiddleware : IChannelMiddleware { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ConversationPipelineTurnContext.cs b/agents/Aevatar.GAgents.Channel.Runtime/ConversationPipelineTurnContext.cs similarity index 92% rename from agents/Aevatar.GAgents.ChannelRuntime/ConversationPipelineTurnContext.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ConversationPipelineTurnContext.cs index 759ac7904..d3d10af4e 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ConversationPipelineTurnContext.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ConversationPipelineTurnContext.cs @@ -1,8 +1,8 @@ using Aevatar.GAgents.Channel.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; -internal sealed class ConversationPipelineTurnContext : ITurnContext +public sealed class ConversationPipelineTurnContext : ITurnContext { public ConversationPipelineTurnContext( ChatActivity activity, diff --git a/agents/Aevatar.GAgents.Channel.Runtime/DependencyInjection/ChannelRuntimeServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.Channel.Runtime/DependencyInjection/ChannelRuntimeServiceCollectionExtensions.cs index 2d1b5197a..07e84d819 100644 --- a/agents/Aevatar.GAgents.Channel.Runtime/DependencyInjection/ChannelRuntimeServiceCollectionExtensions.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/DependencyInjection/ChannelRuntimeServiceCollectionExtensions.cs @@ -1,6 +1,15 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.DependencyInjection; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; +using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; +using Aevatar.CQRS.Projection.Runtime.DependencyInjection; +using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.GAgents.Channel.Abstractions; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; namespace Aevatar.GAgents.Channel.Runtime; @@ -10,23 +19,106 @@ namespace Aevatar.GAgents.Channel.Runtime; public static class ChannelRuntimeServiceCollectionExtensions { /// - /// Registers the channel runtime middlewares, diagnostics, and default turn-runner fallback. - /// Consumers override with a real implementation bound to - /// their bot + outbound adapter. + /// Backwards-compat overload — registers the channel runtime middlewares, + /// diagnostics, default turn-runner fallback, ChannelBotRegistration projection + /// pipeline, and pipeline composition without an . + /// Falls back to the InMemory projection store. /// public static IServiceCollection AddChannelRuntime(this IServiceCollection services) + => AddChannelRuntime(services, configuration: null); + + /// + /// Registers the channel runtime middlewares, diagnostics, default turn-runner + /// fallback, ChannelBotRegistration projection pipeline, and pipeline composition. + /// Pass so the document projection store matches + /// the host environment (Elasticsearch in prod, InMemory for local dev / tests). + /// + public static IServiceCollection AddChannelRuntime( + this IServiceCollection services, IConfiguration? configuration) { ArgumentNullException.ThrowIfNull(services); + // ─── Core middlewares + default turn runner ─── services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(_ => new MiddlewarePipelineBuilder() + + // ─── Tombstone compaction options + diagnostics + ES watermark ─── + services.AddOptions(); + services.TryAddSingleton(); + services.TryAddSingleton(); + if (configuration != null) + { + services.Configure( + configuration.GetSection("ChannelRuntime:TombstoneCompaction")); + } + + // ─── Projection pipeline shared infrastructure ─── + services.AddProjectionReadModelRuntime(); + services.TryAddSingleton(); + + // Detect projection store provider from configuration. The helper logs a + // misconfiguration warning (Console.Error during SCE composition; structured + // log when a real logger is wired in tests) when configuration is present + // but Endpoints/Enabled are both empty, so operators see the InMemory + // fallback instead of discovering it after a restart wipes the replica. + var useElasticsearch = ElasticsearchProjectionConfiguration.IsEnabled( + configuration, + storeName: "ChannelRuntime"); + + // ─── Channel Bot Registration projection pipeline ─── + services.AddProjectionMaterializationRuntimeCore< + ChannelBotRegistrationMaterializationContext, + ChannelBotRegistrationMaterializationRuntimeLease, + ProjectionMaterializationScopeGAgent>( + static scopeKey => new ChannelBotRegistrationMaterializationContext + { + RootActorId = scopeKey.RootActorId, + ProjectionKind = scopeKey.ProjectionKind, + }, + static context => new ChannelBotRegistrationMaterializationRuntimeLease(context)); + services.AddCurrentStateProjectionMaterializer< + ChannelBotRegistrationMaterializationContext, + ChannelBotRegistrationProjector>(); + services.TryAddSingleton, + ChannelBotRegistrationDocumentMetadataProvider>(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.AddHostedService(); + + if (useElasticsearch) + { + services.AddElasticsearchDocumentProjectionStore( + optionsFactory: _ => ElasticsearchProjectionConfiguration.BindOptions(configuration!), + metadataFactory: sp => sp.GetRequiredService>().Metadata, + keySelector: static doc => doc.Id, + keyFormatter: static key => key); + } + else + { + services.AddInMemoryDocumentProjectionStore( + static doc => doc.Id, static key => key); + } + + // ─── Channel pipeline composition ─── + services.TryAddSingleton(); + services.Replace(ServiceDescriptor.Singleton(_ => new MiddlewarePipelineBuilder() .Use() .Use() - .Use()); + .Use() + .Use())); + services.TryAddSingleton(sp => sp.GetRequiredService().Build(sp)); + + // ─── Tombstone compaction service ─── + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + services.TryAddSingleton(); + services.AddHostedService(); return services; } + } diff --git a/agents/Aevatar.GAgents.ChannelRuntime/IChannelBotRegistrationQueryByNyxIdentityPort.cs b/agents/Aevatar.GAgents.Channel.Runtime/IChannelBotRegistrationQueryByNyxIdentityPort.cs similarity index 96% rename from agents/Aevatar.GAgents.ChannelRuntime/IChannelBotRegistrationQueryByNyxIdentityPort.cs rename to agents/Aevatar.GAgents.Channel.Runtime/IChannelBotRegistrationQueryByNyxIdentityPort.cs index a302700aa..c8d8d7eb6 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/IChannelBotRegistrationQueryByNyxIdentityPort.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/IChannelBotRegistrationQueryByNyxIdentityPort.cs @@ -1,4 +1,4 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; public interface IChannelBotRegistrationQueryByNyxIdentityPort { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/IChannelBotRegistrationQueryPort.cs b/agents/Aevatar.GAgents.Channel.Runtime/IChannelBotRegistrationQueryPort.cs similarity index 93% rename from agents/Aevatar.GAgents.ChannelRuntime/IChannelBotRegistrationQueryPort.cs rename to agents/Aevatar.GAgents.Channel.Runtime/IChannelBotRegistrationQueryPort.cs index bd19ddc56..5ee102c42 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/IChannelBotRegistrationQueryPort.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/IChannelBotRegistrationQueryPort.cs @@ -1,4 +1,4 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; public interface IChannelBotRegistrationQueryPort { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/IChannelBotRegistrationRuntimeQueryPort.cs b/agents/Aevatar.GAgents.Channel.Runtime/IChannelBotRegistrationRuntimeQueryPort.cs similarity index 89% rename from agents/Aevatar.GAgents.ChannelRuntime/IChannelBotRegistrationRuntimeQueryPort.cs rename to agents/Aevatar.GAgents.Channel.Runtime/IChannelBotRegistrationRuntimeQueryPort.cs index 99469f03a..6828b72c4 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/IChannelBotRegistrationRuntimeQueryPort.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/IChannelBotRegistrationRuntimeQueryPort.cs @@ -1,4 +1,4 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; /// /// Runtime registration read path used by legacy call sites that still expect a diff --git a/agents/Aevatar.GAgents.Channel.Runtime/IConversationReplyGenerator.cs b/agents/Aevatar.GAgents.Channel.Runtime/IConversationReplyGenerator.cs new file mode 100644 index 000000000..bc7c04912 --- /dev/null +++ b/agents/Aevatar.GAgents.Channel.Runtime/IConversationReplyGenerator.cs @@ -0,0 +1,17 @@ +using Aevatar.GAgents.Channel.Abstractions; + +namespace Aevatar.GAgents.Channel.Runtime; + +public interface IConversationReplyGenerator +{ + /// + /// Generates the full LLM reply text. If is supplied, the + /// generator forwards progressive deltas as the stream advances; implementations must tolerate + /// a null sink by simply accumulating the final text. + /// + Task GenerateReplyAsync( + ChatActivity activity, + IReadOnlyDictionary metadata, + IStreamingReplySink? streamingSink, + CancellationToken ct); +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/IStreamingReplySink.cs b/agents/Aevatar.GAgents.Channel.Runtime/IStreamingReplySink.cs similarity index 93% rename from agents/Aevatar.GAgents.ChannelRuntime/IStreamingReplySink.cs rename to agents/Aevatar.GAgents.Channel.Runtime/IStreamingReplySink.cs index 8697d6f46..1769c0a4a 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/IStreamingReplySink.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/IStreamingReplySink.cs @@ -1,4 +1,4 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; /// /// Receives per-delta streaming updates from so the reply @@ -13,7 +13,7 @@ namespace Aevatar.GAgents.ChannelRuntime; /// turn, or an earlier failure invalidated the turn); generators must tolerate a null sink by /// simply accumulating the final text without calling any sink method. /// -internal interface IStreamingReplySink +public interface IStreamingReplySink { /// /// Reports the accumulated reply text after a new delta has arrived. The implementation diff --git a/agents/Aevatar.GAgents.Channel.Runtime/ITombstoneCompactionTarget.cs b/agents/Aevatar.GAgents.Channel.Runtime/ITombstoneCompactionTarget.cs new file mode 100644 index 000000000..4d303799f --- /dev/null +++ b/agents/Aevatar.GAgents.Channel.Runtime/ITombstoneCompactionTarget.cs @@ -0,0 +1,36 @@ +using Aevatar.Foundation.Abstractions; +using Google.Protobuf; + +namespace Aevatar.GAgents.Channel.Runtime; + +/// +/// Plugin contract for tombstone-compaction targets owned by per-package agent +/// modules. Each target tells the compactor which actor to address, which +/// projection scope to read the safe watermark from, and which protobuf command +/// to dispatch — but does not perform delivery itself. is the only writer; it goes +/// through so envelope routing stays on the +/// standard inbox/dispatch path. +/// +public interface ITombstoneCompactionTarget +{ + /// Well-known actor id for the compaction-owning aggregate. + string ActorId { get; } + + /// Projection scope used to look up the safe state-version watermark. + string ProjectionKind { get; } + + /// Human-readable label for diagnostics / log lines. + string TargetName { get; } + + /// + /// Materializes the actor lifecycle (so the dispatched envelope has a live + /// inbox). Implementations call + + /// with their concrete + /// GAgent type — the compactor stays generic. + /// + Task EnsureActorAsync(IActorRuntime actorRuntime, CancellationToken ct); + + /// Builds the per-target compaction command keyed off the safe state version. + IMessage CreateCommand(long safeStateVersion); +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/InboundMessage.cs b/agents/Aevatar.GAgents.Channel.Runtime/InboundMessage.cs similarity index 94% rename from agents/Aevatar.GAgents.ChannelRuntime/InboundMessage.cs rename to agents/Aevatar.GAgents.Channel.Runtime/InboundMessage.cs index 82ecb9e0c..43673897c 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/InboundMessage.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/InboundMessage.cs @@ -1,6 +1,6 @@ using Aevatar.GAgents.Channel.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; /// /// Normalized inbound message parsed from a platform-specific webhook payload. diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ProjectionWaitDefaults.cs b/agents/Aevatar.GAgents.Channel.Runtime/ProjectionWaitDefaults.cs similarity index 80% rename from agents/Aevatar.GAgents.ChannelRuntime/ProjectionWaitDefaults.cs rename to agents/Aevatar.GAgents.Channel.Runtime/ProjectionWaitDefaults.cs index 51b7c9db2..5ee878e36 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ProjectionWaitDefaults.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ProjectionWaitDefaults.cs @@ -1,11 +1,11 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; /// /// Shared default polling budget for tools that wait on the read model after /// dispatching a write to the user agent catalog actor. 30 attempts × 500 ms /// = 15 s — covers the production projection lag the prior 5 s budget lost to. /// -internal static class ProjectionWaitDefaults +public static class ProjectionWaitDefaults { public const int Attempts = 30; public const int DelayMilliseconds = 500; diff --git a/agents/Aevatar.GAgents.ChannelRuntime/TurnStreamingReplySink.cs b/agents/Aevatar.GAgents.Channel.Runtime/TurnStreamingReplySink.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/TurnStreamingReplySink.cs rename to agents/Aevatar.GAgents.Channel.Runtime/TurnStreamingReplySink.cs index a98e96e5e..7aa2ce57b 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/TurnStreamingReplySink.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/TurnStreamingReplySink.cs @@ -4,7 +4,7 @@ using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.Runtime; /// /// Drives progressive (edit-in-place) rendering of an LLM reply for a single turn by dispatching @@ -42,7 +42,7 @@ namespace Aevatar.GAgents.ChannelRuntime; /// _dispatchInProgress so the conversation actor observes a strict ordering of edits. /// /// -internal sealed class TurnStreamingReplySink : IStreamingReplySink, IDisposable +public sealed class TurnStreamingReplySink : IStreamingReplySink, IDisposable { private readonly IActor _targetActor; private readonly string _correlationId; diff --git a/agents/Aevatar.GAgents.Channel.Runtime/protos/channel_bot_registration.proto b/agents/Aevatar.GAgents.Channel.Runtime/protos/channel_bot_registration.proto new file mode 100644 index 000000000..32dd0bb1d --- /dev/null +++ b/agents/Aevatar.GAgents.Channel.Runtime/protos/channel_bot_registration.proto @@ -0,0 +1,116 @@ +syntax = "proto3"; + +package aevatar.gagents.channel.runtime; + +option csharp_namespace = "Aevatar.GAgents.Channel.Runtime"; + +import "google/protobuf/timestamp.proto"; + +// ─── Persistent State ─── + +message ChannelBotRegistrationEntry { + string id = 1; + string platform = 2; + string nyx_provider_slug = 3; + string scope_id = 4; + google.protobuf.Timestamp created_at = 5; + string webhook_url = 6; // Full callback URL configured via setWebhook + // Soft-delete marker retained for projector watermark coordination + // (Channel RFC §7.1.1): the registration stays in state after unregister so + // the projector can emit a Tombstone verdict → IProjectionWriteDispatcher + // .DeleteAsync. Housekeeping cleans watermark-passed entries. + bool tombstoned = 7; + int64 tombstone_state_version = 8; + string nyx_channel_bot_id = 9; + string nyx_agent_api_key_id = 10; + string nyx_conversation_route_id = 11; + reserved 12; + reserved "credential_ref"; +} + +message ChannelBotRegistrationStoreState { + repeated ChannelBotRegistrationEntry registrations = 1; +} + +// Domain events for ChannelBotRegistrationGAgent +message ChannelBotRegisteredEvent { + ChannelBotRegistrationEntry entry = 1; +} + +message ChannelBotUnregisteredEvent { + string registration_id = 1; + int64 tombstone_state_version = 2; +} + +// Commands dispatched to ChannelBotRegistrationGAgent via EventEnvelope +message ChannelBotRegisterCommand { + string platform = 1; + string nyx_provider_slug = 2; + string scope_id = 3; + string webhook_url = 4; + string requested_id = 5; // Caller-provided ID; actor uses if non-empty, else generates + string nyx_channel_bot_id = 6; + string nyx_agent_api_key_id = 7; + string nyx_conversation_route_id = 8; + reserved 9; + reserved "credential_ref"; +} + +message ChannelBotUnregisterCommand { + string registration_id = 1; +} + +message ChannelBotRebuildProjectionCommand { + string reason = 1; +} + +message ChannelBotCompactTombstonesCommand { + int64 safe_state_version = 1; +} + +message ChannelBotProjectionRebuildRequestedEvent { + string reason = 1; + google.protobuf.Timestamp requested_at = 2; +} + +message ChannelBotTombstonesCompactedEvent { + repeated string registration_ids = 1; + int64 safe_state_version = 2; +} + +// Projection read model for channel bot registrations +message ChannelBotRegistrationDocument { + string id = 1; // Registration ID (= document key) + string platform = 2; + string nyx_provider_slug = 3; + string scope_id = 4; + string webhook_url = 5; + int64 state_version = 6; + string last_event_id = 7; + google.protobuf.Timestamp updated_at_utc = 8; + string actor_id = 9; // Source actor ID + string nyx_channel_bot_id = 10; + string nyx_agent_api_key_id = 11; + string nyx_conversation_route_id = 12; + reserved 13; + reserved "credential_ref"; +} + +// ─── Relay / conversation normalization ─── + +// Normalized inbound callback payload used by relay-aware channel flows +// (for example deterministic builder/tool routing in ChannelConversationTurnRunner). +message ChannelInboundEvent { + string text = 1; + string sender_id = 2; + string sender_name = 3; + string conversation_id = 4; + string message_id = 5; + string chat_type = 6; + string platform = 7; + string registration_id = 8; + string registration_token = 9; // inbound-scoped token propagated to the LLM provider + string registration_scope_id = 10; + string nyx_provider_slug = 11; + map extra = 12; +} diff --git a/agents/Aevatar.GAgents.Channel.Runtime/protos/conversation_events.proto b/agents/Aevatar.GAgents.Channel.Runtime/protos/conversation_events.proto index 225b1f058..ea0cc98e6 100644 --- a/agents/Aevatar.GAgents.Channel.Runtime/protos/conversation_events.proto +++ b/agents/Aevatar.GAgents.Channel.Runtime/protos/conversation_events.proto @@ -5,10 +5,8 @@ package aevatar.gagents.channel.runtime; option csharp_namespace = "Aevatar.GAgents.Channel.Runtime"; import "google/protobuf/empty.proto"; -import "google/protobuf/timestamp.proto"; import "chat_activity.proto"; import "channel_contracts.proto"; -import "schedule.proto"; message OutboundDeliveryReceipt { string reply_message_id = 1; @@ -193,36 +191,3 @@ enum FailureKind { FAILURE_KIND_CREDENTIAL_RESOLUTION_FAILED = 3; FAILURE_KIND_PLATFORM_UNAVAILABLE = 4; } - -message ChannelBotRegistrationEntry { - aevatar.gagents.channel.abstractions.ChannelTransportBinding transport_binding = 1; - string webhook_url = 2; - google.protobuf.Timestamp created_at = 3; - bool is_deleted = 4; -} - -message UserAgentCatalogEntry { - string agent_id = 1; - aevatar.gagents.channel.abstractions.ConversationReference conversation = 2; - string nyx_provider_slug = 3; - string owner_nyx_user_id = 4; - string agent_type = 5; - string template_name = 6; - string scope_id = 7; - string api_key_id = 8; - aevatar.gagents.channel.abstractions.ScheduleState schedule = 9; - string status = 10; - google.protobuf.Timestamp created_at = 11; - google.protobuf.Timestamp updated_at = 12; - bool is_deleted = 13; -} - -message DeviceRegistrationEntry { - string id = 1; - string scope_id = 2; - string nyx_conversation_id = 3; - string description = 4; - string credential_ref = 5; - google.protobuf.Timestamp created_at = 6; - bool is_deleted = 7; -} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelRuntimeTombstoneCompactor.cs b/agents/Aevatar.GAgents.ChannelRuntime/ChannelRuntimeTombstoneCompactor.cs deleted file mode 100644 index c629b575e..000000000 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelRuntimeTombstoneCompactor.cs +++ /dev/null @@ -1,86 +0,0 @@ -using Aevatar.CQRS.Projection.Core.Abstractions; -using Aevatar.Foundation.Abstractions; -using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; -using Microsoft.Extensions.Logging; - -namespace Aevatar.GAgents.ChannelRuntime; - -public sealed class ChannelRuntimeTombstoneCompactor -{ - private readonly IProjectionScopeWatermarkQueryPort _watermarkQueryPort; - private readonly IActorRuntime _actorRuntime; - private readonly ILogger _logger; - - public ChannelRuntimeTombstoneCompactor( - IProjectionScopeWatermarkQueryPort watermarkQueryPort, - IActorRuntime actorRuntime, - ILogger logger) - { - _watermarkQueryPort = watermarkQueryPort ?? throw new ArgumentNullException(nameof(watermarkQueryPort)); - _actorRuntime = actorRuntime ?? throw new ArgumentNullException(nameof(actorRuntime)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task RunOnceAsync(CancellationToken ct = default) - { - await CompactAsync( - ChannelBotRegistrationGAgent.WellKnownId, - ChannelBotRegistrationProjectionPort.ProjectionKind, - safeVersion => new ChannelBotCompactTombstonesCommand { SafeStateVersion = safeVersion }, - "channel bot registration", - ct); - - await CompactAsync( - DeviceRegistrationGAgent.WellKnownId, - DeviceRegistrationProjectionPort.ProjectionKind, - safeVersion => new DeviceCompactTombstonesCommand { SafeStateVersion = safeVersion }, - "device registration", - ct); - - await CompactAsync( - UserAgentCatalogGAgent.WellKnownId, - UserAgentCatalogProjectionPort.ProjectionKind, - safeVersion => new UserAgentCatalogCompactTombstonesCommand { SafeStateVersion = safeVersion }, - "user agent catalog", - ct); - } - - private async Task CompactAsync( - string actorId, - string projectionKind, - Func commandFactory, - string targetName, - CancellationToken ct) - where TCommand : IMessage - { - var safeVersion = await _watermarkQueryPort.GetLastSuccessfulVersionAsync( - new ProjectionRuntimeScopeKey(actorId, projectionKind, ProjectionRuntimeMode.DurableMaterialization), - ct); - if (!safeVersion.HasValue || safeVersion.Value <= 0) - return; - - var actor = await _actorRuntime.GetAsync(actorId); - if (actor is null) - return; - - await actor.HandleEventAsync( - new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(commandFactory(safeVersion.Value)), - Route = new EnvelopeRoute - { - Direct = new DirectRoute { TargetActorId = actor.Id }, - }, - }, - ct); - - _logger.LogDebug( - "Dispatched tombstone compaction for {TargetName}: actorId={ActorId} safeStateVersion={SafeStateVersion}", - targetName, - actorId, - safeVersion.Value); - } -} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs deleted file mode 100644 index 6899b4455..000000000 --- a/agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,280 +0,0 @@ -using Aevatar.AI.Abstractions.Middleware; -using Aevatar.AI.Abstractions.ToolProviders; -using Aevatar.AI.ToolProviders.Channel; -using Aevatar.AI.ToolProviders.NyxId; -using Aevatar.CQRS.Projection.Core.Abstractions; -using Aevatar.CQRS.Projection.Core.DependencyInjection; -using Aevatar.CQRS.Projection.Core.Orchestration; -using Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; -using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; -using Aevatar.CQRS.Projection.Runtime.DependencyInjection; -using Aevatar.CQRS.Projection.Stores.Abstractions; -using Aevatar.GAgents.Channel.Abstractions; -using Aevatar.GAgents.Channel.Runtime; -using Aevatar.GAgents.Channel.NyxIdRelay; -using Aevatar.GAgents.Platform.Lark; -using Aevatar.GAgents.ChannelRuntime.Outbound; -using Aevatar.Foundation.Abstractions.HumanInteraction; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Hosting; - -namespace Aevatar.GAgents.ChannelRuntime; - -public static class ServiceCollectionExtensions -{ - /// - /// Registers channel runtime services. Pass IConfiguration so the document - /// projection store matches the host environment (Elasticsearch in prod, - /// InMemory for local dev / tests). - /// - public static IServiceCollection AddChannelRuntime( - this IServiceCollection services, IConfiguration? configuration = null) - { - Aevatar.GAgents.Channel.Runtime.ChannelRuntimeServiceCollectionExtensions.AddChannelRuntime(services); - - services.AddOptions(); - services.TryAddSingleton(); - services.TryAddSingleton(); - if (configuration != null) - { - services.Configure( - configuration.GetSection("ChannelRuntime:TombstoneCompaction")); - } - - // Projection pipeline shared infrastructure - services.AddProjectionReadModelRuntime(); - services.TryAddSingleton(); - - // Detect projection store provider from configuration - var useElasticsearch = ResolveElasticsearchEnabled(configuration); - - // ─── Device Registration projection pipeline ─── - services.AddProjectionMaterializationRuntimeCore< - DeviceRegistrationMaterializationContext, - DeviceRegistrationMaterializationRuntimeLease, - ProjectionMaterializationScopeGAgent>( - static scopeKey => new DeviceRegistrationMaterializationContext - { - RootActorId = scopeKey.RootActorId, - ProjectionKind = scopeKey.ProjectionKind, - }, - static context => new DeviceRegistrationMaterializationRuntimeLease(context)); - services.AddCurrentStateProjectionMaterializer< - DeviceRegistrationMaterializationContext, - DeviceRegistrationProjector>(); - services.TryAddSingleton, - DeviceRegistrationDocumentMetadataProvider>(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.AddHostedService(); - - if (useElasticsearch) - { - services.AddElasticsearchDocumentProjectionStore( - optionsFactory: _ => BuildElasticsearchOptions(configuration!), - metadataFactory: sp => sp.GetRequiredService>().Metadata, - keySelector: static doc => doc.Id, - keyFormatter: static key => key); - } - else - { - services.AddInMemoryDocumentProjectionStore( - static doc => doc.Id, static key => key); - } - - // ─── Channel Bot Registration projection pipeline ─── - services.AddProjectionMaterializationRuntimeCore< - ChannelBotRegistrationMaterializationContext, - ChannelBotRegistrationMaterializationRuntimeLease, - ProjectionMaterializationScopeGAgent>( - static scopeKey => new ChannelBotRegistrationMaterializationContext - { - RootActorId = scopeKey.RootActorId, - ProjectionKind = scopeKey.ProjectionKind, - }, - static context => new ChannelBotRegistrationMaterializationRuntimeLease(context)); - services.AddCurrentStateProjectionMaterializer< - ChannelBotRegistrationMaterializationContext, - ChannelBotRegistrationProjector>(); - services.TryAddSingleton, - ChannelBotRegistrationDocumentMetadataProvider>(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.TryAddSingleton(); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.AddHostedService(); - - if (useElasticsearch) - { - services.AddElasticsearchDocumentProjectionStore( - optionsFactory: _ => BuildElasticsearchOptions(configuration!), - metadataFactory: sp => sp.GetRequiredService>().Metadata, - keySelector: static doc => doc.Id, - keyFormatter: static key => key); - } - else - { - services.AddInMemoryDocumentProjectionStore( - static doc => doc.Id, static key => key); - } - - // ─── User Agent Catalog projection pipeline ─── - services.AddProjectionMaterializationRuntimeCore< - UserAgentCatalogMaterializationContext, - UserAgentCatalogMaterializationRuntimeLease, - ProjectionMaterializationScopeGAgent>( - static scopeKey => new UserAgentCatalogMaterializationContext - { - RootActorId = scopeKey.RootActorId, - ProjectionKind = scopeKey.ProjectionKind, - }, - static context => new UserAgentCatalogMaterializationRuntimeLease(context)); - services.AddCurrentStateProjectionMaterializer< - UserAgentCatalogMaterializationContext, - UserAgentCatalogProjector>(); - services.AddCurrentStateProjectionMaterializer< - UserAgentCatalogMaterializationContext, - UserAgentCatalogNyxCredentialProjector>(); - services.TryAddSingleton, - UserAgentCatalogDocumentMetadataProvider>(); - services.TryAddSingleton, - UserAgentCatalogNyxCredentialDocumentMetadataProvider>(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.AddHostedService(); - - if (useElasticsearch) - { - services.AddElasticsearchDocumentProjectionStore( - optionsFactory: _ => BuildElasticsearchOptions(configuration!), - metadataFactory: sp => sp.GetRequiredService>().Metadata, - keySelector: static doc => doc.Id, - keyFormatter: static key => key); - services.AddElasticsearchDocumentProjectionStore( - optionsFactory: _ => BuildElasticsearchOptions(configuration!), - metadataFactory: sp => sp.GetRequiredService>().Metadata, - keySelector: static doc => doc.Id, - keyFormatter: static key => key); - } - else - { - services.AddInMemoryDocumentProjectionStore( - static doc => doc.Id, static key => key); - services.AddInMemoryDocumentProjectionStore( - static doc => doc.Id, static key => key); - } - - services.Replace(ServiceDescriptor.Singleton()); - - // channel runtime tools - services.TryAddEnumerable( - ServiceDescriptor.Singleton()); - services.TryAddEnumerable( - ServiceDescriptor.Singleton()); - services.TryAddEnumerable( - ServiceDescriptor.Singleton()); - - // interactive reply composer registry, collector, dispatcher, and LLM-facing tool - services.AddChannelInteractiveReplyTools(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.AddHostedService(); - - services.AddHttpClient(LarkConversationHostDefaults.HttpClientName, client => - { - client.BaseAddress = LarkConversationHostDefaults.BaseAddress; - }); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddEnumerable(ServiceDescriptor.Singleton( - sp => sp.GetRequiredService())); - services.TryAddEnumerable(ServiceDescriptor.Singleton( - sp => sp.GetRequiredService())); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddEnumerable(ServiceDescriptor.Singleton( - sp => sp.GetRequiredService())); - services.TryAddEnumerable(ServiceDescriptor.Singleton( - sp => sp.GetRequiredService())); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(sp => - { - var relayOptions = sp.GetService() ?? new NyxIdRelayOptions(); - return new NyxIdRelayReplayGuard( - TimeSpan.FromSeconds(Math.Max(1, relayOptions.CallbackReplayWindowSeconds)), - TimeProvider.System); - }); - services.TryAddSingleton(); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.Replace(ServiceDescriptor.Singleton()); - services.Replace(ServiceDescriptor.Singleton(_ => new MiddlewarePipelineBuilder() - .Use() - .Use() - .Use() - .Use())); - services.TryAddSingleton(sp => sp.GetRequiredService().Build(sp)); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(sp => sp.GetRequiredService()); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.TryAddSingleton(); - services.TryAddSingleton(sp => sp.GetRequiredService()); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - - return services; - } - - /// - /// Detects whether Elasticsearch is the projection store. - /// Reuses the same detection logic as Scripting projections: - /// explicit Enabled=true, or auto-detect from Endpoints presence. - /// When configuration is null (unit tests), falls back to InMemory. - /// When configuration is present but ES is not configured, logs a warning - /// because production should always use ES. - /// - private static bool ResolveElasticsearchEnabled(IConfiguration? configuration) - { - if (configuration == null) return false; - - var section = configuration.GetSection("Projection:Document:Providers:Elasticsearch"); - var explicitEnabled = section["Enabled"]; - if (!string.IsNullOrWhiteSpace(explicitEnabled)) - return string.Equals(explicitEnabled.Trim(), "true", StringComparison.OrdinalIgnoreCase); - - // Auto-detect: if endpoints are configured, ES is enabled - var hasEndpoints = section.GetSection("Endpoints").GetChildren() - .Any(x => !string.IsNullOrWhiteSpace(x.Value)); - - if (!hasEndpoints) - { - // Not a test (configuration is present) but no ES configured. - // This is expected for local dev, but a misconfiguration in prod. - Console.Error.WriteLine( - "[WARN] ChannelRuntime: Elasticsearch not configured — using volatile InMemory projection store. " + - "Registration data will be lost on restart. Set Projection:Document:Providers:Elasticsearch:Enabled=true for production."); - } - - return hasEndpoints; - } - - private static Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration.ElasticsearchProjectionDocumentStoreOptions - BuildElasticsearchOptions(IConfiguration configuration) - { - var options = new Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration.ElasticsearchProjectionDocumentStoreOptions(); - configuration.GetSection("Projection:Document:Providers:Elasticsearch").Bind(options); - return options; - } -} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/channel_runtime_messages.proto b/agents/Aevatar.GAgents.ChannelRuntime/channel_runtime_messages.proto deleted file mode 100644 index 6504947fa..000000000 --- a/agents/Aevatar.GAgents.ChannelRuntime/channel_runtime_messages.proto +++ /dev/null @@ -1,578 +0,0 @@ -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 scope_id = 4; - google.protobuf.Timestamp created_at = 5; - string webhook_url = 6; // Full callback URL configured via setWebhook - // Soft-delete marker retained for projector watermark coordination - // (Channel RFC §7.1.1): the registration stays in state after unregister so - // the projector can emit a Tombstone verdict → IProjectionWriteDispatcher - // .DeleteAsync. Housekeeping cleans watermark-passed entries. - bool tombstoned = 7; - int64 tombstone_state_version = 8; - string nyx_channel_bot_id = 9; - string nyx_agent_api_key_id = 10; - string nyx_conversation_route_id = 11; - reserved 12; - reserved "credential_ref"; -} - -message ChannelBotRegistrationStoreState { - repeated ChannelBotRegistrationEntry registrations = 1; -} - -// ─── Device Registration (Actor-backed, #164 pattern) ─── - -message DeviceRegistrationEntry { - string id = 1; - string scope_id = 2; // Maps to household-{scope_id} actor - string hmac_key = 3; // HMAC-SHA256 signing key for NyxID relay verification - string nyx_conversation_id = 4; // NyxID conversation ID - string description = 5; // Human-readable label - google.protobuf.Timestamp created_at = 6; - // Soft-delete marker retained for projector watermark coordination - // (Channel RFC §7.1.1): the registration stays in state after unregister so - // the projector can emit a Tombstone verdict → IProjectionWriteDispatcher - // .DeleteAsync. Housekeeping cleans watermark-passed entries. - bool tombstoned = 7; - int64 tombstone_state_version = 8; -} - -message DeviceRegistrationState { - repeated DeviceRegistrationEntry registrations = 1; -} - -// Domain events for DeviceRegistrationGAgent -message DeviceRegisteredEvent { - DeviceRegistrationEntry entry = 1; -} - -message DeviceUnregisteredEvent { - string registration_id = 1; - int64 tombstone_state_version = 2; -} - -// Commands dispatched to DeviceRegistrationGAgent via EventEnvelope -message DeviceRegisterCommand { - string scope_id = 1; - string hmac_key = 2; - string nyx_conversation_id = 3; - string description = 4; -} - -message DeviceUnregisterCommand { - string registration_id = 1; -} - -message DeviceCompactTombstonesCommand { - int64 safe_state_version = 1; -} - -message DeviceTombstonesCompactedEvent { - repeated string registration_ids = 1; - int64 safe_state_version = 2; -} - -// Projection read model for device registrations -message DeviceRegistrationDocument { - string id = 1; // Registration ID (= document key) - string scope_id = 2; - string hmac_key = 3; - string nyx_conversation_id = 4; - string description = 5; - int64 state_version = 6; - string last_event_id = 7; - google.protobuf.Timestamp updated_at_utc = 8; - string actor_id = 9; // Source actor ID -} - -// ─── Channel Bot Registration (Actor-backed, same pattern as DeviceRegistration) ─── - -// Domain events for ChannelBotRegistrationGAgent -message ChannelBotRegisteredEvent { - ChannelBotRegistrationEntry entry = 1; -} - -message ChannelBotUnregisteredEvent { - string registration_id = 1; - int64 tombstone_state_version = 2; -} - -// Commands dispatched to ChannelBotRegistrationGAgent via EventEnvelope -message ChannelBotRegisterCommand { - string platform = 1; - string nyx_provider_slug = 2; - string scope_id = 3; - string webhook_url = 4; - string requested_id = 5; // Caller-provided ID; actor uses if non-empty, else generates - string nyx_channel_bot_id = 6; - string nyx_agent_api_key_id = 7; - string nyx_conversation_route_id = 8; - reserved 9; - reserved "credential_ref"; -} - -message ChannelBotUnregisterCommand { - string registration_id = 1; -} - -message ChannelBotRebuildProjectionCommand { - string reason = 1; -} - -message ChannelBotCompactTombstonesCommand { - int64 safe_state_version = 1; -} - -message ChannelBotProjectionRebuildRequestedEvent { - string reason = 1; - google.protobuf.Timestamp requested_at = 2; -} - -message ChannelBotTombstonesCompactedEvent { - repeated string registration_ids = 1; - int64 safe_state_version = 2; -} - -// Projection read model for channel bot registrations -message ChannelBotRegistrationDocument { - string id = 1; // Registration ID (= document key) - string platform = 2; - string nyx_provider_slug = 3; - string scope_id = 4; - string webhook_url = 5; - int64 state_version = 6; - string last_event_id = 7; - google.protobuf.Timestamp updated_at_utc = 8; - string actor_id = 9; // Source actor ID - string nyx_channel_bot_id = 10; - string nyx_agent_api_key_id = 11; - string nyx_conversation_route_id = 12; - reserved 13; - reserved "credential_ref"; -} - -// ─── User Agent Catalog (actor-backed delivery target store) ─── - -message UserAgentCatalogEntry { - string agent_id = 1; - string platform = 2; - string conversation_id = 3; - string nyx_provider_slug = 4; - string nyx_api_key = 5; - string owner_nyx_user_id = 6; - google.protobuf.Timestamp created_at = 7; - google.protobuf.Timestamp updated_at = 8; - bool tombstoned = 9; - string agent_type = 10; - string template_name = 11; - string scope_id = 12; - string api_key_id = 13; - string schedule_cron = 14; - string schedule_timezone = 15; - string status = 16; - google.protobuf.Timestamp last_run_at = 17; - google.protobuf.Timestamp next_run_at = 18; - int32 error_count = 19; - string last_error = 20; - int64 tombstone_state_version = 21; - // Authoritative Lark outbound delivery target captured at create time. When - // both fields are present, outbound senders use them verbatim instead of - // inferring receive_id_type from the conversation_id prefix. The creator - // pins the chat-scoped `chat_id` (`oc_*`) here when available because it is - // the only identifier that survives both cross-app and cross-tenant - // boundaries when the same Lark app is on both ends of the relay. - string lark_receive_id = 22; - string lark_receive_id_type = 23; - // Secondary outbound delivery target. The runtime attempts the primary - // (`lark_receive_id`/`lark_receive_id_type`) first and falls back to this - // pair on a Lark `230002 bot not in chat` rejection — the failure mode - // when the outbound app is a different Lark app than the one that received - // the inbound DM (cross-app same-tenant deployment). The creator pins - // `union_id` (`on_*`) here so the fallback survives a chat_id rejection - // without needing a fresh ingress event. - string lark_receive_id_fallback = 24; - string lark_receive_id_type_fallback = 25; -} - -message UserAgentCatalogState { - repeated UserAgentCatalogEntry entries = 1; -} - -message UserAgentCatalogUpsertCommand { - string agent_id = 1; - string platform = 2; - string conversation_id = 3; - string nyx_provider_slug = 4; - string nyx_api_key = 5; - string owner_nyx_user_id = 6; - string agent_type = 7; - string template_name = 8; - string scope_id = 9; - string api_key_id = 10; - string schedule_cron = 11; - string schedule_timezone = 12; - string status = 13; - // See UserAgentCatalogEntry.lark_receive_id for semantics. Empty values - // preserve any existing entry value via the merge-non-empty upsert policy. - string lark_receive_id = 14; - string lark_receive_id_type = 15; - // See UserAgentCatalogEntry.lark_receive_id_fallback for semantics. - string lark_receive_id_fallback = 16; - string lark_receive_id_type_fallback = 17; -} - -message UserAgentCatalogTombstoneCommand { - string agent_id = 1; -} - -message UserAgentCatalogExecutionUpdateCommand { - string agent_id = 1; - string status = 2; - google.protobuf.Timestamp last_run_at = 3; - google.protobuf.Timestamp next_run_at = 4; - int32 error_count = 5; - string last_error = 6; -} - -message UserAgentCatalogCompactTombstonesCommand { - int64 safe_state_version = 1; -} - -message UserAgentCatalogUpsertedEvent { - UserAgentCatalogEntry entry = 1; -} - -message UserAgentCatalogTombstonedEvent { - string agent_id = 1; - int64 tombstone_state_version = 2; -} - -message UserAgentCatalogExecutionUpdatedEvent { - string agent_id = 1; - string status = 2; - google.protobuf.Timestamp last_run_at = 3; - google.protobuf.Timestamp next_run_at = 4; - int32 error_count = 5; - string last_error = 6; -} - -message UserAgentCatalogTombstonesCompactedEvent { - repeated string agent_ids = 1; - int64 safe_state_version = 2; -} - -message UserAgentCatalogDocument { - string id = 1; - string platform = 2; - string conversation_id = 3; - string nyx_provider_slug = 4; - reserved 5; - reserved "nyx_api_key"; - string owner_nyx_user_id = 6; - bool tombstoned = 7; - int64 state_version = 8; - string last_event_id = 9; - google.protobuf.Timestamp updated_at_utc = 10; - string actor_id = 11; - google.protobuf.Timestamp created_at_utc = 12; - string agent_type = 13; - string template_name = 14; - string scope_id = 15; - string api_key_id = 16; - string schedule_cron = 17; - string schedule_timezone = 18; - string status = 19; - google.protobuf.Timestamp last_run_at_utc = 20; - google.protobuf.Timestamp next_run_at_utc = 21; - int32 error_count = 22; - string last_error = 23; - // Mirrors UserAgentCatalogEntry.lark_receive_id*. Required so catalog-backed - // outbound senders (FeishuCardHumanInteractionPort) read the typed target - // through the projection rather than re-deriving from conversation_id. - string lark_receive_id = 24; - string lark_receive_id_type = 25; - // Mirrors UserAgentCatalogEntry.lark_receive_id_fallback*. Carried through - // the projection so catalog-backed senders see the same primary+fallback - // pair as actor-state senders. - string lark_receive_id_fallback = 26; - string lark_receive_id_type_fallback = 27; -} - -// Runtime-only Nyx credential read model for delivery-target execution paths. -message UserAgentCatalogNyxCredentialDocument { - string id = 1; - string nyx_api_key = 2; - int64 state_version = 3; - string last_event_id = 4; - google.protobuf.Timestamp updated_at_utc = 5; - string actor_id = 6; -} - -// ─── Skill Runner (persistent scheduled agent) ─── - -message SkillRunnerOutboundConfig { - string conversation_id = 1; - string nyx_provider_slug = 2; - string nyx_api_key = 3; - string owner_nyx_user_id = 4; - string api_key_id = 5; - // Channel platform identifier (e.g. "lark", "telegram"). Empty = unspecified; - // UserAgentCatalog upsert defaults to "lark" for backward compatibility. - string platform = 6; - // Authoritative Lark outbound delivery target captured at create time. When - // both fields are present, SkillRunnerGAgent.SendOutputAsync uses them - // verbatim; conversation_id stays for LLM metadata propagation only. - string lark_receive_id = 7; - string lark_receive_id_type = 8; - // Secondary outbound delivery target. Used by SkillRunnerGAgent.SendOutputAsync - // when the primary chat_id-typed send is rejected with Lark `230002 bot not in - // chat` — the failure mode for cross-app same-tenant deployments where the - // outbound app is not a member of the inbound DM. See - // UserAgentCatalogEntry.lark_receive_id_fallback for the recommended pinning. - string lark_receive_id_fallback = 9; - string lark_receive_id_type_fallback = 10; -} - -message SkillRunnerState { - string skill_name = 1; - string template_name = 2; - string skill_content = 3; - string execution_prompt = 4; - string schedule_cron = 5; - string schedule_timezone = 6; - SkillRunnerOutboundConfig outbound_config = 7; - google.protobuf.Timestamp last_run_at = 8; - google.protobuf.Timestamp next_run_at = 9; - int32 error_count = 10; - string last_output = 11; - string last_error = 12; - bool enabled = 13; - string scope_id = 14; - string provider_name = 15; - string model = 16; - optional double temperature = 17; - optional int32 max_tokens = 18; - optional int32 max_tool_rounds = 19; - optional int32 max_history_messages = 20; -} - -message InitializeSkillRunnerCommand { - string skill_name = 1; - string template_name = 2; - string skill_content = 3; - string execution_prompt = 4; - string schedule_cron = 5; - string schedule_timezone = 6; - SkillRunnerOutboundConfig outbound_config = 7; - bool enabled = 8; - string scope_id = 9; - string provider_name = 10; - string model = 11; - optional double temperature = 12; - optional int32 max_tokens = 13; - optional int32 max_tool_rounds = 14; - optional int32 max_history_messages = 15; -} - -message SkillRunnerInitializedEvent { - string skill_name = 1; - string template_name = 2; - string skill_content = 3; - string execution_prompt = 4; - string schedule_cron = 5; - string schedule_timezone = 6; - SkillRunnerOutboundConfig outbound_config = 7; - bool enabled = 8; - string scope_id = 9; - string provider_name = 10; - string model = 11; - optional double temperature = 12; - optional int32 max_tokens = 13; - optional int32 max_tool_rounds = 14; - optional int32 max_history_messages = 15; -} - -message TriggerSkillRunnerExecutionCommand { - string reason = 1; - // 0 = first attempt, 1 = backoff retry after a failed first attempt. - // Bounded by SkillRunnerDefaults.MaxRetryAttempts; retries are event-ified - // via ScheduleSelfDurableTimeoutAsync and never block the actor turn. - int32 retry_attempt = 2; -} - -message SkillRunnerNextRunScheduledEvent { - google.protobuf.Timestamp next_run_at = 1; -} - -message SkillRunnerExecutionCompletedEvent { - google.protobuf.Timestamp completed_at = 1; - string output = 2; -} - -message SkillRunnerExecutionFailedEvent { - google.protobuf.Timestamp failed_at = 1; - string error = 2; -} - -message DisableSkillRunnerCommand { - string reason = 1; -} - -message EnableSkillRunnerCommand { - string reason = 1; -} - -message SkillRunnerDisabledEvent { - string reason = 1; -} - -message SkillRunnerEnabledEvent { - string reason = 1; -} - -// ─── Workflow Agent (persistent scheduled workflow trigger) ─── - -message WorkflowAgentState { - string workflow_id = 1; - string workflow_name = 2; - string workflow_actor_id = 3; - string execution_prompt = 4; - string schedule_cron = 5; - string schedule_timezone = 6; - string conversation_id = 7; - string nyx_provider_slug = 8; - string nyx_api_key = 9; - string owner_nyx_user_id = 10; - string api_key_id = 11; - google.protobuf.Timestamp last_run_at = 12; - google.protobuf.Timestamp next_run_at = 13; - int32 error_count = 14; - string last_error = 15; - bool enabled = 16; - string scope_id = 17; - // Channel platform identifier (e.g. "lark", "telegram"). Empty = unspecified; - // UserAgentCatalog upsert defaults to "lark" for backward compatibility. - string platform = 18; - // See UserAgentCatalogEntry.lark_receive_id for semantics; copied verbatim - // into the catalog entry on UpsertRegistryAsync so downstream Lark senders - // (e.g. FeishuCardHumanInteractionPort) read the typed target. - string lark_receive_id = 19; - string lark_receive_id_type = 20; - // Secondary outbound delivery target. See UserAgentCatalogEntry - // .lark_receive_id_fallback for runtime fallback semantics. - string lark_receive_id_fallback = 21; - string lark_receive_id_type_fallback = 22; -} - -message InitializeWorkflowAgentCommand { - string workflow_id = 1; - string workflow_name = 2; - string workflow_actor_id = 3; - string execution_prompt = 4; - string schedule_cron = 5; - string schedule_timezone = 6; - string conversation_id = 7; - string nyx_provider_slug = 8; - string nyx_api_key = 9; - string owner_nyx_user_id = 10; - string api_key_id = 11; - bool enabled = 12; - string scope_id = 13; - // Channel platform identifier; empty → default "lark" at upsert time. - string platform = 14; - string lark_receive_id = 15; - string lark_receive_id_type = 16; - // Secondary outbound delivery target. See UserAgentCatalogEntry - // .lark_receive_id_fallback for runtime fallback semantics. - string lark_receive_id_fallback = 17; - string lark_receive_id_type_fallback = 18; -} - -message WorkflowAgentInitializedEvent { - string workflow_id = 1; - string workflow_name = 2; - string workflow_actor_id = 3; - string execution_prompt = 4; - string schedule_cron = 5; - string schedule_timezone = 6; - string conversation_id = 7; - string nyx_provider_slug = 8; - string nyx_api_key = 9; - string owner_nyx_user_id = 10; - string api_key_id = 11; - bool enabled = 12; - string scope_id = 13; - // Channel platform identifier; empty → default "lark" at upsert time. - string platform = 14; - string lark_receive_id = 15; - string lark_receive_id_type = 16; - // Secondary outbound delivery target. See UserAgentCatalogEntry - // .lark_receive_id_fallback for runtime fallback semantics. - string lark_receive_id_fallback = 17; - string lark_receive_id_type_fallback = 18; -} - -message TriggerWorkflowAgentExecutionCommand { - string reason = 1; - string revision_feedback = 2; -} - -message WorkflowAgentNextRunScheduledEvent { - google.protobuf.Timestamp next_run_at = 1; -} - -message WorkflowAgentExecutionDispatchedEvent { - google.protobuf.Timestamp dispatched_at = 1; - string workflow_run_actor_id = 2; - string command_id = 3; -} - -message WorkflowAgentExecutionFailedEvent { - google.protobuf.Timestamp failed_at = 1; - string error = 2; -} - -message DisableWorkflowAgentCommand { - string reason = 1; -} - -message EnableWorkflowAgentCommand { - string reason = 1; -} - -message WorkflowAgentDisabledEvent { - string reason = 1; -} - -message WorkflowAgentEnabledEvent { - string reason = 1; -} - -// ─── Relay / conversation normalization ─── - -// Normalized inbound callback payload used by relay-aware channel flows -// (for example deterministic builder/tool routing in ChannelConversationTurnRunner). -message ChannelInboundEvent { - string text = 1; - string sender_id = 2; - string sender_name = 3; - string conversation_id = 4; - string message_id = 5; - string chat_type = 6; - string platform = 7; - string registration_id = 8; - string registration_token = 9; // inbound-scoped token propagated to the LLM provider - string registration_scope_id = 10; - string nyx_provider_slug = 11; - map extra = 12; -} diff --git a/agents/Aevatar.GAgents.Device/Aevatar.GAgents.Device.csproj b/agents/Aevatar.GAgents.Device/Aevatar.GAgents.Device.csproj new file mode 100644 index 000000000..190bb19a0 --- /dev/null +++ b/agents/Aevatar.GAgents.Device/Aevatar.GAgents.Device.csproj @@ -0,0 +1,46 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.Device + Aevatar.GAgents.Device + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/agents/Aevatar.GAgents.Device/DependencyInjection/DeviceServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.Device/DependencyInjection/DeviceServiceCollectionExtensions.cs new file mode 100644 index 000000000..981a8fa92 --- /dev/null +++ b/agents/Aevatar.GAgents.Device/DependencyInjection/DeviceServiceCollectionExtensions.cs @@ -0,0 +1,76 @@ +using Aevatar.CQRS.Projection.Core.DependencyInjection; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; +using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.GAgents.Channel.Runtime; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aevatar.GAgents.Device; + +/// +/// DI registration entry point for the device registration package. +/// +public static class DeviceServiceCollectionExtensions +{ + /// + /// Registers the Device Registration projection pipeline (materialization runtime, + /// projector, query port, document metadata, startup service, and projection store). + /// Pass so the document projection store matches the + /// host environment (Elasticsearch in prod, InMemory for local dev / tests). + /// + public static IServiceCollection AddDeviceRegistration( + this IServiceCollection services, IConfiguration? configuration = null) + { + ArgumentNullException.ThrowIfNull(services); + + // The helper logs a misconfiguration warning (Console.Error during SCE + // composition; structured log when a real logger is wired in tests) when + // configuration is present but Endpoints/Enabled are both empty, so + // operators see the InMemory fallback at startup. + var useElasticsearch = ElasticsearchProjectionConfiguration.IsEnabled( + configuration, + storeName: "DeviceRegistration"); + + // ─── Device Registration projection pipeline ─── + services.AddProjectionMaterializationRuntimeCore< + DeviceRegistrationMaterializationContext, + DeviceRegistrationMaterializationRuntimeLease, + ProjectionMaterializationScopeGAgent>( + static scopeKey => new DeviceRegistrationMaterializationContext + { + RootActorId = scopeKey.RootActorId, + ProjectionKind = scopeKey.ProjectionKind, + }, + static context => new DeviceRegistrationMaterializationRuntimeLease(context)); + services.AddCurrentStateProjectionMaterializer< + DeviceRegistrationMaterializationContext, + DeviceRegistrationProjector>(); + services.TryAddSingleton, + DeviceRegistrationDocumentMetadataProvider>(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.AddHostedService(); + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + + if (useElasticsearch) + { + services.AddElasticsearchDocumentProjectionStore( + optionsFactory: _ => ElasticsearchProjectionConfiguration.BindOptions(configuration!), + metadataFactory: sp => sp.GetRequiredService>().Metadata, + keySelector: static doc => doc.Id, + keyFormatter: static key => key); + } + else + { + services.AddInMemoryDocumentProjectionStore( + static doc => doc.Id, static key => key); + } + + return services; + } + +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/DeviceEventEndpoints.cs b/agents/Aevatar.GAgents.Device/DeviceEventEndpoints.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/DeviceEventEndpoints.cs rename to agents/Aevatar.GAgents.Device/DeviceEventEndpoints.cs index 42efc6586..80e226368 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/DeviceEventEndpoints.cs +++ b/agents/Aevatar.GAgents.Device/DeviceEventEndpoints.cs @@ -12,7 +12,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Device; public sealed class DeviceEventOptions { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationDocument.Partial.cs b/agents/Aevatar.GAgents.Device/DeviceRegistrationDocument.Partial.cs similarity index 90% rename from agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationDocument.Partial.cs rename to agents/Aevatar.GAgents.Device/DeviceRegistrationDocument.Partial.cs index 70a1c0b6d..6652d2749 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationDocument.Partial.cs +++ b/agents/Aevatar.GAgents.Device/DeviceRegistrationDocument.Partial.cs @@ -1,7 +1,7 @@ using Aevatar.CQRS.Projection.Stores.Abstractions; using Google.Protobuf.WellKnownTypes; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Device; public sealed partial class DeviceRegistrationDocument : IProjectionReadModel { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationDocumentMetadataProvider.cs b/agents/Aevatar.GAgents.Device/DeviceRegistrationDocumentMetadataProvider.cs similarity index 93% rename from agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationDocumentMetadataProvider.cs rename to agents/Aevatar.GAgents.Device/DeviceRegistrationDocumentMetadataProvider.cs index be0d312de..4700ee91b 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationDocumentMetadataProvider.cs +++ b/agents/Aevatar.GAgents.Device/DeviceRegistrationDocumentMetadataProvider.cs @@ -1,6 +1,6 @@ using Aevatar.CQRS.Projection.Stores.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Device; public sealed class DeviceRegistrationDocumentMetadataProvider : IProjectionDocumentMetadataProvider diff --git a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationGAgent.cs b/agents/Aevatar.GAgents.Device/DeviceRegistrationGAgent.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationGAgent.cs rename to agents/Aevatar.GAgents.Device/DeviceRegistrationGAgent.cs index bee2665ac..c17f0f537 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationGAgent.cs +++ b/agents/Aevatar.GAgents.Device/DeviceRegistrationGAgent.cs @@ -5,7 +5,7 @@ using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Device; /// /// Actor-backed device registration store. diff --git a/agents/Aevatar.GAgents.Device/DeviceRegistrationLegacyAliases.cs b/agents/Aevatar.GAgents.Device/DeviceRegistrationLegacyAliases.cs new file mode 100644 index 000000000..24c5d71ae --- /dev/null +++ b/agents/Aevatar.GAgents.Device/DeviceRegistrationLegacyAliases.cs @@ -0,0 +1,49 @@ +using Aevatar.Foundation.Abstractions.Compatibility; + +namespace Aevatar.GAgents.Device; + +internal static class DeviceRegistrationLegacyAliases +{ + private const string ProtoPrefix = "aevatar.gagents.channelruntime."; + private const string ClrPrefix = "Aevatar.GAgents.ChannelRuntime."; + + internal const string EntryProto = ProtoPrefix + "DeviceRegistrationEntry"; + internal const string StateProto = ProtoPrefix + "DeviceRegistrationState"; + internal const string DocumentProto = ProtoPrefix + "DeviceRegistrationDocument"; + internal const string RegisteredEventProto = ProtoPrefix + "DeviceRegisteredEvent"; + internal const string UnregisteredEventProto = ProtoPrefix + "DeviceUnregisteredEvent"; + internal const string RegisterCommandProto = ProtoPrefix + "DeviceRegisterCommand"; + internal const string UnregisterCommandProto = ProtoPrefix + "DeviceUnregisterCommand"; + internal const string CompactTombstonesCommandProto = ProtoPrefix + "DeviceCompactTombstonesCommand"; + internal const string TombstonesCompactedEventProto = ProtoPrefix + "DeviceTombstonesCompactedEvent"; + + internal const string StateClr = ClrPrefix + "DeviceRegistrationState"; +} + +[LegacyProtoFullName(DeviceRegistrationLegacyAliases.EntryProto)] +public sealed partial class DeviceRegistrationEntry; + +[LegacyProtoFullName(DeviceRegistrationLegacyAliases.StateProto)] +[LegacyClrTypeName(DeviceRegistrationLegacyAliases.StateClr)] +public sealed partial class DeviceRegistrationState; + +[LegacyProtoFullName(DeviceRegistrationLegacyAliases.DocumentProto)] +public sealed partial class DeviceRegistrationDocument; + +[LegacyProtoFullName(DeviceRegistrationLegacyAliases.RegisteredEventProto)] +public sealed partial class DeviceRegisteredEvent; + +[LegacyProtoFullName(DeviceRegistrationLegacyAliases.UnregisteredEventProto)] +public sealed partial class DeviceUnregisteredEvent; + +[LegacyProtoFullName(DeviceRegistrationLegacyAliases.RegisterCommandProto)] +public sealed partial class DeviceRegisterCommand; + +[LegacyProtoFullName(DeviceRegistrationLegacyAliases.UnregisterCommandProto)] +public sealed partial class DeviceUnregisterCommand; + +[LegacyProtoFullName(DeviceRegistrationLegacyAliases.CompactTombstonesCommandProto)] +public sealed partial class DeviceCompactTombstonesCommand; + +[LegacyProtoFullName(DeviceRegistrationLegacyAliases.TombstonesCompactedEventProto)] +public sealed partial class DeviceTombstonesCompactedEvent; diff --git a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationMaterializationContext.cs b/agents/Aevatar.GAgents.Device/DeviceRegistrationMaterializationContext.cs similarity index 86% rename from agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationMaterializationContext.cs rename to agents/Aevatar.GAgents.Device/DeviceRegistrationMaterializationContext.cs index 84d0c7f6f..277167306 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationMaterializationContext.cs +++ b/agents/Aevatar.GAgents.Device/DeviceRegistrationMaterializationContext.cs @@ -1,6 +1,6 @@ using Aevatar.CQRS.Projection.Core.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Device; public sealed class DeviceRegistrationMaterializationContext : IProjectionMaterializationContext diff --git a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationMaterializationRuntimeLease.cs b/agents/Aevatar.GAgents.Device/DeviceRegistrationMaterializationRuntimeLease.cs similarity index 93% rename from agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationMaterializationRuntimeLease.cs rename to agents/Aevatar.GAgents.Device/DeviceRegistrationMaterializationRuntimeLease.cs index cb75a4e30..18320fabd 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationMaterializationRuntimeLease.cs +++ b/agents/Aevatar.GAgents.Device/DeviceRegistrationMaterializationRuntimeLease.cs @@ -1,7 +1,7 @@ using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Core.Orchestration; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Device; public sealed class DeviceRegistrationMaterializationRuntimeLease : ProjectionRuntimeLeaseBase, diff --git a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationProjectionPort.cs b/agents/Aevatar.GAgents.Device/DeviceRegistrationProjectionPort.cs similarity index 95% rename from agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationProjectionPort.cs rename to agents/Aevatar.GAgents.Device/DeviceRegistrationProjectionPort.cs index b1b4a86ca..9fd34066c 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationProjectionPort.cs +++ b/agents/Aevatar.GAgents.Device/DeviceRegistrationProjectionPort.cs @@ -1,7 +1,7 @@ using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Core.Orchestration; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Device; public sealed class DeviceRegistrationProjectionPort : MaterializationProjectionPortBase diff --git a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationProjector.cs b/agents/Aevatar.GAgents.Device/DeviceRegistrationProjector.cs similarity index 98% rename from agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationProjector.cs rename to agents/Aevatar.GAgents.Device/DeviceRegistrationProjector.cs index 0ab34af75..f74470479 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationProjector.cs +++ b/agents/Aevatar.GAgents.Device/DeviceRegistrationProjector.cs @@ -4,7 +4,7 @@ using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.Channel.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Device; /// /// Materializes into per-entry diff --git a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationQueryPort.cs b/agents/Aevatar.GAgents.Device/DeviceRegistrationQueryPort.cs similarity index 97% rename from agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationQueryPort.cs rename to agents/Aevatar.GAgents.Device/DeviceRegistrationQueryPort.cs index 4958e3cbe..a0e03bc48 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationQueryPort.cs +++ b/agents/Aevatar.GAgents.Device/DeviceRegistrationQueryPort.cs @@ -1,6 +1,6 @@ using Aevatar.CQRS.Projection.Stores.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Device; public sealed class DeviceRegistrationQueryPort : IDeviceRegistrationQueryPort { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationStartupService.cs b/agents/Aevatar.GAgents.Device/DeviceRegistrationStartupService.cs similarity index 97% rename from agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationStartupService.cs rename to agents/Aevatar.GAgents.Device/DeviceRegistrationStartupService.cs index 986f2b62b..a7f4f03db 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/DeviceRegistrationStartupService.cs +++ b/agents/Aevatar.GAgents.Device/DeviceRegistrationStartupService.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Device; public sealed class DeviceRegistrationStartupService : IHostedService { diff --git a/agents/Aevatar.GAgents.Device/DeviceTombstoneCompactionTarget.cs b/agents/Aevatar.GAgents.Device/DeviceTombstoneCompactionTarget.cs new file mode 100644 index 000000000..19849e46d --- /dev/null +++ b/agents/Aevatar.GAgents.Device/DeviceTombstoneCompactionTarget.cs @@ -0,0 +1,24 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.Channel.Runtime; +using Google.Protobuf; + +namespace Aevatar.GAgents.Device; + +internal sealed class DeviceTombstoneCompactionTarget : ITombstoneCompactionTarget +{ + public string ActorId => DeviceRegistrationGAgent.WellKnownId; + public string ProjectionKind => DeviceRegistrationProjectionPort.ProjectionKind; + public string TargetName => "device registration"; + + public async Task EnsureActorAsync(IActorRuntime actorRuntime, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(actorRuntime); + _ = await actorRuntime.GetAsync(DeviceRegistrationGAgent.WellKnownId) + ?? await actorRuntime.CreateAsync( + DeviceRegistrationGAgent.WellKnownId, + ct); + } + + public IMessage CreateCommand(long safeStateVersion) => + new DeviceCompactTombstonesCommand { SafeStateVersion = safeStateVersion }; +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/IDeviceRegistrationQueryPort.cs b/agents/Aevatar.GAgents.Device/IDeviceRegistrationQueryPort.cs similarity index 85% rename from agents/Aevatar.GAgents.ChannelRuntime/IDeviceRegistrationQueryPort.cs rename to agents/Aevatar.GAgents.Device/IDeviceRegistrationQueryPort.cs index 42f6ccc4d..b91d370d7 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/IDeviceRegistrationQueryPort.cs +++ b/agents/Aevatar.GAgents.Device/IDeviceRegistrationQueryPort.cs @@ -1,4 +1,4 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Device; public interface IDeviceRegistrationQueryPort { diff --git a/agents/Aevatar.GAgents.Device/protos/device_registration.proto b/agents/Aevatar.GAgents.Device/protos/device_registration.proto new file mode 100644 index 000000000..67a0e06e0 --- /dev/null +++ b/agents/Aevatar.GAgents.Device/protos/device_registration.proto @@ -0,0 +1,72 @@ +syntax = "proto3"; + +package aevatar.gagents.device; + +option csharp_namespace = "Aevatar.GAgents.Device"; + +import "google/protobuf/timestamp.proto"; + +// ─── Device Registration (Actor-backed, #164 pattern) ─── + +message DeviceRegistrationEntry { + string id = 1; + string scope_id = 2; // Maps to household-{scope_id} actor + string hmac_key = 3; // HMAC-SHA256 signing key for NyxID relay verification + string nyx_conversation_id = 4; // NyxID conversation ID + string description = 5; // Human-readable label + google.protobuf.Timestamp created_at = 6; + // Soft-delete marker retained for projector watermark coordination + // (Channel RFC §7.1.1): the registration stays in state after unregister so + // the projector can emit a Tombstone verdict → IProjectionWriteDispatcher + // .DeleteAsync. Housekeeping cleans watermark-passed entries. + bool tombstoned = 7; + int64 tombstone_state_version = 8; +} + +message DeviceRegistrationState { + repeated DeviceRegistrationEntry registrations = 1; +} + +// Domain events for DeviceRegistrationGAgent +message DeviceRegisteredEvent { + DeviceRegistrationEntry entry = 1; +} + +message DeviceUnregisteredEvent { + string registration_id = 1; + int64 tombstone_state_version = 2; +} + +// Commands dispatched to DeviceRegistrationGAgent via EventEnvelope +message DeviceRegisterCommand { + string scope_id = 1; + string hmac_key = 2; + string nyx_conversation_id = 3; + string description = 4; +} + +message DeviceUnregisterCommand { + string registration_id = 1; +} + +message DeviceCompactTombstonesCommand { + int64 safe_state_version = 1; +} + +message DeviceTombstonesCompactedEvent { + repeated string registration_ids = 1; + int64 safe_state_version = 2; +} + +// Projection read model for device registrations +message DeviceRegistrationDocument { + string id = 1; // Registration ID (= document key) + string scope_id = 2; + string hmac_key = 3; + string nyx_conversation_id = 4; + string description = 5; + int64 state_version = 6; + string last_event_id = 7; + google.protobuf.Timestamp updated_at_utc = 8; + string actor_id = 9; // Source actor ID +} diff --git a/agents/Aevatar.GAgents.NyxidChat/Aevatar.GAgents.NyxidChat.csproj b/agents/Aevatar.GAgents.NyxidChat/Aevatar.GAgents.NyxidChat.csproj index 8cf9b5dc6..3a46dd65e 100644 --- a/agents/Aevatar.GAgents.NyxidChat/Aevatar.GAgents.NyxidChat.csproj +++ b/agents/Aevatar.GAgents.NyxidChat/Aevatar.GAgents.NyxidChat.csproj @@ -8,14 +8,18 @@ + + + + + - diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelCardActionRouting.cs b/agents/Aevatar.GAgents.NyxidChat/ChannelCardActionRouting.cs similarity index 97% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelCardActionRouting.cs rename to agents/Aevatar.GAgents.NyxidChat/ChannelCardActionRouting.cs index a247b6f21..c02b60f81 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelCardActionRouting.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ChannelCardActionRouting.cs @@ -1,8 +1,9 @@ +using Aevatar.GAgents.Channel.Runtime; using Aevatar.Workflow.Application.Abstractions.Runs; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.NyxidChat; -internal static class ChannelCardActionRouting +public static class ChannelCardActionRouting { private const string CardActionChatType = "card_action"; diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelContextMiddleware.cs b/agents/Aevatar.GAgents.NyxidChat/ChannelContextMiddleware.cs similarity index 97% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelContextMiddleware.cs rename to agents/Aevatar.GAgents.NyxidChat/ChannelContextMiddleware.cs index 9ac482d74..76afe60c3 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelContextMiddleware.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ChannelContextMiddleware.cs @@ -1,9 +1,10 @@ using System.Text.Json; using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.AI.Abstractions.Middleware; +using Aevatar.GAgents.Channel.Runtime; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.NyxidChat; internal sealed class ChannelContextMiddleware : ILLMCallMiddleware { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelConversationTurnRunner.cs b/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelConversationTurnRunner.cs rename to agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs index 5f9121ec4..61eb65f07 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelConversationTurnRunner.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs @@ -2,18 +2,21 @@ using Aevatar.AI.Abstractions.ToolProviders; using Aevatar.AI.ToolProviders.NyxId; using Aevatar.CQRS.Core.Abstractions.Commands; +using Aevatar.GAgents.Authoring.Lark; using Aevatar.GAgents.Channel.Abstractions; using Aevatar.GAgents.Channel.NyxIdRelay; +using Aevatar.GAgents.Channel.NyxIdRelay.Outbound; using Aevatar.GAgents.Channel.Runtime; -using Aevatar.GAgents.ChannelRuntime.Outbound; +using Aevatar.GAgents.Platform.Lark; +using Aevatar.GAgents.Scheduled; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Workflow.Application.Abstractions.Runs; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.NyxidChat; -internal sealed class ChannelConversationTurnRunner : IConversationTurnRunner +public sealed class ChannelConversationTurnRunner : IConversationTurnRunner { private readonly IServiceProvider _services; private readonly IChannelBotRegistrationQueryPort _registrationQueryPort; diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelLlmReplyInboxRuntime.cs b/agents/Aevatar.GAgents.NyxidChat/ChannelLlmReplyInboxRuntime.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelLlmReplyInboxRuntime.cs rename to agents/Aevatar.GAgents.NyxidChat/ChannelLlmReplyInboxRuntime.cs index 26cdf63e4..45be4787f 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelLlmReplyInboxRuntime.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ChannelLlmReplyInboxRuntime.cs @@ -10,9 +10,9 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.NyxidChat; -internal sealed class ChannelLlmReplyInboxRuntime : +public sealed class ChannelLlmReplyInboxRuntime : IHostedService, IAsyncDisposable, IChannelLlmReplyInbox @@ -428,7 +428,7 @@ private bool ShouldCaptureInteractiveReply(ChatActivity? activity) } } -internal sealed class ChannelLlmReplyInboxHostedService : IHostedService +public sealed class ChannelLlmReplyInboxHostedService : IHostedService { private readonly ChannelLlmReplyInboxRuntime _runtime; diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ConversationReplyGenerator.cs b/agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs similarity index 91% rename from agents/Aevatar.GAgents.ChannelRuntime/ConversationReplyGenerator.cs rename to agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs index c2dd2f13d..106a67ea8 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ConversationReplyGenerator.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs @@ -7,25 +7,11 @@ using Aevatar.AI.Core.Tools; using Aevatar.AI.ToolProviders.Skills; using Aevatar.GAgents.Channel.Abstractions; -using Aevatar.GAgents.NyxidChat; +using Aevatar.GAgents.Channel.Runtime; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.NyxidChat; -internal interface IConversationReplyGenerator -{ - /// - /// Generates the full LLM reply text. If is supplied, the - /// generator forwards progressive deltas as the stream advances; implementations must tolerate - /// a null sink by simply accumulating the final text. - /// - Task GenerateReplyAsync( - ChatActivity activity, - IReadOnlyDictionary metadata, - IStreamingReplySink? streamingSink, - CancellationToken ct); -} - -internal sealed class NyxIdConversationReplyGenerator : IConversationReplyGenerator +public sealed class NyxIdConversationReplyGenerator : IConversationReplyGenerator { private const int MaxToolRounds = 40; private const int MaxHistoryMessages = 100; @@ -37,7 +23,7 @@ internal sealed class NyxIdConversationReplyGenerator : IConversationReplyGenera private readonly IReadOnlyList _toolMiddlewares; private readonly IReadOnlyList _llmMiddlewares; private readonly SkillRegistry? _skillRegistry; - private readonly NyxIdRelayOptions? _relayOptions; + private readonly global::Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions? _relayOptions; private readonly INyxIdUserLlmPreferencesStore? _preferencesStore; private readonly IUserMemoryStore? _userMemoryStore; @@ -48,7 +34,7 @@ public NyxIdConversationReplyGenerator( IEnumerable? toolMiddlewares = null, IEnumerable? llmMiddlewares = null, SkillRegistry? skillRegistry = null, - NyxIdRelayOptions? relayOptions = null, + global::Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions? relayOptions = null, INyxIdUserLlmPreferencesStore? preferencesStore = null, IUserMemoryStore? userMemoryStore = null) { @@ -154,6 +140,10 @@ private async Task> BuildEffectiveMetadataAs if (preferences.MaxToolRounds > 0) effective[LLMRequestMetadataKeys.MaxToolRoundsOverride] = preferences.MaxToolRounds.ToString(); } + catch (OperationCanceledException) + { + throw; + } catch { // User config is additive only; channel runtime falls back to server defaults on failure. @@ -168,6 +158,10 @@ private async Task> BuildEffectiveMetadataAs if (!string.IsNullOrWhiteSpace(promptSection)) effective[LLMRequestMetadataKeys.UserMemoryPrompt] = promptSection; } + catch (OperationCanceledException) + { + throw; + } catch { // User memory is best-effort context and must not break the main reply path. diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdRelayPromptConfiguration.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdRelayPromptConfiguration.cs index 0d12758c1..abf0f4fac 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdRelayPromptConfiguration.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdRelayPromptConfiguration.cs @@ -5,7 +5,7 @@ public static class NyxIdRelayPromptConfiguration public const string RelayCallbackPath = "/api/webhooks/nyxid-relay"; private const string UnconfiguredCallback = "[nyx relay webhook base URL is not configured in this host]"; - public static string ResolveRelayCallbackUrl(NyxIdRelayOptions? options) + public static string ResolveRelayCallbackUrl(global::Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions? options) { return ResolveRelayCallbackUrl(options?.WebhookBaseUrl); } @@ -19,7 +19,7 @@ public static string ResolveRelayCallbackUrl(string? webhookBaseUrl) return $"{baseUrl.TrimEnd('/')}{RelayCallbackPath}"; } - public static string BuildChannelRuntimeConfigurationSection(NyxIdRelayOptions? options) + public static string BuildChannelRuntimeConfigurationSection(global::Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions? options) { var relayCallbackUrl = ResolveRelayCallbackUrl(options); return $""" diff --git a/agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs index c34838d4c..bc65e9790 100644 --- a/agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs @@ -1,8 +1,11 @@ using System.Runtime.CompilerServices; +using Aevatar.AI.Abstractions.Middleware; using Aevatar.GAgents.Channel.NyxIdRelay; +using Aevatar.GAgents.Channel.Runtime; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; namespace Aevatar.GAgents.NyxidChat; @@ -10,6 +13,7 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddNyxIdChat(this IServiceCollection services, IConfiguration? configuration = null) { + ArgumentNullException.ThrowIfNull(services); RuntimeHelpers.RunClassConstructor(typeof(NyxIdChatGAgent).TypeHandle); services.AddHttpClient(); @@ -26,6 +30,22 @@ public static IServiceCollection AddNyxIdChat(this IServiceCollection services, services.TryAddSingleton(); services.TryAddSingleton(); + // ─── Channel LLM reply inbox runtime + hosted service ─── + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + // ─── Conversation turn-runner override + reply generator ─── + services.Replace(ServiceDescriptor.Singleton()); + services.TryAddSingleton(); + + // ─── LLM-call middleware that injects channel context into LLM requests ─── + // Lives here (not in Channel.Runtime) because it implements ILLMCallMiddleware + // (AI.Abstractions); keeping it in NyxidChat lets Channel.Runtime stay free of + // AI / Workflow dependencies. ChannelCardActionRouting (workflow resume binding) + // is in this package for the same reason. + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return services; } diff --git a/agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj b/agents/Aevatar.GAgents.Scheduled/Aevatar.GAgents.Scheduled.csproj similarity index 70% rename from agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj rename to agents/Aevatar.GAgents.Scheduled/Aevatar.GAgents.Scheduled.csproj index 7c0b51cba..8a61250e3 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj +++ b/agents/Aevatar.GAgents.Scheduled/Aevatar.GAgents.Scheduled.csproj @@ -3,49 +3,46 @@ net10.0 enable enable - Aevatar.GAgents.ChannelRuntime - Aevatar.GAgents.ChannelRuntime + Aevatar.GAgents.Scheduled + Aevatar.GAgents.Scheduled + - - - + - + + - - - - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive + + + - - - - + diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelScheduleCalculator.cs b/agents/Aevatar.GAgents.Scheduled/ChannelScheduleCalculator.cs similarity index 96% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelScheduleCalculator.cs rename to agents/Aevatar.GAgents.Scheduled/ChannelScheduleCalculator.cs index bab9ed487..a8c9b125a 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelScheduleCalculator.cs +++ b/agents/Aevatar.GAgents.Scheduled/ChannelScheduleCalculator.cs @@ -1,13 +1,13 @@ using Cronos; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; /// /// Cron/timezone helpers shared by channel-triggered scheduled runners (skill runner, /// workflow agent, agent-builder tooling). Pure and stateless; callers wrap the result /// with their own lease/persistence bookkeeping. /// -internal static class ChannelScheduleCalculator +public static class ChannelScheduleCalculator { public static bool TryGetNextOccurrence( string cronExpression, diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelScheduleRunner.cs b/agents/Aevatar.GAgents.Scheduled/ChannelScheduleRunner.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelScheduleRunner.cs rename to agents/Aevatar.GAgents.Scheduled/ChannelScheduleRunner.cs index 35f99c4d6..9141f0eab 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelScheduleRunner.cs +++ b/agents/Aevatar.GAgents.Scheduled/ChannelScheduleRunner.cs @@ -5,7 +5,7 @@ using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; /// /// Encapsulates cron-driven self-scheduling duplicated across SkillRunnerGAgent / WorkflowAgentGAgent. diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelWorkflowTextRouting.cs b/agents/Aevatar.GAgents.Scheduled/ChannelWorkflowTextRouting.cs similarity index 97% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelWorkflowTextRouting.cs rename to agents/Aevatar.GAgents.Scheduled/ChannelWorkflowTextRouting.cs index 8e972febf..2bbbf5c5d 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelWorkflowTextRouting.cs +++ b/agents/Aevatar.GAgents.Scheduled/ChannelWorkflowTextRouting.cs @@ -1,8 +1,9 @@ +using Aevatar.GAgents.Channel.Runtime; using Aevatar.Workflow.Application.Abstractions.Runs; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; -internal static class ChannelWorkflowTextRouting +public static class ChannelWorkflowTextRouting { private const string ApproveCommand = "/approve"; private const string RejectCommand = "/reject"; diff --git a/agents/Aevatar.GAgents.Scheduled/DependencyInjection/ScheduledServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.Scheduled/DependencyInjection/ScheduledServiceCollectionExtensions.cs new file mode 100644 index 000000000..d8135f5d7 --- /dev/null +++ b/agents/Aevatar.GAgents.Scheduled/DependencyInjection/ScheduledServiceCollectionExtensions.cs @@ -0,0 +1,93 @@ +using Aevatar.CQRS.Projection.Core.DependencyInjection; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; +using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.GAgents.Channel.Runtime; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aevatar.GAgents.Scheduled; + +/// +/// DI registration entry point for the scheduled-agent / user-agent-catalog package. +/// +public static class ScheduledServiceCollectionExtensions +{ + /// + /// Registers the User Agent Catalog projection pipeline (materialization runtime, + /// catalog + Nyx credential projectors, query ports, document metadata, startup + /// service, and projection stores). Pass so the + /// document projection store matches the host environment (Elasticsearch in prod, + /// InMemory for local dev / tests). + /// + public static IServiceCollection AddScheduledAgents( + this IServiceCollection services, IConfiguration? configuration = null) + { + ArgumentNullException.ThrowIfNull(services); + + // The helper logs a misconfiguration warning (Console.Error during SCE + // composition; structured log when a real logger is wired in tests) when + // configuration is present but Endpoints/Enabled are both empty, so + // operators see the InMemory fallback at startup. + var useElasticsearch = ElasticsearchProjectionConfiguration.IsEnabled( + configuration, + storeName: "ScheduledAgents"); + + // ─── User Agent Catalog projection pipeline ─── + services.AddProjectionMaterializationRuntimeCore< + UserAgentCatalogMaterializationContext, + UserAgentCatalogMaterializationRuntimeLease, + ProjectionMaterializationScopeGAgent>( + static scopeKey => new UserAgentCatalogMaterializationContext + { + RootActorId = scopeKey.RootActorId, + ProjectionKind = scopeKey.ProjectionKind, + }, + static context => new UserAgentCatalogMaterializationRuntimeLease(context)); + services.AddCurrentStateProjectionMaterializer< + UserAgentCatalogMaterializationContext, + UserAgentCatalogProjector>(); + services.AddCurrentStateProjectionMaterializer< + UserAgentCatalogMaterializationContext, + UserAgentCatalogNyxCredentialProjector>(); + services.TryAddSingleton, + UserAgentCatalogDocumentMetadataProvider>(); + services.TryAddSingleton, + UserAgentCatalogNyxCredentialDocumentMetadataProvider>(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.AddHostedService(); + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + + if (useElasticsearch) + { + services.AddElasticsearchDocumentProjectionStore( + optionsFactory: _ => ElasticsearchProjectionConfiguration.BindOptions(configuration!), + metadataFactory: sp => sp.GetRequiredService>().Metadata, + keySelector: static doc => doc.Id, + keyFormatter: static key => key); + services.AddElasticsearchDocumentProjectionStore( + optionsFactory: _ => ElasticsearchProjectionConfiguration.BindOptions(configuration!), + metadataFactory: sp => sp.GetRequiredService>().Metadata, + keySelector: static doc => doc.Id, + keyFormatter: static key => key); + } + else + { + services.AddInMemoryDocumentProjectionStore( + static doc => doc.Id, static key => key); + services.AddInMemoryDocumentProjectionStore( + static doc => doc.Id, static key => key); + } + + return services; + } + +} diff --git a/agents/Aevatar.GAgents.Scheduled/ISkillRunnerCommandPort.cs b/agents/Aevatar.GAgents.Scheduled/ISkillRunnerCommandPort.cs new file mode 100644 index 000000000..b4f94ee66 --- /dev/null +++ b/agents/Aevatar.GAgents.Scheduled/ISkillRunnerCommandPort.cs @@ -0,0 +1,32 @@ +namespace Aevatar.GAgents.Scheduled; + +/// +/// Application-service surface for SkillRunner lifecycle. Owns actor lifecycle +/// (Get-or-Create), catalog projection priming, and envelope dispatch via +/// so callers +/// (LLM tools, admin endpoints) stay parameter-mapping adapters and never +/// reach for actor.HandleEventAsync directly. +/// +public interface ISkillRunnerCommandPort +{ + /// + /// Materializes the SkillRunner actor for , primes the + /// UserAgentCatalog projection scope, dispatches the supplied + /// , and optionally follows it + /// with a first . + /// + Task InitializeAsync( + string agentId, + InitializeSkillRunnerCommand command, + bool runImmediately, + CancellationToken ct = default); + + /// Dispatches a to an existing SkillRunner. + Task TriggerAsync(string agentId, string reason, CancellationToken ct = default); + + /// Dispatches a to an existing SkillRunner. + Task DisableAsync(string agentId, string reason, CancellationToken ct = default); + + /// Dispatches an to an existing SkillRunner. + Task EnableAsync(string agentId, string reason, CancellationToken ct = default); +} diff --git a/agents/Aevatar.GAgents.Scheduled/IUserAgentCatalogCommandPort.cs b/agents/Aevatar.GAgents.Scheduled/IUserAgentCatalogCommandPort.cs new file mode 100644 index 000000000..425381923 --- /dev/null +++ b/agents/Aevatar.GAgents.Scheduled/IUserAgentCatalogCommandPort.cs @@ -0,0 +1,38 @@ +namespace Aevatar.GAgents.Scheduled; + +/// +/// Honest accepted / observed status returned by the catalog command port. +/// +public enum CatalogCommandOutcome +{ + /// Command was dispatched into the catalog actor inbox; projection has not yet caught up. + Accepted = 0, + + /// Command was dispatched and the projection has materialized the resulting state version. + Observed = 1, + + /// Tombstone path: requested agent id was not present at the time of the call. + NotFound = 2, +} + +public sealed record UserAgentCatalogUpsertResult(CatalogCommandOutcome Outcome); + +public sealed record UserAgentCatalogTombstoneResult(CatalogCommandOutcome Outcome); + +/// +/// Application-service surface for catalog mutations. Owns projection +/// priming, envelope construction, dispatch through +/// , and +/// projection-version polling so callers (LLM tools, Studio admin endpoints, +/// etc.) stay thin parameter-mapping adapters. +/// +public interface IUserAgentCatalogCommandPort +{ + Task UpsertAsync( + UserAgentCatalogUpsertCommand command, + CancellationToken ct = default); + + Task TombstoneAsync( + string agentId, + CancellationToken ct = default); +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/IUserAgentCatalogQueryPort.cs b/agents/Aevatar.GAgents.Scheduled/IUserAgentCatalogQueryPort.cs similarity index 88% rename from agents/Aevatar.GAgents.ChannelRuntime/IUserAgentCatalogQueryPort.cs rename to agents/Aevatar.GAgents.Scheduled/IUserAgentCatalogQueryPort.cs index 1b7997f52..69a6189c9 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/IUserAgentCatalogQueryPort.cs +++ b/agents/Aevatar.GAgents.Scheduled/IUserAgentCatalogQueryPort.cs @@ -1,4 +1,4 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public interface IUserAgentCatalogQueryPort { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/IUserAgentCatalogRuntimeQueryPort.cs b/agents/Aevatar.GAgents.Scheduled/IUserAgentCatalogRuntimeQueryPort.cs similarity index 88% rename from agents/Aevatar.GAgents.ChannelRuntime/IUserAgentCatalogRuntimeQueryPort.cs rename to agents/Aevatar.GAgents.Scheduled/IUserAgentCatalogRuntimeQueryPort.cs index fca0454af..558f689b7 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/IUserAgentCatalogRuntimeQueryPort.cs +++ b/agents/Aevatar.GAgents.Scheduled/IUserAgentCatalogRuntimeQueryPort.cs @@ -1,4 +1,4 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public interface IUserAgentCatalogRuntimeQueryPort { diff --git a/agents/Aevatar.GAgents.Scheduled/IWorkflowAgentCommandPort.cs b/agents/Aevatar.GAgents.Scheduled/IWorkflowAgentCommandPort.cs new file mode 100644 index 000000000..a94e97bcf --- /dev/null +++ b/agents/Aevatar.GAgents.Scheduled/IWorkflowAgentCommandPort.cs @@ -0,0 +1,27 @@ +namespace Aevatar.GAgents.Scheduled; + +/// +/// Application-service surface for WorkflowAgent lifecycle. Mirrors +/// : owns actor lifecycle, catalog +/// projection priming, and envelope dispatch through +/// so LLM +/// tools and admin endpoints stop reaching for actor.HandleEventAsync. +/// +public interface IWorkflowAgentCommandPort +{ + Task InitializeAsync( + string agentId, + InitializeWorkflowAgentCommand command, + bool runImmediately, + CancellationToken ct = default); + + Task TriggerAsync( + string agentId, + string reason, + string? revisionFeedback, + CancellationToken ct = default); + + Task DisableAsync(string agentId, string reason, CancellationToken ct = default); + + Task EnableAsync(string agentId, string reason, CancellationToken ct = default); +} diff --git a/agents/Aevatar.GAgents.Scheduled/SkillRunnerCommandPort.cs b/agents/Aevatar.GAgents.Scheduled/SkillRunnerCommandPort.cs new file mode 100644 index 000000000..4c17cf5f3 --- /dev/null +++ b/agents/Aevatar.GAgents.Scheduled/SkillRunnerCommandPort.cs @@ -0,0 +1,87 @@ +using Aevatar.Foundation.Abstractions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.GAgents.Scheduled; + +internal sealed class SkillRunnerCommandPort : ISkillRunnerCommandPort +{ + private const string PublisherActorId = "scheduled.skill-runner"; + + private readonly IActorRuntime _actorRuntime; + private readonly IActorDispatchPort _actorDispatchPort; + private readonly UserAgentCatalogProjectionPort _catalogProjectionPort; + + public SkillRunnerCommandPort( + IActorRuntime actorRuntime, + IActorDispatchPort actorDispatchPort, + UserAgentCatalogProjectionPort catalogProjectionPort) + { + _actorRuntime = actorRuntime ?? throw new ArgumentNullException(nameof(actorRuntime)); + _actorDispatchPort = actorDispatchPort ?? throw new ArgumentNullException(nameof(actorDispatchPort)); + _catalogProjectionPort = catalogProjectionPort ?? throw new ArgumentNullException(nameof(catalogProjectionPort)); + } + + public async Task InitializeAsync( + string agentId, + InitializeSkillRunnerCommand command, + bool runImmediately, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ArgumentNullException.ThrowIfNull(command); + + await EnsureSkillRunnerActorAsync(agentId, ct); + // Prime the catalog projection scope BEFORE dispatch — a late prime + // can't recover an event the projector already missed when the + // SkillRunner emits its initialize → catalog upsert chain. + await _catalogProjectionPort.EnsureProjectionForActorAsync(UserAgentCatalogGAgent.WellKnownId, ct); + + await DispatchAsync(agentId, command, ct); + + if (runImmediately) + { + await DispatchAsync(agentId, new TriggerSkillRunnerExecutionCommand { Reason = "create_agent" }, ct); + } + } + + public async Task TriggerAsync(string agentId, string reason, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + await EnsureSkillRunnerActorAsync(agentId, ct); + await DispatchAsync(agentId, new TriggerSkillRunnerExecutionCommand { Reason = reason ?? string.Empty }, ct); + } + + public async Task DisableAsync(string agentId, string reason, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + await EnsureSkillRunnerActorAsync(agentId, ct); + await DispatchAsync(agentId, new DisableSkillRunnerCommand { Reason = reason ?? string.Empty }, ct); + } + + public async Task EnableAsync(string agentId, string reason, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + await EnsureSkillRunnerActorAsync(agentId, ct); + await DispatchAsync(agentId, new EnableSkillRunnerCommand { Reason = reason ?? string.Empty }, ct); + } + + private async Task EnsureSkillRunnerActorAsync(string agentId, CancellationToken ct) + { + _ = await _actorRuntime.GetAsync(agentId) + ?? await _actorRuntime.CreateAsync(agentId, ct); + } + + private Task DispatchAsync(string agentId, TCommand command, CancellationToken ct) + where TCommand : class, IMessage + { + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(command), + Route = EnvelopeRouteSemantics.CreateDirect(PublisherActorId, agentId), + }; + return _actorDispatchPort.DispatchAsync(agentId, envelope, ct); + } +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/SkillRunnerDefaults.cs b/agents/Aevatar.GAgents.Scheduled/SkillRunnerDefaults.cs similarity index 91% rename from agents/Aevatar.GAgents.ChannelRuntime/SkillRunnerDefaults.cs rename to agents/Aevatar.GAgents.Scheduled/SkillRunnerDefaults.cs index 87ded7fb9..3ead1f644 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/SkillRunnerDefaults.cs +++ b/agents/Aevatar.GAgents.Scheduled/SkillRunnerDefaults.cs @@ -1,6 +1,6 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; -internal static class SkillRunnerDefaults +public static class SkillRunnerDefaults { public const string AgentType = "skill_runner"; public const string ActorIdPrefix = "skill-runner"; diff --git a/agents/Aevatar.GAgents.ChannelRuntime/SkillRunnerGAgent.cs b/agents/Aevatar.GAgents.Scheduled/SkillRunnerGAgent.cs similarity index 95% rename from agents/Aevatar.GAgents.ChannelRuntime/SkillRunnerGAgent.cs rename to agents/Aevatar.GAgents.Scheduled/SkillRunnerGAgent.cs index baa86249c..1b5bb9011 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/SkillRunnerGAgent.cs +++ b/agents/Aevatar.GAgents.Scheduled/SkillRunnerGAgent.cs @@ -10,12 +10,14 @@ using Aevatar.Foundation.Abstractions.Attributes; using Aevatar.Foundation.Core.EventSourcing; using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.Runtime; +using Aevatar.GAgents.Platform.Lark; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed class SkillRunnerGAgent : AIGAgentBase { @@ -433,12 +435,6 @@ private string BuildExecutionPrompt(DateTimeOffset now, string? reason) private async Task UpsertRegistryAsync(string status, CancellationToken ct) { - var runtime = Services.GetService(); - if (runtime is null) return; - - var actor = await runtime.GetAsync(UserAgentCatalogGAgent.WellKnownId) - ?? await runtime.CreateAsync(UserAgentCatalogGAgent.WellKnownId, ct); - var command = new UserAgentCatalogUpsertCommand { AgentId = Id, @@ -460,7 +456,7 @@ private async Task UpsertRegistryAsync(string status, CancellationToken ct) LarkReceiveIdTypeFallback = State.OutboundConfig?.LarkReceiveIdTypeFallback ?? string.Empty, }; - await actor.HandleEventAsync(BuildDirectEnvelope(actor.Id, command), ct); + await UserAgentCatalogStoreCommands.DispatchUpsertAsync(Services, Id, command, ct); await UpdateRegistryExecutionAsync(status, State.LastRunAt, State.NextRunAt, State.ErrorCount, State.LastError, ct); } @@ -468,29 +464,15 @@ private async Task UpdateRegistryExecutionAsync( string status, Timestamp? lastRunAt, Timestamp? nextRunAt, int errorCount, string? lastError, CancellationToken ct) { - var runtime = Services.GetService(); - if (runtime is null) return; - - var actor = await runtime.GetAsync(UserAgentCatalogGAgent.WellKnownId) - ?? await runtime.CreateAsync(UserAgentCatalogGAgent.WellKnownId, ct); - var command = new UserAgentCatalogExecutionUpdateCommand { AgentId = Id, Status = status, LastRunAt = lastRunAt, NextRunAt = nextRunAt, ErrorCount = errorCount, LastError = lastError ?? string.Empty, }; - await actor.HandleEventAsync(BuildDirectEnvelope(actor.Id, command), ct); + await UserAgentCatalogStoreCommands.DispatchExecutionUpdateAsync(Services, Id, command, ct); } - private static EventEnvelope BuildDirectEnvelope(string actorId, IMessage payload) => new() - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(payload), - Route = new EnvelopeRoute { Direct = new DirectRoute { TargetActorId = actorId } }, - }; - private static SkillRunnerState ApplyInitialized(SkillRunnerState current, SkillRunnerInitializedEvent evt) { var next = current.Clone(); diff --git a/agents/Aevatar.GAgents.Scheduled/SkillRunnerLegacyAliases.cs b/agents/Aevatar.GAgents.Scheduled/SkillRunnerLegacyAliases.cs new file mode 100644 index 000000000..507ab5f94 --- /dev/null +++ b/agents/Aevatar.GAgents.Scheduled/SkillRunnerLegacyAliases.cs @@ -0,0 +1,61 @@ +using Aevatar.Foundation.Abstractions.Compatibility; + +namespace Aevatar.GAgents.Scheduled; + +internal static class SkillRunnerLegacyAliases +{ + private const string ProtoPrefix = "aevatar.gagents.channelruntime."; + private const string ClrPrefix = "Aevatar.GAgents.ChannelRuntime."; + + internal const string OutboundConfigProto = ProtoPrefix + "SkillRunnerOutboundConfig"; + internal const string StateProto = ProtoPrefix + "SkillRunnerState"; + internal const string InitializeCommandProto = ProtoPrefix + "InitializeSkillRunnerCommand"; + internal const string InitializedEventProto = ProtoPrefix + "SkillRunnerInitializedEvent"; + internal const string TriggerCommandProto = ProtoPrefix + "TriggerSkillRunnerExecutionCommand"; + internal const string NextRunScheduledEventProto = ProtoPrefix + "SkillRunnerNextRunScheduledEvent"; + internal const string CompletedEventProto = ProtoPrefix + "SkillRunnerExecutionCompletedEvent"; + internal const string FailedEventProto = ProtoPrefix + "SkillRunnerExecutionFailedEvent"; + internal const string DisableCommandProto = ProtoPrefix + "DisableSkillRunnerCommand"; + internal const string EnableCommandProto = ProtoPrefix + "EnableSkillRunnerCommand"; + internal const string DisabledEventProto = ProtoPrefix + "SkillRunnerDisabledEvent"; + internal const string EnabledEventProto = ProtoPrefix + "SkillRunnerEnabledEvent"; + + internal const string StateClr = ClrPrefix + "SkillRunnerState"; +} + +[LegacyProtoFullName(SkillRunnerLegacyAliases.OutboundConfigProto)] +public sealed partial class SkillRunnerOutboundConfig; + +[LegacyProtoFullName(SkillRunnerLegacyAliases.StateProto)] +[LegacyClrTypeName(SkillRunnerLegacyAliases.StateClr)] +public sealed partial class SkillRunnerState; + +[LegacyProtoFullName(SkillRunnerLegacyAliases.InitializeCommandProto)] +public sealed partial class InitializeSkillRunnerCommand; + +[LegacyProtoFullName(SkillRunnerLegacyAliases.InitializedEventProto)] +public sealed partial class SkillRunnerInitializedEvent; + +[LegacyProtoFullName(SkillRunnerLegacyAliases.TriggerCommandProto)] +public sealed partial class TriggerSkillRunnerExecutionCommand; + +[LegacyProtoFullName(SkillRunnerLegacyAliases.NextRunScheduledEventProto)] +public sealed partial class SkillRunnerNextRunScheduledEvent; + +[LegacyProtoFullName(SkillRunnerLegacyAliases.CompletedEventProto)] +public sealed partial class SkillRunnerExecutionCompletedEvent; + +[LegacyProtoFullName(SkillRunnerLegacyAliases.FailedEventProto)] +public sealed partial class SkillRunnerExecutionFailedEvent; + +[LegacyProtoFullName(SkillRunnerLegacyAliases.DisableCommandProto)] +public sealed partial class DisableSkillRunnerCommand; + +[LegacyProtoFullName(SkillRunnerLegacyAliases.EnableCommandProto)] +public sealed partial class EnableSkillRunnerCommand; + +[LegacyProtoFullName(SkillRunnerLegacyAliases.DisabledEventProto)] +public sealed partial class SkillRunnerDisabledEvent; + +[LegacyProtoFullName(SkillRunnerLegacyAliases.EnabledEventProto)] +public sealed partial class SkillRunnerEnabledEvent; diff --git a/agents/Aevatar.GAgents.ChannelRuntime/SkillRunnerState.Partial.cs b/agents/Aevatar.GAgents.Scheduled/SkillRunnerState.Partial.cs similarity index 90% rename from agents/Aevatar.GAgents.ChannelRuntime/SkillRunnerState.Partial.cs rename to agents/Aevatar.GAgents.Scheduled/SkillRunnerState.Partial.cs index 712c0f64e..fb721a256 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/SkillRunnerState.Partial.cs +++ b/agents/Aevatar.GAgents.Scheduled/SkillRunnerState.Partial.cs @@ -1,6 +1,6 @@ using Aevatar.GAgents.Channel.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed partial class SkillRunnerState : ISchedulable { diff --git a/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogCommandPort.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogCommandPort.cs new file mode 100644 index 000000000..dfafb5635 --- /dev/null +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogCommandPort.cs @@ -0,0 +1,154 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.Channel.Runtime; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.GAgents.Scheduled; + +/// +/// Production implementation of . +/// Routes catalog upsert / tombstone through +/// (no direct HandleEventAsync on the actor instance) and polls the +/// runtime query port for the projected state version so callers can return +/// honest when materialization +/// catches up within the wait budget, falling back to +/// otherwise. +/// +internal sealed class UserAgentCatalogCommandPort : IUserAgentCatalogCommandPort +{ + private const string PublisherActorId = "scheduled.user-agent-catalog"; + + private readonly IUserAgentCatalogRuntimeQueryPort _runtimeQueryPort; + private readonly UserAgentCatalogProjectionPort _projectionPort; + private readonly IActorRuntime _actorRuntime; + private readonly IActorDispatchPort _actorDispatchPort; + private readonly int _projectionWaitAttempts; + private readonly int _projectionWaitDelayMilliseconds; + + public UserAgentCatalogCommandPort( + IUserAgentCatalogRuntimeQueryPort runtimeQueryPort, + UserAgentCatalogProjectionPort projectionPort, + IActorRuntime actorRuntime, + IActorDispatchPort actorDispatchPort) + : this( + runtimeQueryPort, + projectionPort, + actorRuntime, + actorDispatchPort, + ProjectionWaitDefaults.Attempts, + ProjectionWaitDefaults.DelayMilliseconds) + { + } + + internal UserAgentCatalogCommandPort( + IUserAgentCatalogRuntimeQueryPort runtimeQueryPort, + UserAgentCatalogProjectionPort projectionPort, + IActorRuntime actorRuntime, + IActorDispatchPort actorDispatchPort, + int projectionWaitAttempts, + int projectionWaitDelayMilliseconds) + { + _runtimeQueryPort = runtimeQueryPort ?? throw new ArgumentNullException(nameof(runtimeQueryPort)); + _projectionPort = projectionPort ?? throw new ArgumentNullException(nameof(projectionPort)); + _actorRuntime = actorRuntime ?? throw new ArgumentNullException(nameof(actorRuntime)); + _actorDispatchPort = actorDispatchPort ?? throw new ArgumentNullException(nameof(actorDispatchPort)); + _projectionWaitAttempts = projectionWaitAttempts; + _projectionWaitDelayMilliseconds = projectionWaitDelayMilliseconds; + } + + public async Task UpsertAsync( + UserAgentCatalogUpsertCommand command, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(command); + if (string.IsNullOrWhiteSpace(command.AgentId)) + throw new ArgumentException("AgentId is required for upsert.", nameof(command)); + + await _projectionPort.EnsureProjectionForActorAsync(UserAgentCatalogGAgent.WellKnownId, ct); + var versionBefore = await _runtimeQueryPort.GetStateVersionAsync(command.AgentId, ct) ?? -1; + await EnsureCatalogActorAsync(ct); + + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(command), + Route = EnvelopeRouteSemantics.CreateDirect(PublisherActorId, UserAgentCatalogGAgent.WellKnownId), + }; + await _actorDispatchPort.DispatchAsync(UserAgentCatalogGAgent.WellKnownId, envelope, ct); + + for (var attempt = 0; attempt < _projectionWaitAttempts; attempt++) + { + if (attempt > 0) + await Task.Delay(_projectionWaitDelayMilliseconds, ct); + + var versionAfter = await _runtimeQueryPort.GetStateVersionAsync(command.AgentId, ct) ?? -1; + if (versionAfter <= versionBefore) + continue; + + var after = await _runtimeQueryPort.GetAsync(command.AgentId, ct); + if (after is null) + continue; + + if (Matches(after, command)) + return new UserAgentCatalogUpsertResult(CatalogCommandOutcome.Observed); + } + + return new UserAgentCatalogUpsertResult(CatalogCommandOutcome.Accepted); + } + + public async Task TombstoneAsync( + string agentId, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(agentId)) + throw new ArgumentException("agentId is required.", nameof(agentId)); + + var existing = await _runtimeQueryPort.GetAsync(agentId, ct); + if (existing is null) + return new UserAgentCatalogTombstoneResult(CatalogCommandOutcome.NotFound); + + var versionBefore = await _runtimeQueryPort.GetStateVersionAsync(agentId, ct) ?? -1; + await _projectionPort.EnsureProjectionForActorAsync(UserAgentCatalogGAgent.WellKnownId, ct); + await EnsureCatalogActorAsync(ct); + + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(new UserAgentCatalogTombstoneCommand { AgentId = agentId }), + Route = EnvelopeRouteSemantics.CreateDirect(PublisherActorId, UserAgentCatalogGAgent.WellKnownId), + }; + await _actorDispatchPort.DispatchAsync(UserAgentCatalogGAgent.WellKnownId, envelope, ct); + + for (var attempt = 0; attempt < _projectionWaitAttempts; attempt++) + { + if (attempt > 0) + await Task.Delay(_projectionWaitDelayMilliseconds, ct); + + var versionAfter = await _runtimeQueryPort.GetStateVersionAsync(agentId, ct); + if (versionAfter is null) + return new UserAgentCatalogTombstoneResult(CatalogCommandOutcome.Observed); + + if (versionAfter.Value <= versionBefore) + continue; + + if (await _runtimeQueryPort.GetAsync(agentId, ct) is null) + return new UserAgentCatalogTombstoneResult(CatalogCommandOutcome.Observed); + } + + return new UserAgentCatalogTombstoneResult(CatalogCommandOutcome.Accepted); + } + + private async Task EnsureCatalogActorAsync(CancellationToken ct) + { + _ = await _actorRuntime.GetAsync(UserAgentCatalogGAgent.WellKnownId) + ?? await _actorRuntime.CreateAsync(UserAgentCatalogGAgent.WellKnownId, ct); + } + + private static bool Matches(UserAgentCatalogEntry entry, UserAgentCatalogUpsertCommand command) => + string.Equals(entry.Platform, command.Platform, StringComparison.OrdinalIgnoreCase) && + string.Equals(entry.ConversationId, command.ConversationId, StringComparison.Ordinal) && + string.Equals(entry.NyxProviderSlug, command.NyxProviderSlug, StringComparison.Ordinal) && + string.Equals(entry.NyxApiKey, command.NyxApiKey, StringComparison.Ordinal); +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogDocument.Partial.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogDocument.Partial.cs similarity index 93% rename from agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogDocument.Partial.cs rename to agents/Aevatar.GAgents.Scheduled/UserAgentCatalogDocument.Partial.cs index 05a15e060..03fd42406 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogDocument.Partial.cs +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogDocument.Partial.cs @@ -1,7 +1,7 @@ using Aevatar.CQRS.Projection.Stores.Abstractions; using Google.Protobuf.WellKnownTypes; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed partial class UserAgentCatalogDocument : IProjectionReadModel { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogDocumentMetadataProvider.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogDocumentMetadataProvider.cs similarity index 93% rename from agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogDocumentMetadataProvider.cs rename to agents/Aevatar.GAgents.Scheduled/UserAgentCatalogDocumentMetadataProvider.cs index 737edc4b5..75121f9bd 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogDocumentMetadataProvider.cs +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogDocumentMetadataProvider.cs @@ -1,6 +1,6 @@ using Aevatar.CQRS.Projection.Stores.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed class UserAgentCatalogDocumentMetadataProvider : IProjectionDocumentMetadataProvider diff --git a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogGAgent.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogGAgent.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogGAgent.cs rename to agents/Aevatar.GAgents.Scheduled/UserAgentCatalogGAgent.cs index 27cb56430..6f6914577 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogGAgent.cs +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogGAgent.cs @@ -5,7 +5,7 @@ using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed class UserAgentCatalogGAgent : GAgentBase { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogLegacyAliases.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogLegacyAliases.cs similarity index 89% rename from agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogLegacyAliases.cs rename to agents/Aevatar.GAgents.Scheduled/UserAgentCatalogLegacyAliases.cs index d9979b9e2..72ae8f91b 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogLegacyAliases.cs +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogLegacyAliases.cs @@ -1,6 +1,6 @@ using Aevatar.Foundation.Abstractions.Compatibility; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; internal static class UserAgentCatalogLegacyAliases { @@ -18,6 +18,8 @@ internal static class UserAgentCatalogLegacyAliases internal const string ExecutionUpdatedEventProto = ProtoPrefix + "ExecutionUpdatedEvent"; internal const string TombstonesCompactedEventProto = ProtoPrefix + "TombstonesCompactedEvent"; internal const string DocumentProto = ProtoPrefix + "Document"; + internal const string NyxCredentialDocumentProto = + "aevatar.gagents.channelruntime.UserAgentCatalogNyxCredentialDocument"; internal const string StateClr = ClrPrefix + "State"; } @@ -55,3 +57,6 @@ public sealed partial class UserAgentCatalogTombstonesCompactedEvent; [LegacyProtoFullName(UserAgentCatalogLegacyAliases.DocumentProto)] public sealed partial class UserAgentCatalogDocument; + +[LegacyProtoFullName(UserAgentCatalogLegacyAliases.NyxCredentialDocumentProto)] +public sealed partial class UserAgentCatalogNyxCredentialDocument; diff --git a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogMaterializationContext.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogMaterializationContext.cs similarity index 86% rename from agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogMaterializationContext.cs rename to agents/Aevatar.GAgents.Scheduled/UserAgentCatalogMaterializationContext.cs index 5d6846176..00fd14f22 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogMaterializationContext.cs +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogMaterializationContext.cs @@ -1,6 +1,6 @@ using Aevatar.CQRS.Projection.Core.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed class UserAgentCatalogMaterializationContext : IProjectionMaterializationContext { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogMaterializationRuntimeLease.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogMaterializationRuntimeLease.cs similarity index 93% rename from agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogMaterializationRuntimeLease.cs rename to agents/Aevatar.GAgents.Scheduled/UserAgentCatalogMaterializationRuntimeLease.cs index b7352467d..60bf7f266 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogMaterializationRuntimeLease.cs +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogMaterializationRuntimeLease.cs @@ -1,7 +1,7 @@ using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Core.Orchestration; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed class UserAgentCatalogMaterializationRuntimeLease : ProjectionRuntimeLeaseBase, diff --git a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogNyxCredentialDocument.Partial.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogNyxCredentialDocument.Partial.cs similarity index 91% rename from agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogNyxCredentialDocument.Partial.cs rename to agents/Aevatar.GAgents.Scheduled/UserAgentCatalogNyxCredentialDocument.Partial.cs index 881c28136..3bcf6d9ec 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogNyxCredentialDocument.Partial.cs +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogNyxCredentialDocument.Partial.cs @@ -1,7 +1,7 @@ using Aevatar.CQRS.Projection.Stores.Abstractions; using Google.Protobuf.WellKnownTypes; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed partial class UserAgentCatalogNyxCredentialDocument : IProjectionReadModel { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogNyxCredentialDocumentMetadataProvider.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogNyxCredentialDocumentMetadataProvider.cs similarity index 93% rename from agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogNyxCredentialDocumentMetadataProvider.cs rename to agents/Aevatar.GAgents.Scheduled/UserAgentCatalogNyxCredentialDocumentMetadataProvider.cs index 0b37ee10d..02d661374 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogNyxCredentialDocumentMetadataProvider.cs +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogNyxCredentialDocumentMetadataProvider.cs @@ -1,6 +1,6 @@ using Aevatar.CQRS.Projection.Stores.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed class UserAgentCatalogNyxCredentialDocumentMetadataProvider : IProjectionDocumentMetadataProvider diff --git a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogNyxCredentialProjector.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogNyxCredentialProjector.cs similarity index 97% rename from agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogNyxCredentialProjector.cs rename to agents/Aevatar.GAgents.Scheduled/UserAgentCatalogNyxCredentialProjector.cs index d464f8741..ba1dadd2f 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogNyxCredentialProjector.cs +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogNyxCredentialProjector.cs @@ -4,7 +4,7 @@ using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.Channel.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed class UserAgentCatalogNyxCredentialProjector : PerEntryDocumentProjector< diff --git a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogProjectionPort.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogProjectionPort.cs similarity index 95% rename from agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogProjectionPort.cs rename to agents/Aevatar.GAgents.Scheduled/UserAgentCatalogProjectionPort.cs index 5a3284f78..b98d78d5f 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogProjectionPort.cs +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogProjectionPort.cs @@ -1,7 +1,7 @@ using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Core.Orchestration; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed class UserAgentCatalogProjectionPort : MaterializationProjectionPortBase diff --git a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogProjector.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogProjector.cs similarity index 98% rename from agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogProjector.cs rename to agents/Aevatar.GAgents.Scheduled/UserAgentCatalogProjector.cs index 018df56a5..ef29f7f60 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogProjector.cs +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogProjector.cs @@ -4,7 +4,7 @@ using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.Channel.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed class UserAgentCatalogProjector : PerEntryDocumentProjector< diff --git a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogQueryPort.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogQueryPort.cs similarity index 98% rename from agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogQueryPort.cs rename to agents/Aevatar.GAgents.Scheduled/UserAgentCatalogQueryPort.cs index b60a5f80a..5f6535c5e 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogQueryPort.cs +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogQueryPort.cs @@ -1,6 +1,6 @@ using Aevatar.CQRS.Projection.Stores.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed class UserAgentCatalogQueryPort : IUserAgentCatalogQueryPort { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogRuntimeQueryPort.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogRuntimeQueryPort.cs similarity index 98% rename from agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogRuntimeQueryPort.cs rename to agents/Aevatar.GAgents.Scheduled/UserAgentCatalogRuntimeQueryPort.cs index c67373a35..5084231d3 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogRuntimeQueryPort.cs +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogRuntimeQueryPort.cs @@ -1,6 +1,6 @@ using Aevatar.CQRS.Projection.Stores.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed class UserAgentCatalogRuntimeQueryPort : IUserAgentCatalogRuntimeQueryPort { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogStartupService.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogStartupService.cs similarity index 98% rename from agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogStartupService.cs rename to agents/Aevatar.GAgents.Scheduled/UserAgentCatalogStartupService.cs index 8d6d242d6..91a84f075 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogStartupService.cs +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogStartupService.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed class UserAgentCatalogStartupService : IHostedService { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogStorageContracts.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogStorageContracts.cs similarity index 93% rename from agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogStorageContracts.cs rename to agents/Aevatar.GAgents.Scheduled/UserAgentCatalogStorageContracts.cs index 07c24f88d..9995d622e 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/UserAgentCatalogStorageContracts.cs +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogStorageContracts.cs @@ -1,4 +1,4 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; internal static class UserAgentCatalogStorageContracts { diff --git a/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogStoreCommands.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogStoreCommands.cs new file mode 100644 index 000000000..448b21461 --- /dev/null +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogStoreCommands.cs @@ -0,0 +1,51 @@ +using Aevatar.Foundation.Abstractions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.GAgents.Scheduled; + +internal static class UserAgentCatalogStoreCommands +{ + public static Task DispatchUpsertAsync( + IServiceProvider services, + string publisherActorId, + UserAgentCatalogUpsertCommand command, + CancellationToken ct = default) => + DispatchAsync(services, publisherActorId, command, ct); + + public static Task DispatchExecutionUpdateAsync( + IServiceProvider services, + string publisherActorId, + UserAgentCatalogExecutionUpdateCommand command, + CancellationToken ct = default) => + DispatchAsync(services, publisherActorId, command, ct); + + private static async Task DispatchAsync( + IServiceProvider services, + string publisherActorId, + IMessage command, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(command); + + var actorRuntime = services.GetService(); + var dispatchPort = services.GetService(); + if (actorRuntime is null || dispatchPort is null) + return; + + _ = await actorRuntime.GetAsync(UserAgentCatalogGAgent.WellKnownId) + ?? await actorRuntime.CreateAsync(UserAgentCatalogGAgent.WellKnownId, ct); + + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(command), + Route = EnvelopeRouteSemantics.CreateDirect(publisherActorId, UserAgentCatalogGAgent.WellKnownId), + }; + + await dispatchPort.DispatchAsync(UserAgentCatalogGAgent.WellKnownId, envelope, ct); + } +} diff --git a/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogTombstoneCompactionTarget.cs b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogTombstoneCompactionTarget.cs new file mode 100644 index 000000000..55220861f --- /dev/null +++ b/agents/Aevatar.GAgents.Scheduled/UserAgentCatalogTombstoneCompactionTarget.cs @@ -0,0 +1,24 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.Channel.Runtime; +using Google.Protobuf; + +namespace Aevatar.GAgents.Scheduled; + +internal sealed class UserAgentCatalogTombstoneCompactionTarget : ITombstoneCompactionTarget +{ + public string ActorId => UserAgentCatalogGAgent.WellKnownId; + public string ProjectionKind => UserAgentCatalogProjectionPort.ProjectionKind; + public string TargetName => "user agent catalog"; + + public async Task EnsureActorAsync(IActorRuntime actorRuntime, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(actorRuntime); + _ = await actorRuntime.GetAsync(UserAgentCatalogGAgent.WellKnownId) + ?? await actorRuntime.CreateAsync( + UserAgentCatalogGAgent.WellKnownId, + ct); + } + + public IMessage CreateCommand(long safeStateVersion) => + new UserAgentCatalogCompactTombstonesCommand { SafeStateVersion = safeStateVersion }; +} diff --git a/agents/Aevatar.GAgents.Scheduled/WorkflowAgentCommandPort.cs b/agents/Aevatar.GAgents.Scheduled/WorkflowAgentCommandPort.cs new file mode 100644 index 000000000..422f2bf21 --- /dev/null +++ b/agents/Aevatar.GAgents.Scheduled/WorkflowAgentCommandPort.cs @@ -0,0 +1,98 @@ +using Aevatar.Foundation.Abstractions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.GAgents.Scheduled; + +internal sealed class WorkflowAgentCommandPort : IWorkflowAgentCommandPort +{ + private const string PublisherActorId = "scheduled.workflow-agent"; + + private readonly IActorRuntime _actorRuntime; + private readonly IActorDispatchPort _actorDispatchPort; + private readonly UserAgentCatalogProjectionPort _catalogProjectionPort; + + public WorkflowAgentCommandPort( + IActorRuntime actorRuntime, + IActorDispatchPort actorDispatchPort, + UserAgentCatalogProjectionPort catalogProjectionPort) + { + _actorRuntime = actorRuntime ?? throw new ArgumentNullException(nameof(actorRuntime)); + _actorDispatchPort = actorDispatchPort ?? throw new ArgumentNullException(nameof(actorDispatchPort)); + _catalogProjectionPort = catalogProjectionPort ?? throw new ArgumentNullException(nameof(catalogProjectionPort)); + } + + public async Task InitializeAsync( + string agentId, + InitializeWorkflowAgentCommand command, + bool runImmediately, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ArgumentNullException.ThrowIfNull(command); + + await EnsureWorkflowAgentActorAsync(agentId, ct); + await _catalogProjectionPort.EnsureProjectionForActorAsync(UserAgentCatalogGAgent.WellKnownId, ct); + + await DispatchAsync(agentId, command, ct); + + if (runImmediately) + { + await DispatchAsync( + agentId, + new TriggerWorkflowAgentExecutionCommand { Reason = "create_agent" }, + ct); + } + } + + public async Task TriggerAsync( + string agentId, + string reason, + string? revisionFeedback, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + await EnsureWorkflowAgentActorAsync(agentId, ct); + await DispatchAsync( + agentId, + new TriggerWorkflowAgentExecutionCommand + { + Reason = reason ?? string.Empty, + RevisionFeedback = revisionFeedback ?? string.Empty, + }, + ct); + } + + public async Task DisableAsync(string agentId, string reason, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + await EnsureWorkflowAgentActorAsync(agentId, ct); + await DispatchAsync(agentId, new DisableWorkflowAgentCommand { Reason = reason ?? string.Empty }, ct); + } + + public async Task EnableAsync(string agentId, string reason, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + await EnsureWorkflowAgentActorAsync(agentId, ct); + await DispatchAsync(agentId, new EnableWorkflowAgentCommand { Reason = reason ?? string.Empty }, ct); + } + + private async Task EnsureWorkflowAgentActorAsync(string agentId, CancellationToken ct) + { + _ = await _actorRuntime.GetAsync(agentId) + ?? await _actorRuntime.CreateAsync(agentId, ct); + } + + private Task DispatchAsync(string agentId, TCommand command, CancellationToken ct) + where TCommand : class, IMessage + { + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(command), + Route = EnvelopeRouteSemantics.CreateDirect(PublisherActorId, agentId), + }; + return _actorDispatchPort.DispatchAsync(agentId, envelope, ct); + } +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/WorkflowAgentDefaults.cs b/agents/Aevatar.GAgents.Scheduled/WorkflowAgentDefaults.cs similarity index 87% rename from agents/Aevatar.GAgents.ChannelRuntime/WorkflowAgentDefaults.cs rename to agents/Aevatar.GAgents.Scheduled/WorkflowAgentDefaults.cs index b5367d386..715ab830f 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/WorkflowAgentDefaults.cs +++ b/agents/Aevatar.GAgents.Scheduled/WorkflowAgentDefaults.cs @@ -1,6 +1,6 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; -internal static class WorkflowAgentDefaults +public static class WorkflowAgentDefaults { public const string AgentType = "workflow_agent"; public const string ActorIdPrefix = "workflow-agent"; diff --git a/agents/Aevatar.GAgents.ChannelRuntime/WorkflowAgentGAgent.cs b/agents/Aevatar.GAgents.Scheduled/WorkflowAgentGAgent.cs similarity index 93% rename from agents/Aevatar.GAgents.ChannelRuntime/WorkflowAgentGAgent.cs rename to agents/Aevatar.GAgents.Scheduled/WorkflowAgentGAgent.cs index 671cf012b..dd42c7901 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/WorkflowAgentGAgent.cs +++ b/agents/Aevatar.GAgents.Scheduled/WorkflowAgentGAgent.cs @@ -5,13 +5,14 @@ using Aevatar.Foundation.Core; using Aevatar.Foundation.Core.EventSourcing; using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.Runtime; using Aevatar.Workflow.Application.Abstractions.Runs; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed class WorkflowAgentGAgent : GAgentBase { @@ -212,12 +213,6 @@ private string BuildExecutionPrompt(string? reason, string? revisionFeedback) private async Task UpsertRegistryAsync(string status, CancellationToken ct) { - var runtime = Services.GetService(); - if (runtime is null) return; - - var actor = await runtime.GetAsync(UserAgentCatalogGAgent.WellKnownId) - ?? await runtime.CreateAsync(UserAgentCatalogGAgent.WellKnownId, ct); - var command = new UserAgentCatalogUpsertCommand { AgentId = Id, @@ -239,7 +234,7 @@ private async Task UpsertRegistryAsync(string status, CancellationToken ct) LarkReceiveIdTypeFallback = State.LarkReceiveIdTypeFallback ?? string.Empty, }; - await actor.HandleEventAsync(BuildDirectEnvelope(actor.Id, command), ct); + await UserAgentCatalogStoreCommands.DispatchUpsertAsync(Services, Id, command, ct); await UpdateRegistryExecutionAsync(status, State.LastRunAt, State.NextRunAt, State.ErrorCount, State.LastError, ct); } @@ -247,29 +242,15 @@ private async Task UpdateRegistryExecutionAsync( string status, Timestamp? lastRunAt, Timestamp? nextRunAt, int errorCount, string? lastError, CancellationToken ct) { - var runtime = Services.GetService(); - if (runtime is null) return; - - var actor = await runtime.GetAsync(UserAgentCatalogGAgent.WellKnownId) - ?? await runtime.CreateAsync(UserAgentCatalogGAgent.WellKnownId, ct); - var command = new UserAgentCatalogExecutionUpdateCommand { AgentId = Id, Status = status, LastRunAt = lastRunAt, NextRunAt = nextRunAt, ErrorCount = errorCount, LastError = lastError ?? string.Empty, }; - await actor.HandleEventAsync(BuildDirectEnvelope(actor.Id, command), ct); + await UserAgentCatalogStoreCommands.DispatchExecutionUpdateAsync(Services, Id, command, ct); } - private static EventEnvelope BuildDirectEnvelope(string actorId, IMessage payload) => new() - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(payload), - Route = new EnvelopeRoute { Direct = new DirectRoute { TargetActorId = actorId } }, - }; - private static WorkflowAgentState ApplyInitialized(WorkflowAgentState current, WorkflowAgentInitializedEvent evt) { var next = current.Clone(); diff --git a/agents/Aevatar.GAgents.Scheduled/WorkflowAgentLegacyAliases.cs b/agents/Aevatar.GAgents.Scheduled/WorkflowAgentLegacyAliases.cs new file mode 100644 index 000000000..122d20ddb --- /dev/null +++ b/agents/Aevatar.GAgents.Scheduled/WorkflowAgentLegacyAliases.cs @@ -0,0 +1,57 @@ +using Aevatar.Foundation.Abstractions.Compatibility; + +namespace Aevatar.GAgents.Scheduled; + +internal static class WorkflowAgentLegacyAliases +{ + private const string ProtoPrefix = "aevatar.gagents.channelruntime."; + private const string ClrPrefix = "Aevatar.GAgents.ChannelRuntime."; + + internal const string StateProto = ProtoPrefix + "WorkflowAgentState"; + internal const string InitializeCommandProto = ProtoPrefix + "InitializeWorkflowAgentCommand"; + internal const string InitializedEventProto = ProtoPrefix + "WorkflowAgentInitializedEvent"; + internal const string TriggerCommandProto = ProtoPrefix + "TriggerWorkflowAgentExecutionCommand"; + internal const string NextRunScheduledEventProto = ProtoPrefix + "WorkflowAgentNextRunScheduledEvent"; + internal const string ExecutionDispatchedEventProto = ProtoPrefix + "WorkflowAgentExecutionDispatchedEvent"; + internal const string ExecutionFailedEventProto = ProtoPrefix + "WorkflowAgentExecutionFailedEvent"; + internal const string DisableCommandProto = ProtoPrefix + "DisableWorkflowAgentCommand"; + internal const string EnableCommandProto = ProtoPrefix + "EnableWorkflowAgentCommand"; + internal const string DisabledEventProto = ProtoPrefix + "WorkflowAgentDisabledEvent"; + internal const string EnabledEventProto = ProtoPrefix + "WorkflowAgentEnabledEvent"; + + internal const string StateClr = ClrPrefix + "WorkflowAgentState"; +} + +[LegacyProtoFullName(WorkflowAgentLegacyAliases.StateProto)] +[LegacyClrTypeName(WorkflowAgentLegacyAliases.StateClr)] +public sealed partial class WorkflowAgentState; + +[LegacyProtoFullName(WorkflowAgentLegacyAliases.InitializeCommandProto)] +public sealed partial class InitializeWorkflowAgentCommand; + +[LegacyProtoFullName(WorkflowAgentLegacyAliases.InitializedEventProto)] +public sealed partial class WorkflowAgentInitializedEvent; + +[LegacyProtoFullName(WorkflowAgentLegacyAliases.TriggerCommandProto)] +public sealed partial class TriggerWorkflowAgentExecutionCommand; + +[LegacyProtoFullName(WorkflowAgentLegacyAliases.NextRunScheduledEventProto)] +public sealed partial class WorkflowAgentNextRunScheduledEvent; + +[LegacyProtoFullName(WorkflowAgentLegacyAliases.ExecutionDispatchedEventProto)] +public sealed partial class WorkflowAgentExecutionDispatchedEvent; + +[LegacyProtoFullName(WorkflowAgentLegacyAliases.ExecutionFailedEventProto)] +public sealed partial class WorkflowAgentExecutionFailedEvent; + +[LegacyProtoFullName(WorkflowAgentLegacyAliases.DisableCommandProto)] +public sealed partial class DisableWorkflowAgentCommand; + +[LegacyProtoFullName(WorkflowAgentLegacyAliases.EnableCommandProto)] +public sealed partial class EnableWorkflowAgentCommand; + +[LegacyProtoFullName(WorkflowAgentLegacyAliases.DisabledEventProto)] +public sealed partial class WorkflowAgentDisabledEvent; + +[LegacyProtoFullName(WorkflowAgentLegacyAliases.EnabledEventProto)] +public sealed partial class WorkflowAgentEnabledEvent; diff --git a/agents/Aevatar.GAgents.ChannelRuntime/WorkflowAgentState.Partial.cs b/agents/Aevatar.GAgents.Scheduled/WorkflowAgentState.Partial.cs similarity index 90% rename from agents/Aevatar.GAgents.ChannelRuntime/WorkflowAgentState.Partial.cs rename to agents/Aevatar.GAgents.Scheduled/WorkflowAgentState.Partial.cs index 6d21161fb..f6bace9a3 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/WorkflowAgentState.Partial.cs +++ b/agents/Aevatar.GAgents.Scheduled/WorkflowAgentState.Partial.cs @@ -1,6 +1,6 @@ using Aevatar.GAgents.Channel.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Scheduled; public sealed partial class WorkflowAgentState : ISchedulable { diff --git a/agents/Aevatar.GAgents.Scheduled/protos/skill_runner.proto b/agents/Aevatar.GAgents.Scheduled/protos/skill_runner.proto new file mode 100644 index 000000000..08d663707 --- /dev/null +++ b/agents/Aevatar.GAgents.Scheduled/protos/skill_runner.proto @@ -0,0 +1,129 @@ +syntax = "proto3"; + +package aevatar.gagents.scheduled; + +option csharp_namespace = "Aevatar.GAgents.Scheduled"; + +import "google/protobuf/timestamp.proto"; + +// ─── Skill Runner (persistent scheduled agent) ─── + +message SkillRunnerOutboundConfig { + string conversation_id = 1; + string nyx_provider_slug = 2; + string nyx_api_key = 3; + string owner_nyx_user_id = 4; + string api_key_id = 5; + // Channel platform identifier (e.g. "lark", "telegram"). Empty = unspecified; + // UserAgentCatalog upsert defaults to "lark" for backward compatibility. + string platform = 6; + // Authoritative Lark outbound delivery target captured at create time. When + // both fields are present, SkillRunnerGAgent.SendOutputAsync uses them + // verbatim; conversation_id stays for LLM metadata propagation only. + string lark_receive_id = 7; + string lark_receive_id_type = 8; + // Secondary outbound delivery target. Used by SkillRunnerGAgent.SendOutputAsync + // when the primary chat_id-typed send is rejected with Lark `230002 bot not in + // chat` — the failure mode for cross-app same-tenant deployments where the + // outbound app is not a member of the inbound DM. See + // UserAgentCatalogEntry.lark_receive_id_fallback for the recommended pinning. + string lark_receive_id_fallback = 9; + string lark_receive_id_type_fallback = 10; +} + +message SkillRunnerState { + string skill_name = 1; + string template_name = 2; + string skill_content = 3; + string execution_prompt = 4; + string schedule_cron = 5; + string schedule_timezone = 6; + SkillRunnerOutboundConfig outbound_config = 7; + google.protobuf.Timestamp last_run_at = 8; + google.protobuf.Timestamp next_run_at = 9; + int32 error_count = 10; + string last_output = 11; + string last_error = 12; + bool enabled = 13; + string scope_id = 14; + string provider_name = 15; + string model = 16; + optional double temperature = 17; + optional int32 max_tokens = 18; + optional int32 max_tool_rounds = 19; + optional int32 max_history_messages = 20; +} + +message InitializeSkillRunnerCommand { + string skill_name = 1; + string template_name = 2; + string skill_content = 3; + string execution_prompt = 4; + string schedule_cron = 5; + string schedule_timezone = 6; + SkillRunnerOutboundConfig outbound_config = 7; + bool enabled = 8; + string scope_id = 9; + string provider_name = 10; + string model = 11; + optional double temperature = 12; + optional int32 max_tokens = 13; + optional int32 max_tool_rounds = 14; + optional int32 max_history_messages = 15; +} + +message SkillRunnerInitializedEvent { + string skill_name = 1; + string template_name = 2; + string skill_content = 3; + string execution_prompt = 4; + string schedule_cron = 5; + string schedule_timezone = 6; + SkillRunnerOutboundConfig outbound_config = 7; + bool enabled = 8; + string scope_id = 9; + string provider_name = 10; + string model = 11; + optional double temperature = 12; + optional int32 max_tokens = 13; + optional int32 max_tool_rounds = 14; + optional int32 max_history_messages = 15; +} + +message TriggerSkillRunnerExecutionCommand { + string reason = 1; + // 0 = first attempt, 1 = backoff retry after a failed first attempt. + // Bounded by SkillRunnerDefaults.MaxRetryAttempts; retries are event-ified + // via ScheduleSelfDurableTimeoutAsync and never block the actor turn. + int32 retry_attempt = 2; +} + +message SkillRunnerNextRunScheduledEvent { + google.protobuf.Timestamp next_run_at = 1; +} + +message SkillRunnerExecutionCompletedEvent { + google.protobuf.Timestamp completed_at = 1; + string output = 2; +} + +message SkillRunnerExecutionFailedEvent { + google.protobuf.Timestamp failed_at = 1; + string error = 2; +} + +message DisableSkillRunnerCommand { + string reason = 1; +} + +message EnableSkillRunnerCommand { + string reason = 1; +} + +message SkillRunnerDisabledEvent { + string reason = 1; +} + +message SkillRunnerEnabledEvent { + string reason = 1; +} diff --git a/agents/Aevatar.GAgents.Scheduled/protos/user_agent_catalog.proto b/agents/Aevatar.GAgents.Scheduled/protos/user_agent_catalog.proto new file mode 100644 index 000000000..092599202 --- /dev/null +++ b/agents/Aevatar.GAgents.Scheduled/protos/user_agent_catalog.proto @@ -0,0 +1,164 @@ +syntax = "proto3"; + +package aevatar.gagents.scheduled; + +option csharp_namespace = "Aevatar.GAgents.Scheduled"; + +import "google/protobuf/timestamp.proto"; + +// ─── User Agent Catalog (actor-backed delivery target store) ─── + +message UserAgentCatalogEntry { + string agent_id = 1; + string platform = 2; + string conversation_id = 3; + string nyx_provider_slug = 4; + string nyx_api_key = 5; + string owner_nyx_user_id = 6; + google.protobuf.Timestamp created_at = 7; + google.protobuf.Timestamp updated_at = 8; + bool tombstoned = 9; + string agent_type = 10; + string template_name = 11; + string scope_id = 12; + string api_key_id = 13; + string schedule_cron = 14; + string schedule_timezone = 15; + string status = 16; + google.protobuf.Timestamp last_run_at = 17; + google.protobuf.Timestamp next_run_at = 18; + int32 error_count = 19; + string last_error = 20; + int64 tombstone_state_version = 21; + // Authoritative Lark outbound delivery target captured at create time. When + // both fields are present, outbound senders use them verbatim instead of + // inferring receive_id_type from the conversation_id prefix. The creator + // pins the chat-scoped `chat_id` (`oc_*`) here when available because it is + // the only identifier that survives both cross-app and cross-tenant + // boundaries when the same Lark app is on both ends of the relay. + string lark_receive_id = 22; + string lark_receive_id_type = 23; + // Secondary outbound delivery target. The runtime attempts the primary + // (`lark_receive_id`/`lark_receive_id_type`) first and falls back to this + // pair on a Lark `230002 bot not in chat` rejection — the failure mode + // when the outbound app is a different Lark app than the one that received + // the inbound DM (cross-app same-tenant deployment). The creator pins + // `union_id` (`on_*`) here so the fallback survives a chat_id rejection + // without needing a fresh ingress event. + string lark_receive_id_fallback = 24; + string lark_receive_id_type_fallback = 25; +} + +message UserAgentCatalogState { + repeated UserAgentCatalogEntry entries = 1; +} + +message UserAgentCatalogUpsertCommand { + string agent_id = 1; + string platform = 2; + string conversation_id = 3; + string nyx_provider_slug = 4; + string nyx_api_key = 5; + string owner_nyx_user_id = 6; + string agent_type = 7; + string template_name = 8; + string scope_id = 9; + string api_key_id = 10; + string schedule_cron = 11; + string schedule_timezone = 12; + string status = 13; + // See UserAgentCatalogEntry.lark_receive_id for semantics. Empty values + // preserve any existing entry value via the merge-non-empty upsert policy. + string lark_receive_id = 14; + string lark_receive_id_type = 15; + // See UserAgentCatalogEntry.lark_receive_id_fallback for semantics. + string lark_receive_id_fallback = 16; + string lark_receive_id_type_fallback = 17; +} + +message UserAgentCatalogTombstoneCommand { + string agent_id = 1; +} + +message UserAgentCatalogExecutionUpdateCommand { + string agent_id = 1; + string status = 2; + google.protobuf.Timestamp last_run_at = 3; + google.protobuf.Timestamp next_run_at = 4; + int32 error_count = 5; + string last_error = 6; +} + +message UserAgentCatalogCompactTombstonesCommand { + int64 safe_state_version = 1; +} + +message UserAgentCatalogUpsertedEvent { + UserAgentCatalogEntry entry = 1; +} + +message UserAgentCatalogTombstonedEvent { + string agent_id = 1; + int64 tombstone_state_version = 2; +} + +message UserAgentCatalogExecutionUpdatedEvent { + string agent_id = 1; + string status = 2; + google.protobuf.Timestamp last_run_at = 3; + google.protobuf.Timestamp next_run_at = 4; + int32 error_count = 5; + string last_error = 6; +} + +message UserAgentCatalogTombstonesCompactedEvent { + repeated string agent_ids = 1; + int64 safe_state_version = 2; +} + +message UserAgentCatalogDocument { + string id = 1; + string platform = 2; + string conversation_id = 3; + string nyx_provider_slug = 4; + reserved 5; + reserved "nyx_api_key"; + string owner_nyx_user_id = 6; + bool tombstoned = 7; + int64 state_version = 8; + string last_event_id = 9; + google.protobuf.Timestamp updated_at_utc = 10; + string actor_id = 11; + google.protobuf.Timestamp created_at_utc = 12; + string agent_type = 13; + string template_name = 14; + string scope_id = 15; + string api_key_id = 16; + string schedule_cron = 17; + string schedule_timezone = 18; + string status = 19; + google.protobuf.Timestamp last_run_at_utc = 20; + google.protobuf.Timestamp next_run_at_utc = 21; + int32 error_count = 22; + string last_error = 23; + // Mirrors UserAgentCatalogEntry.lark_receive_id*. Required so catalog-backed + // outbound senders (FeishuCardHumanInteractionPort) read the typed target + // through the projection rather than re-deriving from conversation_id. + string lark_receive_id = 24; + string lark_receive_id_type = 25; + // Mirrors UserAgentCatalogEntry.lark_receive_id_fallback*. Carried through + // the projection so catalog-backed senders see the same primary+fallback + // pair as actor-state senders. + string lark_receive_id_fallback = 26; + string lark_receive_id_type_fallback = 27; +} + +// Runtime-only Nyx credential read model for delivery-target execution paths. +message UserAgentCatalogNyxCredentialDocument { + string id = 1; + string nyx_api_key = 2; + int64 state_version = 3; + string last_event_id = 4; + google.protobuf.Timestamp updated_at_utc = 5; + string actor_id = 6; +} diff --git a/agents/Aevatar.GAgents.Scheduled/protos/workflow_agent.proto b/agents/Aevatar.GAgents.Scheduled/protos/workflow_agent.proto new file mode 100644 index 000000000..c316ef820 --- /dev/null +++ b/agents/Aevatar.GAgents.Scheduled/protos/workflow_agent.proto @@ -0,0 +1,125 @@ +syntax = "proto3"; + +package aevatar.gagents.scheduled; + +option csharp_namespace = "Aevatar.GAgents.Scheduled"; + +import "google/protobuf/timestamp.proto"; + +// ─── Workflow Agent (persistent scheduled workflow trigger) ─── + +message WorkflowAgentState { + string workflow_id = 1; + string workflow_name = 2; + string workflow_actor_id = 3; + string execution_prompt = 4; + string schedule_cron = 5; + string schedule_timezone = 6; + string conversation_id = 7; + string nyx_provider_slug = 8; + string nyx_api_key = 9; + string owner_nyx_user_id = 10; + string api_key_id = 11; + google.protobuf.Timestamp last_run_at = 12; + google.protobuf.Timestamp next_run_at = 13; + int32 error_count = 14; + string last_error = 15; + bool enabled = 16; + string scope_id = 17; + // Channel platform identifier (e.g. "lark", "telegram"). Empty = unspecified; + // UserAgentCatalog upsert defaults to "lark" for backward compatibility. + string platform = 18; + // See UserAgentCatalogEntry.lark_receive_id for semantics; copied verbatim + // into the catalog entry on UpsertRegistryAsync so downstream Lark senders + // (e.g. FeishuCardHumanInteractionPort) read the typed target. + string lark_receive_id = 19; + string lark_receive_id_type = 20; + // Secondary outbound delivery target. See UserAgentCatalogEntry + // .lark_receive_id_fallback for runtime fallback semantics. + string lark_receive_id_fallback = 21; + string lark_receive_id_type_fallback = 22; +} + +message InitializeWorkflowAgentCommand { + string workflow_id = 1; + string workflow_name = 2; + string workflow_actor_id = 3; + string execution_prompt = 4; + string schedule_cron = 5; + string schedule_timezone = 6; + string conversation_id = 7; + string nyx_provider_slug = 8; + string nyx_api_key = 9; + string owner_nyx_user_id = 10; + string api_key_id = 11; + bool enabled = 12; + string scope_id = 13; + // Channel platform identifier; empty → default "lark" at upsert time. + string platform = 14; + string lark_receive_id = 15; + string lark_receive_id_type = 16; + // Secondary outbound delivery target. See UserAgentCatalogEntry + // .lark_receive_id_fallback for runtime fallback semantics. + string lark_receive_id_fallback = 17; + string lark_receive_id_type_fallback = 18; +} + +message WorkflowAgentInitializedEvent { + string workflow_id = 1; + string workflow_name = 2; + string workflow_actor_id = 3; + string execution_prompt = 4; + string schedule_cron = 5; + string schedule_timezone = 6; + string conversation_id = 7; + string nyx_provider_slug = 8; + string nyx_api_key = 9; + string owner_nyx_user_id = 10; + string api_key_id = 11; + bool enabled = 12; + string scope_id = 13; + // Channel platform identifier; empty → default "lark" at upsert time. + string platform = 14; + string lark_receive_id = 15; + string lark_receive_id_type = 16; + // Secondary outbound delivery target. See UserAgentCatalogEntry + // .lark_receive_id_fallback for runtime fallback semantics. + string lark_receive_id_fallback = 17; + string lark_receive_id_type_fallback = 18; +} + +message TriggerWorkflowAgentExecutionCommand { + string reason = 1; + string revision_feedback = 2; +} + +message WorkflowAgentNextRunScheduledEvent { + google.protobuf.Timestamp next_run_at = 1; +} + +message WorkflowAgentExecutionDispatchedEvent { + google.protobuf.Timestamp dispatched_at = 1; + string workflow_run_actor_id = 2; + string command_id = 3; +} + +message WorkflowAgentExecutionFailedEvent { + google.protobuf.Timestamp failed_at = 1; + string error = 2; +} + +message DisableWorkflowAgentCommand { + string reason = 1; +} + +message EnableWorkflowAgentCommand { + string reason = 1; +} + +message WorkflowAgentDisabledEvent { + string reason = 1; +} + +message WorkflowAgentEnabledEvent { + string reason = 1; +} diff --git a/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/Aevatar.GAgents.Channel.NyxIdRelay.csproj b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/Aevatar.GAgents.Channel.NyxIdRelay.csproj index c8fbca0af..da1e4ac2f 100644 --- a/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/Aevatar.GAgents.Channel.NyxIdRelay.csproj +++ b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/Aevatar.GAgents.Channel.NyxIdRelay.csproj @@ -11,11 +11,20 @@ + + + + + + + + + diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationScopeBackfill.cs b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/ChannelBotRegistrationScopeBackfill.cs similarity index 95% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationScopeBackfill.cs rename to agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/ChannelBotRegistrationScopeBackfill.cs index fa80cd0be..9d19849ac 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationScopeBackfill.cs +++ b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/ChannelBotRegistrationScopeBackfill.cs @@ -1,23 +1,24 @@ using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.Channel.Runtime; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.NyxIdRelay; -internal sealed record ChannelBotRegistrationScopeBackfillSelection( +public sealed record ChannelBotRegistrationScopeBackfillSelection( string? RegistrationId = null, string? NyxAgentApiKeyId = null, bool Force = false); -internal sealed record ChannelBotRegistrationScopeBackfillAuthorization( +public sealed record ChannelBotRegistrationScopeBackfillAuthorization( string? AccessToken = null, INyxRelayApiKeyOwnershipVerifier? OwnershipVerifier = null); -internal sealed record ChannelBotRegistrationScopeBackfillResult( +public sealed record ChannelBotRegistrationScopeBackfillResult( int EmptyScopeRegistrationsObserved, int CandidateRegistrations, int BackfilledRegistrations, string Note); -internal static class ChannelBotRegistrationScopeBackfill +public static class ChannelBotRegistrationScopeBackfill { public static async Task BackfillAsync( IReadOnlyList registrations, diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/ChannelCallbackEndpoints.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs rename to agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/ChannelCallbackEndpoints.cs index 0c855a401..036bc47e5 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs +++ b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/ChannelCallbackEndpoints.cs @@ -2,6 +2,7 @@ using Aevatar.CQRS.Core.Abstractions.Commands; using Aevatar.AI.ToolProviders.NyxId; using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.Channel.Runtime; using Aevatar.Workflow.Application.Abstractions.Runs; using Google.Protobuf.WellKnownTypes; using Microsoft.AspNetCore.Builder; @@ -11,7 +12,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.NyxIdRelay; public static class ChannelCallbackEndpoints { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelPlatformReplyService.cs b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/ChannelPlatformReplyService.cs similarity index 94% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelPlatformReplyService.cs rename to agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/ChannelPlatformReplyService.cs index 901319b2c..673253379 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelPlatformReplyService.cs +++ b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/ChannelPlatformReplyService.cs @@ -1,12 +1,13 @@ using Aevatar.AI.ToolProviders.NyxId; +using Aevatar.GAgents.Channel.Runtime; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.NyxIdRelay; /// /// Sends outbound platform replies using the latest public registration view. /// -internal sealed class ChannelPlatformReplyService +public sealed class ChannelPlatformReplyService { private readonly IChannelBotRegistrationRuntimeQueryPort _runtimeQueryPort; private readonly NyxIdApiClient _nyxClient; diff --git a/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/DependencyInjection/NyxIdRelayChannelServiceCollectionExtensions.cs b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/DependencyInjection/NyxIdRelayChannelServiceCollectionExtensions.cs new file mode 100644 index 000000000..8d86e3733 --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/DependencyInjection/NyxIdRelayChannelServiceCollectionExtensions.cs @@ -0,0 +1,46 @@ +using Aevatar.AI.ToolProviders.NyxId; +using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.NyxIdRelay.Outbound; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aevatar.GAgents.Channel.NyxIdRelay; + +/// +/// DI registration entry point for the NyxID relay channel package. +/// +public static class NyxIdRelayChannelServiceCollectionExtensions +{ + /// + /// Registers the NyxID relay channel: API client, provisioning services (Lark + Telegram), + /// API-key ownership verifier, scope resolver, channel reply service, outbound port, + /// replay guard, and interactive reply dispatcher. + /// + public static IServiceCollection AddNyxIdRelayChannel(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + // Provisioning service set — both Lark + Telegram are concrete provisioning sources. + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(sp => + { + var relayOptions = sp.GetService() ?? new NyxIdRelayOptions(); + return new NyxIdRelayReplayGuard( + TimeSpan.FromSeconds(Math.Max(1, relayOptions.CallbackReplayWindowSeconds)), + TimeProvider.System); + }); + services.TryAddSingleton(); + + return services; + } +} diff --git a/agents/Aevatar.GAgents.NyxidChat/INyxIdRelayScopeResolver.cs b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/INyxIdRelayScopeResolver.cs similarity index 95% rename from agents/Aevatar.GAgents.NyxidChat/INyxIdRelayScopeResolver.cs rename to agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/INyxIdRelayScopeResolver.cs index a1680c641..8dca63fab 100644 --- a/agents/Aevatar.GAgents.NyxidChat/INyxIdRelayScopeResolver.cs +++ b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/INyxIdRelayScopeResolver.cs @@ -1,4 +1,4 @@ -namespace Aevatar.GAgents.NyxidChat; +namespace Aevatar.GAgents.Channel.NyxIdRelay; /// /// Resolves the canonical Aevatar scope id for a Nyx relay callback when it cannot diff --git a/agents/Aevatar.GAgents.ChannelRuntime/IPlatformAdapter.cs b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/IPlatformAdapter.cs similarity index 95% rename from agents/Aevatar.GAgents.ChannelRuntime/IPlatformAdapter.cs rename to agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/IPlatformAdapter.cs index 57d6e5612..c9784f27c 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/IPlatformAdapter.cs +++ b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/IPlatformAdapter.cs @@ -1,7 +1,8 @@ using Aevatar.AI.ToolProviders.NyxId; +using Aevatar.GAgents.Channel.Runtime; using Microsoft.AspNetCore.Http; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.NyxIdRelay; /// /// Adapter for a bot platform (Lark, Discord, etc.). diff --git a/agents/Aevatar.GAgents.ChannelRuntime/NyxApiResponseHelper.cs b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxApiResponseHelper.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/NyxApiResponseHelper.cs rename to agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxApiResponseHelper.cs index 2d4b23515..7edc131da 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/NyxApiResponseHelper.cs +++ b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxApiResponseHelper.cs @@ -1,7 +1,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.NyxIdRelay; /// /// Shared parsing / rollback helpers for the Nyx-side responses consumed by per-platform diff --git a/agents/Aevatar.GAgents.ChannelRuntime/NyxIdRelayScopeResolver.cs b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxIdRelayScopeResolver.cs similarity index 96% rename from agents/Aevatar.GAgents.ChannelRuntime/NyxIdRelayScopeResolver.cs rename to agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxIdRelayScopeResolver.cs index 3ba63d392..d76a89c26 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/NyxIdRelayScopeResolver.cs +++ b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxIdRelayScopeResolver.cs @@ -1,8 +1,8 @@ -using Aevatar.GAgents.NyxidChat; +using Aevatar.GAgents.Channel.Runtime; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.NyxIdRelay; /// /// Production implementation of backed by diff --git a/agents/Aevatar.GAgents.ChannelRuntime/NyxLarkProvisioningService.cs b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxLarkProvisioningService.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/NyxLarkProvisioningService.cs rename to agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxLarkProvisioningService.cs index 85179c1b3..4d8c9b63e 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/NyxLarkProvisioningService.cs +++ b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxLarkProvisioningService.cs @@ -1,9 +1,10 @@ using System.Text.Json; using Aevatar.AI.ToolProviders.NyxId; using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.Channel.Runtime; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.NyxIdRelay; public sealed record NyxLarkProvisioningRequest( string AccessToken, diff --git a/agents/Aevatar.GAgents.ChannelRuntime/NyxRelayApiKeyOwnershipVerifier.cs b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxRelayApiKeyOwnershipVerifier.cs similarity index 96% rename from agents/Aevatar.GAgents.ChannelRuntime/NyxRelayApiKeyOwnershipVerifier.cs rename to agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxRelayApiKeyOwnershipVerifier.cs index 0ff157e1a..8134c02e3 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/NyxRelayApiKeyOwnershipVerifier.cs +++ b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxRelayApiKeyOwnershipVerifier.cs @@ -3,11 +3,11 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.NyxIdRelay; -internal sealed record NyxRelayApiKeyOwnershipVerification(bool Succeeded, string Detail); +public sealed record NyxRelayApiKeyOwnershipVerification(bool Succeeded, string Detail); -internal interface INyxRelayApiKeyOwnershipVerifier +public interface INyxRelayApiKeyOwnershipVerifier { Task VerifyAsync( string accessToken, @@ -16,7 +16,7 @@ Task VerifyAsync( CancellationToken ct); } -internal sealed class NyxRelayApiKeyOwnershipVerifier : INyxRelayApiKeyOwnershipVerifier +public sealed class NyxRelayApiKeyOwnershipVerifier : INyxRelayApiKeyOwnershipVerifier { private readonly NyxIdApiClient _nyxClient; private readonly ILogger _logger; diff --git a/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxTelegramProvisioningService.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs rename to agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxTelegramProvisioningService.cs index 3ece4fe25..151f5cdd7 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs +++ b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxTelegramProvisioningService.cs @@ -1,9 +1,10 @@ using System.Text.Json; using Aevatar.AI.ToolProviders.NyxId; using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.Channel.Runtime; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Channel.NyxIdRelay; public sealed record NyxTelegramProvisioningRequest( string AccessToken, diff --git a/agents/Aevatar.GAgents.ChannelRuntime/Outbound/NyxIdRelayInteractiveReplyDispatcher.cs b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/Outbound/NyxIdRelayInteractiveReplyDispatcher.cs similarity index 98% rename from agents/Aevatar.GAgents.ChannelRuntime/Outbound/NyxIdRelayInteractiveReplyDispatcher.cs rename to agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/Outbound/NyxIdRelayInteractiveReplyDispatcher.cs index 008066858..b7dc72e49 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/Outbound/NyxIdRelayInteractiveReplyDispatcher.cs +++ b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/Outbound/NyxIdRelayInteractiveReplyDispatcher.cs @@ -2,7 +2,7 @@ using Aevatar.GAgents.Channel.Abstractions; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime.Outbound; +namespace Aevatar.GAgents.Channel.NyxIdRelay.Outbound; /// /// Dispatches interactive reply intents through the NyxID channel relay transport. @@ -109,7 +109,7 @@ private async Task SendTextFallbackAsync( /// and non-input action labels. Used when the downstream channel cannot render a card and the /// intent has no top-level (for example, card-only tool calls). /// - internal static string BuildTextFallback(MessageContent intent) + public static string BuildTextFallback(MessageContent intent) { if (!string.IsNullOrWhiteSpace(intent.Text)) return intent.Text; diff --git a/agents/platforms/Aevatar.GAgents.Platform.Lark/Aevatar.GAgents.Platform.Lark.csproj b/agents/platforms/Aevatar.GAgents.Platform.Lark/Aevatar.GAgents.Platform.Lark.csproj index a544a2cf5..7853f176e 100644 --- a/agents/platforms/Aevatar.GAgents.Platform.Lark/Aevatar.GAgents.Platform.Lark.csproj +++ b/agents/platforms/Aevatar.GAgents.Platform.Lark/Aevatar.GAgents.Platform.Lark.csproj @@ -12,10 +12,13 @@ + + + diff --git a/agents/platforms/Aevatar.GAgents.Platform.Lark/DependencyInjection/LarkPlatformServiceCollectionExtensions.cs b/agents/platforms/Aevatar.GAgents.Platform.Lark/DependencyInjection/LarkPlatformServiceCollectionExtensions.cs new file mode 100644 index 000000000..a321e5cb9 --- /dev/null +++ b/agents/platforms/Aevatar.GAgents.Platform.Lark/DependencyInjection/LarkPlatformServiceCollectionExtensions.cs @@ -0,0 +1,43 @@ +using Aevatar.GAgents.Channel.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; + +namespace Aevatar.GAgents.Platform.Lark; + +/// +/// DI registration entry point for the Lark platform package: HTTP client, message +/// composer, native message producer, payload redactor, and durable inbox runtime. +/// +public static class LarkPlatformServiceCollectionExtensions +{ + /// + /// Registers the Lark platform services: a named for the + /// proxied Lark host, the Lark / + /// pair, the payload redactor, and the + /// durable inbox runtime + hosted service. + /// + public static IServiceCollection AddLarkPlatform(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddHttpClient(LarkConversationHostDefaults.HttpClientName, client => + { + client.BaseAddress = LarkConversationHostDefaults.BaseAddress; + }); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton( + sp => sp.GetRequiredService())); + services.TryAddEnumerable(ServiceDescriptor.Singleton( + sp => sp.GetRequiredService())); + services.TryAddSingleton(); + + // ─── Lark durable inbox runtime + hosted service ─── + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + return services; + } +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/LarkBotErrorCodes.cs b/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkBotErrorCodes.cs similarity index 96% rename from agents/Aevatar.GAgents.ChannelRuntime/LarkBotErrorCodes.cs rename to agents/platforms/Aevatar.GAgents.Platform.Lark/LarkBotErrorCodes.cs index 4db37ee85..d78be878f 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/LarkBotErrorCodes.cs +++ b/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkBotErrorCodes.cs @@ -1,11 +1,11 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Platform.Lark; /// /// Documented Lark Open Platform error codes that the runtime branches on. Add new entries only /// when behavior depends on the specific code (e.g. log gating, retry decisions); generic error /// surfacing should keep using the textual msg from the response body. /// -internal static class LarkBotErrorCodes +public static class LarkBotErrorCodes { /// /// "The operator has no permission to react on the specific message" — recurring tenant diff --git a/agents/Aevatar.GAgents.ChannelRuntime/LarkConversationHostDefaults.cs b/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkConversationHostDefaults.cs similarity index 84% rename from agents/Aevatar.GAgents.ChannelRuntime/LarkConversationHostDefaults.cs rename to agents/platforms/Aevatar.GAgents.Platform.Lark/LarkConversationHostDefaults.cs index e2b4daa3d..9bf333351 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/LarkConversationHostDefaults.cs +++ b/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkConversationHostDefaults.cs @@ -1,4 +1,4 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Platform.Lark; internal static class LarkConversationHostDefaults { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/LarkConversationInboxRuntime.cs b/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkConversationInboxRuntime.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/LarkConversationInboxRuntime.cs rename to agents/platforms/Aevatar.GAgents.Platform.Lark/LarkConversationInboxRuntime.cs index 649451295..f63e2b1f9 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/LarkConversationInboxRuntime.cs +++ b/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkConversationInboxRuntime.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Platform.Lark; internal interface ILarkConversationInbox { diff --git a/agents/Aevatar.GAgents.ChannelRuntime/LarkConversationTargets.cs b/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkConversationTargets.cs similarity index 98% rename from agents/Aevatar.GAgents.ChannelRuntime/LarkConversationTargets.cs rename to agents/platforms/Aevatar.GAgents.Platform.Lark/LarkConversationTargets.cs index e19de2c2f..92d0c1caa 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/LarkConversationTargets.cs +++ b/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkConversationTargets.cs @@ -1,6 +1,6 @@ -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Platform.Lark; -internal static class LarkConversationTargets +public static class LarkConversationTargets { private const string DefaultReceiveIdType = "chat_id"; private const string OpenIdReceiveIdType = "open_id"; @@ -174,7 +174,7 @@ public static LarkReceiveTargetWithFallback BuildFromInboundWithFallback( } } -internal readonly record struct LarkReceiveTarget( +public readonly record struct LarkReceiveTarget( string ReceiveId, string ReceiveIdType, bool FellBackToPrefixInference); @@ -188,6 +188,6 @@ internal readonly record struct LarkReceiveTarget( /// chat_id-first would regress those deployments because chat_id is bot-specific for DMs and /// only valid when the same Lark app received the inbound. /// -internal readonly record struct LarkReceiveTargetWithFallback( +public readonly record struct LarkReceiveTargetWithFallback( LarkReceiveTarget Primary, LarkReceiveTarget? Fallback); diff --git a/agents/Aevatar.GAgents.ChannelRuntime/LarkProxyResponse.cs b/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkProxyResponse.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/LarkProxyResponse.cs rename to agents/platforms/Aevatar.GAgents.Platform.Lark/LarkProxyResponse.cs index b705a7d0b..143d1c82d 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/LarkProxyResponse.cs +++ b/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkProxyResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.GAgents.Platform.Lark; /// /// Inspects response bodies returned by NyxIdApiClient.ProxyRequestAsync for downstream @@ -28,7 +28,7 @@ namespace Aevatar.GAgents.ChannelRuntime; /// larkCode=null because it does not parse the nested body. /// /// -internal static class LarkProxyResponse +public static class LarkProxyResponse { /// /// Returns true when the response body indicates a downstream failure. diff --git a/agents/platforms/Aevatar.GAgents.Platform.Telegram/DependencyInjection/TelegramPlatformServiceCollectionExtensions.cs b/agents/platforms/Aevatar.GAgents.Platform.Telegram/DependencyInjection/TelegramPlatformServiceCollectionExtensions.cs new file mode 100644 index 000000000..0cf99ce8a --- /dev/null +++ b/agents/platforms/Aevatar.GAgents.Platform.Telegram/DependencyInjection/TelegramPlatformServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +using Aevatar.GAgents.Channel.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aevatar.GAgents.Platform.Telegram; + +/// +/// DI registration entry point for the Telegram platform package. +/// +public static class TelegramPlatformServiceCollectionExtensions +{ + /// + /// Registers the Telegram message composer + native producer + payload redactor. + /// + public static IServiceCollection AddTelegramPlatform(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton( + sp => sp.GetRequiredService())); + services.TryAddEnumerable(ServiceDescriptor.Singleton( + sp => sp.GetRequiredService())); + services.TryAddSingleton(); + + return services; + } +} diff --git a/src/Aevatar.AI.ToolProviders.AgentCatalog/Aevatar.AI.ToolProviders.AgentCatalog.csproj b/src/Aevatar.AI.ToolProviders.AgentCatalog/Aevatar.AI.ToolProviders.AgentCatalog.csproj new file mode 100644 index 000000000..48f5c5767 --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.AgentCatalog/Aevatar.AI.ToolProviders.AgentCatalog.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + Aevatar.AI.ToolProviders.AgentCatalog + Aevatar.AI.ToolProviders.AgentCatalog + + + + + + + + + + + + + + + + diff --git a/agents/Aevatar.GAgents.ChannelRuntime/AgentDeliveryTargetTool.cs b/src/Aevatar.AI.ToolProviders.AgentCatalog/AgentDeliveryTargetTool.cs similarity index 65% rename from agents/Aevatar.GAgents.ChannelRuntime/AgentDeliveryTargetTool.cs rename to src/Aevatar.AI.ToolProviders.AgentCatalog/AgentDeliveryTargetTool.cs index effa80fc1..753568b43 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/AgentDeliveryTargetTool.cs +++ b/src/Aevatar.AI.ToolProviders.AgentCatalog/AgentDeliveryTargetTool.cs @@ -2,11 +2,10 @@ using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.AI.Abstractions.ToolProviders; using Aevatar.AI.ToolProviders.NyxId; -using Aevatar.Foundation.Abstractions; -using Google.Protobuf.WellKnownTypes; +using Aevatar.GAgents.Scheduled; using Microsoft.Extensions.DependencyInjection; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.AI.ToolProviders.AgentCatalog; /// /// Tool for managing agent outbound delivery targets used by workflow human interaction cards. @@ -14,20 +13,10 @@ namespace Aevatar.GAgents.ChannelRuntime; public sealed class AgentDeliveryTargetTool : IAgentTool { private readonly IServiceProvider _serviceProvider; - // Per-instance polling budget (see ProjectionWaitDefaults). Tests inject - // shrunk values via the constructor to exercise the budget-exhausted - // branch without burning the production 15 s. - private readonly int _projectionWaitAttempts; - private readonly int _projectionWaitDelayMilliseconds; - - public AgentDeliveryTargetTool( - IServiceProvider serviceProvider, - int projectionWaitAttempts = ProjectionWaitDefaults.Attempts, - int projectionWaitDelayMilliseconds = ProjectionWaitDefaults.DelayMilliseconds) + + public AgentDeliveryTargetTool(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; - _projectionWaitAttempts = projectionWaitAttempts; - _projectionWaitDelayMilliseconds = projectionWaitDelayMilliseconds; } public string Name => "agent_delivery_targets"; @@ -85,21 +74,28 @@ public async Task ExecuteAsync(string argumentsJson, CancellationToken c return """{"error":"No NyxID access token available. User must be authenticated."}"""; var queryPort = _serviceProvider.GetService(); - var actorRuntime = _serviceProvider.GetService(); - if (queryPort is null || actorRuntime is null) - return """{"error":"Agent delivery target runtime not available. IUserAgentCatalogRuntimeQueryPort or IActorRuntime not registered in DI."}"""; + if (queryPort is null) + return """{"error":"Agent delivery target runtime not available. IUserAgentCatalogRuntimeQueryPort not registered in DI."}"""; using var doc = JsonDocument.Parse(argumentsJson); var root = doc.RootElement; var action = GetStr(root, "action") ?? "list"; - return action switch + if (action is "upsert" or "delete") { - "list" => await ListAsync(queryPort, token, ct), - "upsert" => await UpsertAsync(queryPort, actorRuntime, token, root, ct), - "delete" => await DeleteAsync(queryPort, actorRuntime, token, root, ct), - _ => await ListAsync(queryPort, token, ct), - }; + var commandPort = _serviceProvider.GetService(); + if (commandPort is null) + return """{"error":"Agent delivery target runtime not available. IUserAgentCatalogCommandPort not registered in DI."}"""; + + return action switch + { + "upsert" => await UpsertAsync(commandPort, token, root, ct), + "delete" => await DeleteAsync(queryPort, commandPort, token, root, ct), + _ => await ListAsync(queryPort, token, ct), + }; + } + + return await ListAsync(queryPort, token, ct); } private static string? GetStr(JsonElement el, params string[] properties) @@ -161,8 +157,7 @@ private async Task ListAsync(IUserAgentCatalogRuntimeQueryPort queryPort } private async Task UpsertAsync( - IUserAgentCatalogRuntimeQueryPort queryPort, - IActorRuntime actorRuntime, + IUserAgentCatalogCommandPort commandPort, string token, JsonElement args, CancellationToken ct) @@ -193,62 +188,19 @@ private async Task UpsertAsync( }); } - var projectionPort = _serviceProvider.GetService(); - if (projectionPort != null) - await projectionPort.EnsureProjectionForActorAsync(UserAgentCatalogGAgent.WellKnownId, ct); - - var versionBefore = await queryPort.GetStateVersionAsync(agentId.value!, ct) ?? -1; - - var actor = await actorRuntime.GetAsync(UserAgentCatalogGAgent.WellKnownId) - ?? await actorRuntime.CreateAsync(UserAgentCatalogGAgent.WellKnownId); - - var cmd = new UserAgentCatalogUpsertCommand - { - AgentId = agentId.value!, - Platform = platform, - ConversationId = conversationId.value!, - NyxProviderSlug = nyxProviderSlug.value!, - NyxApiKey = nyxApiKey.value!, - OwnerNyxUserId = ownerNyxUserId, - }; - - var envelope = new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(cmd), - Route = new EnvelopeRoute + var result = await commandPort.UpsertAsync( + new UserAgentCatalogUpsertCommand { - Direct = new DirectRoute { TargetActorId = actor.Id }, + AgentId = agentId.value!, + Platform = platform, + ConversationId = conversationId.value!, + NyxProviderSlug = nyxProviderSlug.value!, + NyxApiKey = nyxApiKey.value!, + OwnerNyxUserId = ownerNyxUserId, }, - }; - - await actor.HandleEventAsync(envelope); - - var confirmed = false; - for (var attempt = 0; attempt < _projectionWaitAttempts; attempt++) - { - if (attempt > 0) - await Task.Delay(_projectionWaitDelayMilliseconds, ct); - - var versionAfter = await queryPort.GetStateVersionAsync(agentId.value!, ct) ?? -1; - if (versionAfter <= versionBefore) - continue; - - var after = await queryPort.GetAsync(agentId.value!, ct); - if (after == null) - continue; - - if (string.Equals(after.Platform, platform, StringComparison.OrdinalIgnoreCase) && - string.Equals(after.ConversationId, conversationId.value, StringComparison.Ordinal) && - string.Equals(after.NyxProviderSlug, nyxProviderSlug.value, StringComparison.Ordinal) && - string.Equals(after.NyxApiKey, nyxApiKey.value, StringComparison.Ordinal)) - { - confirmed = true; - break; - } - } + ct); + var confirmed = result.Outcome == CatalogCommandOutcome.Observed; return JsonSerializer.Serialize(new { status = confirmed ? "upserted" : "accepted", @@ -265,7 +217,7 @@ private async Task UpsertAsync( private async Task DeleteAsync( IUserAgentCatalogRuntimeQueryPort queryPort, - IActorRuntime actorRuntime, + IUserAgentCatalogCommandPort commandPort, string token, JsonElement args, CancellationToken ct) @@ -299,62 +251,11 @@ private async Task DeleteAsync( }); } - // Capture version + ensure projection scope is alive (matches the Upsert path - // above). Without priming, an idle-deactivated projection grain leaves the - // tombstone enqueued with no consumer and the document persists indefinitely. - var versionBefore = await queryPort.GetStateVersionAsync(agentId, ct) ?? -1; - - var projectionPort = _serviceProvider.GetService(); - if (projectionPort != null) - await projectionPort.EnsureProjectionForActorAsync(UserAgentCatalogGAgent.WellKnownId, ct); - - var actor = await actorRuntime.GetAsync(UserAgentCatalogGAgent.WellKnownId) - ?? await actorRuntime.CreateAsync(UserAgentCatalogGAgent.WellKnownId); - - var envelope = new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(new UserAgentCatalogTombstoneCommand - { - AgentId = agentId, - }), - Route = new EnvelopeRoute - { - Direct = new DirectRoute { TargetActorId = actor.Id }, - }, - }; - - await actor.HandleEventAsync(envelope); - - // Tombstone triggers IProjectionWriteDispatcher.DeleteAsync (Channel RFC §7.1.1), - // which also removes the document's projected StateVersion. Gate confirmation - // on either document absence or a state-version advance that materializes the - // delete — the prior absence-only check returned false negatives whenever the - // 5 s budget lost the race to projection lag. - var confirmed = false; - for (var attempt = 0; attempt < _projectionWaitAttempts; attempt++) - { - if (attempt > 0) - await Task.Delay(_projectionWaitDelayMilliseconds, ct); - - var versionAfter = await queryPort.GetStateVersionAsync(agentId, ct); - if (versionAfter == null) - { - confirmed = true; - break; - } - - if (versionAfter.Value <= versionBefore) - continue; - - if (await queryPort.GetAsync(agentId, ct) == null) - { - confirmed = true; - break; - } - } + var result = await commandPort.TombstoneAsync(agentId, ct); + if (result.Outcome == CatalogCommandOutcome.NotFound) + return JsonSerializer.Serialize(new { error = $"Delivery target '{agentId}' not found" }); + var confirmed = result.Outcome == CatalogCommandOutcome.Observed; return JsonSerializer.Serialize(new { status = confirmed ? "deleted" : "accepted", diff --git a/agents/Aevatar.GAgents.ChannelRuntime/AgentDeliveryTargetToolSource.cs b/src/Aevatar.AI.ToolProviders.AgentCatalog/AgentDeliveryTargetToolSource.cs similarity index 91% rename from agents/Aevatar.GAgents.ChannelRuntime/AgentDeliveryTargetToolSource.cs rename to src/Aevatar.AI.ToolProviders.AgentCatalog/AgentDeliveryTargetToolSource.cs index 177edff50..548421791 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/AgentDeliveryTargetToolSource.cs +++ b/src/Aevatar.AI.ToolProviders.AgentCatalog/AgentDeliveryTargetToolSource.cs @@ -1,6 +1,6 @@ using Aevatar.AI.Abstractions.ToolProviders; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.AI.ToolProviders.AgentCatalog; public sealed class AgentDeliveryTargetToolSource : IAgentToolSource { diff --git a/src/Aevatar.AI.ToolProviders.AgentCatalog/ServiceCollectionExtensions.cs b/src/Aevatar.AI.ToolProviders.AgentCatalog/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..5421e6655 --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.AgentCatalog/ServiceCollectionExtensions.cs @@ -0,0 +1,24 @@ +using Aevatar.AI.Abstractions.ToolProviders; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aevatar.AI.ToolProviders.AgentCatalog; + +/// +/// DI registration entry point for the agent-catalog tool provider. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers the agent-delivery-target tool source so LLM turns can resolve the + /// catalog of user-owned agents available as delivery targets. + /// + public static IServiceCollection AddAgentCatalogTools(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + return services; + } +} diff --git a/src/Aevatar.AI.ToolProviders.ChannelAdmin/Aevatar.AI.ToolProviders.ChannelAdmin.csproj b/src/Aevatar.AI.ToolProviders.ChannelAdmin/Aevatar.AI.ToolProviders.ChannelAdmin.csproj new file mode 100644 index 000000000..46763bad1 --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.ChannelAdmin/Aevatar.AI.ToolProviders.ChannelAdmin.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.AI.ToolProviders.ChannelAdmin + Aevatar.AI.ToolProviders.ChannelAdmin + + + + + + + + + + + + + + + + + diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelRegistrationTool.cs b/src/Aevatar.AI.ToolProviders.ChannelAdmin/ChannelRegistrationTool.cs similarity index 99% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelRegistrationTool.cs rename to src/Aevatar.AI.ToolProviders.ChannelAdmin/ChannelRegistrationTool.cs index 94f4808b3..25c8f7606 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelRegistrationTool.cs +++ b/src/Aevatar.AI.ToolProviders.ChannelAdmin/ChannelRegistrationTool.cs @@ -2,10 +2,12 @@ using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.AI.Abstractions.ToolProviders; using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.Channel.NyxIdRelay; +using Aevatar.GAgents.Channel.Runtime; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.DependencyInjection; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.AI.ToolProviders.ChannelAdmin; /// /// Tool for NyxID chat to manage ChannelRuntime registrations. diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelRegistrationToolSource.cs b/src/Aevatar.AI.ToolProviders.ChannelAdmin/ChannelRegistrationToolSource.cs similarity index 95% rename from agents/Aevatar.GAgents.ChannelRuntime/ChannelRegistrationToolSource.cs rename to src/Aevatar.AI.ToolProviders.ChannelAdmin/ChannelRegistrationToolSource.cs index eb8d1e6bd..81b5a70a0 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelRegistrationToolSource.cs +++ b/src/Aevatar.AI.ToolProviders.ChannelAdmin/ChannelRegistrationToolSource.cs @@ -1,6 +1,6 @@ using Aevatar.AI.Abstractions.ToolProviders; -namespace Aevatar.GAgents.ChannelRuntime; +namespace Aevatar.AI.ToolProviders.ChannelAdmin; /// /// Tool source that exposes channel_registrations tool to NyxIdChatGAgent. diff --git a/src/Aevatar.AI.ToolProviders.ChannelAdmin/ServiceCollectionExtensions.cs b/src/Aevatar.AI.ToolProviders.ChannelAdmin/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..644c72ea0 --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.ChannelAdmin/ServiceCollectionExtensions.cs @@ -0,0 +1,24 @@ +using Aevatar.AI.Abstractions.ToolProviders; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aevatar.AI.ToolProviders.ChannelAdmin; + +/// +/// DI registration entry point for the channel-admin tool provider. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers the channel-admin tool source so LLM turns can manage channel + /// registrations (create, list, delete) through agent-facing tools. + /// + public static IServiceCollection AddChannelAdminTools(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + return services; + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj index c99c14581..d4a779fe8 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj @@ -11,6 +11,8 @@ + + diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ElasticsearchProjectionConfiguration.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ElasticsearchProjectionConfiguration.cs new file mode 100644 index 000000000..a6091be9b --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ElasticsearchProjectionConfiguration.cs @@ -0,0 +1,98 @@ +using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; + +/// +/// Shared configuration helpers for projection-store SCEs that pick between +/// the Elasticsearch and InMemory document stores at startup. Hosts an +/// Enabled auto-detection rule (explicit flag → endpoints presence → +/// false) plus a typed +/// binder, so individual agent packages don't each copy the same +/// ResolveElasticsearchEnabled / BuildElasticsearchOptions +/// pair into their service-collection extensions. +/// +public static class ElasticsearchProjectionConfiguration +{ + public const string SectionPath = "Projection:Document:Providers:Elasticsearch"; + + /// + /// Returns true when Elasticsearch is the projection store. Honors an + /// explicit Enabled flag; otherwise auto-detects from endpoints + /// presence. When is null (unit-test + /// composition), returns false so the caller falls back to InMemory. + /// + /// Host configuration root, or null. + /// + /// Optional logger; receives a single warning when a configuration is + /// provided but neither the explicit flag nor any endpoint is set + /// (production misconfiguration). When null the warning falls back to + /// so SCE composition (which has no DI-built + /// logger yet) still surfaces the regression in startup output. + /// + /// + /// Caller-supplied identifier for the warning text (e.g. "ChannelRuntime", + /// "DeviceRegistration"). Helps operators trace which projection slice + /// degraded to InMemory. + /// + public static bool IsEnabled( + IConfiguration? configuration, + ILogger? logger = null, + string? storeName = null) + { + if (configuration is null) + return false; + + var section = configuration.GetSection(SectionPath); + var explicitEnabled = section["Enabled"]; + if (!string.IsNullOrWhiteSpace(explicitEnabled)) + return string.Equals(explicitEnabled.Trim(), "true", StringComparison.OrdinalIgnoreCase); + + var hasEndpoints = section.GetSection("Endpoints").GetChildren() + .Any(static x => !string.IsNullOrWhiteSpace(x.Value)); + + if (!hasEndpoints) + { + // Configuration is wired but ES is silent — production misconfiguration. + // Warn so operators can spot the regression in startup logs instead of + // discovering it after the next restart wipes the InMemory replica. + // SCEs run before the host builds its logger pipeline, so they pass a + // null logger and we route the warning to Console.Error (matching the + // pre-helper Console.Error.WriteLine behavior). Tests that wire a mock + // logger receive the structured-log call instead. + var resolvedStoreName = storeName ?? "ProjectionStore"; + if (logger is not null) + { + logger.LogWarning( + "{StoreName}: Elasticsearch is not configured ({Section}:Endpoints empty). " + + "Falling back to volatile InMemory projection store. Set {Section}:Enabled=true " + + "or populate Endpoints for production.", + resolvedStoreName, + SectionPath, + SectionPath); + } + else + { + Console.Error.WriteLine( + $"{resolvedStoreName}: Elasticsearch is not configured ({SectionPath}:Endpoints empty). " + + $"Falling back to volatile InMemory projection store. Set {SectionPath}:Enabled=true " + + $"or populate Endpoints for production."); + } + } + + return hasEndpoints; + } + + /// + /// Binds the typed Elasticsearch projection-store options from the + /// host configuration. Caller is responsible for non-null configuration. + /// + public static ElasticsearchProjectionDocumentStoreOptions BindOptions(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + var options = new ElasticsearchProjectionDocumentStoreOptions(); + configuration.GetSection(SectionPath).Bind(options); + return options; + } +} 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 61bcf49a2..3d89e0bca 100644 --- a/src/Aevatar.Mainnet.Host.Api/Aevatar.Mainnet.Host.Api.csproj +++ b/src/Aevatar.Mainnet.Host.Api/Aevatar.Mainnet.Host.Api.csproj @@ -16,7 +16,17 @@ - + + + + + + + + + + + diff --git a/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs b/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs index 91076e33e..62bc22eca 100644 --- a/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs +++ b/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs @@ -1,3 +1,6 @@ +using Aevatar.AI.ToolProviders.AgentCatalog; +using Aevatar.AI.ToolProviders.Channel; +using Aevatar.AI.ToolProviders.ChannelAdmin; using Aevatar.AI.ToolProviders.ChronoStorage; using Aevatar.AI.ToolProviders.Lark; using Aevatar.AI.ToolProviders.NyxId; @@ -6,9 +9,15 @@ using Aevatar.Authentication.Providers.NyxId; using Aevatar.Bootstrap.Hosting; using Aevatar.GAgentService.Hosting.Endpoints; -using Aevatar.GAgents.ChannelRuntime; +using Aevatar.GAgents.Authoring.Lark; +using Aevatar.GAgents.Channel.NyxIdRelay; +using Aevatar.GAgents.Channel.Runtime; using Aevatar.GAgents.ChatbotClassifier; +using Aevatar.GAgents.Device; using Aevatar.GAgents.NyxidChat; +using Aevatar.GAgents.Platform.Lark; +using Aevatar.GAgents.Platform.Telegram; +using Aevatar.GAgents.Scheduled; using Aevatar.GAgents.StreamingProxy; using Aevatar.Studio.Hosting; using Aevatar.Workflow.Extensions.Hosting; @@ -58,6 +67,15 @@ public static WebApplicationBuilder AddAevatarMainnetHost( builder.Services.AddStreamingProxy(builder.Configuration); builder.Services.AddChatbotClassifier(); builder.Services.AddChannelRuntime(builder.Configuration); + builder.Services.AddDeviceRegistration(builder.Configuration); + builder.Services.AddScheduledAgents(builder.Configuration); + builder.Services.AddLarkAgentAuthoring(); + builder.Services.AddNyxIdRelayChannel(); + builder.Services.AddLarkPlatform(); + builder.Services.AddTelegramPlatform(); + builder.Services.AddChannelInteractiveReplyTools(); + builder.Services.AddChannelAdminTools(); + builder.Services.AddAgentCatalogTools(); builder.Services.Configure( builder.Configuration.GetSection("Aevatar:DeviceEvents")); builder.Services.AddNyxIdTools(o => diff --git a/src/Aevatar.Studio.Projection/Orchestration/StudioProjectionPort.cs b/src/Aevatar.Studio.Projection/Orchestration/StudioProjectionPort.cs index ca432c99b..0cc57b284 100644 --- a/src/Aevatar.Studio.Projection/Orchestration/StudioProjectionPort.cs +++ b/src/Aevatar.Studio.Projection/Orchestration/StudioProjectionPort.cs @@ -14,7 +14,7 @@ namespace Aevatar.Studio.Projection.Orchestration; /// /// Mirror of /// Aevatar.GAgentService.Governance.Projection.Orchestration.ServiceConfigurationProjectionPort -/// and Aevatar.GAgents.ChannelRuntime.ChannelBotRegistrationProjectionPort, +/// and Aevatar.GAgents.Channel.Runtime.ChannelBotRegistrationProjectionPort, /// applied here to the Studio runtime lease. /// public sealed class StudioProjectionPort diff --git a/test/Aevatar.Foundation.Runtime.Hosting.Tests/Aevatar.Foundation.Runtime.Hosting.Tests.csproj b/test/Aevatar.Foundation.Runtime.Hosting.Tests/Aevatar.Foundation.Runtime.Hosting.Tests.csproj index 682510246..0f3d00918 100644 --- a/test/Aevatar.Foundation.Runtime.Hosting.Tests/Aevatar.Foundation.Runtime.Hosting.Tests.csproj +++ b/test/Aevatar.Foundation.Runtime.Hosting.Tests/Aevatar.Foundation.Runtime.Hosting.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/test/Aevatar.Foundation.Runtime.Hosting.Tests/RuntimeActorGrainStateStoreTests.cs b/test/Aevatar.Foundation.Runtime.Hosting.Tests/RuntimeActorGrainStateStoreTests.cs index a6c1b5c0b..edceb7c9e 100644 --- a/test/Aevatar.Foundation.Runtime.Hosting.Tests/RuntimeActorGrainStateStoreTests.cs +++ b/test/Aevatar.Foundation.Runtime.Hosting.Tests/RuntimeActorGrainStateStoreTests.cs @@ -1,5 +1,5 @@ using System.Reflection; -using Aevatar.GAgents.ChannelRuntime; +using Aevatar.GAgents.Scheduled; using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Abstractions.Persistence; using Aevatar.Foundation.Runtime.Implementations.Orleans.DependencyInjection; diff --git a/test/Aevatar.GAgents.Channel.Protocol.Tests/Aevatar.GAgents.Channel.Protocol.Tests.csproj b/test/Aevatar.GAgents.Channel.Protocol.Tests/Aevatar.GAgents.Channel.Protocol.Tests.csproj index ac4b4b4dc..e1bf2b939 100644 --- a/test/Aevatar.GAgents.Channel.Protocol.Tests/Aevatar.GAgents.Channel.Protocol.Tests.csproj +++ b/test/Aevatar.GAgents.Channel.Protocol.Tests/Aevatar.GAgents.Channel.Protocol.Tests.csproj @@ -26,6 +26,8 @@ + + diff --git a/test/Aevatar.GAgents.Channel.Protocol.Tests/ChannelRuntimeProtoTests.cs b/test/Aevatar.GAgents.Channel.Protocol.Tests/ChannelRuntimeProtoTests.cs index 529f23e38..b4d0108ec 100644 --- a/test/Aevatar.GAgents.Channel.Protocol.Tests/ChannelRuntimeProtoTests.cs +++ b/test/Aevatar.GAgents.Channel.Protocol.Tests/ChannelRuntimeProtoTests.cs @@ -1,6 +1,7 @@ using System.Linq; using Aevatar.GAgents.Channel.Abstractions; using Aevatar.GAgents.Channel.Runtime; +using Aevatar.GAgents.Scheduled; using Google.Protobuf; using Google.Protobuf.Reflection; using Google.Protobuf.WellKnownTypes; @@ -75,28 +76,8 @@ public void RuntimeReflections_ShouldExposeChannelRuntimeSchemaMessages() ReplyMessageId = "relay-msg-1", }, }; - var registration = new ChannelBotRegistrationEntry - { - TransportBinding = new ChannelTransportBinding - { - Bot = new ChannelBotDescriptor - { - RegistrationId = "bot-reg-1", - Bot = new BotInstanceId { Value = "ops-bot" }, - Channel = new ChannelId { Value = "slack" }, - ScopeId = "scope-1", - }, - VerificationToken = "verify-me", - }, - WebhookUrl = "https://example.test/callback", - CreatedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - IsDeleted = true, - }; - completed.Clone().ShouldBe(completed); completed.OutboundDelivery.ReplyMessageId.ShouldBe("relay-msg-1"); - registration.Clone().ShouldBe(registration); - registration.TransportBinding.Bot.RegistrationId.ShouldBe("bot-reg-1"); var llmRequested = new NeedsLlmReplyEvent { CorrelationId = "activity-1", @@ -120,8 +101,6 @@ public void RuntimeReflections_ShouldExposeChannelRuntimeSchemaMessages() TerminalState = LlmReplyTerminalState.Completed, ReadyAtUnixMs = 43, }; - ChannelBotRegistrationEntry.Descriptor.FindFieldByName("transport_binding")!.FieldNumber.ShouldBe(1); - ChannelBotRegistrationEntry.Descriptor.FindFieldByName("is_deleted")!.FieldNumber.ShouldBe(4); ConversationEventsReflection.Descriptor.MessageTypes.Select(x => x.Name) .ShouldContain(nameof(ConversationTurnCompletedEvent)); ConversationEventsReflection.Descriptor.MessageTypes.Select(x => x.Name) @@ -130,9 +109,9 @@ public void RuntimeReflections_ShouldExposeChannelRuntimeSchemaMessages() .ShouldContain(nameof(NeedsLlmReplyEvent)); ConversationEventsReflection.Descriptor.MessageTypes.Select(x => x.Name) .ShouldContain(nameof(LlmReplyReadyEvent)); - ConversationEventsReflection.Descriptor.MessageTypes.Select(x => x.Name) + ChannelBotRegistrationReflection.Descriptor.MessageTypes.Select(x => x.Name) .ShouldContain(nameof(ChannelBotRegistrationEntry)); - ConversationEventsReflection.Descriptor.MessageTypes.Select(x => x.Name) + UserAgentCatalogReflection.Descriptor.MessageTypes.Select(x => x.Name) .ShouldContain(nameof(UserAgentCatalogEntry)); SessionStoreReflection.Descriptor.MessageTypes.Select(x => x.Name) .ShouldContain(nameof(LeaseToken)); diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/Aevatar.GAgents.ChannelRuntime.Tests.csproj b/test/Aevatar.GAgents.ChannelRuntime.Tests/Aevatar.GAgents.ChannelRuntime.Tests.csproj index 718da72b9..ad5fafa7a 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/Aevatar.GAgents.ChannelRuntime.Tests.csproj +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/Aevatar.GAgents.ChannelRuntime.Tests.csproj @@ -9,12 +9,20 @@ Aevatar.GAgents.ChannelRuntime.Tests - - + - + + + + + + + + + + diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardContentTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardContentTests.cs index 27c5da163..ccfbec113 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardContentTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardContentTests.cs @@ -2,6 +2,7 @@ using Aevatar.GAgents.Channel.Abstractions; using FluentAssertions; using Xunit; +using Aevatar.GAgents.Authoring.Lark; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardFlowTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardFlowTests.cs index 21ecfffe8..60fc863b2 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardFlowTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardFlowTests.cs @@ -4,6 +4,8 @@ using Aevatar.Studio.Application.Studio.Abstractions; using FluentAssertions; using Xunit; +using Aevatar.GAgents.Authoring.Lark; +using Aevatar.GAgents.Channel.Runtime; using StudioUserConfig = Aevatar.Studio.Application.Studio.Abstractions.UserConfig; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderToolTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderToolTests.cs index 34f3ae433..01c1d1a94 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderToolTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderToolTests.cs @@ -14,6 +14,9 @@ using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; +using Aevatar.GAgents.Authoring.Lark; +using Aevatar.GAgents.Channel.Runtime; +using Aevatar.GAgents.Scheduled; using StudioUserConfig = Aevatar.Studio.Application.Studio.Abstractions.UserConfig; namespace Aevatar.GAgents.ChannelRuntime.Tests; @@ -59,7 +62,9 @@ public async Task ExecuteAsync_CreateAgent_RejectsGroupChats() { var services = new ServiceCollection(); services.AddSingleton(Substitute.For()); - services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); services.AddSingleton(new NyxIdApiClient( new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, new HttpClient(new RoutingJsonHandler()) @@ -108,13 +113,9 @@ public async Task ExecuteAsync_CreateAgent_DispatchesInitializeAndImmediateTrigg Status = SkillRunnerDefaults.StatusRunning, })); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-1"); - - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-1").Returns(Task.FromResult(null)); - actorRuntime.CreateAsync("skill-runner-1", Arg.Any()) - .Returns(Task.FromResult(skillRunnerActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var handler = new RoutingJsonHandler(); handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); @@ -148,7 +149,9 @@ public async Task ExecuteAsync_CreateAgent_DispatchesInitializeAndImmediateTrigg var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(nyxClient); var tool = new AgentBuilderTool(services.BuildServiceProvider()); @@ -183,30 +186,23 @@ public async Task ExecuteAsync_CreateAgent_DispatchesInitializeAndImmediateTrigg doc.RootElement.GetProperty("run_immediately_requested").GetBoolean().Should().BeTrue(); doc.RootElement.GetProperty("github_username_preference_saved").GetBoolean().Should().BeFalse(); - await skillRunnerActor.Received(1).HandleEventAsync( - Arg.Is(e => - e.Payload != null && - e.Payload.Is(InitializeSkillRunnerCommand.Descriptor) && - e.Payload.Unpack().TemplateName == "daily_report" && - e.Payload.Unpack().ScopeId == "scope-1" && - e.Payload.Unpack().OutboundConfig.ConversationId == "oc_chat_1" && - e.Payload.Unpack().OutboundConfig.NyxProviderSlug == "api-lark-bot" && - e.Payload.Unpack().OutboundConfig.NyxApiKey == "full-key-1" && - e.Payload.Unpack().OutboundConfig.ApiKeyId == "key-1" && - e.Payload.Unpack().OutboundConfig.OwnerNyxUserId == "user-1" && + await skillRunnerPort.Received(1).InitializeAsync( + "skill-runner-1", + Arg.Is(c => + c.TemplateName == "daily_report" && + c.ScopeId == "scope-1" && + c.OutboundConfig.ConversationId == "oc_chat_1" && + c.OutboundConfig.NyxProviderSlug == "api-lark-bot" && + c.OutboundConfig.NyxApiKey == "full-key-1" && + c.OutboundConfig.ApiKeyId == "key-1" && + c.OutboundConfig.OwnerNyxUserId == "user-1" && // p2p inbound without LarkUnionId in the request context falls back to the // sender open_id. Lark accepts this only when the relay-side and outbound // apps match; cross-app deployments must populate LarkUnionId at ingress // (see test below) to avoid `code:99992361 open_id cross app` rejections. - e.Payload.Unpack().OutboundConfig.LarkReceiveId == "ou_user_1" && - e.Payload.Unpack().OutboundConfig.LarkReceiveIdType == "open_id"), - Arg.Any()); - - await skillRunnerActor.Received(1).HandleEventAsync( - Arg.Is(e => - e.Payload != null && - e.Payload.Is(TriggerSkillRunnerExecutionCommand.Descriptor) && - e.Payload.Unpack().Reason == "create_agent"), + c.OutboundConfig.LarkReceiveId == "ou_user_1" && + c.OutboundConfig.LarkReceiveIdType == "open_id"), + true, Arg.Any()); var apiKeyRequest = handler.Requests.Should() @@ -250,13 +246,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_PinsLarkChatId_When_Relay Status = SkillRunnerDefaults.StatusRunning, })); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-union-1"); - - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-union-1").Returns(Task.FromResult(null)); - actorRuntime.CreateAsync("skill-runner-union-1", Arg.Any()) - .Returns(Task.FromResult(skillRunnerActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var handler = new RoutingJsonHandler(); handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); @@ -290,7 +282,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_PinsLarkChatId_When_Relay var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(nyxClient); var tool = new AgentBuilderTool(services.BuildServiceProvider()); @@ -320,12 +314,12 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_PinsLarkChatId_When_Relay using var doc = JsonDocument.Parse(result); doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - await skillRunnerActor.Received(1).HandleEventAsync( - Arg.Is(e => - e.Payload != null && - e.Payload.Is(InitializeSkillRunnerCommand.Descriptor) && - e.Payload.Unpack().OutboundConfig.LarkReceiveId == "oc_dm_chat_1" && - e.Payload.Unpack().OutboundConfig.LarkReceiveIdType == "chat_id"), + await skillRunnerPort.Received(1).InitializeAsync( + "skill-runner-union-1", + Arg.Is(c => + c.OutboundConfig.LarkReceiveId == "oc_dm_chat_1" && + c.OutboundConfig.LarkReceiveIdType == "chat_id"), + Arg.Any(), Arg.Any()); } finally @@ -353,12 +347,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_GithubPr queryPort.GetStateVersionAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(null)); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-github-403"); - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-github-403").Returns(Task.FromResult(null)); - actorRuntime.CreateAsync("skill-runner-github-403", Arg.Any()) - .Returns(Task.FromResult(skillRunnerActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var handler = new RoutingJsonHandler(); handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); @@ -404,7 +395,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_GithubPr var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(nyxClient); var tool = new AgentBuilderTool(services.BuildServiceProvider()); @@ -439,10 +432,12 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_GithubPr // is "hint mentions re-authorization", not "hint matches one specific prefix". doc.RootElement.GetProperty("hint").GetString()!.ToLowerInvariant().Should().Contain("re-authorize"); - // The actor must NOT receive InitializeSkillRunnerCommand — preflight aborts - // BEFORE the actor is invoked so we don't leave a broken agent in the catalog. - await skillRunnerActor.DidNotReceive().HandleEventAsync( - Arg.Any(), + // The port must NOT be invoked — preflight aborts BEFORE the lifecycle + // dispatch so we don't leave a broken agent in the catalog. + await skillRunnerPort.DidNotReceive().InitializeAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()); // Codex review (PR #418 r3141846175): even though the api-key carries the right @@ -482,13 +477,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_LogsFallbackBreadcrumb_Wh Status = SkillRunnerDefaults.StatusRunning, })); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-fallback-1"); - - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-fallback-1").Returns(Task.FromResult(null)); - actorRuntime.CreateAsync("skill-runner-fallback-1", Arg.Any()) - .Returns(Task.FromResult(skillRunnerActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var handler = new RoutingJsonHandler(); handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); @@ -522,7 +513,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_LogsFallbackBreadcrumb_Wh var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(nyxClient); var logger = new ListLogger(); @@ -591,13 +584,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_DoesNotLogFallback_When_L Status = SkillRunnerDefaults.StatusRunning, })); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-no-fallback-1"); - - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-no-fallback-1").Returns(Task.FromResult(null)); - actorRuntime.CreateAsync("skill-runner-no-fallback-1", Arg.Any()) - .Returns(Task.FromResult(skillRunnerActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var handler = new RoutingJsonHandler(); handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); @@ -630,7 +619,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_DoesNotLogFallback_When_L var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(nyxClient); var logger = new ListLogger(); @@ -683,13 +674,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_UsesSavedGithubUsernamePr Status = SkillRunnerDefaults.StatusRunning, })); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-pref-1"); - - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-pref-1").Returns(Task.FromResult(null)); - actorRuntime.CreateAsync("skill-runner-pref-1", Arg.Any()) - .Returns(Task.FromResult(skillRunnerActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); // Issue #436 PR #438 review: pin that the no-username `/daily` relay path reads the // saved github_username from the per-end-user composite scope, not the bot's @@ -736,7 +723,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_UsesSavedGithubUsernamePr var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(userConfigQueryPort); services.AddSingleton(nyxClient); var tool = new AgentBuilderTool(services.BuildServiceProvider()); @@ -765,12 +754,12 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_UsesSavedGithubUsernamePr using var doc = JsonDocument.Parse(result); doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - await skillRunnerActor.Received(1).HandleEventAsync( - Arg.Is(e => - e.Payload != null && - e.Payload.Is(InitializeSkillRunnerCommand.Descriptor) && - e.Payload.Unpack().SkillContent.Contains("Primary GitHub username: saved-user", StringComparison.Ordinal) && - e.Payload.Unpack().ExecutionPrompt.Contains("saved-user", StringComparison.Ordinal)), + await skillRunnerPort.Received(1).InitializeAsync( + "skill-runner-pref-1", + Arg.Is(c => + c.SkillContent.Contains("Primary GitHub username: saved-user", StringComparison.Ordinal) && + c.ExecutionPrompt.Contains("saved-user", StringComparison.Ordinal)), + Arg.Any(), Arg.Any()); // Direct evidence the per-end-user scope is what reaches the query port. @@ -802,13 +791,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_DerivesGithubUsername_Fro Status = SkillRunnerDefaults.StatusRunning, })); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-derived-1"); - - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-derived-1").Returns(Task.FromResult(null)); - actorRuntime.CreateAsync("skill-runner-derived-1", Arg.Any()) - .Returns(Task.FromResult(skillRunnerActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var userConfigQueryPort = Substitute.For(); userConfigQueryPort.GetAsync("scope-1", Arg.Any()) @@ -846,7 +831,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_DerivesGithubUsername_Fro var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(userConfigQueryPort); services.AddSingleton(nyxClient); var tool = new AgentBuilderTool(services.BuildServiceProvider()); @@ -873,12 +860,12 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_DerivesGithubUsername_Fro using var doc = JsonDocument.Parse(result); doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - await skillRunnerActor.Received(1).HandleEventAsync( - Arg.Is(e => - e.Payload != null && - e.Payload.Is(InitializeSkillRunnerCommand.Descriptor) && - e.Payload.Unpack().SkillContent.Contains("Primary GitHub username: derived-user", StringComparison.Ordinal) && - e.Payload.Unpack().ExecutionPrompt.Contains("derived-user", StringComparison.Ordinal)), + await skillRunnerPort.Received(1).InitializeAsync( + "skill-runner-derived-1", + Arg.Is(c => + c.SkillContent.Contains("Primary GitHub username: derived-user", StringComparison.Ordinal) && + c.ExecutionPrompt.Contains("derived-user", StringComparison.Ordinal)), + Arg.Any(), Arg.Any()); handler.Requests.Should().Contain(x => x.Path == "/api/v1/proxy/s/api-github/user"); @@ -893,7 +880,9 @@ await skillRunnerActor.Received(1).HandleEventAsync( public async Task ExecuteAsync_CreateAgent_DailyReport_ReturnsCredentialsRequired_WhenUsernameCannotBeResolved() { var queryPort = Substitute.For(); - var actorRuntime = Substitute.For(); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var userConfigQueryPort = Substitute.For(); userConfigQueryPort.GetAsync("scope-1", Arg.Any()) .Returns(Task.FromResult(new StudioUserConfig(string.Empty))); @@ -928,7 +917,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_ReturnsCredentialsRequire var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(userConfigQueryPort); services.AddSingleton(nyxClient); var tool = new AgentBuilderTool(services.BuildServiceProvider()); @@ -956,7 +947,11 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_ReturnsCredentialsRequire doc.RootElement.GetProperty("authorization_url").GetString().Should().Be("https://github.example.com/oauth/start"); doc.RootElement.GetProperty("note").GetString().Should().Contain("run /daily again"); - await actorRuntime.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + await skillRunnerPort.DidNotReceive().InitializeAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); } finally { @@ -979,13 +974,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_SavesGithubUsernamePrefer Status = SkillRunnerDefaults.StatusRunning, })); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-save-1"); - - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-save-1").Returns(Task.FromResult(null)); - actorRuntime.CreateAsync("skill-runner-save-1", Arg.Any()) - .Returns(Task.FromResult(skillRunnerActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var userConfigCommandService = Substitute.For(); @@ -1020,7 +1011,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_SavesGithubUsernamePrefer var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(userConfigCommandService); services.AddSingleton(nyxClient); var tool = new AgentBuilderTool(services.BuildServiceProvider()); @@ -1071,7 +1064,9 @@ await userConfigCommandService.Received(1) public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_RequiredProxyServices_AreMissing() { var queryPort = Substitute.For(); - var actorRuntime = Substitute.For(); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var handler = new RoutingJsonHandler(); handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); @@ -1103,7 +1098,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_Required var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(nyxClient); var tool = new AgentBuilderTool(services.BuildServiceProvider()); @@ -1128,14 +1125,18 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_Required // #417: when a required slug has no UserService row, surface a structured // `service_not_connected` error naming the slug (was: free-text "Missing required - // Nyx proxy services" wrapped in `{error: "..."}`). The actor must NOT be created - // and no api-key request should fire. + // Nyx proxy services" wrapped in `{error: "..."}`). The lifecycle dispatch + // must NOT fire and no api-key request should fire. using var doc = JsonDocument.Parse(result); doc.RootElement.GetProperty("error").GetString().Should().Be("service_not_connected"); doc.RootElement.GetProperty("slug").GetString().Should().Be("api-github"); doc.RootElement.GetProperty("hint").GetString().Should().Contain("api-github"); handler.Requests.Should().NotContain(x => x.Method == HttpMethod.Post && x.Path == "/api/v1/api-keys"); - await actorRuntime.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + await skillRunnerPort.DidNotReceive().InitializeAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); } finally { @@ -1150,7 +1151,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_Required // `is_active: false`, surface `service_inactive` rather than persisting an api-key // that NyxID's enforcement will reject at proxy time. var queryPort = Substitute.For(); - var actorRuntime = Substitute.For(); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var handler = new RoutingJsonHandler(); handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); @@ -1176,7 +1179,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_Required var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(nyxClient); var tool = new AgentBuilderTool(services.BuildServiceProvider()); @@ -1218,7 +1223,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_OrgShare // call later as `org_role_insufficient`. Surface `service_org_viewer_only` so the // user knows to ask an admin or connect a personal credential. var queryPort = Substitute.For(); - var actorRuntime = Substitute.For(); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var handler = new RoutingJsonHandler(); handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); @@ -1244,7 +1251,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_OrgShare var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(nyxClient); var tool = new AgentBuilderTool(services.BuildServiceProvider()); @@ -1300,12 +1309,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_AllowedServiceIds_AreUser Status = SkillRunnerDefaults.StatusRunning, })); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-id-pin"); - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-id-pin").Returns(Task.FromResult(null)); - actorRuntime.CreateAsync("skill-runner-id-pin", Arg.Any()) - .Returns(Task.FromResult(skillRunnerActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var handler = new RoutingJsonHandler(); handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); @@ -1334,7 +1340,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_AllowedServiceIds_AreUser var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(nyxClient); var tool = new AgentBuilderTool(services.BuildServiceProvider()); @@ -1398,12 +1406,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_PicksEligibleRow_When_Dup Status = SkillRunnerDefaults.StatusRunning, })); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-dup"); - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-dup").Returns(Task.FromResult(null)); - actorRuntime.CreateAsync("skill-runner-dup", Arg.Any()) - .Returns(Task.FromResult(skillRunnerActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var handler = new RoutingJsonHandler(); handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); @@ -1437,7 +1442,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_PicksEligibleRow_When_Dup var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(nyxClient); var tool = new AgentBuilderTool(services.BuildServiceProvider()); @@ -1485,7 +1492,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_PicksEligibleRow_When_Dup public async Task ExecuteAsync_CreateAgent_DailyReport_ReturnsOAuthRequirementBeforeCreatingAgent() { var queryPort = Substitute.For(); - var actorRuntime = Substitute.For(); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var handler = new RoutingJsonHandler(); handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); @@ -1517,7 +1526,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_ReturnsOAuthRequirementBe var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(nyxClient); var tool = new AgentBuilderTool(services.BuildServiceProvider()); @@ -1548,7 +1559,11 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_ReturnsOAuthRequirementBe doc.RootElement.GetProperty("provider_id").GetString().Should().Be("provider-github"); doc.RootElement.GetProperty("authorization_url").GetString().Should().Be("https://github.example.com/oauth/start"); - await actorRuntime.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + await skillRunnerPort.DidNotReceive().InitializeAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); handler.Requests.Should().NotContain(x => x.Method == HttpMethod.Post && x.Path == "/api/v1/api-keys"); } finally @@ -1561,7 +1576,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_ReturnsOAuthRequirementBe public async Task ExecuteAsync_CreateAgent_DailyReport_ReturnsCredentialsRequirementBeforeOAuth() { var queryPort = Substitute.For(); - var actorRuntime = Substitute.For(); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var handler = new RoutingJsonHandler(); handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); @@ -1588,7 +1605,9 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_ReturnsCredentialsRequire var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(nyxClient); var tool = new AgentBuilderTool(services.BuildServiceProvider()); @@ -1621,7 +1640,11 @@ public async Task ExecuteAsync_CreateAgent_DailyReport_ReturnsCredentialsRequire handler.Requests.Should().NotContain(x => x.Path == "/api/v1/providers/provider-github/connect/oauth"); handler.Requests.Should().NotContain(x => x.Method == HttpMethod.Post && x.Path == "/api/v1/api-keys"); - await actorRuntime.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + await skillRunnerPort.DidNotReceive().InitializeAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); } finally { @@ -1644,13 +1667,9 @@ public async Task ExecuteAsync_CreateAgent_SocialMedia_UpsertsWorkflowAndInitial Status = WorkflowAgentDefaults.StatusRunning, })); - var workflowAgentActor = Substitute.For(); - workflowAgentActor.Id.Returns("workflow-agent-1"); - - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("workflow-agent-1").Returns(Task.FromResult(null)); - actorRuntime.CreateAsync("workflow-agent-1", Arg.Any()) - .Returns(Task.FromResult(workflowAgentActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var workflowCommandPort = Substitute.For(); workflowCommandPort.UpsertAsync(Arg.Any(), Arg.Any()) @@ -1670,15 +1689,6 @@ public async Task ExecuteAsync_CreateAgent_SocialMedia_UpsertsWorkflowAndInitial "workflow-actor-prefix", "workflow-actor-1"))); - var activationService = Substitute.For>(); - activationService.EnsureAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogMaterializationRuntimeLease(new UserAgentCatalogMaterializationContext - { - RootActorId = UserAgentCatalogGAgent.WellKnownId, - ProjectionKind = UserAgentCatalogProjectionPort.ProjectionKind, - }))); - var projectionPort = new UserAgentCatalogProjectionPort(activationService); - var handler = new RoutingJsonHandler(); handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); handler.Add(HttpMethod.Get, "/api/v1/user-services", """ @@ -1696,10 +1706,11 @@ public async Task ExecuteAsync_CreateAgent_SocialMedia_UpsertsWorkflowAndInitial var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(workflowCommandPort); services.AddSingleton(nyxClient); - services.AddSingleton(projectionPort); var tool = new AgentBuilderTool(services.BuildServiceProvider()); AgentToolRequestContext.CurrentMetadata = new Dictionary @@ -1742,32 +1753,25 @@ await workflowCommandPort.Received(1).UpsertAsync( request.WorkflowYaml.Contains("delivery_target_id: \"workflow-agent-1\"", StringComparison.Ordinal)), Arg.Any()); - await workflowAgentActor.Received(1).HandleEventAsync( - Arg.Is(e => - e.Payload != null && - e.Payload.Is(InitializeWorkflowAgentCommand.Descriptor) && - e.Payload.Unpack().WorkflowActorId == "workflow-actor-1" && - e.Payload.Unpack().ConversationId == "oc_chat_1" && - e.Payload.Unpack().NyxApiKey == "full-key-2" && - e.Payload.Unpack().ApiKeyId == "key-2" && + await workflowAgentPort.Received(1).InitializeAsync( + "workflow-agent-1", + Arg.Is(c => + c.WorkflowActorId == "workflow-actor-1" && + c.ConversationId == "oc_chat_1" && + c.NyxApiKey == "full-key-2" && + c.ApiKeyId == "key-2" && // Mirror of the daily_report p2p assertion: BuildFromInbound must pin the // sender open_id at delivery-target creation time so FeishuCardHumanInteraction // Port reads it through the catalog projection without re-deriving the type. - e.Payload.Unpack().LarkReceiveId == "ou_user_1" && - e.Payload.Unpack().LarkReceiveIdType == "open_id"), + c.LarkReceiveId == "ou_user_1" && + c.LarkReceiveIdType == "open_id"), + false, Arg.Any()); - await workflowAgentActor.Received(1).HandleEventAsync( - Arg.Is(e => - e.Payload != null && - e.Payload.Is(TriggerWorkflowAgentExecutionCommand.Descriptor) && - e.Payload.Unpack().Reason == "create_agent"), - Arg.Any()); - - await activationService.Received(1).EnsureAsync( - Arg.Is(request => - request.RootActorId == UserAgentCatalogGAgent.WellKnownId && - request.ProjectionKind == UserAgentCatalogProjectionPort.ProjectionKind), + await workflowAgentPort.Received(1).TriggerAsync( + "workflow-agent-1", + "create_agent", + null, Arg.Any()); var apiKeyRequest = handler.Requests.Should() @@ -1808,14 +1812,11 @@ public async Task ExecuteAsync_DeleteAgent_DisablesActor_RevokesApiKey_AndTombst queryPort.QueryAllAsync(Arg.Any()) .Returns(Task.FromResult>(Array.Empty())); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-1"); - var registryActor = Substitute.For(); - registryActor.Id.Returns(UserAgentCatalogGAgent.WellKnownId); - - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-1").Returns(Task.FromResult(skillRunnerActor)); - actorRuntime.GetAsync(UserAgentCatalogGAgent.WellKnownId).Returns(Task.FromResult(registryActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); + catalogCommandPort.TombstoneAsync("skill-runner-1", Arg.Any()) + .Returns(Task.FromResult(new UserAgentCatalogTombstoneResult(CatalogCommandOutcome.Observed))); var handler = new RoutingJsonHandler(); handler.Add(HttpMethod.Delete, "/api/v1/api-keys/key-1", """{"ok":true}"""); @@ -1826,7 +1827,9 @@ public async Task ExecuteAsync_DeleteAgent_DisablesActor_RevokesApiKey_AndTombst var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(nyxClient); var tool = new AgentBuilderTool(services.BuildServiceProvider()); @@ -1850,18 +1853,13 @@ public async Task ExecuteAsync_DeleteAgent_DisablesActor_RevokesApiKey_AndTombst doc.RootElement.GetProperty("agents").GetArrayLength().Should().Be(0); doc.RootElement.GetProperty("delete_notice").GetString().Should().Contain("Deleted agent"); - await skillRunnerActor.Received(1).HandleEventAsync( - Arg.Is(e => - e.Payload != null && - e.Payload.Is(DisableSkillRunnerCommand.Descriptor) && - e.Payload.Unpack().Reason == "delete_agent"), + await skillRunnerPort.Received(1).DisableAsync( + "skill-runner-1", + "delete_agent", Arg.Any()); - await registryActor.Received(1).HandleEventAsync( - Arg.Is(e => - e.Payload != null && - e.Payload.Is(UserAgentCatalogTombstoneCommand.Descriptor) && - e.Payload.Unpack().AgentId == "skill-runner-1"), + await catalogCommandPort.Received(1).TombstoneAsync( + "skill-runner-1", Arg.Any()); handler.Requests.Should().ContainSingle(x => @@ -1902,14 +1900,14 @@ public async Task ExecuteAsync_DeleteAgent_ReturnsAcceptedWithPropagatingHint_Wh .Returns(Task.FromResult>( [new UserAgentCatalogEntry { AgentId = "skill-runner-stuck", OwnerNyxUserId = "user-1" }])); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-stuck"); - var registryActor = Substitute.For(); - registryActor.Id.Returns(UserAgentCatalogGAgent.WellKnownId); - - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-stuck").Returns(Task.FromResult(skillRunnerActor)); - actorRuntime.GetAsync(UserAgentCatalogGAgent.WellKnownId).Returns(Task.FromResult(registryActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); + // Tombstone is dispatched but the projection has not yet caught up; the + // port surfaces an Accepted outcome and the tool reports the propagating + // notice so the user knows to re-check /agents. + catalogCommandPort.TombstoneAsync("skill-runner-stuck", Arg.Any()) + .Returns(Task.FromResult(new UserAgentCatalogTombstoneResult(CatalogCommandOutcome.Accepted))); var handler = new RoutingJsonHandler(); handler.Add(HttpMethod.Delete, "/api/v1/api-keys/key-stuck", """{"ok":true}"""); @@ -1919,7 +1917,9 @@ public async Task ExecuteAsync_DeleteAgent_ReturnsAcceptedWithPropagatingHint_Wh var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(nyxClient); // Inject a shrunk wait budget per-instance (3 attempts × 1 ms) so the // not-reflected branch fires in <100 ms instead of the production @@ -1957,11 +1957,8 @@ public async Task ExecuteAsync_DeleteAgent_ReturnsAcceptedWithPropagatingHint_Wh .Should().Contain("propagating") .And.Contain("/agents"); - await registryActor.Received(1).HandleEventAsync( - Arg.Is(e => - e.Payload != null && - e.Payload.Is(UserAgentCatalogTombstoneCommand.Descriptor) && - e.Payload.Unpack().AgentId == "skill-runner-stuck"), + await catalogCommandPort.Received(1).TombstoneAsync( + "skill-runner-stuck", Arg.Any()); } finally @@ -1982,15 +1979,15 @@ public async Task ExecuteAsync_RunAgent_DispatchesManualTrigger() TemplateName = "daily_report", })); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-1"); - - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-1").Returns(Task.FromResult(skillRunnerActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(new NyxIdApiClient( new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, new HttpClient(new RoutingJsonHandler()) @@ -2016,11 +2013,9 @@ public async Task ExecuteAsync_RunAgent_DispatchesManualTrigger() doc.RootElement.GetProperty("status").GetString().Should().Be("accepted"); doc.RootElement.GetProperty("agent_id").GetString().Should().Be("skill-runner-1"); - await skillRunnerActor.Received(1).HandleEventAsync( - Arg.Is(e => - e.Payload != null && - e.Payload.Is(TriggerSkillRunnerExecutionCommand.Descriptor) && - e.Payload.Unpack().Reason == "run_agent"), + await skillRunnerPort.Received(1).TriggerAsync( + "skill-runner-1", + "run_agent", Arg.Any()); } finally @@ -2042,15 +2037,15 @@ public async Task ExecuteAsync_RunAgent_RejectsDisabledAgent() Status = SkillRunnerDefaults.StatusDisabled, })); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-1"); - - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-1").Returns(Task.FromResult(skillRunnerActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(new NyxIdApiClient( new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, new HttpClient(new RoutingJsonHandler()) @@ -2073,7 +2068,10 @@ public async Task ExecuteAsync_RunAgent_RejectsDisabledAgent() """); result.Should().Contain("is disabled"); - await skillRunnerActor.DidNotReceive().HandleEventAsync(Arg.Any(), Arg.Any()); + await skillRunnerPort.DidNotReceive().TriggerAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); } finally { @@ -2094,15 +2092,15 @@ public async Task ExecuteAsync_RunAgent_DispatchesWorkflowTrigger() Status = WorkflowAgentDefaults.StatusRunning, })); - var workflowAgentActor = Substitute.For(); - workflowAgentActor.Id.Returns("workflow-agent-1"); - - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("workflow-agent-1").Returns(Task.FromResult(workflowAgentActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(new NyxIdApiClient( new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, new HttpClient(new RoutingJsonHandler()) @@ -2130,12 +2128,10 @@ public async Task ExecuteAsync_RunAgent_DispatchesWorkflowTrigger() doc.RootElement.GetProperty("agent_id").GetString().Should().Be("workflow-agent-1"); doc.RootElement.GetProperty("note").GetString().Should().Contain("revision feedback"); - await workflowAgentActor.Received(1).HandleEventAsync( - Arg.Is(e => - e.Payload != null && - e.Payload.Is(TriggerWorkflowAgentExecutionCommand.Descriptor) && - e.Payload.Unpack().Reason == "run_agent" && - e.Payload.Unpack().RevisionFeedback == "Need stronger hook"), + await workflowAgentPort.Received(1).TriggerAsync( + "workflow-agent-1", + "run_agent", + "Need stronger hook", Arg.Any()); } finally @@ -2193,14 +2189,15 @@ public async Task ExecuteAsync_DisableAgent_ReturnsStatusFast_WhenProjectionAdva Task.FromResult(42L), Task.FromResult(43L)); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-fast"); - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-fast").Returns(Task.FromResult(skillRunnerActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(new NyxIdApiClient( new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, new HttpClient(new RoutingJsonHandler()) @@ -2278,14 +2275,15 @@ public async Task ExecuteAsync_DisableAgent_KeepsWaitingWhenStatusMatchesButVers queryPort.GetStateVersionAsync("skill-runner-stale", Arg.Any()) .Returns(Task.FromResult(7L)); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-stale"); - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-stale").Returns(Task.FromResult(skillRunnerActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(new NyxIdApiClient( new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, new HttpClient(new RoutingJsonHandler()) @@ -2374,15 +2372,15 @@ public async Task ExecuteAsync_DisableAgent_DispatchesDisableAndReturnsStatus() Task.FromResult(5L), Task.FromResult(6L)); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-1"); - - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-1").Returns(Task.FromResult(skillRunnerActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(new NyxIdApiClient( new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, new HttpClient(new RoutingJsonHandler()) @@ -2408,11 +2406,9 @@ public async Task ExecuteAsync_DisableAgent_DispatchesDisableAndReturnsStatus() doc.RootElement.GetProperty("status").GetString().Should().Be(SkillRunnerDefaults.StatusDisabled); doc.RootElement.GetProperty("note").GetString().Should().Contain("Scheduling paused"); - await skillRunnerActor.Received(1).HandleEventAsync( - Arg.Is(e => - e.Payload != null && - e.Payload.Is(DisableSkillRunnerCommand.Descriptor) && - e.Payload.Unpack().Reason == "disable_agent"), + await skillRunnerPort.Received(1).DisableAsync( + "skill-runner-1", + "disable_agent", Arg.Any()); } finally @@ -2452,15 +2448,15 @@ public async Task ExecuteAsync_EnableAgent_DispatchesEnableAndReturnsStatus() Task.FromResult(5L), Task.FromResult(6L)); - var skillRunnerActor = Substitute.For(); - skillRunnerActor.Id.Returns("skill-runner-1"); - - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("skill-runner-1").Returns(Task.FromResult(skillRunnerActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(new NyxIdApiClient( new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, new HttpClient(new RoutingJsonHandler()) @@ -2486,11 +2482,9 @@ public async Task ExecuteAsync_EnableAgent_DispatchesEnableAndReturnsStatus() doc.RootElement.GetProperty("status").GetString().Should().Be(SkillRunnerDefaults.StatusRunning); doc.RootElement.GetProperty("note").GetString().Should().Contain("Scheduling resumed"); - await skillRunnerActor.Received(1).HandleEventAsync( - Arg.Is(e => - e.Payload != null && - e.Payload.Is(EnableSkillRunnerCommand.Descriptor) && - e.Payload.Unpack().Reason == "enable_agent"), + await skillRunnerPort.Received(1).EnableAsync( + "skill-runner-1", + "enable_agent", Arg.Any()); } finally @@ -2530,15 +2524,15 @@ public async Task ExecuteAsync_DisableAgent_DispatchesWorkflowDisableAndReturnsS Task.FromResult(5L), Task.FromResult(6L)); - var workflowAgentActor = Substitute.For(); - workflowAgentActor.Id.Returns("workflow-agent-1"); - - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync("workflow-agent-1").Returns(Task.FromResult(workflowAgentActor)); + var skillRunnerPort = Substitute.For(); + var workflowAgentPort = Substitute.For(); + var catalogCommandPort = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(skillRunnerPort); + services.AddSingleton(workflowAgentPort); + services.AddSingleton(catalogCommandPort); services.AddSingleton(new NyxIdApiClient( new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, new HttpClient(new RoutingJsonHandler()) @@ -2564,11 +2558,9 @@ public async Task ExecuteAsync_DisableAgent_DispatchesWorkflowDisableAndReturnsS doc.RootElement.GetProperty("status").GetString().Should().Be(WorkflowAgentDefaults.StatusDisabled); doc.RootElement.GetProperty("note").GetString().Should().Contain("Scheduling paused"); - await workflowAgentActor.Received(1).HandleEventAsync( - Arg.Is(e => - e.Payload != null && - e.Payload.Is(DisableWorkflowAgentCommand.Descriptor) && - e.Payload.Unpack().Reason == "disable_agent"), + await workflowAgentPort.Received(1).DisableAsync( + "workflow-agent-1", + "disable_agent", Arg.Any()); } finally diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentDeliveryTargetToolTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentDeliveryTargetToolTests.cs index d399361c8..af690b80e 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentDeliveryTargetToolTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentDeliveryTargetToolTests.cs @@ -10,6 +10,8 @@ using Microsoft.Extensions.DependencyInjection; using NSubstitute; using Xunit; +using Aevatar.AI.ToolProviders.AgentCatalog; +using Aevatar.GAgents.Scheduled; namespace Aevatar.GAgents.ChannelRuntime.Tests; @@ -88,7 +90,6 @@ public async Task ExecuteAsync_List_Masks_NyxApiKey() }, ])); - var actorRuntime = Substitute.For(); var httpClient = new HttpClient(new StaticJsonHandler("""{"user":{"id":"user-1"}}""")) { BaseAddress = new Uri("https://nyx.example.com"), @@ -96,7 +97,6 @@ public async Task ExecuteAsync_List_Masks_NyxApiKey() var nyxClient = new NyxIdApiClient(new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, httpClient); var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); services.AddSingleton(nyxClient); var tool = new AgentDeliveryTargetTool(services.BuildServiceProvider()); @@ -128,7 +128,7 @@ public async Task ExecuteAsync_Upsert_Requires_AgentId() { var services = new ServiceCollection(); services.AddSingleton(Substitute.For()); - services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); var tool = new AgentDeliveryTargetTool(services.BuildServiceProvider()); AgentToolRequestContext.CurrentMetadata = new Dictionary @@ -148,27 +148,12 @@ public async Task ExecuteAsync_Upsert_Requires_AgentId() } [Fact] - public async Task ExecuteAsync_Upsert_Dispatches_Command_And_Resolves_Current_User() + public async Task ExecuteAsync_Upsert_Forwards_Command_To_Port_And_Resolves_Current_User() { var queryPort = Substitute.For(); - queryPort.GetStateVersionAsync("agent-1", Arg.Any()) - .Returns(Task.FromResult(null), Task.FromResult(3)); - queryPort.GetAsync("agent-1", Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "agent-1", - Platform = "lark", - ConversationId = "oc_chat_1", - NyxProviderSlug = "api-lark-bot", - NyxApiKey = "api-key-1234", - OwnerNyxUserId = "user-1", - })); - - var actor = Substitute.For(); - actor.Id.Returns(UserAgentCatalogGAgent.WellKnownId); - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync(UserAgentCatalogGAgent.WellKnownId) - .Returns(Task.FromResult(actor)); + var commandPort = Substitute.For(); + commandPort.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new UserAgentCatalogUpsertResult(CatalogCommandOutcome.Observed))); var httpClient = new HttpClient(new StaticJsonHandler("""{"user":{"id":"user-1"}}""")) { @@ -178,7 +163,7 @@ public async Task ExecuteAsync_Upsert_Dispatches_Command_And_Resolves_Current_Us var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(commandPort); services.AddSingleton(nyxClient); var tool = new AgentDeliveryTargetTool(services.BuildServiceProvider()); @@ -202,17 +187,14 @@ public async Task ExecuteAsync_Upsert_Dispatches_Command_And_Resolves_Current_Us doc.RootElement.GetProperty("status").GetString().Should().Be("upserted"); doc.RootElement.GetProperty("owner_nyx_user_id").GetString().Should().Be("user-1"); - await actor.Received(1).HandleEventAsync(Arg.Is(e => - e.Route != null && - e.Route.Direct != null && - e.Route.Direct.TargetActorId == UserAgentCatalogGAgent.WellKnownId && - e.Payload != null && - e.Payload.Is(UserAgentCatalogUpsertCommand.Descriptor) && - e.Payload.Unpack().AgentId == "agent-1" && - e.Payload.Unpack().ConversationId == "oc_chat_1" && - e.Payload.Unpack().NyxProviderSlug == "api-lark-bot" && - e.Payload.Unpack().NyxApiKey == "api-key-1234" && - e.Payload.Unpack().OwnerNyxUserId == "user-1")); + await commandPort.Received(1).UpsertAsync( + Arg.Is(c => + c.AgentId == "agent-1" && + c.ConversationId == "oc_chat_1" && + c.NyxProviderSlug == "api-lark-bot" && + c.NyxApiKey == "api-key-1234" && + c.OwnerNyxUserId == "user-1"), + Arg.Any()); } finally { @@ -241,7 +223,7 @@ public async Task ExecuteAsync_Delete_Requires_Confirm() var nyxClient = new NyxIdApiClient(new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, httpClient); var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); services.AddSingleton(nyxClient); var tool = new AgentDeliveryTargetTool(services.BuildServiceProvider()); @@ -275,7 +257,7 @@ public async Task ExecuteAsync_Delete_Rejects_NonOwner() OwnerNyxUserId = "user-2", })); - var actorRuntime = Substitute.For(); + var commandPort = Substitute.For(); var httpClient = new HttpClient(new StaticJsonHandler("""{"user":{"id":"user-1"}}""")) { BaseAddress = new Uri("https://nyx.example.com"), @@ -284,7 +266,7 @@ public async Task ExecuteAsync_Delete_Rejects_NonOwner() var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(commandPort); services.AddSingleton(nyxClient); var tool = new AgentDeliveryTargetTool(services.BuildServiceProvider()); @@ -297,7 +279,7 @@ public async Task ExecuteAsync_Delete_Rejects_NonOwner() var result = await tool.ExecuteAsync("""{"action":"delete","agent_id":"agent-2","confirm":true}"""); result.Should().Contain("not found"); - await actorRuntime.DidNotReceive().GetAsync(UserAgentCatalogGAgent.WellKnownId); + await commandPort.DidNotReceive().TombstoneAsync(Arg.Any(), Arg.Any()); } finally { @@ -306,28 +288,21 @@ public async Task ExecuteAsync_Delete_Rejects_NonOwner() } [Fact] - public async Task ExecuteAsync_Delete_Dispatches_Tombstone_Command() + public async Task ExecuteAsync_Delete_Forwards_Tombstone_To_Port() { var queryPort = Substitute.For(); queryPort.GetAsync("agent-3", Arg.Any()) - .Returns( - Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "agent-3", - Platform = "lark", - ConversationId = "oc_chat_3", - NyxProviderSlug = "api-lark-bot", - OwnerNyxUserId = "user-1", - }), - Task.FromResult(null)); - queryPort.GetStateVersionAsync("agent-3", Arg.Any()) - .Returns(Task.FromResult(null)); - - var actor = Substitute.For(); - actor.Id.Returns(UserAgentCatalogGAgent.WellKnownId); - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync(UserAgentCatalogGAgent.WellKnownId) - .Returns(Task.FromResult(actor)); + .Returns(Task.FromResult(new UserAgentCatalogEntry + { + AgentId = "agent-3", + Platform = "lark", + ConversationId = "oc_chat_3", + NyxProviderSlug = "api-lark-bot", + OwnerNyxUserId = "user-1", + })); + var commandPort = Substitute.For(); + commandPort.TombstoneAsync("agent-3", Arg.Any()) + .Returns(Task.FromResult(new UserAgentCatalogTombstoneResult(CatalogCommandOutcome.Observed))); var httpClient = new HttpClient(new StaticJsonHandler("""{"user":{"id":"user-1"}}""")) { @@ -336,7 +311,7 @@ public async Task ExecuteAsync_Delete_Dispatches_Tombstone_Command() var nyxClient = new NyxIdApiClient(new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, httpClient); var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(commandPort); services.AddSingleton(nyxClient); var tool = new AgentDeliveryTargetTool(services.BuildServiceProvider()); @@ -350,10 +325,7 @@ public async Task ExecuteAsync_Delete_Dispatches_Tombstone_Command() using var doc = JsonDocument.Parse(result); doc.RootElement.GetProperty("status").GetString().Should().Be("deleted"); - await actor.Received(1).HandleEventAsync(Arg.Is(e => - e.Payload != null && - e.Payload.Is(UserAgentCatalogTombstoneCommand.Descriptor) && - e.Payload.Unpack().AgentId == "agent-3")); + await commandPort.Received(1).TombstoneAsync("agent-3", Arg.Any()); } finally { @@ -362,33 +334,26 @@ await actor.Received(1).HandleEventAsync(Arg.Is(e => } [Fact] - public async Task ExecuteAsync_Delete_ConfirmsOnDocumentAbsence_WhenStateVersionIsGoneAfterTombstone() + public async Task ExecuteAsync_Delete_ReturnsDeleted_WhenCommandPortReportsObserved() { - // Regression guard for #278 review: the prior confirmation loop required - // versionAfter > versionBefore before checking document absence. Under - // the new tombstone-retention contract DeleteAsync removes the document - // (and its StateVersion) outright, so a successful tombstone must still - // surface as "deleted" when GetStateVersionAsync permanently returns null. + // Regression guard for #278 review: under the tombstone-retention contract + // DeleteAsync removes the document outright, so a successful tombstone must + // surface as "deleted" once the command port reports `Observed`. The polling + // loop now lives in UserAgentCatalogCommandPort; the tool just maps outcome + // to status text. var queryPort = Substitute.For(); queryPort.GetAsync("agent-7", Arg.Any()) - .Returns( - Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "agent-7", - Platform = "lark", - ConversationId = "oc_chat_7", - NyxProviderSlug = "api-lark-bot", - OwnerNyxUserId = "user-1", - }), - Task.FromResult(null)); - queryPort.GetStateVersionAsync("agent-7", Arg.Any()) - .Returns(Task.FromResult(null)); - - var actor = Substitute.For(); - actor.Id.Returns(UserAgentCatalogGAgent.WellKnownId); - var actorRuntime = Substitute.For(); - actorRuntime.GetAsync(UserAgentCatalogGAgent.WellKnownId) - .Returns(Task.FromResult(actor)); + .Returns(Task.FromResult(new UserAgentCatalogEntry + { + AgentId = "agent-7", + Platform = "lark", + ConversationId = "oc_chat_7", + NyxProviderSlug = "api-lark-bot", + OwnerNyxUserId = "user-1", + })); + var commandPort = Substitute.For(); + commandPort.TombstoneAsync("agent-7", Arg.Any()) + .Returns(Task.FromResult(new UserAgentCatalogTombstoneResult(CatalogCommandOutcome.Observed))); var httpClient = new HttpClient(new StaticJsonHandler("""{"user":{"id":"user-1"}}""")) { @@ -397,7 +362,7 @@ public async Task ExecuteAsync_Delete_ConfirmsOnDocumentAbsence_WhenStateVersion var nyxClient = new NyxIdApiClient(new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, httpClient); var services = new ServiceCollection(); services.AddSingleton(queryPort); - services.AddSingleton(actorRuntime); + services.AddSingleton(commandPort); services.AddSingleton(nyxClient); var tool = new AgentDeliveryTargetTool(services.BuildServiceProvider()); diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationProjectorTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationProjectorTests.cs index 2ee823df2..9324e2f53 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationProjectorTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationProjectorTests.cs @@ -6,6 +6,7 @@ using FluentAssertions; using Google.Protobuf.WellKnownTypes; using Xunit; +using Aevatar.GAgents.Channel.Runtime; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationProtoCompatibilityTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationProtoCompatibilityTests.cs index ef37d3cce..bf45d4ecf 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationProtoCompatibilityTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationProtoCompatibilityTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Xunit; +using Aevatar.GAgents.Channel.Runtime; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationStartupServiceTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationStartupServiceTests.cs index a11fcbd1b..2f7434037 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationStartupServiceTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationStartupServiceTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Xunit; +using Aevatar.GAgents.Channel.Runtime; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationStoreTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationStoreTests.cs index eef785a6e..160376a23 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationStoreTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationStoreTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Xunit; +using Aevatar.GAgents.Channel.Runtime; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelCallbackEndpointsTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelCallbackEndpointsTests.cs index 95f1b0e28..3e2b2840e 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelCallbackEndpointsTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelCallbackEndpointsTests.cs @@ -13,6 +13,8 @@ using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Xunit; +using Aevatar.GAgents.Channel.NyxIdRelay; +using Aevatar.GAgents.Channel.Runtime; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelCardActionRoutingTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelCardActionRoutingTests.cs index d7886aeae..b84ffd3c1 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelCardActionRoutingTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelCardActionRoutingTests.cs @@ -1,3 +1,5 @@ +using Aevatar.GAgents.Channel.Runtime; +using Aevatar.GAgents.NyxidChat; using Aevatar.Workflow.Application.Abstractions.Runs; using FluentAssertions; using Xunit; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelConversationTurnRunnerTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelConversationTurnRunnerTests.cs index 4c9de5314..e821dd75c 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelConversationTurnRunnerTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelConversationTurnRunnerTests.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Xunit; +using Aevatar.GAgents.NyxidChat; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelPlatformReplyServiceTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelPlatformReplyServiceTests.cs index 7f61ff1cc..3b73dd318 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelPlatformReplyServiceTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelPlatformReplyServiceTests.cs @@ -4,6 +4,8 @@ using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Xunit; +using Aevatar.GAgents.Channel.NyxIdRelay; +using Aevatar.GAgents.Channel.Runtime; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelRegistrationToolTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelRegistrationToolTests.cs index 485100620..4d71a457a 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelRegistrationToolTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelRegistrationToolTests.cs @@ -7,6 +7,9 @@ using Microsoft.Extensions.DependencyInjection; using NSubstitute; using Xunit; +using Aevatar.AI.ToolProviders.ChannelAdmin; +using Aevatar.GAgents.Channel.NyxIdRelay; +using Aevatar.GAgents.Channel.Runtime; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelRuntimeTombstoneCompactorTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelRuntimeTombstoneCompactorTests.cs index a6705ac33..1df1b042a 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelRuntimeTombstoneCompactorTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelRuntimeTombstoneCompactorTests.cs @@ -6,6 +6,9 @@ using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Xunit; +using Aevatar.GAgents.Channel.Runtime; +using Aevatar.GAgents.Device; +using Aevatar.GAgents.Scheduled; namespace Aevatar.GAgents.ChannelRuntime.Tests; @@ -46,20 +49,31 @@ public async Task RunOnceAsync_DispatchesCompactionCommandsUsingProjectionWaterm actorRuntime.GetAsync(DeviceRegistrationGAgent.WellKnownId).Returns(Task.FromResult(deviceActor)); actorRuntime.GetAsync(UserAgentCatalogGAgent.WellKnownId).Returns(Task.FromResult(registryActor)); + var dispatchPort = Substitute.For(); var sut = new ChannelRuntimeTombstoneCompactor( watermarkQueryPort, actorRuntime, + dispatchPort, + new ITombstoneCompactionTarget[] + { + new ChannelBotRegistrationTombstoneCompactionTarget(), + new DeviceTombstoneCompactionTarget(), + new UserAgentCatalogTombstoneCompactionTarget(), + }, NullLogger.Instance); await sut.RunOnceAsync(); - await channelActor.Received(1).HandleEventAsync( + await dispatchPort.Received(1).DispatchAsync( + ChannelBotRegistrationGAgent.WellKnownId, Arg.Is(env => IsChannelBotCompaction(env, 12)), Arg.Any()); - await deviceActor.Received(1).HandleEventAsync( + await dispatchPort.Received(1).DispatchAsync( + DeviceRegistrationGAgent.WellKnownId, Arg.Is(env => IsDeviceCompaction(env, 22)), Arg.Any()); - await registryActor.Received(1).HandleEventAsync( + await dispatchPort.Received(1).DispatchAsync( + UserAgentCatalogGAgent.WellKnownId, Arg.Is(env => IsUserAgentCatalogCompaction(env, 32)), Arg.Any()); } @@ -72,14 +86,23 @@ public async Task RunOnceAsync_SkipsTargetsWithoutWatermark() .Returns((long?)null); var actorRuntime = Substitute.For(); + var dispatchPort = Substitute.For(); var sut = new ChannelRuntimeTombstoneCompactor( watermarkQueryPort, actorRuntime, + dispatchPort, + new ITombstoneCompactionTarget[] + { + new ChannelBotRegistrationTombstoneCompactionTarget(), + new DeviceTombstoneCompactionTarget(), + new UserAgentCatalogTombstoneCompactionTarget(), + }, NullLogger.Instance); await sut.RunOnceAsync(); actorRuntime.ReceivedCalls().Should().BeEmpty(); + dispatchPort.ReceivedCalls().Should().BeEmpty(); } private static bool IsChannelBotCompaction(EventEnvelope envelope, long safeStateVersion) diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelScheduleCalculatorTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelScheduleCalculatorTests.cs index 9b83191da..849b6a0a3 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelScheduleCalculatorTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelScheduleCalculatorTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Xunit; +using Aevatar.GAgents.Scheduled; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelTextCommandParserTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelTextCommandParserTests.cs index 816692a04..43f3003c0 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelTextCommandParserTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelTextCommandParserTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Xunit; +using Aevatar.GAgents.Channel.Runtime; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelUserConfigScopeTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelUserConfigScopeTests.cs index 5ca5f8be1..983518cbf 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelUserConfigScopeTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelUserConfigScopeTests.cs @@ -1,3 +1,4 @@ +using Aevatar.GAgents.Channel.Runtime; using FluentAssertions; using Xunit; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelWorkflowTextRoutingTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelWorkflowTextRoutingTests.cs index 48bfbf8e7..ef8b2ae4d 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelWorkflowTextRoutingTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelWorkflowTextRoutingTests.cs @@ -1,5 +1,7 @@ using FluentAssertions; using Xunit; +using Aevatar.GAgents.Channel.Runtime; +using Aevatar.GAgents.Scheduled; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ConversationReplyGeneratorTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ConversationReplyGeneratorTests.cs index 22bf9020e..d38922817 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ConversationReplyGeneratorTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ConversationReplyGeneratorTests.cs @@ -1,9 +1,11 @@ using System.Runtime.CompilerServices; using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.GAgents.Channel.Abstractions; -using Aevatar.GAgents.NyxidChat; using FluentAssertions; using Xunit; +using Aevatar.GAgents.Channel.NyxIdRelay; +using Aevatar.GAgents.Channel.Runtime; +using Aevatar.GAgents.NyxidChat; namespace Aevatar.GAgents.ChannelRuntime.Tests; @@ -15,7 +17,7 @@ public async Task GenerateReplyAsync_UsesConfiguredRelayCallbackUrlInSystemPromp var providerFactory = new RecordingProviderFactory(); var generator = new NyxIdConversationReplyGenerator( providerFactory, - relayOptions: new NyxIdRelayOptions + relayOptions: new global::Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { WebhookBaseUrl = "https://dev.aevatar.local/", }); @@ -53,7 +55,7 @@ public async Task GenerateReplyAsync_WithStreamingSinkAndPlaceholderConfigured_E var providerFactory = new RecordingProviderFactory(); var generator = new NyxIdConversationReplyGenerator( providerFactory, - relayOptions: new NyxIdRelayOptions + relayOptions: new global::Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { StreamingPlaceholderText = "…", }); @@ -83,7 +85,7 @@ public async Task GenerateReplyAsync_WithStreamingSinkButEmptyPlaceholderOption_ var providerFactory = new RecordingProviderFactory(); var generator = new NyxIdConversationReplyGenerator( providerFactory, - relayOptions: new NyxIdRelayOptions + relayOptions: new global::Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { StreamingPlaceholderText = string.Empty, }); @@ -109,7 +111,7 @@ public async Task GenerateReplyAsync_WithoutStreamingSink_SkipsPlaceholderEmit() var providerFactory = new RecordingProviderFactory(); var generator = new NyxIdConversationReplyGenerator( providerFactory, - relayOptions: new NyxIdRelayOptions + relayOptions: new global::Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { StreamingPlaceholderText = "…", }); diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/DeviceEventEndpointsTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/DeviceEventEndpointsTests.cs index 7251503a9..93397f94c 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/DeviceEventEndpointsTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/DeviceEventEndpointsTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using Microsoft.AspNetCore.Http; using Xunit; +using Aevatar.GAgents.Device; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/DeviceRegistrationGAgentTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/DeviceRegistrationGAgentTests.cs index 35298f27d..04deeaa32 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/DeviceRegistrationGAgentTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/DeviceRegistrationGAgentTests.cs @@ -5,6 +5,7 @@ using Google.Protobuf; using Microsoft.Extensions.DependencyInjection; using Xunit; +using Aevatar.GAgents.Device; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/DeviceRegistrationProjectorTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/DeviceRegistrationProjectorTests.cs index c44fa193c..39e8c03d4 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/DeviceRegistrationProjectorTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/DeviceRegistrationProjectorTests.cs @@ -6,6 +6,7 @@ using FluentAssertions; using Google.Protobuf.WellKnownTypes; using Xunit; +using Aevatar.GAgents.Device; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ElasticsearchProjectionConfigurationTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ElasticsearchProjectionConfigurationTests.cs new file mode 100644 index 000000000..83227e0fe --- /dev/null +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ElasticsearchProjectionConfigurationTests.cs @@ -0,0 +1,210 @@ +using Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Aevatar.GAgents.ChannelRuntime.Tests; + +public sealed class ElasticsearchProjectionConfigurationTests +{ + [Fact] + public void IsEnabled_ReturnsFalse_WhenConfigurationIsNull() + { + var logger = Substitute.For(); + ElasticsearchProjectionConfiguration.IsEnabled(null, logger).Should().BeFalse(); + // No diagnostics emitted when configuration is missing — caller is in a unit-test composition. + logger.ReceivedCalls().Should().BeEmpty(); + } + + [Fact] + public void IsEnabled_ReturnsTrue_WhenExplicitFlagIsTrue() + { + var configuration = BuildConfiguration(new() + { + ["Projection:Document:Providers:Elasticsearch:Enabled"] = "true", + }); + ElasticsearchProjectionConfiguration.IsEnabled(configuration).Should().BeTrue(); + } + + [Fact] + public void IsEnabled_HonorsCaseInsensitiveExplicitFlag() + { + var configuration = BuildConfiguration(new() + { + ["Projection:Document:Providers:Elasticsearch:Enabled"] = "TRUE", + }); + ElasticsearchProjectionConfiguration.IsEnabled(configuration).Should().BeTrue(); + } + + [Fact] + public void IsEnabled_ReturnsFalse_WhenExplicitFlagIsFalse() + { + var configuration = BuildConfiguration(new() + { + ["Projection:Document:Providers:Elasticsearch:Enabled"] = "false", + // Even if endpoints are populated, the explicit "false" wins. + ["Projection:Document:Providers:Elasticsearch:Endpoints:0"] = "http://localhost:9200", + }); + ElasticsearchProjectionConfiguration.IsEnabled(configuration).Should().BeFalse(); + } + + [Fact] + public void IsEnabled_AutoDetectsTrue_WhenEndpointsArePresentAndFlagAbsent() + { + var configuration = BuildConfiguration(new() + { + ["Projection:Document:Providers:Elasticsearch:Endpoints:0"] = "http://localhost:9200", + }); + ElasticsearchProjectionConfiguration.IsEnabled(configuration).Should().BeTrue(); + } + + [Fact] + public void IsEnabled_LogsWarning_WhenConfigurationPresentButNoFlagOrEndpoint() + { + var configuration = BuildConfiguration(new() + { + // Section exists (path is reachable) but neither Enabled nor Endpoints is populated. + ["Projection:Document:Providers:Elasticsearch:IndexPrefix"] = "aevatar-test", + }); + var logger = Substitute.For(); + logger.IsEnabled(Arg.Any()).Returns(true); + + ElasticsearchProjectionConfiguration.IsEnabled(configuration, logger, "TestStore").Should().BeFalse(); + + logger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public void IsEnabled_WritesConsoleError_WhenLoggerIsNullAndEndpointsEmpty() + { + var configuration = BuildConfiguration(new() + { + ["Projection:Document:Providers:Elasticsearch:IndexPrefix"] = "aevatar-test", + }); + + // SCE composition runs before the host builds its logger pipeline, so + // the helper falls back to Console.Error to keep operator visibility + // (matches the pre-helper Console.Error.WriteLine behavior). + var capturedStderr = new StringWriter(); + var originalStderr = Console.Error; + Console.SetError(capturedStderr); + try + { + ElasticsearchProjectionConfiguration + .IsEnabled(configuration, logger: null, "TestStore") + .Should().BeFalse(); + } + finally + { + Console.SetError(originalStderr); + } + + capturedStderr.ToString().Should().Contain("TestStore"); + capturedStderr.ToString().Should().Contain("Elasticsearch is not configured"); + capturedStderr.ToString().Should().Contain("InMemory"); + } + + [Fact] + public void IsEnabled_DoesNotWriteConsoleError_WhenLoggerIsProvided() + { + var configuration = BuildConfiguration(new() + { + ["Projection:Document:Providers:Elasticsearch:IndexPrefix"] = "aevatar-test", + }); + var logger = Substitute.For(); + logger.IsEnabled(Arg.Any()).Returns(true); + + var capturedStderr = new StringWriter(); + var originalStderr = Console.Error; + Console.SetError(capturedStderr); + try + { + ElasticsearchProjectionConfiguration + .IsEnabled(configuration, logger, "TestStore") + .Should().BeFalse(); + } + finally + { + Console.SetError(originalStderr); + } + + // Logger received the warning; Console.Error must stay clean so + // structured-log consumers don't get duplicate entries. + capturedStderr.ToString().Should().BeEmpty(); + logger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public void IsEnabled_DoesNotLog_WhenEndpointsArePopulated() + { + var configuration = BuildConfiguration(new() + { + ["Projection:Document:Providers:Elasticsearch:Endpoints:0"] = "http://localhost:9200", + }); + var logger = Substitute.For(); + logger.IsEnabled(Arg.Any()).Returns(true); + + ElasticsearchProjectionConfiguration.IsEnabled(configuration, logger).Should().BeTrue(); + logger.DidNotReceive().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public void BindOptions_NullConfiguration_Throws() + { + Action act = () => ElasticsearchProjectionConfiguration.BindOptions(null!); + act.Should().Throw(); + } + + [Fact] + public void BindOptions_PopulatesOptionsFromSection() + { + var configuration = BuildConfiguration(new() + { + ["Projection:Document:Providers:Elasticsearch:Endpoints:0"] = "http://es-1:9200", + ["Projection:Document:Providers:Elasticsearch:Endpoints:1"] = "http://es-2:9200", + ["Projection:Document:Providers:Elasticsearch:IndexPrefix"] = "aevatar-test", + ["Projection:Document:Providers:Elasticsearch:RequestTimeoutMs"] = "5000", + ["Projection:Document:Providers:Elasticsearch:Username"] = "elastic", + ["Projection:Document:Providers:Elasticsearch:Password"] = "secret", + }); + + var options = ElasticsearchProjectionConfiguration.BindOptions(configuration); + + options.Endpoints.Should().BeEquivalentTo(new[] { "http://es-1:9200", "http://es-2:9200" }); + options.IndexPrefix.Should().Be("aevatar-test"); + options.RequestTimeoutMs.Should().Be(5000); + options.Username.Should().Be("elastic"); + options.Password.Should().Be("secret"); + } + + [Fact] + public void BindOptions_WithEmptySection_ReturnsDefaults() + { + var configuration = BuildConfiguration(new()); + var options = ElasticsearchProjectionConfiguration.BindOptions(configuration); + + options.Should().NotBeNull(); + options.IndexPrefix.Should().Be("aevatar"); + options.Endpoints.Should().BeEmpty(); + } + + private static IConfigurationRoot BuildConfiguration(Dictionary values) => + new ConfigurationBuilder().AddInMemoryCollection(values).Build(); +} diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/FeishuCardHumanInteractionPortRoundTripTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/FeishuCardHumanInteractionPortRoundTripTests.cs index 7b2febf35..8484b7af2 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/FeishuCardHumanInteractionPortRoundTripTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/FeishuCardHumanInteractionPortRoundTripTests.cs @@ -3,6 +3,7 @@ using Aevatar.GAgents.Platform.Lark; using FluentAssertions; using Xunit; +using Aevatar.GAgents.Authoring.Lark; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/FeishuCardHumanInteractionPortTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/FeishuCardHumanInteractionPortTests.cs index 9ec6769c2..c5dda3499 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/FeishuCardHumanInteractionPortTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/FeishuCardHumanInteractionPortTests.cs @@ -8,6 +8,8 @@ using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Xunit; +using Aevatar.GAgents.Authoring.Lark; +using Aevatar.GAgents.Scheduled; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/LarkConversationTargetsTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/LarkConversationTargetsTests.cs index 4b367ca3f..632e2eb9e 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/LarkConversationTargetsTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/LarkConversationTargetsTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Xunit; +using Aevatar.GAgents.Platform.Lark; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxIdRelayInteractiveReplyDispatcherTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxIdRelayInteractiveReplyDispatcherTests.cs index 8b4eae848..254e346d8 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxIdRelayInteractiveReplyDispatcherTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxIdRelayInteractiveReplyDispatcherTests.cs @@ -6,11 +6,12 @@ using System.Threading.Tasks; using Aevatar.AI.ToolProviders.NyxId; using Aevatar.GAgents.Channel.Abstractions; -using Aevatar.GAgents.ChannelRuntime.Outbound; +using Aevatar.GAgents.Channel.NyxIdRelay.Outbound; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Xunit; +using Aevatar.GAgents.Channel.NyxIdRelay; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxIdRelayScopeResolverTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxIdRelayScopeResolverTests.cs index 6562d7461..2fe923a41 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxIdRelayScopeResolverTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxIdRelayScopeResolverTests.cs @@ -1,5 +1,7 @@ using FluentAssertions; using NSubstitute; +using Aevatar.GAgents.Channel.NyxIdRelay; +using Aevatar.GAgents.Channel.Runtime; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxLarkProvisioningServiceTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxLarkProvisioningServiceTests.cs index d9245ed03..228f42793 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxLarkProvisioningServiceTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxLarkProvisioningServiceTests.cs @@ -5,6 +5,8 @@ using FluentAssertions; using NSubstitute; using Xunit; +using Aevatar.GAgents.Channel.NyxIdRelay; +using Aevatar.GAgents.Channel.Runtime; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayAgentBuilderFlowTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayAgentBuilderFlowTests.cs index a29a03225..f6e9c999e 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayAgentBuilderFlowTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayAgentBuilderFlowTests.cs @@ -3,6 +3,8 @@ using Aevatar.GAgents.Channel.Abstractions; using FluentAssertions; using Xunit; +using Aevatar.GAgents.Authoring.Lark; +using Aevatar.GAgents.Channel.Runtime; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayApiKeyOwnershipVerifierTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayApiKeyOwnershipVerifierTests.cs index c32e7a6d9..341337fa4 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayApiKeyOwnershipVerifierTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayApiKeyOwnershipVerifierTests.cs @@ -3,6 +3,7 @@ using Aevatar.AI.ToolProviders.NyxId; using FluentAssertions; using Xunit; +using Aevatar.GAgents.Channel.NyxIdRelay; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxTelegramProvisioningServiceTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxTelegramProvisioningServiceTests.cs index dab053b5a..f839e358f 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxTelegramProvisioningServiceTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxTelegramProvisioningServiceTests.cs @@ -5,6 +5,8 @@ using FluentAssertions; using NSubstitute; using Xunit; +using Aevatar.GAgents.Channel.NyxIdRelay; +using Aevatar.GAgents.Channel.Runtime; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/RegistrationQueryPortTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/RegistrationQueryPortTests.cs index 1e1873728..a3dc493a1 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/RegistrationQueryPortTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/RegistrationQueryPortTests.cs @@ -2,6 +2,8 @@ using FluentAssertions; using NSubstitute; using Xunit; +using Aevatar.GAgents.Channel.Runtime; +using Aevatar.GAgents.Device; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/SchedulableStateTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/SchedulableStateTests.cs index 9fd7ca5f9..bc9737d56 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/SchedulableStateTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/SchedulableStateTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using Google.Protobuf.WellKnownTypes; using Xunit; +using Aevatar.GAgents.Scheduled; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ServiceCollectionExtensionsTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ServiceCollectionExtensionsTests.cs index 7b56c955d..0a885df06 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ServiceCollectionExtensionsTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ServiceCollectionExtensionsTests.cs @@ -1,8 +1,11 @@ +using Aevatar.AI.ToolProviders.Channel; using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.GAgents.Channel.Abstractions; using Aevatar.GAgents.Channel.NyxIdRelay; +using Aevatar.GAgents.Channel.Runtime; using Aevatar.GAgents.NyxidChat; using Aevatar.GAgents.Platform.Lark; +using Aevatar.GAgents.Platform.Telegram; using FluentAssertions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -19,6 +22,10 @@ public void AddChannelRuntime_RegistersRegistrationProjectionServices_ForInMemor var services = new ServiceCollection(); var result = services.AddChannelRuntime(); + services.AddNyxIdRelayChannel(); + services.AddLarkPlatform(); + services.AddChannelInteractiveReplyTools(); + services.AddTelegramPlatform(); using var provider = services.BuildServiceProvider(); var registry = provider.GetRequiredService(); @@ -55,6 +62,9 @@ public void AddChannelRuntime_RegistersLarkInteractiveReplyProducer_SoDispatcher var services = new ServiceCollection(); services.AddChannelRuntime(); + services.AddNyxIdRelayChannel(); + services.AddLarkPlatform(); + services.AddChannelInteractiveReplyTools(); using var provider = services.BuildServiceProvider(); var registry = provider.GetRequiredService(); @@ -78,6 +88,10 @@ public void AddChannelRuntime_RegistersOnlyPublicRegistrationProjectionServices_ var services = new ServiceCollection(); var result = services.AddChannelRuntime(configuration); + services.AddNyxIdRelayChannel(); + services.AddLarkPlatform(); + services.AddChannelInteractiveReplyTools(); + services.AddTelegramPlatform(); using var provider = services.BuildServiceProvider(); var registry = provider.GetRequiredService(); diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/SkillRunnerCommandPortTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/SkillRunnerCommandPortTests.cs new file mode 100644 index 000000000..8f76f6717 --- /dev/null +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/SkillRunnerCommandPortTests.cs @@ -0,0 +1,230 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.Foundation.Abstractions; +using FluentAssertions; +using NSubstitute; +using Xunit; +using Aevatar.GAgents.Scheduled; + +namespace Aevatar.GAgents.ChannelRuntime.Tests; + +public sealed class SkillRunnerCommandPortTests +{ + private const string AgentId = "skill-runner-test-1"; + private const string ExpectedPublisher = "scheduled.skill-runner"; + + [Fact] + public async Task InitializeAsync_WhenRunImmediatelyFalse_DispatchesSingleEnvelope_AndCreatesActor_AndPrimesProjection() + { + var fixture = new Fixture(); + fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(null)); + fixture.Runtime.CreateAsync(AgentId, Arg.Any()) + .Returns(Task.FromResult(Substitute.For())); + + var command = new InitializeSkillRunnerCommand + { + SkillName = "demo", + ScheduleCron = "0 */1 * * *", + }; + + await fixture.Port.InitializeAsync(AgentId, command, runImmediately: false, CancellationToken.None); + + await fixture.Runtime.Received(1).GetAsync(AgentId); + await fixture.Runtime.Received(1).CreateAsync(AgentId, Arg.Any()); + await fixture.Activation.Received(1).EnsureAsync( + Arg.Is(r => + r.RootActorId == UserAgentCatalogGAgent.WellKnownId && + r.ProjectionKind == UserAgentCatalogProjectionPort.ProjectionKind), + Arg.Any()); + + fixture.Captured.Should().HaveCount(1); + var envelope = fixture.Captured[0]; + envelope.Payload.Is(InitializeSkillRunnerCommand.Descriptor).Should().BeTrue(); + envelope.Route.PublisherActorId.Should().Be(ExpectedPublisher); + envelope.Route.Direct.TargetActorId.Should().Be(AgentId); + } + + [Fact] + public async Task InitializeAsync_WhenRunImmediatelyTrue_DispatchesInitializeThenTrigger_WithCreateAgentReason() + { + var fixture = new Fixture(); + fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(Substitute.For())); + + var command = new InitializeSkillRunnerCommand { SkillName = "demo" }; + await fixture.Port.InitializeAsync(AgentId, command, runImmediately: true, CancellationToken.None); + + fixture.Captured.Should().HaveCount(2); + fixture.Captured[0].Payload.Is(InitializeSkillRunnerCommand.Descriptor).Should().BeTrue(); + fixture.Captured[1].Payload.Is(TriggerSkillRunnerExecutionCommand.Descriptor).Should().BeTrue(); + fixture.Captured[1].Payload.Unpack().Reason.Should().Be("create_agent"); + fixture.Captured[1].Route.PublisherActorId.Should().Be(ExpectedPublisher); + fixture.Captured[1].Route.Direct.TargetActorId.Should().Be(AgentId); + + // Actor already existed → CreateAsync should not be invoked. + await fixture.Runtime.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task TriggerAsync_DispatchesTriggerCommandWithReason() + { + var fixture = new Fixture(); + fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(Substitute.For())); + + await fixture.Port.TriggerAsync(AgentId, "manual_run", CancellationToken.None); + + fixture.Captured.Should().ContainSingle(); + var env = fixture.Captured[0]; + env.Payload.Is(TriggerSkillRunnerExecutionCommand.Descriptor).Should().BeTrue(); + env.Payload.Unpack().Reason.Should().Be("manual_run"); + env.Route.PublisherActorId.Should().Be(ExpectedPublisher); + env.Route.Direct.TargetActorId.Should().Be(AgentId); + } + + [Fact] + public async Task TriggerAsync_WithNullReason_NormalizesToEmptyString() + { + var fixture = new Fixture(); + fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(Substitute.For())); + + await fixture.Port.TriggerAsync(AgentId, null!, CancellationToken.None); + + fixture.Captured.Should().ContainSingle(); + fixture.Captured[0].Payload.Unpack().Reason.Should().Be(string.Empty); + } + + [Fact] + public async Task DisableAsync_DispatchesDisableCommandWithReason() + { + var fixture = new Fixture(); + fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(Substitute.For())); + + await fixture.Port.DisableAsync(AgentId, "operator_off", CancellationToken.None); + + fixture.Captured.Should().ContainSingle(); + var env = fixture.Captured[0]; + env.Payload.Is(DisableSkillRunnerCommand.Descriptor).Should().BeTrue(); + env.Payload.Unpack().Reason.Should().Be("operator_off"); + env.Route.PublisherActorId.Should().Be(ExpectedPublisher); + env.Route.Direct.TargetActorId.Should().Be(AgentId); + } + + [Fact] + public async Task EnableAsync_DispatchesEnableCommandWithReason() + { + var fixture = new Fixture(); + fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(Substitute.For())); + + await fixture.Port.EnableAsync(AgentId, "operator_on", CancellationToken.None); + + fixture.Captured.Should().ContainSingle(); + var env = fixture.Captured[0]; + env.Payload.Is(EnableSkillRunnerCommand.Descriptor).Should().BeTrue(); + env.Payload.Unpack().Reason.Should().Be("operator_on"); + env.Route.PublisherActorId.Should().Be(ExpectedPublisher); + env.Route.Direct.TargetActorId.Should().Be(AgentId); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task InitializeAsync_WithInvalidAgentId_Throws(string? agentId) + { + var fixture = new Fixture(); + var command = new InitializeSkillRunnerCommand(); + var act = () => fixture.Port.InitializeAsync(agentId!, command, runImmediately: false, CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task InitializeAsync_WithNullCommand_Throws() + { + var fixture = new Fixture(); + var act = () => fixture.Port.InitializeAsync(AgentId, null!, runImmediately: false, CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task TriggerAsync_WithInvalidAgentId_Throws(string? agentId) + { + var fixture = new Fixture(); + var act = () => fixture.Port.TriggerAsync(agentId!, "reason", CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task DisableAsync_WithInvalidAgentId_Throws(string? agentId) + { + var fixture = new Fixture(); + var act = () => fixture.Port.DisableAsync(agentId!, "reason", CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task EnableAsync_WithInvalidAgentId_Throws(string? agentId) + { + var fixture = new Fixture(); + var act = () => fixture.Port.EnableAsync(agentId!, "reason", CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Fact] + public void Constructor_NullDependencies_Throws() + { + var dispatch = Substitute.For(); + var runtime = Substitute.For(); + var projection = Fixture.CreateProjectionPort(out _, out _); + + Action ctor1 = () => new SkillRunnerCommandPort(null!, dispatch, projection); + Action ctor2 = () => new SkillRunnerCommandPort(runtime, null!, projection); + Action ctor3 = () => new SkillRunnerCommandPort(runtime, dispatch, null!); + ctor1.Should().Throw(); + ctor2.Should().Throw(); + ctor3.Should().Throw(); + } + + private sealed class Fixture + { + public IActorRuntime Runtime { get; } + public IActorDispatchPort Dispatch { get; } + public UserAgentCatalogProjectionPort Projection { get; } + public IProjectionScopeActivationService Activation { get; } + public List Captured { get; } = new(); + public SkillRunnerCommandPort Port { get; } + + public Fixture() + { + Runtime = Substitute.For(); + Dispatch = Substitute.For(); + Projection = CreateProjectionPort(out var activation, out _); + Activation = activation; + Dispatch.DispatchAsync(Arg.Any(), Arg.Do(env => Captured.Add(env)), Arg.Any()) + .Returns(Task.CompletedTask); + Port = new SkillRunnerCommandPort(Runtime, Dispatch, Projection); + } + + public static UserAgentCatalogProjectionPort CreateProjectionPort( + out IProjectionScopeActivationService activation, + out UserAgentCatalogMaterializationRuntimeLease lease) + { + activation = Substitute.For>(); + lease = new UserAgentCatalogMaterializationRuntimeLease( + new UserAgentCatalogMaterializationContext + { + RootActorId = UserAgentCatalogGAgent.WellKnownId, + ProjectionKind = UserAgentCatalogProjectionPort.ProjectionKind, + }); + activation.EnsureAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(lease)); + return new UserAgentCatalogProjectionPort(activation); + } + } +} diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/SkillRunnerGAgentTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/SkillRunnerGAgentTests.cs index 1cd05cb5f..d755bddac 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/SkillRunnerGAgentTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/SkillRunnerGAgentTests.cs @@ -10,6 +10,8 @@ using FluentAssertions; using Google.Protobuf; using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Aevatar.GAgents.Scheduled; namespace Aevatar.GAgents.ChannelRuntime.Tests; @@ -22,15 +24,7 @@ public sealed class SkillRunnerGAgentTests : IAsyncLifetime public async Task InitializeAsync() { _store = new InMemoryEventStore(); - - var services = new ServiceCollection(); - services.AddSingleton(_store); - services.AddSingleton(); - services.AddTransient( - typeof(IEventSourcingBehaviorFactory<>), - typeof(DefaultEventSourcingBehaviorFactory<>)); - - _serviceProvider = services.BuildServiceProvider(); + _serviceProvider = BuildServiceProvider(_store); _agent = CreateAgent("skill-runner-test"); await _agent.ActivateAsync(); } @@ -95,6 +89,44 @@ public async Task HandleInitializeAsync_WhenMaxTokensIsExplicitZero_ShouldPreser _agent.EffectiveConfig.MaxTokens.Should().BeNull(); } + [Fact] + public async Task HandleInitializeAsync_ShouldDispatchCatalogCommandsThroughDispatchPort() + { + var catalogActor = Substitute.For(); + var runtime = Substitute.For(); + runtime.GetAsync(UserAgentCatalogGAgent.WellKnownId) + .Returns(Task.FromResult(catalogActor)); + + var dispatch = Substitute.For(); + var captured = new List(); + dispatch.DispatchAsync( + UserAgentCatalogGAgent.WellKnownId, + Arg.Do(captured.Add), + Arg.Any()) + .Returns(Task.CompletedTask); + + using var provider = BuildServiceProvider( + new InMemoryEventStore(), + services => + { + services.AddSingleton(runtime); + services.AddSingleton(dispatch); + }); + var agent = CreateAgent("skill-runner-dispatch-test", provider); + await agent.ActivateAsync(); + + await agent.HandleInitializeAsync(CreateInitializeCommand()); + + captured.Should().HaveCount(2); + captured[0].Payload.Is(UserAgentCatalogUpsertCommand.Descriptor).Should().BeTrue(); + captured[1].Payload.Is(UserAgentCatalogExecutionUpdateCommand.Descriptor).Should().BeTrue(); + captured.Should().OnlyContain(envelope => + envelope.Route.PublisherActorId == "skill-runner-dispatch-test" && + envelope.Route.Direct.TargetActorId == UserAgentCatalogGAgent.WellKnownId); + await catalogActor.DidNotReceive() + .HandleEventAsync(Arg.Any(), Arg.Any()); + } + [Fact] public async Task SendOutputAsync_ShouldUseTypedReceiveTarget_WhenLarkReceiveIdIsPopulated() { @@ -478,18 +510,33 @@ protected override async Task SendAsync(HttpRequestMessage } } - private SkillRunnerGAgent CreateAgent(string actorId) + private SkillRunnerGAgent CreateAgent(string actorId, ServiceProvider? serviceProvider = null) { + var resolvedServices = serviceProvider ?? _serviceProvider; var agent = new SkillRunnerGAgent { - Services = _serviceProvider, + Services = resolvedServices, EventSourcingBehaviorFactory = - _serviceProvider.GetRequiredService>(), + resolvedServices.GetRequiredService>(), }; AssignActorId(agent, actorId); return agent; } + private static ServiceProvider BuildServiceProvider( + IEventStore eventStore, + Action? configure = null) + { + var services = new ServiceCollection(); + services.AddSingleton(eventStore); + services.AddSingleton(); + services.AddTransient( + typeof(IEventSourcingBehaviorFactory<>), + typeof(DefaultEventSourcingBehaviorFactory<>)); + configure?.Invoke(services); + return services.BuildServiceProvider(); + } + private static InitializeSkillRunnerCommand CreateInitializeCommand() => new() { SkillName = "daily_report", diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/TombstoneCompactionTargetTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/TombstoneCompactionTargetTests.cs new file mode 100644 index 000000000..87bbc3f7d --- /dev/null +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/TombstoneCompactionTargetTests.cs @@ -0,0 +1,134 @@ +using Aevatar.Foundation.Abstractions; +using FluentAssertions; +using NSubstitute; +using Xunit; +using Aevatar.GAgents.Channel.Runtime; +using Aevatar.GAgents.Device; +using Aevatar.GAgents.Scheduled; + +namespace Aevatar.GAgents.ChannelRuntime.Tests; + +/// +/// Smoke tests for the three +/// implementations. The behaviour they share — Get-or-Create lifecycle and +/// versioned command construction — is also exercised end-to-end through +/// ; these focused tests +/// keep direct coverage on the small leaf classes in the patch. +/// +public sealed class TombstoneCompactionTargetTests +{ + [Fact] + public async Task UserAgentCatalog_EnsureActorAsync_CreatesActor_WhenMissing() + { + var target = new UserAgentCatalogTombstoneCompactionTarget(); + var runtime = Substitute.For(); + runtime.GetAsync(target.ActorId).Returns(Task.FromResult(null)); + runtime.CreateAsync(target.ActorId, Arg.Any()) + .Returns(Task.FromResult(Substitute.For())); + + await target.EnsureActorAsync(runtime, CancellationToken.None); + + await runtime.Received(1).GetAsync(target.ActorId); + await runtime.Received(1).CreateAsync(target.ActorId, Arg.Any()); + } + + [Fact] + public async Task UserAgentCatalog_EnsureActorAsync_DoesNotCreate_WhenActorExists() + { + var target = new UserAgentCatalogTombstoneCompactionTarget(); + var runtime = Substitute.For(); + runtime.GetAsync(target.ActorId).Returns(Task.FromResult(Substitute.For())); + + await target.EnsureActorAsync(runtime, CancellationToken.None); + + await runtime.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UserAgentCatalog_EnsureActorAsync_NullRuntime_Throws() + { + var target = new UserAgentCatalogTombstoneCompactionTarget(); + var act = () => target.EnsureActorAsync(null!, CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Fact] + public void UserAgentCatalog_CreateCommand_CarriesSafeStateVersion() + { + var target = new UserAgentCatalogTombstoneCompactionTarget(); + var command = target.CreateCommand(42); + + command.Should().BeOfType() + .Which.SafeStateVersion.Should().Be(42); + } + + [Fact] + public void UserAgentCatalog_Properties_DescribeTarget() + { + var target = new UserAgentCatalogTombstoneCompactionTarget(); + target.ActorId.Should().Be(UserAgentCatalogGAgent.WellKnownId); + target.ProjectionKind.Should().Be(UserAgentCatalogProjectionPort.ProjectionKind); + target.TargetName.Should().Be("user agent catalog"); + } + + [Fact] + public async Task ChannelBotRegistration_EnsureActorAsync_CreatesActor_WhenMissing() + { + ITombstoneCompactionTarget target = ResolveChannelBotTarget(); + var runtime = Substitute.For(); + runtime.GetAsync(target.ActorId).Returns(Task.FromResult(null)); + runtime.CreateAsync(target.ActorId, Arg.Any()) + .Returns(Task.FromResult(Substitute.For())); + + await target.EnsureActorAsync(runtime, CancellationToken.None); + + await runtime.Received(1).CreateAsync(target.ActorId, Arg.Any()); + } + + [Fact] + public void ChannelBotRegistration_CreateCommand_CarriesSafeStateVersion() + { + ITombstoneCompactionTarget target = ResolveChannelBotTarget(); + var command = target.CreateCommand(99); + command.Should().BeOfType() + .Which.SafeStateVersion.Should().Be(99); + } + + [Fact] + public async Task Device_EnsureActorAsync_CreatesActor_WhenMissing() + { + ITombstoneCompactionTarget target = ResolveDeviceTarget(); + var runtime = Substitute.For(); + runtime.GetAsync(target.ActorId).Returns(Task.FromResult(null)); + runtime.CreateAsync(target.ActorId, Arg.Any()) + .Returns(Task.FromResult(Substitute.For())); + + await target.EnsureActorAsync(runtime, CancellationToken.None); + + await runtime.Received(1).CreateAsync(target.ActorId, Arg.Any()); + } + + [Fact] + public void Device_CreateCommand_CarriesSafeStateVersion() + { + ITombstoneCompactionTarget target = ResolveDeviceTarget(); + var command = target.CreateCommand(77); + command.Should().BeOfType() + .Which.SafeStateVersion.Should().Be(77); + } + + private static ITombstoneCompactionTarget ResolveChannelBotTarget() + { + // Internal type — instantiate via reflection, kept stable for the patch's smoke coverage. + var t = typeof(ChannelBotRegistrationGAgent).Assembly + .GetType("Aevatar.GAgents.Channel.Runtime.ChannelBotRegistrationTombstoneCompactionTarget", throwOnError: true)!; + return (ITombstoneCompactionTarget)Activator.CreateInstance(t)!; + } + + private static ITombstoneCompactionTarget ResolveDeviceTarget() + { + var t = typeof(DeviceRegistrationGAgent).Assembly + .GetType("Aevatar.GAgents.Device.DeviceTombstoneCompactionTarget", throwOnError: true)!; + return (ITombstoneCompactionTarget)Activator.CreateInstance(t)!; + } +} diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogCommandPortTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogCommandPortTests.cs new file mode 100644 index 000000000..e7b5e9cf7 --- /dev/null +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogCommandPortTests.cs @@ -0,0 +1,236 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.Foundation.Abstractions; +using FluentAssertions; +using NSubstitute; +using Xunit; +using Aevatar.GAgents.Scheduled; + +namespace Aevatar.GAgents.ChannelRuntime.Tests; + +public sealed class UserAgentCatalogCommandPortTests +{ + private const string CatalogActorId = UserAgentCatalogGAgent.WellKnownId; + private const string ExpectedPublisher = "scheduled.user-agent-catalog"; + + [Fact] + public async Task UpsertAsync_ReturnsObserved_WhenStateVersionAdvances_AndEntryMatches() + { + var fixture = new Fixture(); + const string agentId = "agent-upsert-1"; + var command = new UserAgentCatalogUpsertCommand + { + AgentId = agentId, + Platform = "lark", + ConversationId = "oc_chat_1", + NyxProviderSlug = "api-lark-bot", + NyxApiKey = "api-key-1", + }; + + // Initial state-version is 0; after dispatch it advances to 1 and the entry materializes matching the command. + fixture.QueryPort.GetStateVersionAsync(agentId, Arg.Any()) + .Returns(0L, 1L); + fixture.QueryPort.GetAsync(agentId, Arg.Any()) + .Returns(new UserAgentCatalogEntry + { + AgentId = agentId, + Platform = "lark", + ConversationId = "oc_chat_1", + NyxProviderSlug = "api-lark-bot", + NyxApiKey = "api-key-1", + }); + + var result = await fixture.Port.UpsertAsync(command, CancellationToken.None); + + result.Outcome.Should().Be(CatalogCommandOutcome.Observed); + fixture.Captured.Should().ContainSingle(); + var env = fixture.Captured[0]; + env.Payload.Is(UserAgentCatalogUpsertCommand.Descriptor).Should().BeTrue(); + env.Payload.Unpack().AgentId.Should().Be(agentId); + env.Route.PublisherActorId.Should().Be(ExpectedPublisher); + env.Route.Direct.TargetActorId.Should().Be(CatalogActorId); + await fixture.Dispatch.Received(1).DispatchAsync(CatalogActorId, Arg.Any(), Arg.Any()); + // Lifecycle: ensure GetAsync(catalogActorId) was called for actor lifecycle. + await fixture.Runtime.Received().GetAsync(CatalogActorId); + } + + [Fact] + public async Task UpsertAsync_ReturnsAccepted_WhenPollingBudgetExhausts() + { + var fixture = new Fixture(projectionWaitAttempts: 3); + const string agentId = "agent-upsert-stuck"; + var command = new UserAgentCatalogUpsertCommand + { + AgentId = agentId, + Platform = "lark", + ConversationId = "oc_chat_1", + NyxProviderSlug = "api-lark-bot", + NyxApiKey = "api-key-1", + }; + + fixture.QueryPort.GetStateVersionAsync(agentId, Arg.Any()).Returns(0L); + fixture.QueryPort.GetAsync(agentId, Arg.Any()) + .Returns((UserAgentCatalogEntry?)null); + + var result = await fixture.Port.UpsertAsync(command, CancellationToken.None); + + result.Outcome.Should().Be(CatalogCommandOutcome.Accepted); + } + + [Fact] + public async Task UpsertAsync_WithNullCommand_Throws() + { + var fixture = new Fixture(); + var act = () => fixture.Port.UpsertAsync(null!, CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task UpsertAsync_WithInvalidAgentId_Throws(string? agentId) + { + var fixture = new Fixture(); + var command = new UserAgentCatalogUpsertCommand { AgentId = agentId ?? string.Empty }; + var act = () => fixture.Port.UpsertAsync(command, CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task TombstoneAsync_ReturnsNotFound_WhenAgentDoesNotExistAtCallTime() + { + var fixture = new Fixture(); + const string agentId = "agent-missing"; + fixture.QueryPort.GetAsync(agentId, Arg.Any()) + .Returns((UserAgentCatalogEntry?)null); + + var result = await fixture.Port.TombstoneAsync(agentId, CancellationToken.None); + + result.Outcome.Should().Be(CatalogCommandOutcome.NotFound); + fixture.Captured.Should().BeEmpty(); + await fixture.Dispatch.DidNotReceive().DispatchAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task TombstoneAsync_ReturnsObserved_WhenStateVersionAdvances_AndEntryVanishes() + { + var fixture = new Fixture(); + const string agentId = "agent-tombstone-1"; + + var existing = new UserAgentCatalogEntry { AgentId = agentId, Platform = "lark" }; + // First GetAsync (existence check) returns the entry; subsequent calls (after dispatch) return null. + fixture.QueryPort.GetAsync(agentId, Arg.Any()) + .Returns(existing, (UserAgentCatalogEntry?)null); + fixture.QueryPort.GetStateVersionAsync(agentId, Arg.Any()) + .Returns(5L, 6L); + + var result = await fixture.Port.TombstoneAsync(agentId, CancellationToken.None); + + result.Outcome.Should().Be(CatalogCommandOutcome.Observed); + fixture.Captured.Should().ContainSingle(); + var env = fixture.Captured[0]; + env.Payload.Is(UserAgentCatalogTombstoneCommand.Descriptor).Should().BeTrue(); + env.Payload.Unpack().AgentId.Should().Be(agentId); + env.Route.PublisherActorId.Should().Be(ExpectedPublisher); + env.Route.Direct.TargetActorId.Should().Be(CatalogActorId); + } + + [Fact] + public async Task TombstoneAsync_ReturnsObserved_WhenStateVersionGoesNull() + { + var fixture = new Fixture(); + const string agentId = "agent-tombstone-version-null"; + var existing = new UserAgentCatalogEntry { AgentId = agentId, Platform = "lark" }; + fixture.QueryPort.GetAsync(agentId, Arg.Any()) + .Returns(existing); + // First call returns 5 (versionBefore); subsequent polls return null → tombstone observed via doc removal. + fixture.QueryPort.GetStateVersionAsync(agentId, Arg.Any()) + .Returns(5L, (long?)null); + + var result = await fixture.Port.TombstoneAsync(agentId, CancellationToken.None); + + result.Outcome.Should().Be(CatalogCommandOutcome.Observed); + } + + [Fact] + public async Task TombstoneAsync_ReturnsAccepted_WhenPollingBudgetExhausts() + { + var fixture = new Fixture(projectionWaitAttempts: 3); + const string agentId = "agent-tombstone-stuck"; + var existing = new UserAgentCatalogEntry { AgentId = agentId, Platform = "lark" }; + // Existence check returns entry, subsequent polls keep returning entry (entry never vanishes). + fixture.QueryPort.GetAsync(agentId, Arg.Any()) + .Returns(existing); + // Version stays at the same value → never advances, never observes. + fixture.QueryPort.GetStateVersionAsync(agentId, Arg.Any()).Returns(5L); + + var result = await fixture.Port.TombstoneAsync(agentId, CancellationToken.None); + + result.Outcome.Should().Be(CatalogCommandOutcome.Accepted); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task TombstoneAsync_WithInvalidAgentId_Throws(string? agentId) + { + var fixture = new Fixture(); + var act = () => fixture.Port.TombstoneAsync(agentId!, CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task UpsertAsync_EnsuresCatalogActorLifecycle_WhenActorMissing() + { + var fixture = new Fixture(); + // No actor existed; runtime returns null then creates fresh. + fixture.Runtime.GetAsync(CatalogActorId).Returns(Task.FromResult(null)); + fixture.Runtime.CreateAsync(CatalogActorId, Arg.Any()) + .Returns(Task.FromResult(Substitute.For())); + fixture.QueryPort.GetStateVersionAsync(Arg.Any(), Arg.Any()).Returns(0L); + + var command = new UserAgentCatalogUpsertCommand { AgentId = "agent-1" }; + await fixture.Port.UpsertAsync(command, CancellationToken.None); + + await fixture.Runtime.Received(1).CreateAsync(CatalogActorId, Arg.Any()); + } + + private sealed class Fixture + { + public IUserAgentCatalogRuntimeQueryPort QueryPort { get; } + public UserAgentCatalogProjectionPort ProjectionPort { get; } + public IActorRuntime Runtime { get; } + public IActorDispatchPort Dispatch { get; } + public List Captured { get; } = new(); + public UserAgentCatalogCommandPort Port { get; } + + public Fixture(int projectionWaitAttempts = 3) + { + QueryPort = Substitute.For(); + Runtime = Substitute.For(); + Dispatch = Substitute.For(); + + var activation = Substitute.For>(); + activation.EnsureAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new UserAgentCatalogMaterializationRuntimeLease( + new UserAgentCatalogMaterializationContext + { + RootActorId = UserAgentCatalogGAgent.WellKnownId, + ProjectionKind = UserAgentCatalogProjectionPort.ProjectionKind, + }))); + ProjectionPort = new UserAgentCatalogProjectionPort(activation); + + Dispatch.DispatchAsync(Arg.Any(), Arg.Do(env => Captured.Add(env)), Arg.Any()) + .Returns(Task.CompletedTask); + + Port = new UserAgentCatalogCommandPort( + QueryPort, + ProjectionPort, + Runtime, + Dispatch, + projectionWaitAttempts: projectionWaitAttempts, + projectionWaitDelayMilliseconds: 1); + } + } +} diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogCompatibilityTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogCompatibilityTests.cs index 16af11fca..43ea6d745 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogCompatibilityTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogCompatibilityTests.cs @@ -9,6 +9,7 @@ using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.DependencyInjection; using Xunit; +using Aevatar.GAgents.Scheduled; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogGAgentTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogGAgentTests.cs index f3bdb38f0..00355e402 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogGAgentTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogGAgentTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Xunit; +using Aevatar.GAgents.Scheduled; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogProjectionPortTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogProjectionPortTests.cs index 45099974a..261072492 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogProjectionPortTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogProjectionPortTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using NSubstitute; using Xunit; +using Aevatar.GAgents.Scheduled; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogProjectorTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogProjectorTests.cs index c35851e3c..71cd07658 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogProjectorTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogProjectorTests.cs @@ -6,6 +6,7 @@ using FluentAssertions; using Google.Protobuf.WellKnownTypes; using Xunit; +using Aevatar.GAgents.Scheduled; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogStartupServiceTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogStartupServiceTests.cs index badc48ece..fbf63dceb 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogStartupServiceTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogStartupServiceTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; using System.Collections.Concurrent; using Xunit; +using Aevatar.GAgents.Scheduled; namespace Aevatar.GAgents.ChannelRuntime.Tests; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowAgentCommandPortTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowAgentCommandPortTests.cs new file mode 100644 index 000000000..adf94d232 --- /dev/null +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowAgentCommandPortTests.cs @@ -0,0 +1,236 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.Foundation.Abstractions; +using FluentAssertions; +using NSubstitute; +using Xunit; +using Aevatar.GAgents.Scheduled; + +namespace Aevatar.GAgents.ChannelRuntime.Tests; + +public sealed class WorkflowAgentCommandPortTests +{ + private const string AgentId = "workflow-agent-test-1"; + private const string ExpectedPublisher = "scheduled.workflow-agent"; + + [Fact] + public async Task InitializeAsync_WhenRunImmediatelyFalse_DispatchesSingleEnvelope_AndCreatesActor_AndPrimesProjection() + { + var fixture = new Fixture(); + fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(null)); + fixture.Runtime.CreateAsync(AgentId, Arg.Any()) + .Returns(Task.FromResult(Substitute.For())); + + var command = new InitializeWorkflowAgentCommand + { + WorkflowId = "wf-1", + WorkflowName = "demo", + ExecutionPrompt = "do the thing", + ScheduleCron = "0 */1 * * *", + }; + + await fixture.Port.InitializeAsync(AgentId, command, runImmediately: false, CancellationToken.None); + + await fixture.Runtime.Received(1).GetAsync(AgentId); + await fixture.Runtime.Received(1).CreateAsync(AgentId, Arg.Any()); + await fixture.Activation.Received(1).EnsureAsync( + Arg.Is(r => + r.RootActorId == UserAgentCatalogGAgent.WellKnownId && + r.ProjectionKind == UserAgentCatalogProjectionPort.ProjectionKind), + Arg.Any()); + + fixture.Captured.Should().HaveCount(1); + var envelope = fixture.Captured[0]; + envelope.Payload.Is(InitializeWorkflowAgentCommand.Descriptor).Should().BeTrue(); + envelope.Route.PublisherActorId.Should().Be(ExpectedPublisher); + envelope.Route.Direct.TargetActorId.Should().Be(AgentId); + } + + [Fact] + public async Task InitializeAsync_WhenRunImmediatelyTrue_DispatchesInitializeThenTrigger_WithCreateAgentReason() + { + var fixture = new Fixture(); + fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(Substitute.For())); + + var command = new InitializeWorkflowAgentCommand { WorkflowId = "wf-1" }; + await fixture.Port.InitializeAsync(AgentId, command, runImmediately: true, CancellationToken.None); + + fixture.Captured.Should().HaveCount(2); + fixture.Captured[0].Payload.Is(InitializeWorkflowAgentCommand.Descriptor).Should().BeTrue(); + fixture.Captured[1].Payload.Is(TriggerWorkflowAgentExecutionCommand.Descriptor).Should().BeTrue(); + var trigger = fixture.Captured[1].Payload.Unpack(); + trigger.Reason.Should().Be("create_agent"); + trigger.RevisionFeedback.Should().Be(string.Empty); + fixture.Captured[1].Route.PublisherActorId.Should().Be(ExpectedPublisher); + fixture.Captured[1].Route.Direct.TargetActorId.Should().Be(AgentId); + + await fixture.Runtime.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task TriggerAsync_DispatchesTriggerCommand_WithReasonAndRevisionFeedback() + { + var fixture = new Fixture(); + fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(Substitute.For())); + + await fixture.Port.TriggerAsync(AgentId, "operator_run", "tighten the prompt", CancellationToken.None); + + fixture.Captured.Should().ContainSingle(); + var env = fixture.Captured[0]; + env.Payload.Is(TriggerWorkflowAgentExecutionCommand.Descriptor).Should().BeTrue(); + var trigger = env.Payload.Unpack(); + trigger.Reason.Should().Be("operator_run"); + trigger.RevisionFeedback.Should().Be("tighten the prompt"); + env.Route.PublisherActorId.Should().Be(ExpectedPublisher); + env.Route.Direct.TargetActorId.Should().Be(AgentId); + } + + [Fact] + public async Task TriggerAsync_WithNullArguments_NormalizesToEmptyString() + { + var fixture = new Fixture(); + fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(Substitute.For())); + + await fixture.Port.TriggerAsync(AgentId, null!, null, CancellationToken.None); + + fixture.Captured.Should().ContainSingle(); + var trigger = fixture.Captured[0].Payload.Unpack(); + trigger.Reason.Should().Be(string.Empty); + trigger.RevisionFeedback.Should().Be(string.Empty); + } + + [Fact] + public async Task DisableAsync_DispatchesDisableCommandWithReason() + { + var fixture = new Fixture(); + fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(Substitute.For())); + + await fixture.Port.DisableAsync(AgentId, "operator_off", CancellationToken.None); + + fixture.Captured.Should().ContainSingle(); + var env = fixture.Captured[0]; + env.Payload.Is(DisableWorkflowAgentCommand.Descriptor).Should().BeTrue(); + env.Payload.Unpack().Reason.Should().Be("operator_off"); + env.Route.PublisherActorId.Should().Be(ExpectedPublisher); + env.Route.Direct.TargetActorId.Should().Be(AgentId); + } + + [Fact] + public async Task EnableAsync_DispatchesEnableCommandWithReason() + { + var fixture = new Fixture(); + fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(Substitute.For())); + + await fixture.Port.EnableAsync(AgentId, "operator_on", CancellationToken.None); + + fixture.Captured.Should().ContainSingle(); + var env = fixture.Captured[0]; + env.Payload.Is(EnableWorkflowAgentCommand.Descriptor).Should().BeTrue(); + env.Payload.Unpack().Reason.Should().Be("operator_on"); + env.Route.PublisherActorId.Should().Be(ExpectedPublisher); + env.Route.Direct.TargetActorId.Should().Be(AgentId); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task InitializeAsync_WithInvalidAgentId_Throws(string? agentId) + { + var fixture = new Fixture(); + var command = new InitializeWorkflowAgentCommand(); + var act = () => fixture.Port.InitializeAsync(agentId!, command, runImmediately: false, CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task InitializeAsync_WithNullCommand_Throws() + { + var fixture = new Fixture(); + var act = () => fixture.Port.InitializeAsync(AgentId, null!, runImmediately: false, CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task TriggerAsync_WithInvalidAgentId_Throws(string? agentId) + { + var fixture = new Fixture(); + var act = () => fixture.Port.TriggerAsync(agentId!, "reason", null, CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task DisableAsync_WithInvalidAgentId_Throws(string? agentId) + { + var fixture = new Fixture(); + var act = () => fixture.Port.DisableAsync(agentId!, "reason", CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task EnableAsync_WithInvalidAgentId_Throws(string? agentId) + { + var fixture = new Fixture(); + var act = () => fixture.Port.EnableAsync(agentId!, "reason", CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Fact] + public void Constructor_NullDependencies_Throws() + { + var dispatch = Substitute.For(); + var runtime = Substitute.For(); + var projection = Fixture.CreateProjectionPort(out _); + + Action ctor1 = () => new WorkflowAgentCommandPort(null!, dispatch, projection); + Action ctor2 = () => new WorkflowAgentCommandPort(runtime, null!, projection); + Action ctor3 = () => new WorkflowAgentCommandPort(runtime, dispatch, null!); + ctor1.Should().Throw(); + ctor2.Should().Throw(); + ctor3.Should().Throw(); + } + + private sealed class Fixture + { + public IActorRuntime Runtime { get; } + public IActorDispatchPort Dispatch { get; } + public UserAgentCatalogProjectionPort Projection { get; } + public IProjectionScopeActivationService Activation { get; } + public List Captured { get; } = new(); + public WorkflowAgentCommandPort Port { get; } + + public Fixture() + { + Runtime = Substitute.For(); + Dispatch = Substitute.For(); + Projection = CreateProjectionPort(out var activation); + Activation = activation; + Dispatch.DispatchAsync(Arg.Any(), Arg.Do(env => Captured.Add(env)), Arg.Any()) + .Returns(Task.CompletedTask); + Port = new WorkflowAgentCommandPort(Runtime, Dispatch, Projection); + } + + public static UserAgentCatalogProjectionPort CreateProjectionPort( + out IProjectionScopeActivationService activation) + { + activation = Substitute.For>(); + var lease = new UserAgentCatalogMaterializationRuntimeLease( + new UserAgentCatalogMaterializationContext + { + RootActorId = UserAgentCatalogGAgent.WellKnownId, + ProjectionKind = UserAgentCatalogProjectionPort.ProjectionKind, + }); + activation.EnsureAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(lease)); + return new UserAgentCatalogProjectionPort(activation); + } + } +} diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowAgentGAgentTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowAgentGAgentTests.cs index ae501cdd9..4e307c80b 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowAgentGAgentTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowAgentGAgentTests.cs @@ -1,11 +1,17 @@ +using System.Reflection; using Aevatar.CQRS.Core.Abstractions.Commands; using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Abstractions.Persistence; +using Aevatar.Foundation.Core; using Aevatar.Foundation.Core.EventSourcing; using Aevatar.Workflow.Application.Abstractions.Runs; using FluentAssertions; +using Google.Protobuf; using Microsoft.Extensions.DependencyInjection; +using NSubstitute; using Xunit; +using Aevatar.GAgents.Channel.Runtime; +using Aevatar.GAgents.Scheduled; namespace Aevatar.GAgents.ChannelRuntime.Tests; @@ -17,17 +23,8 @@ public sealed class WorkflowAgentGAgentTests : IAsyncLifetime public async Task InitializeAsync() { - var services = new ServiceCollection(); - services.AddSingleton(); - services.AddSingleton(); - services.AddTransient( - typeof(IEventSourcingBehaviorFactory<>), - typeof(DefaultEventSourcingBehaviorFactory<>)); - _dispatchService = new CapturingWorkflowDispatchService(); - services.AddSingleton>(_dispatchService); - - _serviceProvider = services.BuildServiceProvider(); + _serviceProvider = BuildServiceProvider(_dispatchService); _agent = new WorkflowAgentGAgent { Services = _serviceProvider, @@ -73,6 +70,61 @@ await _agent.HandleTriggerAsync(new TriggerWorkflowAgentExecutionCommand _dispatchService.LastCommand.Metadata.Should().Contain(new KeyValuePair("scope_id", "scope-1")); } + [Fact] + public async Task HandleInitializeAsync_ShouldDispatchCatalogCommandsThroughDispatchPort() + { + var catalogActor = Substitute.For(); + var runtime = Substitute.For(); + runtime.GetAsync(UserAgentCatalogGAgent.WellKnownId) + .Returns(Task.FromResult(catalogActor)); + + var dispatch = Substitute.For(); + var captured = new List(); + dispatch.DispatchAsync( + UserAgentCatalogGAgent.WellKnownId, + Arg.Do(captured.Add), + Arg.Any()) + .Returns(Task.CompletedTask); + + using var provider = BuildServiceProvider( + new CapturingWorkflowDispatchService(), + services => + { + services.AddSingleton(runtime); + services.AddSingleton(dispatch); + }); + var agent = new WorkflowAgentGAgent + { + Services = provider, + EventSourcingBehaviorFactory = + provider.GetRequiredService>(), + }; + AssignActorId(agent, "workflow-agent-dispatch-test"); + await agent.ActivateAsync(); + + await agent.HandleInitializeAsync(new InitializeWorkflowAgentCommand + { + WorkflowId = "social-media-agent-1", + WorkflowName = "social_media_agent_1", + WorkflowActorId = "workflow-actor-1", + ExecutionPrompt = "Generate the scheduled social media draft for review.", + ConversationId = "oc_chat_1", + NyxProviderSlug = "api-lark-bot", + NyxApiKey = "nyx-api-key-1", + Enabled = true, + ScopeId = "scope-1", + }); + + captured.Should().HaveCount(2); + captured[0].Payload.Is(UserAgentCatalogUpsertCommand.Descriptor).Should().BeTrue(); + captured[1].Payload.Is(UserAgentCatalogExecutionUpdateCommand.Descriptor).Should().BeTrue(); + captured.Should().OnlyContain(envelope => + envelope.Route.PublisherActorId == "workflow-agent-dispatch-test" && + envelope.Route.Direct.TargetActorId == UserAgentCatalogGAgent.WellKnownId); + await catalogActor.DidNotReceive() + .HandleEventAsync(Arg.Any(), Arg.Any()); + } + private sealed class CapturingWorkflowDispatchService : ICommandDispatchService { @@ -92,6 +144,30 @@ public Task? configure = null) + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient( + typeof(IEventSourcingBehaviorFactory<>), + typeof(DefaultEventSourcingBehaviorFactory<>)); + services.AddSingleton>(dispatchService); + configure?.Invoke(services); + return services.BuildServiceProvider(); + } + + private static void AssignActorId(GAgentBase agent, string actorId) + { + var setIdMethod = typeof(GAgentBase).GetMethod( + "SetId", + BindingFlags.Instance | BindingFlags.NonPublic); + setIdMethod.Should().NotBeNull(); + setIdMethod!.Invoke(agent, [actorId]); + } + private sealed class InMemoryEventStore : IEventStore { private readonly Dictionary> _events = new(StringComparer.Ordinal); diff --git a/tools/ci/channel_card_literal_guard.sh b/tools/ci/channel_card_literal_guard.sh index 8589faf3c..11a5b65df 100755 --- a/tools/ci/channel_card_literal_guard.sh +++ b/tools/ci/channel_card_literal_guard.sh @@ -10,11 +10,11 @@ cd "${REPO_ROOT}" # # Outbound rich interactions must flow through a composer + dispatcher; raw Lark 2.0 card # JSON literals (msg_type=interactive / schema=2.0 / tag=button / tag=form / tag=input) -# inside agents/Aevatar.GAgents.ChannelRuntime or agents/Aevatar.GAgents.NyxidChat are a +# inside agents/Aevatar.GAgents.Authoring.Lark or agents/Aevatar.GAgents.NyxidChat are a # regression signal that someone sidestepped the composer. # # Scan: -# - agents/Aevatar.GAgents.ChannelRuntime/**/*.cs +# - agents/Aevatar.GAgents.Authoring.Lark/**/*.cs # - agents/Aevatar.GAgents.NyxidChat/**/*.cs # Exclude: # - */bin/*, */obj/* @@ -41,7 +41,7 @@ violations="" # the guard grandfathers them so new code cannot add further offenders while this list shrinks. # TODO(#350-followup): migrate AgentBuilderCardFlow onto IChannelMessageComposerRegistry then drop the allowlist entry. allowlist=( - "agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderCardFlow.cs" + "agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardFlow.cs" ) is_allowlisted() { @@ -86,7 +86,7 @@ ${hits}" done < <(rg --files "${project_root}" -g '*.cs' || true) } -scan_project "agents/Aevatar.GAgents.ChannelRuntime" +scan_project "agents/Aevatar.GAgents.Authoring.Lark" scan_project "agents/Aevatar.GAgents.NyxidChat" if [ -n "${violations}" ]; then diff --git a/tools/ci/channel_relay_nyx_chat_direct_create_guard.sh b/tools/ci/channel_relay_nyx_chat_direct_create_guard.sh index 2ffea9914..ab93fd433 100755 --- a/tools/ci/channel_relay_nyx_chat_direct_create_guard.sh +++ b/tools/ci/channel_relay_nyx_chat_direct_create_guard.sh @@ -6,7 +6,7 @@ repo_root="$(cd "$(dirname "$0")/../.." && pwd)" cd "${repo_root}" if rg -n "CreateAsync|GetAsync" \ - agents/Aevatar.GAgents.ChannelRuntime \ + agents/Aevatar.GAgents.Channel.Runtime \ agents/channels then echo "Channel relay/runtime code must not talk to NyxIdChatGAgent directly. Go through ConversationGAgent + deferred LLM reply pipeline." diff --git a/tools/ci/channel_tombstone_proto_field_guard.sh b/tools/ci/channel_tombstone_proto_field_guard.sh index f8be28bc2..98f0ef709 100755 --- a/tools/ci/channel_tombstone_proto_field_guard.sh +++ b/tools/ci/channel_tombstone_proto_field_guard.sh @@ -10,13 +10,13 @@ cd "${REPO_ROOT}" # device registration proto must carry a tombstone marker field. # # Two naming conventions are accepted: -# - `bool is_deleted` — used in the new dotted Aevatar.GAgents.Channel.* +# - `bool is_deleted` — used by the dotted Aevatar.GAgents.Channel.* # abstractions tree (agents/Aevatar.GAgents.Channel.Runtime/protos/**). -# - `bool tombstoned` — used in the legacy Aevatar.GAgents.ChannelRuntime -# tree and the Channel RFC §7.1.1 projector watermark coordination -# pattern. Implementations on that side also carry -# `int64 tombstone_state_version`, but only the boolean is enforced -# here. +# - `bool tombstoned` — used by the per-package proto trees split out of +# the original ChannelRuntime megamodule (Aevatar.GAgents.{Channel.Runtime, +# Scheduled,Device}) following Channel RFC §7.1.1 projector watermark +# coordination. Implementations on that side also carry +# `int64 tombstone_state_version`, but only the boolean is enforced here. # # Scope: every .proto file under agents/. Issue #265 item 1.4 enforces the # tombstone contract without carve-outs. diff --git a/tools/ci/solution_split_guards.sh b/tools/ci/solution_split_guards.sh index a81ba38be..9055dff63 100755 --- a/tools/ci/solution_split_guards.sh +++ b/tools/ci/solution_split_guards.sh @@ -9,6 +9,7 @@ filters=( "aevatar.foundation.slnf" "aevatar.channels.slnf" "aevatar.platforms.slnf" + "aevatar.agents.slnf" "aevatar.ai.slnf" "aevatar.cqrs.slnf" "aevatar.workflow.slnf"