From 1df944f0396287c975ed21a226943e4a4f66e075 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Tue, 8 Sep 2026 12:51:03 +0800 Subject: [PATCH 1/6] Tighten scheduled reminder tool routing. Co-Authored-By: Claude Opus 4.6 --- .../AgentTurnToolCatalogMaterializer.cs | 90 +++++++++- src/Aevatar.Mainnet.Host.Api/appsettings.json | 2 +- .../AgentTurnToolCatalogMaterializerTests.cs | 154 +++++++++++++++++- .../MainnetBootScriptTests.cs | 13 ++ 4 files changed, 246 insertions(+), 13 deletions(-) diff --git a/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/AgentTurnToolCatalogMaterializer.cs b/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/AgentTurnToolCatalogMaterializer.cs index 056f525a0..da3ec3b04 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); + 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)); 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)); return fallbackNames.Count > 0; } @@ -1207,15 +1242,54 @@ 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) + { + if (HasExplicitOrnnSkillAutomationIntent(userMessage)) + return ScheduledAutomationToolNames; + + return HasRecurringScheduledAutomationIntent(userMessage) + ? ScheduledAutomationClarificationToolNames + : DirectScheduledAutomationToolNames; + } + + 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/src/Aevatar.Mainnet.Host.Api/appsettings.json b/src/Aevatar.Mainnet.Host.Api/appsettings.json index df965c0e3..43b2d1999 100644 --- a/src/Aevatar.Mainnet.Host.Api/appsettings.json +++ b/src/Aevatar.Mainnet.Host.Api/appsettings.json @@ -111,7 +111,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. Use agent_builder or ornn_publish_skill only 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/test/Aevatar.AI.Tests/AgentTurnToolCatalogMaterializerTests.cs b/test/Aevatar.AI.Tests/AgentTurnToolCatalogMaterializerTests.cs index 1683317df..f7ba8c71f 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,167 @@ 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_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.Capabilities.Tests/MainnetBootScriptTests.cs b/test/Aevatar.Capabilities.Tests/MainnetBootScriptTests.cs index 7ad67cabf..32e195fd5 100644 --- a/test/Aevatar.Capabilities.Tests/MainnetBootScriptTests.cs +++ b/test/Aevatar.Capabilities.Tests/MainnetBootScriptTests.cs @@ -31,6 +31,19 @@ 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("Do not call ornn_search_skills, ornn_publish_skill, or agent_builder before handling ordinary scheduled task or reminder requests"); + instructions.Should().Contain("Use agent_builder or ornn_publish_skill only when the user explicitly asks to design, publish, or reuse a portable Ornn skill or reusable agent first"); + instructions.Should().Contain("Call ornn_search_skills only when the user explicitly asks to find, browse, load, reuse, or inspect Ornn skills"); + } + [Fact] public async Task DistributedAppSettings_ShouldDisableGraphProvidersByDefault() { From 3b8e711d3695183f99e8dc93b92293ee6d00ef28 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Tue, 8 Sep 2026 14:11:22 +0800 Subject: [PATCH 2/6] Preserve Ornn fallback for scheduled automation. Co-Authored-By: Claude Opus 4.6 --- .../AgentTurnToolCatalogMaterializer.cs | 22 +++++-- src/Aevatar.Mainnet.Host.Api/appsettings.json | 2 +- .../AgentTurnToolCatalogMaterializerTests.cs | 61 +++++++++++++++++++ .../MainnetBootScriptTests.cs | 4 +- 4 files changed, 80 insertions(+), 9 deletions(-) diff --git a/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/AgentTurnToolCatalogMaterializer.cs b/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/AgentTurnToolCatalogMaterializer.cs index da3ec3b04..9f40c9c0b 100644 --- a/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/AgentTurnToolCatalogMaterializer.cs +++ b/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/AgentTurnToolCatalogMaterializer.cs @@ -1129,7 +1129,7 @@ private static void ApplyProfileTaskRouteIntentFilter( var normalizedUserMessage = userMessage ?? string.Empty; if (HasScheduledAutomationIntent(normalizedUserMessage)) { - var scheduledAutomationToolNames = SelectScheduledAutomationToolNames(normalizedUserMessage); + var scheduledAutomationToolNames = SelectScheduledAutomationToolNames(normalizedUserMessage, availableToolNames); AddAvailableScheduledAutomationTools( availableToolNames, selectedToolNames, @@ -1188,7 +1188,7 @@ private static bool TryCreateScheduledAutomationFallbackNames( AddAvailableScheduledAutomationTools( availableToolNames, fallbackNames, - SelectScheduledAutomationToolNames(normalizedUserMessage)); + SelectScheduledAutomationToolNames(normalizedUserMessage, availableToolNames)); return fallbackNames.Count > 0; } @@ -1225,7 +1225,7 @@ private static bool TryCreateOrdinaryScheduledAutomationFallbackNames( AddAvailableScheduledAutomationTools( availableToolNames, fallbackNames, - SelectScheduledAutomationToolNames(normalizedUserMessage)); + SelectScheduledAutomationToolNames(normalizedUserMessage, availableToolNames)); return fallbackNames.Count > 0; } @@ -1252,16 +1252,26 @@ private static void AddAvailableScheduledAutomationTools( } } - private static IReadOnlySet SelectScheduledAutomationToolNames(string userMessage) + private static IReadOnlySet SelectScheduledAutomationToolNames( + string userMessage, + IReadOnlySet availableToolNames) { if (HasExplicitOrnnSkillAutomationIntent(userMessage)) return ScheduledAutomationToolNames; - return HasRecurringScheduledAutomationIntent(userMessage) + if (!HasRecurringScheduledAutomationIntent(userMessage)) + return DirectScheduledAutomationToolNames; + + return HasAvailableDirectScheduledAutomationTool(availableToolNames) ? ScheduledAutomationClarificationToolNames - : DirectScheduledAutomationToolNames; + : 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); diff --git a/src/Aevatar.Mainnet.Host.Api/appsettings.json b/src/Aevatar.Mainnet.Host.Api/appsettings.json index 43b2d1999..a47a61d58 100644 --- a/src/Aevatar.Mainnet.Host.Api/appsettings.json +++ b/src/Aevatar.Mainnet.Host.Api/appsettings.json @@ -111,7 +111,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. 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. Use agent_builder or ornn_publish_skill only 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.", + "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/test/Aevatar.AI.Tests/AgentTurnToolCatalogMaterializerTests.cs b/test/Aevatar.AI.Tests/AgentTurnToolCatalogMaterializerTests.cs index f7ba8c71f..79a470492 100644 --- a/test/Aevatar.AI.Tests/AgentTurnToolCatalogMaterializerTests.cs +++ b/test/Aevatar.AI.Tests/AgentTurnToolCatalogMaterializerTests.cs @@ -999,6 +999,67 @@ public async Task PrepareNyxIdChatAsync_EmptyMembersRecurringReminderWithoutSkil 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() { diff --git a/test/Aevatar.Capabilities.Tests/MainnetBootScriptTests.cs b/test/Aevatar.Capabilities.Tests/MainnetBootScriptTests.cs index 32e195fd5..4d9ab9b06 100644 --- a/test/Aevatar.Capabilities.Tests/MainnetBootScriptTests.cs +++ b/test/Aevatar.Capabilities.Tests/MainnetBootScriptTests.cs @@ -39,8 +39,8 @@ public void AppSettings_ShouldRouteScheduledProfileRequestsToScheduledCreator() instructions.Should().NotBeNullOrWhiteSpace(); instructions.Should().Contain("use scheduled_agent_creator directly"); - instructions.Should().Contain("Do not call ornn_search_skills, ornn_publish_skill, or agent_builder before handling ordinary scheduled task or reminder requests"); - instructions.Should().Contain("Use agent_builder or ornn_publish_skill only when the user explicitly asks to design, publish, or reuse a portable Ornn skill or reusable agent first"); + 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"); } From 50868deb0f1cc6fb5547a05274065f0eb8fb52fa Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Wed, 9 Sep 2026 12:12:37 +0800 Subject: [PATCH 3/6] Carry scheduled invocation authorization into chat schedules. Co-Authored-By: Claude Opus 4.6 --- .../ChannelMetadataKeys.cs | 2 + .../ChannelConversationTurnRunner.cs | 24 ++--- .../NyxIdAssistantActionsOptions.cs | 4 + .../NyxIdChatConversationGAgent.cs | 2 +- .../NyxIdChatEndpoints.Streaming.cs | 6 +- .../NyxIdChatEndpoints.cs | 5 +- .../NyxIdChatInteraction.cs | 2 + .../NyxIdChatLifecycleFacade.cs | 2 + .../NyxIdChatTaskLifecycle.cs | 13 ++- .../ScheduledDeliveryMetadataBuilder.cs | 71 ++++++++++++++ .../protos/agent_run.proto | 1 + .../ScheduledAgentCreateRequestMapper.cs | 76 ++++++++++++++- .../Authoring/ScheduledAgentCreatorTool.cs | 74 ++++++++++++++- .../ChannelMetadataCallerScopeResolver.cs | 8 +- .../NyxIdNativeCallerScopeResolver.cs | 13 +++ .../Schedules/WorkflowScheduleModels.cs | 44 ++++++++- .../WorkflowScheduleConfigurationMapper.cs | 55 ++++++++++- .../NyxIdChatConversationGAgentTests.cs | 17 +--- .../NyxIdChatPublicEndpointsTests.cs | 64 +++++++++++++ .../NyxIdChatTaskLifecycleTests.cs | 45 +++++++++ .../ScheduledAgentCreatorToolTests.cs | 94 +++++++++++++++++++ .../UnifyCallerScopeAcceptanceTests.cs | 84 +++++++++++++++++ ...WorkflowScheduleApplicationServiceTests.cs | 55 +++++++++++ 23 files changed, 716 insertions(+), 45 deletions(-) create mode 100644 agents/Aevatar.GAgents.NyxidChat/ScheduledDeliveryMetadataBuilder.cs 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/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..34c0f5dbd 100644 --- a/agents/Aevatar.GAgents.Scheduled/Authoring/ScheduledAgentCreateRequestMapper.cs +++ b/agents/Aevatar.GAgents.Scheduled/Authoring/ScheduledAgentCreateRequestMapper.cs @@ -3,9 +3,11 @@ 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 +123,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 +190,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 +217,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 +407,66 @@ 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)); + } + + 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/src/workflow/Aevatar.Workflow.Application.Abstractions/Schedules/WorkflowScheduleModels.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Schedules/WorkflowScheduleModels.cs index 1664ffaf1..ea8f6d5fb 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Schedules/WorkflowScheduleModels.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Schedules/WorkflowScheduleModels.cs @@ -26,7 +26,49 @@ 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); + +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..401109a49 100644 --- a/src/workflow/Aevatar.Workflow.Application/Schedules/WorkflowScheduleConfigurationMapper.cs +++ b/src/workflow/Aevatar.Workflow.Application/Schedules/WorkflowScheduleConfigurationMapper.cs @@ -23,7 +23,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, @@ -129,6 +130,58 @@ 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()), + null); + } + private static IReadOnlyDictionary BuildWorkflowScheduleHeaders( WorkflowScheduleConfiguration configuration) { 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.GAgents.ChannelRuntime.Tests/ScheduledAgentCreatorToolTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ScheduledAgentCreatorToolTests.cs index 20efd70fe..8ef36f29f 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,60 @@ 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); + 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..0091e62ff 100644 --- a/test/Aevatar.Workflow.Application.Tests/WorkflowScheduleApplicationServiceTests.cs +++ b/test/Aevatar.Workflow.Application.Tests/WorkflowScheduleApplicationServiceTests.cs @@ -209,6 +209,30 @@ 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] public async Task CreateAsync_ShouldRejectWorkflowScheduleWithoutCredentialSource() { @@ -1234,6 +1258,37 @@ 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))); + } + private static WorkflowScheduleConfiguration CreateScopeOwnerWorkflowConfiguration(string scheduleId) => CreateConfiguration(scheduleId) with { From 5f835a7e7c21f6f34665d6cc5df206248955a4c1 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Wed, 9 Sep 2026 12:24:28 +0800 Subject: [PATCH 4/6] Refresh NyxID wire conformance pin. Co-Authored-By: Claude Opus 4.6 --- .../nyxid-code-execution-conformance/v1/sources.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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": [ From 450993ce815ba2f6479a08b5487f432cc59105ee Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Wed, 9 Sep 2026 12:51:12 +0800 Subject: [PATCH 5/6] Preserve scheduled owner LLM authorization. Co-Authored-By: Claude Opus 4.6 --- .../ScheduledAgentCreateRequestMapper.cs | 20 +++++++++++++++++- src/Aevatar.Mainnet.Host.Api/appsettings.json | 4 +++- .../Schedules/WorkflowScheduleModels.cs | 17 ++++++++++++++- .../WorkflowScheduleConfigurationMapper.cs | 21 ++++++++++++++++++- .../MainnetBootScriptTests.cs | 4 ++++ .../ScheduledAgentCreatorToolTests.cs | 7 +++++++ ...WorkflowScheduleApplicationServiceTests.cs | 14 ++++++++++++- 7 files changed, 82 insertions(+), 5 deletions(-) diff --git a/agents/Aevatar.GAgents.Scheduled/Authoring/ScheduledAgentCreateRequestMapper.cs b/agents/Aevatar.GAgents.Scheduled/Authoring/ScheduledAgentCreateRequestMapper.cs index 34c0f5dbd..f33af9d09 100644 --- a/agents/Aevatar.GAgents.Scheduled/Authoring/ScheduledAgentCreateRequestMapper.cs +++ b/agents/Aevatar.GAgents.Scheduled/Authoring/ScheduledAgentCreateRequestMapper.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Aevatar.AI.Abstractions; using Aevatar.AI.Abstractions.ToolProviders; using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Abstractions.Credentials; @@ -452,9 +453,26 @@ private static WorkflowScheduleAuthorizationFact BuildWorkflowAuthorizationFact( catalog?.ContentDigest ?? string.Empty, catalog?.ContractVersion ?? string.Empty, catalog?.PolicyVersion ?? string.Empty, - catalog?.EvaluatedAt?.ToDateTimeOffset() ?? default)); + 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", diff --git a/src/Aevatar.Mainnet.Host.Api/appsettings.json b/src/Aevatar.Mainnet.Host.Api/appsettings.json index a47a61d58..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": { diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Schedules/WorkflowScheduleModels.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Schedules/WorkflowScheduleModels.cs index ea8f6d5fb..fd5482771 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Schedules/WorkflowScheduleModels.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Schedules/WorkflowScheduleModels.cs @@ -38,7 +38,22 @@ public sealed record WorkflowScheduleAuthorizationFact( DateTimeOffset ExpiresAt, bool ServiceGrantsNotRequired, WorkflowScheduleAuthorizationDisclosure Disclosure, - WorkflowScheduleAuthorizationAuthority Authority); + 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, diff --git a/src/workflow/Aevatar.Workflow.Application/Schedules/WorkflowScheduleConfigurationMapper.cs b/src/workflow/Aevatar.Workflow.Application/Schedules/WorkflowScheduleConfigurationMapper.cs index 401109a49..901e47560 100644 --- a/src/workflow/Aevatar.Workflow.Application/Schedules/WorkflowScheduleConfigurationMapper.cs +++ b/src/workflow/Aevatar.Workflow.Application/Schedules/WorkflowScheduleConfigurationMapper.cs @@ -1,6 +1,7 @@ using Aevatar.AI.Abstractions; 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; @@ -179,9 +180,27 @@ private static ScheduledServiceInvocationNyxIdSubjectRef MapNyxIdSubject( NormalizeOptional(fact.Authority.CatalogContractVersion, string.Empty), NormalizeOptional(fact.Authority.CatalogPolicyVersion, string.Empty), fact.Authority.CatalogEvaluatedAt.ToUniversalTime()), - null); + 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.Capabilities.Tests/MainnetBootScriptTests.cs b/test/Aevatar.Capabilities.Tests/MainnetBootScriptTests.cs index 4d9ab9b06..34d47cf38 100644 --- a/test/Aevatar.Capabilities.Tests/MainnetBootScriptTests.cs +++ b/test/Aevatar.Capabilities.Tests/MainnetBootScriptTests.cs @@ -42,6 +42,10 @@ public void AppSettings_ShouldRouteScheduledProfileRequestsToScheduledCreator() 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] diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ScheduledAgentCreatorToolTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ScheduledAgentCreatorToolTests.cs index 8ef36f29f..150609f8c 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ScheduledAgentCreatorToolTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ScheduledAgentCreatorToolTests.cs @@ -1076,6 +1076,13 @@ await WithToolContext(CreateToolContext(externalMetadata: metadata), async () => .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"); }); diff --git a/test/Aevatar.Workflow.Application.Tests/WorkflowScheduleApplicationServiceTests.cs b/test/Aevatar.Workflow.Application.Tests/WorkflowScheduleApplicationServiceTests.cs index 0091e62ff..a6b7f52c3 100644 --- a/test/Aevatar.Workflow.Application.Tests/WorkflowScheduleApplicationServiceTests.cs +++ b/test/Aevatar.Workflow.Application.Tests/WorkflowScheduleApplicationServiceTests.cs @@ -231,6 +231,12 @@ await service.CreateAsync(CreateConfiguration("authorization-fact-schedule") wit 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"); } [Fact] @@ -1286,7 +1292,13 @@ [new WorkflowScheduleAuthorizationServiceGrant("svc-alpha", [], true)], CatalogContentDigest: "catalog-digest-alpha", CatalogContractVersion: "catalog-contract-alpha", CatalogPolicyVersion: "catalog-policy-alpha", - CatalogEvaluatedAt: now.AddMinutes(-6))); + 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) => From b65e44f5c8f547dd8f4c74c3174c2f820e993677 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Wed, 9 Sep 2026 12:57:22 +0800 Subject: [PATCH 6/6] Populate scheduled workflow LLM controls. Co-Authored-By: Claude Opus 4.6 --- .../WorkflowScheduleConfigurationMapper.cs | 19 +++++++++++++++++++ ...WorkflowScheduleApplicationServiceTests.cs | 5 +++++ 2 files changed, 24 insertions(+) diff --git a/src/workflow/Aevatar.Workflow.Application/Schedules/WorkflowScheduleConfigurationMapper.cs b/src/workflow/Aevatar.Workflow.Application/Schedules/WorkflowScheduleConfigurationMapper.cs index 901e47560..a068810b7 100644 --- a/src/workflow/Aevatar.Workflow.Application/Schedules/WorkflowScheduleConfigurationMapper.cs +++ b/src/workflow/Aevatar.Workflow.Application/Schedules/WorkflowScheduleConfigurationMapper.cs @@ -1,4 +1,5 @@ using Aevatar.AI.Abstractions; +using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.GAgentService.Abstractions; using Aevatar.GAgentService.Abstractions.Schedules; using Aevatar.GAgentService.Abstractions.Schedules.Authorization; @@ -68,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)) @@ -76,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 diff --git a/test/Aevatar.Workflow.Application.Tests/WorkflowScheduleApplicationServiceTests.cs b/test/Aevatar.Workflow.Application.Tests/WorkflowScheduleApplicationServiceTests.cs index a6b7f52c3..73fc24952 100644 --- a/test/Aevatar.Workflow.Application.Tests/WorkflowScheduleApplicationServiceTests.cs +++ b/test/Aevatar.Workflow.Application.Tests/WorkflowScheduleApplicationServiceTests.cs @@ -237,6 +237,11 @@ await service.CreateAsync(CreateConfiguration("authorization-fact-schedule") wit 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]