diff --git a/agents/Aevatar.GAgents.Channel.Runtime/ChannelMetadataKeys.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelMetadataKeys.cs index 691c4852d..4e4f13f98 100644 --- a/agents/Aevatar.GAgents.Channel.Runtime/ChannelMetadataKeys.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelMetadataKeys.cs @@ -26,6 +26,8 @@ public static class ChannelMetadataKeys /// channel-delivery route selected by the inbound adapter. /// public const string OutboundProviderSlug = "channel.outbound.provider_slug"; + /// Exact NyxID UserService id for when known by the host. + public const string OutboundProviderUserServiceId = "channel.outbound.user_service_id"; /// Provider-interpreted primary outbound address for the current channel turn. public const string DeliveryAddressId = "channel.delivery.address_id"; /// Provider-interpreted type for . diff --git a/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/AgentTurnToolCatalogMaterializer.cs b/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/AgentTurnToolCatalogMaterializer.cs index 056f525a0..9f40c9c0b 100644 --- a/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/AgentTurnToolCatalogMaterializer.cs +++ b/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/AgentTurnToolCatalogMaterializer.cs @@ -100,6 +100,21 @@ public sealed class AgentTurnToolCatalogMaterializer : IAgentProfileTurnToolCata "agent_builder", }; + private static readonly IReadOnlySet DirectScheduledAutomationToolNames = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "ask_user", + "use_skill", + "scheduled_agent_creator", + "agent_builder", + }; + + private static readonly IReadOnlySet ScheduledAutomationClarificationToolNames = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "ask_user", + }; + private static readonly IReadOnlySet ScheduledAutomationExclusiveToolNames = new HashSet(StringComparer.OrdinalIgnoreCase) { @@ -1036,11 +1051,15 @@ private static bool HasScheduledAutomationIntent(string userMessage) "recurring", "repeat", "daily", + "weekday", + "weekdays", "weekly", "monthly", "hourly", "remind", + "reminds", "reminder", + "reminders", "automation", "automate") || ContainsAny( @@ -1107,12 +1126,20 @@ private static void ApplyProfileTaskRouteIntentFilter( return; } - if (HasScheduledAutomationIntent(userMessage ?? string.Empty)) + var normalizedUserMessage = userMessage ?? string.Empty; + if (HasScheduledAutomationIntent(normalizedUserMessage)) { - AddAvailableScheduledAutomationTools(availableToolNames, selectedToolNames); + var scheduledAutomationToolNames = SelectScheduledAutomationToolNames(normalizedUserMessage, availableToolNames); + AddAvailableScheduledAutomationTools( + availableToolNames, + selectedToolNames, + scheduledAutomationToolNames); selectedToolNames.RemoveWhere(name => ManagedWorkflowExecutionToolNames.Contains(name) && !ScheduledAutomationToolNames.Contains(name)); + selectedToolNames.RemoveWhere(name => + ScheduledAutomationToolNames.Contains(name) && + !scheduledAutomationToolNames.Contains(name)); return; } @@ -1151,13 +1178,17 @@ private static bool TryCreateScheduledAutomationFallbackNames( out HashSet fallbackNames) { fallbackNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var normalizedUserMessage = userMessage ?? string.Empty; if (!string.Equals(intentId, ProfileTaskRouteIntentId, StringComparison.Ordinal) || - !HasScheduledAutomationIntent(userMessage ?? string.Empty)) + !HasScheduledAutomationIntent(normalizedUserMessage)) { return false; } - AddAvailableScheduledAutomationTools(availableToolNames, fallbackNames); + AddAvailableScheduledAutomationTools( + availableToolNames, + fallbackNames, + SelectScheduledAutomationToolNames(normalizedUserMessage, availableToolNames)); return fallbackNames.Count > 0; } @@ -1187,10 +1218,14 @@ private static bool TryCreateOrdinaryScheduledAutomationFallbackNames( out HashSet fallbackNames) { fallbackNames = new HashSet(StringComparer.OrdinalIgnoreCase); - if (!HasScheduledAutomationIntent(userMessage ?? string.Empty)) + var normalizedUserMessage = userMessage ?? string.Empty; + if (!HasScheduledAutomationIntent(normalizedUserMessage)) return false; - AddAvailableScheduledAutomationTools(availableToolNames, fallbackNames); + AddAvailableScheduledAutomationTools( + availableToolNames, + fallbackNames, + SelectScheduledAutomationToolNames(normalizedUserMessage, availableToolNames)); return fallbackNames.Count > 0; } @@ -1207,15 +1242,64 @@ private static void AddAvailableManagedWorkflowTools( private static void AddAvailableScheduledAutomationTools( IReadOnlySet availableToolNames, - HashSet selectedToolNames) + HashSet selectedToolNames, + IReadOnlySet candidateToolNames) { - foreach (var name in ScheduledAutomationToolNames) + foreach (var name in candidateToolNames) { if (availableToolNames.Contains(name)) selectedToolNames.Add(name); } } + private static IReadOnlySet SelectScheduledAutomationToolNames( + string userMessage, + IReadOnlySet availableToolNames) + { + if (HasExplicitOrnnSkillAutomationIntent(userMessage)) + return ScheduledAutomationToolNames; + + if (!HasRecurringScheduledAutomationIntent(userMessage)) + return DirectScheduledAutomationToolNames; + + return HasAvailableDirectScheduledAutomationTool(availableToolNames) + ? ScheduledAutomationClarificationToolNames + : ScheduledAutomationToolNames; + } + + private static bool HasAvailableDirectScheduledAutomationTool(IReadOnlySet availableToolNames) => + availableToolNames.Contains("use_skill") || + availableToolNames.Contains("scheduled_agent_creator") || + availableToolNames.Contains("agent_builder"); + + private static bool HasExplicitOrnnSkillAutomationIntent(string userMessage) + { + var tokens = TokenizeSelectionText(userMessage); + return HasAnyToken(tokens, "ornn", "skill", "skills", "publish", "published", "reuse", "reusable") || + ContainsAny(userMessage, "技能", "发布", "复用", "可复用"); + } + + private static bool HasRecurringScheduledAutomationIntent(string userMessage) + { + var tokens = TokenizeSelectionText(userMessage); + return HasAnyToken( + tokens, + "every", + "each", + "per", + "daily", + "weekday", + "weekdays", + "weekly", + "monthly", + "hourly", + "recurring", + "repeat", + "repeats", + "repeated") || + ContainsAny(userMessage, "每天", "每日", "每周", "每月", "每小时", "周期", "循环", "重复", "长期", "持续"); + } + private static bool HasContextToken(IReadOnlySet tokens) => HasAnyToken(tokens, "profile", "context", "preference", "preferences", "settings", "config", "account", "user", "me"); diff --git a/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs b/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs index 27038ca9f..57cdbfa3c 100644 --- a/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs @@ -2123,10 +2123,9 @@ private async Task BuildReplyChannelContextAsync( // as the failure-notification provider so a failed outbound delivery // (e.g. cross-tenant Lark 99992364) can still notify the user via the bot they just // successfully messaged. See issue #423 §C and ChannelMetadataKeys.InboundChannelBotProxySlug. + ScheduledDeliveryMetadataBuilder.ApplyDefaultOutboundProvider(metadata, inboundEvent.NyxProviderSlug); if (!string.IsNullOrWhiteSpace(inboundEvent.NyxProviderSlug)) { - metadata[ChannelMetadataKeys.InboundChannelBotProxySlug] = inboundEvent.NyxProviderSlug; - metadata[ChannelMetadataKeys.OutboundProviderSlug] = inboundEvent.NyxProviderSlug; // The inbound bot is also the default OUTBOUND delivery provider for a chat-triggered // scheduled task: the scheduled run replies via the same Lark bot that received the // message, so scheduled_agent_creator can resolve a provider without manual Studio/Web @@ -2156,21 +2155,12 @@ private async Task BuildReplyChannelContextAsync( AddIdentityHint(identityHints, "conversation", "platform", larkChatId); } - var deliveryAddressId = NormalizeOptional(activity?.TransportExtras?.DeliveryAddressId); - if (!string.IsNullOrWhiteSpace(deliveryAddressId)) - metadata[ChannelMetadataKeys.DeliveryAddressId] = deliveryAddressId; - - var deliveryAddressType = NormalizeOptional(activity?.TransportExtras?.DeliveryAddressType); - if (!string.IsNullOrWhiteSpace(deliveryAddressType)) - metadata[ChannelMetadataKeys.DeliveryAddressType] = deliveryAddressType; - - var deliveryFallbackAddressId = NormalizeOptional(activity?.TransportExtras?.DeliveryFallbackAddressId); - if (!string.IsNullOrWhiteSpace(deliveryFallbackAddressId)) - metadata[ChannelMetadataKeys.DeliveryFallbackAddressId] = deliveryFallbackAddressId; - - var deliveryFallbackAddressType = NormalizeOptional(activity?.TransportExtras?.DeliveryFallbackAddressType); - if (!string.IsNullOrWhiteSpace(deliveryFallbackAddressType)) - metadata[ChannelMetadataKeys.DeliveryFallbackAddressType] = deliveryFallbackAddressType; + ScheduledDeliveryMetadataBuilder.ApplyDeliveryAddress( + metadata, + activity?.TransportExtras?.DeliveryAddressId, + activity?.TransportExtras?.DeliveryAddressType, + activity?.TransportExtras?.DeliveryFallbackAddressId, + activity?.TransportExtras?.DeliveryFallbackAddressType); var larkOperatorUserId = NormalizeOptional(activity?.TransportExtras?.NyxLarkOperatorUserId); if (!string.IsNullOrWhiteSpace(larkOperatorUserId)) diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionsOptions.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionsOptions.cs index 6c8abd8e9..b5abde396 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionsOptions.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionsOptions.cs @@ -5,4 +5,8 @@ public sealed class NyxIdAssistantActionsOptions public const string ConfigSection = "Aevatar:NyxId:AssistantActions"; public bool Enabled { get; set; } + + public string? ScheduledDeliveryProviderSlug { get; set; } + + public string? ScheduledDeliveryProviderUserServiceId { get; set; } } diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationGAgent.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationGAgent.cs index bbfb021c0..c9ba6da6b 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationGAgent.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationGAgent.cs @@ -201,7 +201,7 @@ public async Task HandleCreateConversationAsync( ArgumentNullException.ThrowIfNull(command); var scopeId = NormalizeRequired(command.ScopeId, nameof(command.ScopeId)); var ownerSubject = command.FirstTurn is null - ? null + ? NormalizeRequired(command.OwnerSubject, nameof(command.OwnerSubject)) : NormalizeRequired( command.FirstTurn.ToolContext?.Caller?.OwnerSubject, "owner_subject"); diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Streaming.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Streaming.cs index 51650da63..637699f5a 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Streaming.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Streaming.cs @@ -281,7 +281,11 @@ await NyxIdChatAguiSseEventWriter.WriteAsync( } else { - var metadata = new Dictionary(StringComparer.Ordinal); + var assistantActionsOptions = http.RequestServices.GetService(); + var metadata = ScheduledDeliveryMetadataBuilder.CreateNyxIdAssistantMetadata( + actorId, + assistantActionsOptions?.ScheduledDeliveryProviderSlug, + assistantActionsOptions?.ScheduledDeliveryProviderUserServiceId); var llmControl = await BuildLlmControlAsync(http, accessToken, ct); var rawInputParts = request.InputParts?.Select(static part => part.ToProto()).ToArray() ?? []; var commandId = NyxIdChatPublicIdentity.CreateChatCommandId( diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs index aaa06d8c8..085d767c3 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs @@ -129,7 +129,10 @@ private static async Task HandleCreateConversationAsync( // Refactor (iter56/cluster-891-endpoint-ack-honesty): old=200-shaped accepted, new=202 + Location // The create facade returns accepted/admission-visible command trace, not read-model-observed conversation state. // Clients must poll the conversation list or observe the stream/status path instead of treating this body as committed. - var receipt = await lifecycleFacade.CreateConversationAsync(scopeId, ct); + if (!AevatarPrincipalSubjectResolver.TryResolveNyxIdSubject(http.User, out var ownerSubject)) + return Results.Unauthorized(); + + var receipt = await lifecycleFacade.CreateConversationAsync(scopeId, ownerSubject, ct); return receipt.Status switch { NyxIdChatConversationCreateStatus.Accepted => Results.Accepted( diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatInteraction.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatInteraction.cs index ad3913574..95fd47d4d 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatInteraction.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatInteraction.cs @@ -415,6 +415,7 @@ public async Task CreateConversationAsync( string scopeId, + string ownerSubject, CancellationToken ct = default) { // Refactor (iter77/cluster-077-cqrs-command-outcome-stream-rpc): @@ -86,6 +87,7 @@ public async Task CreateConversationAsync( new NyxIdChatConversationCreateCommand { ScopeId = NormalizeRequired(scopeId, nameof(scopeId)), + OwnerSubject = NormalizeRequired(ownerSubject, nameof(ownerSubject)), }, ct); diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatTaskLifecycle.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatTaskLifecycle.cs index 90d17faa2..665127e06 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatTaskLifecycle.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatTaskLifecycle.cs @@ -269,8 +269,7 @@ NyxIdChatAuthorizationResumeRequirement.CompleteOriginalServiceRequest or next, normalizedSignal.Key, now, - normalizedSignal.Tool.Receipt.MutationStage == - AgentToolReceiptMutationStage.ReadModelObserved); + HasProviderConfirmedMutationStage(normalizedSignal.Tool.Receipt.MutationStage)); } else if (currentStep.Kind == NyxIdChatStepKind.Tool && FindCurrentStep(next, operationKey)?.Status is @@ -1542,11 +1541,15 @@ private static NyxIdChatTaskStepState BuildVerificationStep( return step; } + private static bool HasProviderConfirmedMutationStage(AgentToolReceiptMutationStage mutationStage) => + mutationStage is AgentToolReceiptMutationStage.Accepted or + AgentToolReceiptMutationStage.ReadModelObserved; + private static NyxIdChatOperationDispatchCommand? ActivatePlannedVerificationStep( NyxIdChatConversationGAgentState state, NyxIdChatOperationKey completedToolKey, Timestamp now, - bool mutationReadModelObserved = false) + bool providerConfirmedMutationStage = false) { var step = state.ActiveTask.Steps.SingleOrDefault(candidate => candidate.Kind is (NyxIdChatStepKind.Llm or NyxIdChatStepKind.Postcondition) && @@ -1556,10 +1559,10 @@ candidate.Kind is (NyxIdChatStepKind.Llm or NyxIdChatStepKind.Postcondition) && if (step?.Operation?.Key is null) return null; - if (mutationReadModelObserved) + if (providerConfirmedMutationStage) { step.Kind = NyxIdChatStepKind.Llm; - step.Description = "Communicate the typed mutation result observed from its canonical read model."; + step.Description = "Communicate the provider-confirmed typed mutation result."; step.Source = new NyxIdChatStepSource { Llm = new NyxIdChatLLMStepSource() }; step.Operation.Kind = NyxIdChatStepKind.Llm; } diff --git a/agents/Aevatar.GAgents.NyxidChat/ScheduledDeliveryMetadataBuilder.cs b/agents/Aevatar.GAgents.NyxidChat/ScheduledDeliveryMetadataBuilder.cs new file mode 100644 index 000000000..174562715 --- /dev/null +++ b/agents/Aevatar.GAgents.NyxidChat/ScheduledDeliveryMetadataBuilder.cs @@ -0,0 +1,71 @@ +using Aevatar.GAgents.Channel.Runtime; + +namespace Aevatar.GAgents.NyxidChat; + +internal static class ScheduledDeliveryMetadataBuilder +{ + internal const string NyxIdAssistantPlatform = "nyxid-chat"; + + public static Dictionary CreateNyxIdAssistantMetadata( + string conversationId, + string? providerSlug, + string? providerUserServiceId) + { + var metadata = new Dictionary(StringComparer.Ordinal) + { + [ChannelMetadataKeys.Platform] = NyxIdAssistantPlatform, + [ChannelMetadataKeys.ConversationId] = conversationId, + }; + + ApplyDefaultOutboundProvider(metadata, providerSlug, providerUserServiceId); + ApplyDeliveryAddress(metadata, conversationId, NyxIdAssistantPlatform, null, null); + return metadata; + } + + public static void ApplyDefaultOutboundProvider( + IDictionary metadata, + string? providerSlug, + string? providerUserServiceId) + { + ApplyDefaultOutboundProvider(metadata, providerSlug); + PutIfPresent(metadata, ChannelMetadataKeys.OutboundProviderUserServiceId, providerUserServiceId); + } + + public static void ApplyDefaultOutboundProvider( + IDictionary metadata, + string? providerSlug) + { + var normalizedProviderSlug = Normalize(providerSlug); + if (normalizedProviderSlug is null) + return; + + metadata[ChannelMetadataKeys.InboundChannelBotProxySlug] = normalizedProviderSlug; + metadata[ChannelMetadataKeys.OutboundProviderSlug] = normalizedProviderSlug; + } + + public static void ApplyDeliveryAddress( + IDictionary metadata, + string? addressId, + string? addressType, + string? fallbackAddressId, + string? fallbackAddressType) + { + PutIfPresent(metadata, ChannelMetadataKeys.DeliveryAddressId, addressId); + PutIfPresent(metadata, ChannelMetadataKeys.DeliveryAddressType, addressType); + PutIfPresent(metadata, ChannelMetadataKeys.DeliveryFallbackAddressId, fallbackAddressId); + PutIfPresent(metadata, ChannelMetadataKeys.DeliveryFallbackAddressType, fallbackAddressType); + } + + private static void PutIfPresent( + IDictionary metadata, + string key, + string? value) + { + var normalized = Normalize(value); + if (normalized is not null) + metadata[key] = normalized; + } + + private static string? Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/agents/Aevatar.GAgents.NyxidChat/protos/agent_run.proto b/agents/Aevatar.GAgents.NyxidChat/protos/agent_run.proto index bb2d97e7f..da1aeac62 100644 --- a/agents/Aevatar.GAgents.NyxidChat/protos/agent_run.proto +++ b/agents/Aevatar.GAgents.NyxidChat/protos/agent_run.proto @@ -1012,6 +1012,7 @@ message NyxIdChatConversationCreateCommand { string requested_actor_id = 5; aevatar.gagentservice.AgentProfileReference agent_profile_reference = 6; aevatar.ai.ConversationContextAttachmentSet context_attachments = 7; + string owner_subject = 8; } message NyxIdChatConversationDeleteCommand { diff --git a/agents/Aevatar.GAgents.Scheduled/Authoring/ScheduledAgentCreateRequestMapper.cs b/agents/Aevatar.GAgents.Scheduled/Authoring/ScheduledAgentCreateRequestMapper.cs index 5c625accc..f33af9d09 100644 --- a/agents/Aevatar.GAgents.Scheduled/Authoring/ScheduledAgentCreateRequestMapper.cs +++ b/agents/Aevatar.GAgents.Scheduled/Authoring/ScheduledAgentCreateRequestMapper.cs @@ -1,11 +1,14 @@ using System.Text.Json; +using Aevatar.AI.Abstractions; using Aevatar.AI.Abstractions.ToolProviders; using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Abstractions.Credentials; using Aevatar.GAgentService.Abstractions.Schedules; +using Aevatar.GAgentService.Abstractions.Schedules.Authorization; using Aevatar.GAgents.Channel.Runtime; using Aevatar.Workflow.Abstractions; using Aevatar.Workflow.Application.Abstractions.Schedules; +using Google.Protobuf.WellKnownTypes; namespace Aevatar.GAgents.Scheduled; @@ -121,7 +124,12 @@ public ScheduledAgentCreatePlanResult Plan(string argumentsJson, OwnerScope call return ScheduledAgentCreatePlanResult.Failed(requiredNyxServicesError); var requestedOutboundSlug = Normalize(args.Str("nyx_provider_slug")); - var primaryOutboundUserServiceId = Normalize(args.Str("nyx_user_service_id")) ?? string.Empty; + var requestedOutboundUserServiceId = Normalize(args.Str("nyx_user_service_id")); + var contextOutboundUserServiceId = Normalize(AgentToolRequestContext.TryGetExternalMetadata( + ChannelMetadataKeys.OutboundProviderUserServiceId)); + var primaryOutboundUserServiceId = requestedOutboundSlug is null + ? requestedOutboundUserServiceId ?? contextOutboundUserServiceId ?? string.Empty + : requestedOutboundUserServiceId ?? string.Empty; if (requestedOutboundSlug is not null && scheduleMode != ScheduledAgentScheduleMode.OneShot) return ScheduledAgentCreatePlanResult.Failed("nyx_provider_slug is only supported for one_shot schedules"); @@ -183,11 +191,13 @@ public ScheduledAgentCreatePlanResult Plan(string argumentsJson, OwnerScope call public ScheduledAgentCreateMapResult Map( ScheduledAgentCreatePlannedRequest request, ScheduledAgentApiKeyIssueResult issuedKey, - SecretReference secretReference) + SecretReference secretReference, + ValidatedScheduledInvocationAuthorizationPlan validatedPlan) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(issuedKey); ArgumentNullException.ThrowIfNull(secretReference); + ArgumentNullException.ThrowIfNull(validatedPlan); if (!issuedKey.Success || string.IsNullOrWhiteSpace(issuedKey.ApiKeyId)) return ScheduledAgentCreateMapResult.Failed("api_key_unavailable"); @@ -208,7 +218,8 @@ public ScheduledAgentCreateMapResult Map( ScheduleMode: request.ScheduleMode == ScheduledAgentScheduleMode.OneShot ? WorkflowScheduleMode.OneShotAtUtc : WorkflowScheduleMode.RecurringCron, - OneShotFireAt: request.OneShotRunAtUtc); + OneShotFireAt: request.OneShotRunAtUtc, + AuthorizationFact: BuildWorkflowAuthorizationFact(validatedPlan.Plan)); var catalog = BuildCatalogUpsertCommand(request, issuedKey, secretReference); @@ -397,6 +408,83 @@ private static WorkflowScheduleAuth BuildWorkflowScheduleAuth( issuedKey.KeyExpiresAtUnixMs, issuedKey.DurableOperationGrants)); + private static WorkflowScheduleAuthorizationFact BuildWorkflowAuthorizationFact( + ScheduledInvocationAuthorizationPlan plan) + { + var policy = plan.CredentialPolicy + ?? throw new InvalidOperationException("scheduled_authorization_policy_missing"); + var catalog = plan.CatalogAuthority; + if (catalog is null && policy.ServiceGrantRequirement != AuthorizationGrantRequirement.NotRequired) + throw new InvalidOperationException("scheduled_authorization_catalog_authority_missing"); + + var disclosure = plan.Disclosures.ToHashSet(); + var grants = plan.NyxIdServiceGrants.Select(static grant => + new WorkflowScheduleAuthorizationServiceGrant( + grant.UserServiceId, + grant.NodeIds.ToArray(), + grant.NodeGrantRequirement == AuthorizationGrantRequirement.NotRequired)) + .ToArray(); + + return new WorkflowScheduleAuthorizationFact( + plan.PermissionDigest, + policy.PolicyVersion, + new WorkflowScheduleAuthorizationOwner( + plan.Owner.Authority, + plan.Owner.OwnerKind.ToString(), + plan.Owner.OwnerSubject), + grants, + string.Join(' ', policy.Scopes.Select(ToScopeName).Order(StringComparer.Ordinal)), + policy.ExpiresAt.ToDateTimeOffset(), + policy.ServiceGrantRequirement == AuthorizationGrantRequirement.NotRequired, + new WorkflowScheduleAuthorizationDisclosure( + disclosure.Contains(ScheduledInvocationDisclosure.DedicatedCredential), + disclosure.Contains(ScheduledInvocationDisclosure.AevatarSecretCustody), + !disclosure.Contains(ScheduledInvocationDisclosure.BrowserNeverReceivesSecret), + disclosure.Contains(ScheduledInvocationDisclosure.DeleteRevokesCredential), + !disclosure.Contains(ScheduledInvocationDisclosure.PauseResumePreservesCredential)), + new WorkflowScheduleAuthorizationAuthority( + SourceVersion(plan, AuthorizationSourceKind.StudioMember), + SourceVersion(plan, AuthorizationSourceKind.WorkflowRevision), + SourceVersion(plan, AuthorizationSourceKind.ConnectorCatalog), + SourceVersion(plan, AuthorizationSourceKind.OwnerLlmRoute), + catalog?.ActorStateVersion ?? 0, + catalog?.ObservedAt?.ToDateTimeOffset() ?? default, + catalog?.FreshUntil?.ToDateTimeOffset() ?? default, + catalog?.ContentDigest ?? string.Empty, + catalog?.ContractVersion ?? string.Empty, + catalog?.PolicyVersion ?? string.Empty, + catalog?.EvaluatedAt?.ToDateTimeOffset() ?? default), + MapOwnerLLMSelection(plan.OwnerLlmSelection)); + } + + private static WorkflowScheduleOwnerLLMSelection? MapOwnerLLMSelection( + ScheduledInvocationOwnerLLMSelection? selection) => + selection is null + ? null + : new WorkflowScheduleOwnerLLMSelection( + selection.RouteKind switch + { + LLMRouteKind.Gateway => WorkflowScheduleOwnerLLMRouteKind.Gateway, + LLMRouteKind.NyxIdUserService => WorkflowScheduleOwnerLLMRouteKind.NyxIdUserService, + _ => WorkflowScheduleOwnerLLMRouteKind.Unspecified, + }, + selection.RouteValue, + selection.NyxIdUserServiceId, + selection.ServiceSlugSnapshot, + selection.Model); + + private static string ToScopeName(NyxIdCredentialScope scope) => scope switch + { + NyxIdCredentialScope.Read => "read", + NyxIdCredentialScope.Proxy => "proxy", + _ => throw new InvalidOperationException("scheduled_authorization_scope_invalid"), + }; + + private static long SourceVersion( + ScheduledInvocationAuthorizationPlan plan, + AuthorizationSourceKind sourceKind) => + plan.SourceStamps.FirstOrDefault(stamp => stamp.SourceKind == sourceKind)?.StateVersion ?? 0; + private static IReadOnlyDictionary BuildWorkflowHeaders( ScheduledAgentCreatePlannedRequest request, ScheduledAgentApiKeyIssueResult issuedKey) diff --git a/agents/Aevatar.GAgents.Scheduled/Authoring/ScheduledAgentCreatorTool.cs b/agents/Aevatar.GAgents.Scheduled/Authoring/ScheduledAgentCreatorTool.cs index 9d0e66002..900166b2a 100644 --- a/agents/Aevatar.GAgents.Scheduled/Authoring/ScheduledAgentCreatorTool.cs +++ b/agents/Aevatar.GAgents.Scheduled/Authoring/ScheduledAgentCreatorTool.cs @@ -1,7 +1,9 @@ using System.Text.Json; +using Aevatar.AI.Abstractions; using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.AI.Abstractions.ToolProviders; using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Tools; using Aevatar.GAgentService.Abstractions.Schedules.Authorization; using Aevatar.GAgents.Scheduled; using Aevatar.Workflow.Abstractions; @@ -240,7 +242,7 @@ public async Task ExecuteAsync(string argumentsJson, CancellationToken c return provisioned.IssuedKey.ToErrorJson(); var key = provisioned.IssuedKey; - var mapped = _mapper.Map(plan.Request!, key, provisioned.SecretReference!); + var mapped = _mapper.Map(plan.Request!, key, provisioned.SecretReference!, validation.ValidatedPlan!); if (!mapped.Success) { await _credentialLifecycle.RequestRevocationAsync( @@ -270,6 +272,76 @@ await _credentialLifecycle.RequestRevocationAsync( }); } + public AgentToolReceipt? CreateResultReceipt( + string callId, + string toolName, + string argumentsJson, + string resultJson) + { + try + { + using var document = JsonDocument.Parse(resultJson ?? string.Empty); + var root = document.RootElement; + if (root.TryGetProperty("error", out var errorElement)) + { + var error = ResolveErrorCode(root, errorElement); + return new AgentToolReceipt + { + CallId = callId ?? string.Empty, + ToolName = string.IsNullOrWhiteSpace(toolName) ? Name : toolName, + Status = AgentToolReceiptStatus.Error, + ResultJson = resultJson ?? string.Empty, + ErrorCode = error, + ErrorMessage = error, + FailureOutcome = AgentToolFailureOutcome.CalleeConfirmed, + }; + } + + if (!root.TryGetProperty("status", out var statusElement) || + statusElement.ValueKind != JsonValueKind.String || + !string.Equals(statusElement.GetString()?.Trim(), "accepted", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var agentId = root.TryGetProperty("agent_id", out var agentElement) && + agentElement.ValueKind == JsonValueKind.String + ? agentElement.GetString()?.Trim() + : string.Empty; + + return new AgentToolReceipt + { + CallId = callId ?? string.Empty, + ToolName = string.IsNullOrWhiteSpace(toolName) ? Name : toolName, + Status = AgentToolReceiptStatus.Success, + Effect = AgentToolReceiptEffect.Mutating, + ResultJson = resultJson ?? string.Empty, + SubjectKind = "scheduled_agent", + SubjectId = agentId ?? string.Empty, + MutationStage = AgentToolReceiptMutationStage.Accepted, + }; + } + catch (JsonException) + { + return null; + } + } + + private static string ResolveErrorCode(JsonElement root, JsonElement errorElement) + { + if (errorElement.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(errorElement.GetString())) + return errorElement.GetString()!; + + if (root.TryGetProperty("detail", out var detailElement) && + detailElement.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(detailElement.GetString())) + { + return detailElement.GetString()!; + } + + return "scheduled_agent_create_failed"; + } + private ScheduledInvocationAuthorizationRequest? BuildAuthorizationRequest( ScheduledAgentCreatePlanResult plan, OwnerScope caller, diff --git a/agents/Aevatar.GAgents.Scheduled/ChannelMetadataCallerScopeResolver.cs b/agents/Aevatar.GAgents.Scheduled/ChannelMetadataCallerScopeResolver.cs index 814186db7..3715d17aa 100644 --- a/agents/Aevatar.GAgents.Scheduled/ChannelMetadataCallerScopeResolver.cs +++ b/agents/Aevatar.GAgents.Scheduled/ChannelMetadataCallerScopeResolver.cs @@ -38,16 +38,20 @@ public ChannelMetadataCallerScopeResolver(INyxIdCurrentUserResolver nyxIdCurrent public async Task TryResolveAsync(CancellationToken ct = default) { - var platform = NormalizeOptional(AgentToolRequestContext.ChannelPlatform); + var context = AgentToolRequestContext.Current; + var platform = NormalizeOptional(context?.Channel.Platform); if (platform is null) { // Not a channel-surface request; let the composite try the next resolver. return null; } - var senderId = NormalizeOptional(AgentToolRequestContext.ChannelSenderId); + var senderId = NormalizeOptional(context?.Channel.SenderId); if (senderId is null) { + if (context?.Chat.Surface == AgentChatInvocationSurface.NyxIdAssistant) + return null; + throw new CallerScopeUnavailableException( $"Channel platform metadata is present (platform=\"{platform}\") but channel.sender_id is missing. Cannot scope agent operations safely."); } diff --git a/agents/Aevatar.GAgents.Scheduled/NyxIdNativeCallerScopeResolver.cs b/agents/Aevatar.GAgents.Scheduled/NyxIdNativeCallerScopeResolver.cs index 744279fa1..cd625c487 100644 --- a/agents/Aevatar.GAgents.Scheduled/NyxIdNativeCallerScopeResolver.cs +++ b/agents/Aevatar.GAgents.Scheduled/NyxIdNativeCallerScopeResolver.cs @@ -27,6 +27,13 @@ public NyxIdNativeCallerScopeResolver(INyxIdCurrentUserResolver nyxIdCurrentUser public async Task TryResolveAsync(CancellationToken ct = default) { + var verifiedCallerSubject = NormalizeOptional(AgentToolRequestContext.Current?.Caller.OwnerSubject); + if (AgentToolRequestContext.Current?.Chat.Surface == AgentChatInvocationSurface.NyxIdAssistant && + verifiedCallerSubject is not null) + { + return OwnerScope.ForNyxIdNative(verifiedCallerSubject); + } + var token = AgentToolRequestContext.NyxIdAccessToken; if (string.IsNullOrWhiteSpace(token)) { @@ -45,4 +52,10 @@ public NyxIdNativeCallerScopeResolver(INyxIdCurrentUserResolver nyxIdCurrentUser return OwnerScope.ForNyxIdNative(nyxUserId.Trim()); } + + private static string? NormalizeOptional(string? value) + { + var normalized = (value ?? string.Empty).Trim(); + return normalized.Length == 0 ? null : normalized; + } } diff --git a/docs/contracts/nyxid-code-execution-conformance/v1/sources.json b/docs/contracts/nyxid-code-execution-conformance/v1/sources.json index 7d1ad5eb0..d3251a4fa 100644 --- a/docs/contracts/nyxid-code-execution-conformance/v1/sources.json +++ b/docs/contracts/nyxid-code-execution-conformance/v1/sources.json @@ -3,17 +3,17 @@ "nyxid": { "repository": "https://github.com/ChronoAIProject/NyxID.git", "tracked_ref": "main", - "reviewed_revision": "aab29289f1b7bf4a18530a5730cd45e1964f1784" + "reviewed_revision": "49ff362fed374b99e535980bb5f6c007de1c67b8" }, "wire_contract": { "revision": "nyxid-code-execution-wire.v1", "files": { - "backend/src/handlers/api_keys.rs": "3a92ee111928d1f51f215a193cd0f77334a2e78ab6375bea35e1f6ca67186931", + "backend/src/handlers/api_keys.rs": "bcda08182e3186fa30d9f8a6be81181b96ea3310bf8bbb89804ecdb886052793", "backend/src/handlers/keys.rs": "d9a048dc03e33fa1795581474472e9428ee1fdb3ef3eed586c6931d3bb8eea7c", - "backend/src/handlers/proxy.rs": "a60213a8d4c813e38020b8c2ccfe967fd58dffd27f92180945a5bc36a7c2dc39", + "backend/src/handlers/proxy.rs": "31a4825242480b6b2c7d6d22bba10aa1dffcb6ec852f7da80551ec285531364a", "backend/src/handlers/user_services_handler.rs": "f0afeb1315b581d597e3f076bef35795f967fc014d950ebd56e6dfad20843309", "backend/src/services/catalog_identity_service.rs": "bf27e10c89a6d419910db20f39fe535d3f8b987e6e1f57fc306ee78a151e6979", - "backend/src/services/unified_key_service.rs": "d901bce776dc7f78f21423aaedb473dc3b7f356d90adac6359d49893bad02038" + "backend/src/services/unified_key_service.rs": "1ba467b765b56f82862847d8c3ee3ab04a2a6504e3d7ec5fd0c57abea5163ec7" }, "required_markers": { "backend/src/handlers/api_keys.rs": [ diff --git a/src/Aevatar.Mainnet.Host.Api/appsettings.json b/src/Aevatar.Mainnet.Host.Api/appsettings.json index df965c0e3..4f38787d5 100644 --- a/src/Aevatar.Mainnet.Host.Api/appsettings.json +++ b/src/Aevatar.Mainnet.Host.Api/appsettings.json @@ -23,7 +23,9 @@ "EnableDebugDiagnostics": true }, "AssistantActions": { - "Enabled": true + "Enabled": true, + "ScheduledDeliveryProviderSlug": "aevatar", + "ScheduledDeliveryProviderUserServiceId": "3b94bb41-ea10-4b38-b8ea-c14e8897a3e8" } }, "CodexExecution": { @@ -111,7 +113,7 @@ "ProfileSlug": "nyxid-chat-default", "DisplayName": "NyxID Chat Default", "Purpose": "Default public NyxID chat surface with managed workflow execution and scheduled automation.", - "Instructions": "Help users through ordinary chat. Do not start a managed workflow for ordinary questions, small talk, general information requests, weather questions, explanations, or troubleshooting. Start a managed workflow only when the current user request clearly asks for a dinner reservation, dinner date, restaurant booking, or restaurant-selection task that matches the configured dinner_date workflow. Before starting that workflow, use any available current-user read-only profile, preference, or context tool that is relevant to the dinner task. Interpret recovered context semantically against the selected workflow's expected input: the current user message always overrides recovered defaults for the same meaning, recovered context fills only missing task inputs, and the assistant asks the user only for inputs still missing after applying the current message, recovered context, and obvious conversational defaults. For dinner reservation or dinner date requests, start the configured managed workflow directly with the current request and any recovered semantic values instead of asking for planning details up front; the workflow start dispatcher may enrich a sparse JSON object with recovered context before execution. If the user names one companion and no party size is otherwise available, use party_size 2. The exact configured workflow_id is dinner_date; do not use policy revision ids, template revision ids, display names, or workflow names as workflow_id. Start dinner_date with aevatar_start_workflow and build the workflow input according to the published dinner_date input contract. Map semantic values from the current request and recovered context into the selected workflow's contract fields when those fields are known; preserve nested contract object structure only when that nesting exists in the published contract shape; do not create new grouping objects outside the contract shape; do not wrap them in a new schema, invent source-specific preference field names, or copy raw preference text into workflow evidence fields.", + "Instructions": "Help users through ordinary chat. Do not start a managed workflow for ordinary questions, small talk, general information requests, weather questions, explanations, or troubleshooting. For explicit one-shot reminder requests, use scheduled_agent_creator directly when the request already contains the delay or run time and reminder message. For recurring scheduled automation, use scheduled_agent_creator directly only when the request also names a reusable skill_ref or explicitly asks to use a reusable Ornn skill. If an ordinary recurring reminder or scheduled task does not name a reusable skill, ask which reusable skill should run or whether the user wants a one-shot reminder instead; do not call scheduled_agent_creator first. Do not call ornn_search_skills, ornn_publish_skill, or agent_builder before handling ordinary scheduled task or reminder requests when a direct admitted scheduled automation tool can handle the request. Use Ornn skill search or publish only when no direct admitted scheduling or agent-management tool can handle the requested automation, or when the user explicitly asks to design, publish, or reuse a portable Ornn skill or reusable agent first. Call ornn_search_skills only when the user explicitly asks to find, browse, load, reuse, or inspect Ornn skills, names an external skill to use, or a required capability is unavailable from the admitted scheduled automation tools. Ask only for missing required scheduling fields such as timezone, destination, cadence, target action, or reusable skill_ref when recurring mode is requested. Start a managed workflow only when the current user request clearly asks for a dinner reservation, dinner date, restaurant booking, or restaurant-selection task that matches the configured dinner_date workflow. Before starting that workflow, use any available current-user read-only profile, preference, or context tool that is relevant to the dinner task. Interpret recovered context semantically against the selected workflow's expected input: the current user message always overrides recovered defaults for the same meaning, recovered context fills only missing task inputs, and the assistant asks the user only for inputs still missing after applying the current message, recovered context, and obvious conversational defaults. For dinner reservation or dinner date requests, start the configured managed workflow directly with the current request and any recovered semantic values instead of asking for planning details up front; the workflow start dispatcher may enrich a sparse JSON object with recovered context before execution. If the user names one companion and no party size is otherwise available, use party_size 2. The exact configured workflow_id is dinner_date; do not use policy revision ids, template revision ids, display names, or workflow names as workflow_id. Start dinner_date with aevatar_start_workflow and build the workflow input according to the published dinner_date input contract. Map semantic values from the current request and recovered context into the selected workflow's contract fields when those fields are known; preserve nested contract object structure only when that nesting exists in the published contract shape; do not create new grouping objects outside the contract shape; do not wrap them in a new schema, invent source-specific preference field names, or copy raw preference text into workflow evidence fields.", "PolicyRevision": "nyxid-chat-managed-workflow-schedule-v3", "MaximumToolPolicy": { "ToolNames": [ diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Schedules/WorkflowScheduleModels.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Schedules/WorkflowScheduleModels.cs index 1664ffaf1..fd5482771 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Schedules/WorkflowScheduleModels.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Schedules/WorkflowScheduleModels.cs @@ -26,7 +26,64 @@ public sealed record WorkflowScheduleConfiguration( WorkflowScheduleAuth? Auth = null, WorkflowScheduleMutationContext? MutationContext = null, WorkflowScheduleMode ScheduleMode = WorkflowScheduleMode.RecurringCron, - DateTimeOffset? OneShotFireAt = null); + DateTimeOffset? OneShotFireAt = null, + WorkflowScheduleAuthorizationFact? AuthorizationFact = null); + +public sealed record WorkflowScheduleAuthorizationFact( + string PermissionDigest, + string PolicyVersion, + WorkflowScheduleAuthorizationOwner Owner, + IReadOnlyList ServiceGrants, + string Scopes, + DateTimeOffset ExpiresAt, + bool ServiceGrantsNotRequired, + WorkflowScheduleAuthorizationDisclosure Disclosure, + WorkflowScheduleAuthorizationAuthority Authority, + WorkflowScheduleOwnerLLMSelection? OwnerLLMSelection = null); + +public enum WorkflowScheduleOwnerLLMRouteKind +{ + Unspecified = 0, + Gateway = 1, + NyxIdUserService = 2, +} + +public sealed record WorkflowScheduleOwnerLLMSelection( + WorkflowScheduleOwnerLLMRouteKind RouteKind, + string RouteValue, + string NyxIdUserServiceId, + string ServiceSlugSnapshot, + string Model); + +public sealed record WorkflowScheduleAuthorizationOwner( + string Authority, + string OwnerKind, + string OwnerSubject); + +public sealed record WorkflowScheduleAuthorizationServiceGrant( + string ServiceId, + IReadOnlyList NodeIds, + bool NodeGrantsNotRequired); + +public sealed record WorkflowScheduleAuthorizationDisclosure( + bool DedicatedToSchedule, + bool SecretManagedByAevatar, + bool BrowserReceivesRawKey, + bool DeleteRevokesCredential, + bool PauseResumeRevokesCredential); + +public sealed record WorkflowScheduleAuthorizationAuthority( + long MemberStateVersion, + long WorkflowStateVersion, + long ConnectorStateVersion, + long OwnerLLMStateVersion, + long CatalogStateVersion, + DateTimeOffset CatalogObservedAt, + DateTimeOffset CatalogFreshUntil, + string CatalogContentDigest, + string CatalogContractVersion, + string CatalogPolicyVersion, + DateTimeOffset CatalogEvaluatedAt); public sealed record WorkflowScheduleMutationContext( string? AuthenticatedScopeId = null, diff --git a/src/workflow/Aevatar.Workflow.Application/Schedules/WorkflowScheduleConfigurationMapper.cs b/src/workflow/Aevatar.Workflow.Application/Schedules/WorkflowScheduleConfigurationMapper.cs index a5193b135..a068810b7 100644 --- a/src/workflow/Aevatar.Workflow.Application/Schedules/WorkflowScheduleConfigurationMapper.cs +++ b/src/workflow/Aevatar.Workflow.Application/Schedules/WorkflowScheduleConfigurationMapper.cs @@ -1,6 +1,8 @@ using Aevatar.AI.Abstractions; +using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.GAgentService.Abstractions; using Aevatar.GAgentService.Abstractions.Schedules; +using Aevatar.GAgentService.Abstractions.Schedules.Authorization; using Aevatar.GAgentService.Abstractions.Services; using Aevatar.Workflow.Application.Abstractions.Schedules; using Google.Protobuf.WellKnownTypes; @@ -23,7 +25,8 @@ public static ScheduledDispatchConfiguration ToScheduledDispatchConfiguration( "chat", Any.Pack(BuildWorkflowChatRequest(configuration)), configuration.RevisionId, - Auth: BuildWorkflowServiceInvocationAuth(configuration))), + Auth: BuildWorkflowServiceInvocationAuth(configuration), + AuthorizationFact: BuildWorkflowAuthorizationFact(configuration))), configuration.CronExpression, configuration.Timezone, configuration.Enabled, @@ -66,6 +69,7 @@ private static ChatRequestEvent BuildWorkflowChatRequest(WorkflowScheduleConfigu var request = new ChatRequestEvent { Prompt = NormalizeOptional(configuration.Prompt, string.Empty), + LlmControl = BuildWorkflowLLMControl(configuration), }; foreach (var (key, value) in BuildWorkflowScheduleHeaders(configuration)) @@ -74,6 +78,23 @@ private static ChatRequestEvent BuildWorkflowChatRequest(WorkflowScheduleConfigu return request; } + private static LLMControlContextPayload? BuildWorkflowLLMControl( + WorkflowScheduleConfiguration configuration) + { + var ownerLLMSelection = configuration.AuthorizationFact?.OwnerLLMSelection; + if (ownerLLMSelection == null) + return null; + + return new LLMControlContext( + NyxIdAccessToken: null, + NyxIdOrgToken: null, + SenderNyxIdAccessToken: null, + ModelOverride: NormalizeOptional(ownerLLMSelection.Model, string.Empty), + NyxIdRoutePreference: NormalizeOptional(ownerLLMSelection.RouteValue, string.Empty), + MaxToolRoundsOverride: null, + UserMemoryPrompt: null).ToPayload(); + } + private static ScheduledDispatchScheduleMode ToScheduledDispatchScheduleMode(WorkflowScheduleMode mode) => mode == WorkflowScheduleMode.OneShotAtUtc ? ScheduledDispatchScheduleMode.OneShotAtUtc @@ -129,6 +150,76 @@ private static ScheduledServiceInvocationNyxIdSubjectRef MapNyxIdSubject( NormalizeOptional(subject.Tenant, string.Empty), NormalizeRequired(subject.ExternalUserId, nameof(subject.ExternalUserId))); + private static ScheduledInvocationAuthorizationFact? BuildWorkflowAuthorizationFact( + WorkflowScheduleConfiguration configuration) + { + var fact = configuration.AuthorizationFact; + if (fact == null) + return null; + + var grants = (fact.ServiceGrants ?? []) + .Select(static grant => new ScheduledInvocationAuthorizationServiceGrant( + NormalizeRequired(grant.ServiceId, nameof(grant.ServiceId)), + (grant.NodeIds ?? []) + .Select(static nodeId => NormalizeRequired(nodeId, nameof(nodeId))) + .Order(StringComparer.Ordinal) + .ToArray(), + grant.NodeGrantsNotRequired)) + .OrderBy(static grant => grant.ServiceId, StringComparer.Ordinal) + .ThenBy(static grant => grant.NodeGrantsNotRequired) + .ThenBy(static grant => string.Join('\n', grant.NodeIds), StringComparer.Ordinal) + .ToArray(); + + return new ScheduledInvocationAuthorizationFact( + NormalizeRequired(fact.PermissionDigest, nameof(fact.PermissionDigest)), + NormalizeRequired(fact.PolicyVersion, nameof(fact.PolicyVersion)), + new ScheduledInvocationAuthorizationOwner( + NormalizeRequired(fact.Owner.Authority, nameof(fact.Owner.Authority)), + NormalizeRequired(fact.Owner.OwnerKind, nameof(fact.Owner.OwnerKind)), + NormalizeRequired(fact.Owner.OwnerSubject, nameof(fact.Owner.OwnerSubject))), + grants, + NormalizeOptional(fact.Scopes, string.Empty), + fact.ExpiresAt.ToUniversalTime(), + fact.ServiceGrantsNotRequired, + new ScheduledInvocationAuthorizationDisclosure( + fact.Disclosure.DedicatedToSchedule, + fact.Disclosure.SecretManagedByAevatar, + fact.Disclosure.BrowserReceivesRawKey, + fact.Disclosure.DeleteRevokesCredential, + fact.Disclosure.PauseResumeRevokesCredential), + new ScheduledInvocationAuthorizationAuthority( + fact.Authority.MemberStateVersion, + fact.Authority.WorkflowStateVersion, + fact.Authority.ConnectorStateVersion, + fact.Authority.OwnerLLMStateVersion, + fact.Authority.CatalogStateVersion, + fact.Authority.CatalogObservedAt.ToUniversalTime(), + fact.Authority.CatalogFreshUntil.ToUniversalTime(), + NormalizeOptional(fact.Authority.CatalogContentDigest, string.Empty), + NormalizeOptional(fact.Authority.CatalogContractVersion, string.Empty), + NormalizeOptional(fact.Authority.CatalogPolicyVersion, string.Empty), + fact.Authority.CatalogEvaluatedAt.ToUniversalTime()), + MapOwnerLLMSelection(fact.OwnerLLMSelection)); + } + + private static ScheduledInvocationOwnerLLMSelection? MapOwnerLLMSelection( + WorkflowScheduleOwnerLLMSelection? selection) => + selection is null + ? null + : new ScheduledInvocationOwnerLLMSelection + { + RouteKind = selection.RouteKind switch + { + WorkflowScheduleOwnerLLMRouteKind.Gateway => LLMRouteKind.Gateway, + WorkflowScheduleOwnerLLMRouteKind.NyxIdUserService => LLMRouteKind.NyxIdUserService, + _ => LLMRouteKind.Unspecified, + }, + RouteValue = NormalizeOptional(selection.RouteValue, string.Empty), + NyxIdUserServiceId = NormalizeOptional(selection.NyxIdUserServiceId, string.Empty), + ServiceSlugSnapshot = NormalizeOptional(selection.ServiceSlugSnapshot, string.Empty), + Model = NormalizeOptional(selection.Model, string.Empty), + }; + private static IReadOnlyDictionary BuildWorkflowScheduleHeaders( WorkflowScheduleConfiguration configuration) { diff --git a/test/Aevatar.AI.Tests/AgentTurnToolCatalogMaterializerTests.cs b/test/Aevatar.AI.Tests/AgentTurnToolCatalogMaterializerTests.cs index 1683317df..79a470492 100644 --- a/test/Aevatar.AI.Tests/AgentTurnToolCatalogMaterializerTests.cs +++ b/test/Aevatar.AI.Tests/AgentTurnToolCatalogMaterializerTests.cs @@ -914,7 +914,7 @@ public async Task PrepareNyxIdChatAsync_EmptyMembersWeatherRequest_ShouldNotExpo } [Fact] - public async Task PrepareNyxIdChatAsync_EmptyMembersScheduleRequest_ShouldExposeScheduledAutomationTools() + public async Task PrepareNyxIdChatAsync_EmptyMembersRecurringReminderWithoutSkill_ShouldAskForClarification() { IAgentTool[] tools = [ @@ -984,21 +984,228 @@ public async Task PrepareNyxIdChatAsync_EmptyMembersScheduleRequest_ShouldExpose .Be(AgentTurnToolCatalogMaterializer.ProfileTaskRouteIntentId); preparation.Authority.AuthorityCeilingToolNames.Should().BeEquivalentTo( "ask_user", + "nyxid_services"); + preparation.Authority.AuthorityCeilingToolNames.Should().NotContain([ "use_skill", "ornn_search_skills", "ornn_publish_skill", "scheduled_agent_creator", "agent_builder", - "nyxid_services"); - preparation.Authority.AuthorityCeilingToolNames.Should().NotContain([ "aevatar_start_workflow", "aevatar_observe_run", "aevatar_read_workflow_run_artifact", ]); materialization.Catalog.FinalAllowedToolNames.Should().BeEquivalentTo( preparation.Authority.AuthorityCeilingToolNames); + } + + [Fact] + public async Task PrepareNyxIdChatAsync_EmptyMembersRecurringReminderWithoutDirectTools_ShouldExposeOrnnFallbackTools() + { + IAgentTool[] tools = + [ + new TestTool("ask_user"), + new TestTool("ornn_search_skills"), + new TestTool("ornn_publish_skill"), + new TestTool("nyxid_services"), + ]; + var profile = BuildProfile(); + profile.Instructions = "Use direct scheduled tools first and Ornn skills only as fallback."; + profile.Members.Clear(); + profile.MaximumToolPolicy.ToolNames.Clear(); + profile.MaximumToolPolicy.ToolNames.Add([ + "ask_user", + "ornn_search_skills", + "ornn_publish_skill", + "nyxid_services", + ]); + profile.RecoveryToolPolicy.ToolNames.Clear(); + profile.RecoveryToolPolicy.ToolNames.Add([ + "ask_user", + "ornn_search_skills", + "ornn_publish_skill", + ]); + var sealedProfile = SealProfile(profile); + var materializer = NewMaterializer( + RegistryWithRoute(tools), + new SequencedClassifier( + AgentProfileTurnClassificationResult.Matched( + AgentTurnToolCatalogMaterializer.ProfileTaskRouteIntentId), + AgentProfileTurnClassificationResult.Failed("classifier_not_configured")), + fetcher: null); + + var preparation = await materializer.PrepareNyxIdChatAsync( + sealedProfile, + "session-schedule-ornn-fallback-empty-members", + "Remind me every weekday at 9am to check the deployment dashboard.", + tools, + ToolContext(), + llmControl: null, + CancellationToken.None); + var materialization = await materializer.MaterializeCommittedAsync( + sealedProfile, + preparation.Authority, + accessToken: null, + tools, + ToolContext(), + CancellationToken.None); + + preparation.Authority.AuthorityKind.Should().Be(AgentProfileTurnAuthorityKind.Selected); + preparation.Authority.AuthorityCeilingToolNames.Should().BeEquivalentTo( + "ask_user", + "ornn_search_skills", + "ornn_publish_skill", + "nyxid_services"); + materialization.Catalog.FinalAllowedToolNames.Should().BeEquivalentTo( + preparation.Authority.AuthorityCeilingToolNames); + } + + [Fact] + public async Task PrepareNyxIdChatAsync_EmptyMembersOneShotReminder_ShouldExposeScheduledAutomationTools() + { + IAgentTool[] tools = + [ + new TestTool("ask_user"), + new TestTool("use_skill"), + new TestTool("ornn_search_skills"), + new TestTool("ornn_publish_skill"), + new TestTool("scheduled_agent_creator"), + new TestTool("agent_builder"), + new TestTool("nyxid_services"), + ]; + var profile = BuildProfile(); + profile.Instructions = "Use scheduled_agent_creator for one-shot reminders."; + profile.Members.Clear(); + profile.MaximumToolPolicy.ToolNames.Clear(); + profile.MaximumToolPolicy.ToolNames.Add([ + "ask_user", + "use_skill", + "ornn_search_skills", + "ornn_publish_skill", + "scheduled_agent_creator", + "agent_builder", + "nyxid_services", + ]); + profile.RecoveryToolPolicy.ToolNames.Clear(); + profile.RecoveryToolPolicy.ToolNames.Add([ + "ask_user", + "use_skill", + "ornn_search_skills", + "scheduled_agent_creator", + "agent_builder", + ]); + var sealedProfile = SealProfile(profile); + var materializer = NewMaterializer( + RegistryWithRoute(tools), + new SequencedClassifier( + AgentProfileTurnClassificationResult.Matched( + AgentTurnToolCatalogMaterializer.ProfileTaskRouteIntentId), + AgentProfileTurnClassificationResult.Failed("classifier_not_configured")), + fetcher: null); + + var preparation = await materializer.PrepareNyxIdChatAsync( + sealedProfile, + "session-one-shot-reminder-empty-members", + "Remind me in 30 minutes to check the deployment dashboard.", + tools, + ToolContext(), + llmControl: null, + CancellationToken.None); + var materialization = await materializer.MaterializeCommittedAsync( + sealedProfile, + preparation.Authority, + accessToken: null, + tools, + ToolContext(), + CancellationToken.None); + + preparation.Authority.AuthorityKind.Should().Be(AgentProfileTurnAuthorityKind.Selected); + preparation.Authority.AuthorityCeilingToolNames.Should().BeEquivalentTo( + "ask_user", + "use_skill", + "scheduled_agent_creator", + "agent_builder", + "nyxid_services"); + preparation.Authority.AuthorityCeilingToolNames.Should().NotContain([ + "ornn_search_skills", + "ornn_publish_skill", + ]); + materialization.Catalog.FinalAllowedToolNames.Should().BeEquivalentTo( + preparation.Authority.AuthorityCeilingToolNames); materialization.Catalog.ExactTools.Keys.Should().Contain("scheduled_agent_creator"); - materialization.Catalog.ExactTools.Keys.Should().Contain("agent_builder"); + } + + [Fact] + public async Task PrepareNyxIdChatAsync_ExplicitOrnnScheduleRequest_ShouldExposeOrnnAuthoringTools() + { + IAgentTool[] tools = + [ + new TestTool("ask_user"), + new TestTool("use_skill"), + new TestTool("ornn_search_skills"), + new TestTool("ornn_publish_skill"), + new TestTool("scheduled_agent_creator"), + new TestTool("agent_builder"), + new TestTool("nyxid_services"), + ]; + var profile = BuildProfile(); + profile.Instructions = "Use scheduled_agent_creator for reminders. Use Ornn tools only for explicit skill publishing."; + profile.Members.Clear(); + profile.MaximumToolPolicy.ToolNames.Clear(); + profile.MaximumToolPolicy.ToolNames.Add([ + "ask_user", + "use_skill", + "ornn_search_skills", + "ornn_publish_skill", + "scheduled_agent_creator", + "agent_builder", + "nyxid_services", + ]); + profile.RecoveryToolPolicy.ToolNames.Clear(); + profile.RecoveryToolPolicy.ToolNames.Add([ + "ask_user", + "use_skill", + "ornn_search_skills", + "ornn_publish_skill", + "scheduled_agent_creator", + "agent_builder", + ]); + var sealedProfile = SealProfile(profile); + var materializer = NewMaterializer( + RegistryWithRoute(tools), + new SequencedClassifier( + AgentProfileTurnClassificationResult.Matched( + AgentTurnToolCatalogMaterializer.ProfileTaskRouteIntentId), + AgentProfileTurnClassificationResult.Failed("classifier_not_configured")), + fetcher: null); + + var preparation = await materializer.PrepareNyxIdChatAsync( + sealedProfile, + "session-ornn-schedule-empty-members", + "Publish a reusable Ornn skill that reminds me every weekday at 9am.", + tools, + ToolContext(), + llmControl: null, + CancellationToken.None); + var materialization = await materializer.MaterializeCommittedAsync( + sealedProfile, + preparation.Authority, + accessToken: null, + tools, + ToolContext(), + CancellationToken.None); + + preparation.Authority.AuthorityKind.Should().Be(AgentProfileTurnAuthorityKind.Selected); + preparation.Authority.AuthorityCeilingToolNames.Should().BeEquivalentTo( + "ask_user", + "use_skill", + "ornn_search_skills", + "ornn_publish_skill", + "scheduled_agent_creator", + "agent_builder", + "nyxid_services"); + materialization.Catalog.FinalAllowedToolNames.Should().BeEquivalentTo( + preparation.Authority.AuthorityCeilingToolNames); } [Fact] diff --git a/test/Aevatar.AI.Tests/NyxIdChatConversationGAgentTests.cs b/test/Aevatar.AI.Tests/NyxIdChatConversationGAgentTests.cs index 7cb8737e1..c5355c6fa 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatConversationGAgentTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatConversationGAgentTests.cs @@ -397,31 +397,24 @@ public async Task StartTurn_WithConflictingOwner_ShouldCommitSafeRejectionWithou } [Fact] - public async Task StartTurn_OnOwnerlessConversation_ShouldRejectOwnerClaim() + public async Task CreateConversation_WithoutFirstTurn_ShouldBindOwnerBeforeFirstStreamTurn() { - const string actorId = "conversation-ownerless"; + const string actorId = "conversation-create-only-owner"; var eventStore = new InMemoryEventStoreForTests(); var dispatch = new RecordingActorDispatchPort([], static (_, _) => Task.CompletedTask); using var services = BuildEventSourcingServices(eventStore); var agent = CreateController(services, actorId, dispatch); await agent.ActivateAsync(); + await agent.HandleEventAsync(CreateEnvelope(actorId, new NyxIdChatConversationCreateCommand { ScopeId = "scope-alpha", + OwnerSubject = "owner-alpha", CreatedLocally = true, RequestedActorId = actorId, })); - var turn = CreateStartTurnCommand(); - turn.ConversationActorId = actorId; - SetOwner(turn, "owner-alpha"); - await agent.HandleEventAsync(CreateEnvelope(actorId, turn)); - - var rejection = (await eventStore.GetEventsAsync(actorId))[^1].EventData - .Unpack(); - rejection.ReasonCode.Should().Be("NYXID_CHAT_OWNER_MISMATCH"); - agent.State.OwnerSubject.Should().BeEmpty(); - dispatch.OperationCalls.Should().BeEmpty(); + agent.State.OwnerSubject.Should().Be("owner-alpha"); } [Fact] diff --git a/test/Aevatar.AI.Tests/NyxIdChatPublicEndpointsTests.cs b/test/Aevatar.AI.Tests/NyxIdChatPublicEndpointsTests.cs index add905d50..2924feadf 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatPublicEndpointsTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatPublicEndpointsTests.cs @@ -12,6 +12,7 @@ using Aevatar.Foundation.Abstractions; using Aevatar.GAgentService.Abstractions; using Aevatar.GAgentService.Abstractions.ScopeGAgents; +using Aevatar.GAgents.Channel.Runtime; using Aevatar.GAgents.NyxidChat; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Workflow.Application.Abstractions.Runs; @@ -79,6 +80,7 @@ await NyxIdChatEndpoints.HandlePublicChatAsync(context, Parse(""" "correlation-alpha", new Dictionary())); var create = envelope.Payload.Unpack(); + create.OwnerSubject.Should().Be("user-alpha"); create.AgentProfileReference.Should().BeEquivalentTo(command.AgentProfileReference); var start = create.FirstTurn; start.ToolContext.Caller.ScopeId.Should().Be("scope-alpha"); @@ -101,6 +103,68 @@ await NyxIdChatEndpoints.HandlePublicChatAsync(context, Parse(""" body.Should().Contain(command.ActorId).And.Contain(command.TurnId); } + [Fact] + public async Task FirstText_WithScheduledDeliveryProvider_ShouldCarryNyxIdAssistantDeliveryMetadata() + { + var chat = new RecordingInteraction(); + var context = CreateContext("scope-alpha", services => services + .AddSingleton(new NyxIdAssistantActionsOptions + { + Enabled = true, + ScheduledDeliveryProviderSlug = "aevatar-local-diag-catalog", + ScheduledDeliveryProviderUserServiceId = "service-local-diag-catalog", + }) + .AddSingleton>(chat) + .AddSingleton>(new RecordingInteraction()) + .AddSingleton(new RecordingAdmissionPort())); + context.Request.Headers.Authorization = "Bearer delegated-token"; + context.Response.Body = new MemoryStream(); + + await NyxIdChatEndpoints.HandlePublicChatAsync(context, Parse(""" + { + "type": "text", + "clientRequestId": "scheduled-request", + "prompt": "30秒后提醒我喝水" + } + """)); + + var command = chat.Commands.Should().ContainSingle().Which; + command.Metadata.Should().Contain(new KeyValuePair( + ChannelMetadataKeys.Platform, + "nyxid-chat")); + command.Metadata.Should().Contain(new KeyValuePair( + ChannelMetadataKeys.ConversationId, + command.ActorId)); + command.Metadata.Should().Contain(new KeyValuePair( + ChannelMetadataKeys.OutboundProviderSlug, + "aevatar-local-diag-catalog")); + command.Metadata.Should().Contain(new KeyValuePair( + ChannelMetadataKeys.OutboundProviderUserServiceId, + "service-local-diag-catalog")); + command.Metadata.Should().Contain(new KeyValuePair( + ChannelMetadataKeys.DeliveryAddressId, + command.ActorId)); + + var envelope = new NyxIdChatCommandEnvelopeFactory().CreateEnvelope( + command, + new CommandContext( + command.ActorId, + "command-alpha", + "correlation-alpha", + new Dictionary())); + var toolContext = AgentToolExecutionContextMapper.FromPayload( + envelope.Payload.Unpack().FirstTurn.ToolContext); + toolContext.ExternalMetadata.Should().Contain(new KeyValuePair( + ChannelMetadataKeys.OutboundProviderSlug, + "aevatar-local-diag-catalog")); + toolContext.ExternalMetadata.Should().Contain(new KeyValuePair( + ChannelMetadataKeys.OutboundProviderUserServiceId, + "service-local-diag-catalog")); + toolContext.ExternalMetadata.Should().Contain(new KeyValuePair( + ChannelMetadataKeys.ConversationId, + command.ActorId)); + } + [Fact] public async Task FirstText_WithProxyDelegation_ShouldPreserveCredentialKind() { diff --git a/test/Aevatar.AI.Tests/NyxIdChatTaskLifecycleTests.cs b/test/Aevatar.AI.Tests/NyxIdChatTaskLifecycleTests.cs index bed98c049..580c31034 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatTaskLifecycleTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatTaskLifecycleTests.cs @@ -422,6 +422,51 @@ public void MutationObservedByCanonicalReadModel_ShouldContinueWithoutAdmittedEx .Status.Should().Be(NyxIdChatStepStatus.Running); } + [Fact] + public void MutationAcceptedByProviderReceipt_ShouldContinueWithoutAdmittedExternalReadBack() + { + var admission = ExactWriteAdmission(); + admission.ReadBack = null; + var planSignal = LlmWithToolCall(); + planSignal.Llm.ToolCalls.Single().OperationAdmission = admission; + var planned = NyxIdChatTaskLifecycle.ApplyOperationResult( + ActiveState(NyxIdChatStepKind.Llm, "step-llm-alpha", "operation-llm-alpha"), + planSignal, + Now).State; + var tool = planned.ActiveTask.Steps.Single(step => step.Kind == NyxIdChatStepKind.Tool); + + var decision = NyxIdChatTaskLifecycle.ApplyOperationResult( + planned, + new NyxIdChatOperationResultSignal + { + Key = tool.Operation.Key.Clone(), + Tool = new NyxIdChatToolOperationResult + { + Receipt = new AgentToolReceipt + { + CallId = "call-alpha", + ToolName = "scheduled_agent_creator", + Status = AgentToolReceiptStatus.Success, + Effect = AgentToolReceiptEffect.Mutating, + MutationStage = AgentToolReceiptMutationStage.Accepted, + }, + ExternalEffect = NyxIdChatEffectEvidence.MayHaveChanged, + }, + }, + Now); + + decision.NextCommand.Should().NotBeNull(); + decision.NextCommand!.InputCase.Should().Be( + NyxIdChatOperationDispatchCommand.InputOneofCase.Llm); + decision.State.ActiveTask.Steps.Should().NotContain(step => + step.Kind == NyxIdChatStepKind.Postcondition); + decision.State.ActiveTask.Steps.Single(step => step.StepId == tool.StepId) + .ExternalEffect.Should().Be(NyxIdChatEffectEvidence.MayHaveChanged); + decision.State.ActiveTask.Steps.Single(step => + step.Kind == NyxIdChatStepKind.Llm && step.DependsOn.Contains(tool.StepId)) + .Status.Should().Be(NyxIdChatStepStatus.Running); + } + [Fact] public void VerificationNotApplied_ShouldUnlockExplicitToolRetryWithoutChangingTaskIdentity() { diff --git a/test/Aevatar.Capabilities.Tests/MainnetBootScriptTests.cs b/test/Aevatar.Capabilities.Tests/MainnetBootScriptTests.cs index 7ad67cabf..34d47cf38 100644 --- a/test/Aevatar.Capabilities.Tests/MainnetBootScriptTests.cs +++ b/test/Aevatar.Capabilities.Tests/MainnetBootScriptTests.cs @@ -31,6 +31,23 @@ public async Task AppSettings_ShouldUseProvisionedConsoleOAuthClient() .Be("a6ff2946-f02f-4c35-8203-1ec46132b660"); } + [Fact] + public void AppSettings_ShouldRouteScheduledProfileRequestsToScheduledCreator() + { + var configuration = BuildMainnetConfiguration(); + var instructions = configuration["AgentProfiles:SystemDefaultNyxIdChat:Instructions"]; + + instructions.Should().NotBeNullOrWhiteSpace(); + instructions.Should().Contain("use scheduled_agent_creator directly"); + instructions.Should().Contain("when a direct admitted scheduled automation tool can handle the request"); + instructions.Should().Contain("Use Ornn skill search or publish only when no direct admitted scheduling or agent-management tool can handle the requested automation"); + instructions.Should().Contain("Call ornn_search_skills only when the user explicitly asks to find, browse, load, reuse, or inspect Ornn skills"); + configuration["Aevatar:NyxId:AssistantActions:ScheduledDeliveryProviderSlug"] + .Should().NotBeNullOrWhiteSpace(); + configuration["Aevatar:NyxId:AssistantActions:ScheduledDeliveryProviderUserServiceId"] + .Should().NotBeNullOrWhiteSpace(); + } + [Fact] public async Task DistributedAppSettings_ShouldDisableGraphProvidersByDefault() { diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ScheduledAgentCreatorToolTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ScheduledAgentCreatorToolTests.cs index 20efd70fe..150609f8c 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ScheduledAgentCreatorToolTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ScheduledAgentCreatorToolTests.cs @@ -64,6 +64,46 @@ public void ToolContract_ShouldNeverRequireApproval_AndExposeClosedSchema() .Should().BeEmpty(); } + [Fact] + public void CreateResultReceipt_WhenCreateAccepted_ShouldReturnAcceptedDispatchReceipt() + { + var tool = CreateHarness().Tool; + const string resultJson = """ + { + "status": "accepted", + "agent_id": "scheduled-agent-alpha", + "api_key_id": "api-key-alpha" + } + """; + + var receipt = tool.CreateResultReceipt("call-alpha", tool.Name, "{}", resultJson); + + receipt.Should().NotBeNull(); + receipt!.CallId.Should().Be("call-alpha"); + receipt.ToolName.Should().Be(tool.Name); + receipt.Status.Should().Be(AgentToolReceiptStatus.Success); + receipt.Effect.Should().Be(AgentToolReceiptEffect.Mutating); + receipt.SubjectKind.Should().Be("scheduled_agent"); + receipt.SubjectId.Should().Be("scheduled-agent-alpha"); + receipt.MutationStage.Should().Be(AgentToolReceiptMutationStage.Accepted); + receipt.ResultJson.Should().Be(resultJson); + } + + [Fact] + public void CreateResultReceipt_WhenCreateFails_ShouldReturnCalleeConfirmedErrorReceipt() + { + var tool = CreateHarness().Tool; + const string resultJson = """{"error":"validation_error","detail":"schedule_mode is invalid"}"""; + + var receipt = tool.CreateResultReceipt("call-alpha", tool.Name, "{}", resultJson); + + receipt.Should().NotBeNull(); + receipt!.Status.Should().Be(AgentToolReceiptStatus.Error); + receipt.ErrorCode.Should().Be("validation_error"); + receipt.FailureOutcome.Should().Be(AgentToolFailureOutcome.CalleeConfirmed); + receipt.ResultJson.Should().Be(resultJson); + } + [Fact] public async Task ExecuteAsync_WhenNoToken_ShouldFailClosed() { @@ -987,6 +1027,67 @@ await WithToolContext(async () => }); } + [Fact] + public async Task ExecuteAsync_OneShotReminderWithTrustedOutboundServiceId_ShouldMintScopedKey() + { + var handler = CreateSuccessHandler(); + var harness = CreateHarness(handler: handler); + ScheduledWorkflowAgentCreateRequest? captured = null; + harness.CreationPort.CreateAsync( + Arg.Do(value => captured = value), + Arg.Any()) + .Returns(callInfo => + { + var request = callInfo.Arg(); + return Task.FromResult(new ScheduledWorkflowAgentCreationReceipt( + request.Schedule.ScheduleId, + $"actor:{request.Schedule.ScheduleId}", + true, + "command-1", + "correlation-1", + DateTimeOffset.UtcNow, + "accepted")); + }); + var metadata = new Dictionary(BaseExternalMetadata(), StringComparer.Ordinal) + { + [ChannelMetadataKeys.OutboundProviderUserServiceId] = "svc-lark", + }; + + await WithToolContext(CreateToolContext(externalMetadata: metadata), async () => + { + var result = await harness.Tool.ExecuteAsync(""" + { + "schedule_mode": "one_shot", + "delay_seconds": 120, + "one_shot_message": "Submit the report", + "required_nyx_services": [ + {"user_service_id":"svc-lark-failure","service_slug_snapshot":"api-lark-bot-inbound"} + ] + } + """); + + using var document = JsonDocument.Parse(result); + document.RootElement.GetProperty("status").GetString().Should().Be("accepted"); + captured.Should().NotBeNull(); + captured!.CatalogEntry.NyxProviderSlug.Should().Be("api-lark-bot"); + captured.Schedule.AuthorizationFact.Should().NotBeNull(); + captured.Schedule.AuthorizationFact!.Owner.OwnerSubject.Should().Be("nyx-user-1"); + captured.Schedule.AuthorizationFact.ServiceGrants.Select(static grant => grant.ServiceId) + .Should().BeEquivalentTo("svc-lark", "svc-lark-failure", "svc-llm"); + captured.Schedule.AuthorizationFact.PolicyVersion.Should() + .Be(ScheduledInvocationAuthorizationContractVersions.CredentialPolicy); + captured.Schedule.AuthorizationFact.OwnerLLMSelection.Should().BeEquivalentTo( + new WorkflowScheduleOwnerLLMSelection( + WorkflowScheduleOwnerLLMRouteKind.NyxIdUserService, + "/api/v1/proxy/s/chrono-llm-public", + "svc-llm", + "chrono-llm-public", + "gpt-5.5")); + IssuedServiceIds(harness) + .Should().BeEquivalentTo("svc-lark", "svc-lark-failure", "svc-llm"); + }); + } + [Fact] public async Task ExecuteAsync_OneShotReminder_ShouldMintLarkScopedKeyWithoutOrnnPreflight() { diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/UnifyCallerScopeAcceptanceTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/UnifyCallerScopeAcceptanceTests.cs index 6bf978c80..c110dfe54 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/UnifyCallerScopeAcceptanceTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/UnifyCallerScopeAcceptanceTests.cs @@ -380,6 +380,48 @@ public async Task NyxIdNativeCallerScopeResolver_NyxIdMeFails_ThrowsFailClosed() } } + [Fact] + public async Task NyxIdNativeCallerScopeResolver_NyxIdAssistantUsesVerifiedCallerSubject() + { + var inner = Substitute.For(); + inner.ResolveCurrentUserIdAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + var resolver = new NyxIdNativeCallerScopeResolver(inner); + + AgentToolRequestContext.Current = AgentToolExecutionContext.Empty with + { + Credentials = AgentToolCredentials.Empty with + { + NyxIdAccessToken = "proxy-delegation", + NyxIdCredentialKind = AgentToolNyxIdCredentialKind.ProxyDelegation, + }, + Caller = new AgentToolCallerContext( + "scope-alpha", + "user-alpha", + "turn-alpha", + OwnerScopeId: "scope-alpha"), + Chat = new AgentChatInvocationContext( + AgentChatInvocationSurface.NyxIdAssistant, + "conversation-alpha", + "turn-alpha", + "task-alpha", + null, + null), + }; + try + { + var scope = await resolver.TryResolveAsync(); + + scope.Should().NotBeNull(); + scope!.MatchesStrictly(OwnerScope.ForNyxIdNative("user-alpha")).Should().BeTrue(); + await inner.DidNotReceiveWithAnyArgs().ResolveCurrentUserIdAsync(default!, default); + } + finally + { + AgentToolRequestContext.Current = null; + } + } + [Fact] public async Task ChannelMetadataCallerScopeResolver_PlatformWithoutSenderId_ThrowsFailClosed() { @@ -424,6 +466,48 @@ public async Task ChannelMetadataCallerScopeResolver_NoPlatform_ReturnsNull_Allo } } + [Fact] + public async Task ChannelMetadataCallerScopeResolver_NyxIdAssistantPlatformOnly_ReturnsNull_AllowsNativeFallthrough() + { + var inner = Substitute.For(); + var resolver = new ChannelMetadataCallerScopeResolver(inner); + + AgentToolRequestContext.Current = AgentToolExecutionContext.Empty with + { + Credentials = AgentToolCredentials.Empty with + { + NyxIdAccessToken = "proxy-delegation", + }, + Caller = new AgentToolCallerContext( + "scope-alpha", + "user-alpha", + "turn-alpha", + OwnerScopeId: "scope-alpha"), + Channel = new AgentToolChannelContext( + "nyxid-chat", + null, + "scope-alpha", + null, + null), + Chat = new AgentChatInvocationContext( + AgentChatInvocationSurface.NyxIdAssistant, + "conversation-alpha", + "turn-alpha", + "task-alpha", + null, + null), + }; + try + { + (await resolver.TryResolveAsync()).Should().BeNull( + "NyxID Assistant uses Channel.Platform to select its local tool source, not as external channel sender metadata"); + } + finally + { + AgentToolRequestContext.Current = null; + } + } + [Fact] public async Task ChannelMetadataCallerScopeResolver_OwnerScopeIdPresent_DoesNotCallNyxIdMe() { diff --git a/test/Aevatar.Workflow.Application.Tests/WorkflowScheduleApplicationServiceTests.cs b/test/Aevatar.Workflow.Application.Tests/WorkflowScheduleApplicationServiceTests.cs index a413cd04b..73fc24952 100644 --- a/test/Aevatar.Workflow.Application.Tests/WorkflowScheduleApplicationServiceTests.cs +++ b/test/Aevatar.Workflow.Application.Tests/WorkflowScheduleApplicationServiceTests.cs @@ -209,6 +209,41 @@ await service.CreateAsync(new WorkflowScheduleConfiguration( invocation.Auth.SenderNyxId.Scope.Should().Be("proxy"); } + [Fact] + public async Task CreateAsync_ShouldMapWorkflowAuthorizationFactToServiceInvocationTarget() + { + var actorPort = new FakeWorkflowScheduleActorPort + { + ResolveActorId = string.Empty, + }; + var service = CreateService(actorPort); + + await service.CreateAsync(CreateConfiguration("authorization-fact-schedule") with + { + AuthorizationFact = CreateWorkflowAuthorizationFact(), + }); + + var fact = actorPort.Created.Single().Configuration.Target.ServiceInvocation!.AuthorizationFact; + fact.Should().NotBeNull(); + fact!.PermissionDigest.Should().Be("digest-alpha"); + fact.PolicyVersion.Should().Be("policy-alpha"); + fact.Owner.OwnerSubject.Should().Be("owner-alpha"); + fact.ServiceGrants.Should().ContainSingle() + .Which.ServiceId.Should().Be("svc-alpha"); + fact.Authority.CatalogStateVersion.Should().Be(42); + fact.OwnerLLMSelection.Should().NotBeNull(); + fact.OwnerLLMSelection!.RouteKind.Should().Be(LLMRouteKind.NyxIdUserService); + fact.OwnerLLMSelection.RouteValue.Should().Be("/api/v1/proxy/s/chrono-llm"); + fact.OwnerLLMSelection.NyxIdUserServiceId.Should().Be("svc-chrono"); + fact.OwnerLLMSelection.ServiceSlugSnapshot.Should().Be("chrono-llm"); + fact.OwnerLLMSelection.Model.Should().Be("gpt-5.5"); + + var chatRequest = actorPort.Created.Single().Configuration.Target.ServiceInvocation!.Payload.Unpack(); + chatRequest.LlmControl.Should().NotBeNull(); + chatRequest.LlmControl!.NyxIdRoutePreference.Should().Be("/api/v1/proxy/s/chrono-llm"); + chatRequest.LlmControl.ModelOverride.Should().Be("gpt-5.5"); + } + [Fact] public async Task CreateAsync_ShouldRejectWorkflowScheduleWithoutCredentialSource() { @@ -1234,6 +1269,43 @@ private static WorkflowScheduleAuth CreateDefaultAuth() => new WorkflowScheduleNyxIdSubjectRef("lark", "tenant-1", "ou-user-1"), "proxy")); + private static WorkflowScheduleAuthorizationFact CreateWorkflowAuthorizationFact() + { + var now = DateTimeOffset.UtcNow; + return new WorkflowScheduleAuthorizationFact( + "digest-alpha", + "policy-alpha", + new WorkflowScheduleAuthorizationOwner("nyxid", "Personal", "owner-alpha"), + [new WorkflowScheduleAuthorizationServiceGrant("svc-alpha", [], true)], + "proxy read", + now.AddDays(30), + false, + new WorkflowScheduleAuthorizationDisclosure( + DedicatedToSchedule: true, + SecretManagedByAevatar: true, + BrowserReceivesRawKey: false, + DeleteRevokesCredential: true, + PauseResumeRevokesCredential: false), + new WorkflowScheduleAuthorizationAuthority( + MemberStateVersion: 0, + WorkflowStateVersion: 0, + ConnectorStateVersion: 0, + OwnerLLMStateVersion: 0, + CatalogStateVersion: 42, + CatalogObservedAt: now.AddMinutes(-5), + CatalogFreshUntil: now.AddMinutes(10), + CatalogContentDigest: "catalog-digest-alpha", + CatalogContractVersion: "catalog-contract-alpha", + CatalogPolicyVersion: "catalog-policy-alpha", + CatalogEvaluatedAt: now.AddMinutes(-6)), + new WorkflowScheduleOwnerLLMSelection( + WorkflowScheduleOwnerLLMRouteKind.NyxIdUserService, + "/api/v1/proxy/s/chrono-llm", + "svc-chrono", + "chrono-llm", + "gpt-5.5")); + } + private static WorkflowScheduleConfiguration CreateScopeOwnerWorkflowConfiguration(string scheduleId) => CreateConfiguration(scheduleId) with {