From 4e2b66121fd8b014860da16a26d0698520784527 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Tue, 15 Sep 2026 12:20:54 +0800 Subject: [PATCH 1/3] Clarify missing channel default skill setup. Co-Authored-By: Claude Opus 4.6 --- .../ChannelConversationTurnRunner.cs | 3 ++ .../UseSkillTool.cs | 27 +++++++++++++++-- .../LocalSkillCatalogTests.cs | 30 +++++++++++++++++++ .../ChannelConversationTurnRunnerTests.cs | 2 ++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs b/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs index 47722cd33..fb9dd1bb0 100644 --- a/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs @@ -2946,6 +2946,9 @@ private bool TryBuildSlashSkillDiscoveryPrompt( $"Follow those skill instructions exactly, with `args` = {argsJson}, until the command's final result is ready.\n" + "Stick to the data sources the loaded skill names. Do NOT invent repository/path guesses, do NOT call `/api/v1/skills/.../files` (skill files are already inlined in the `use_skill` response above), and do NOT fall back to generic `nyxid_proxy` discovery when the loaded skill did not point you there.\n" + "If no matching skill was actually loaded above, or every matching skill fails to load, give one concise actionable failure that names the command and the Ornn lookup/load problem.\n" + + (viaDefaultSkillBinding + ? "If the configured default skill is not found in Ornn, tell the operator to create and publish an Ornn skill with that exact name, or update the channel registration `default_skill_name` to an existing published skill.\n" + : string.Empty) + blockerRecoveryInstruction + "Do not narrate intermediate work, path guesses, or partial findings as the user-visible reply.\n" + "The only final user-visible answer should be the completed command result or a concise actionable failure after the required tool/skill recovery attempts have been exhausted.\n" + diff --git a/src/Aevatar.AI.ToolProviders.Skills/UseSkillTool.cs b/src/Aevatar.AI.ToolProviders.Skills/UseSkillTool.cs index d644445bc..13487d790 100644 --- a/src/Aevatar.AI.ToolProviders.Skills/UseSkillTool.cs +++ b/src/Aevatar.AI.ToolProviders.Skills/UseSkillTool.cs @@ -340,14 +340,37 @@ public async Task ExecuteAsync(string argumentsJson, CancellationToken c } } + var skillNotFoundError = BuildSkillNotFoundError(skillName); return BuildLoadResult( skillName: skillName, loaded: false, - error: $"Skill '{skillName}' not found.", + error: skillNotFoundError, status: "not_found", - text: BuildErrorWithAvailableSkills($"Skill '{skillName}' not found.")); + text: BuildErrorWithAvailableSkills(skillNotFoundError)); } + private static string BuildSkillNotFoundError(string skillName) => + IsChannelDefaultSkillBindingRequest(skillName) + ? $"Channel default skill '{skillName}' was not found in Ornn. Create and publish an Ornn skill named '{skillName}', or update the channel registration default_skill_name to an existing published skill." + : $"Skill '{skillName}' not found."; + + private static bool IsChannelDefaultSkillBindingRequest(string skillName) + { + var recovery = AgentToolRequestContext.Current?.SkillRecovery; + return recovery?.FromChannelDefaultSkillBinding == true && + MatchesRequestedSkill(skillName, recovery.PrimarySkillName) && + MatchesRequestedSkill(skillName, recovery.CommandName); + } + + private static bool MatchesRequestedSkill(string skillName, string? expectedSkillName) => + string.Equals( + NormalizeSkillName(skillName), + NormalizeSkillName(expectedSkillName), + StringComparison.Ordinal); + + private static string NormalizeSkillName(string? value) => + value?.Trim().TrimStart('/').ToLowerInvariant() ?? string.Empty; + private async Task BuildLoadResultAsync( string? skillName, bool loaded, diff --git a/test/Aevatar.AI.ToolProviders.Ornn.Tests/LocalSkillCatalogTests.cs b/test/Aevatar.AI.ToolProviders.Ornn.Tests/LocalSkillCatalogTests.cs index b80e12e22..bd76fd819 100644 --- a/test/Aevatar.AI.ToolProviders.Ornn.Tests/LocalSkillCatalogTests.cs +++ b/test/Aevatar.AI.ToolProviders.Ornn.Tests/LocalSkillCatalogTests.cs @@ -119,6 +119,21 @@ public async Task UseSkillTool_WhenRequestTokenResolutionFails_DoesNotUseAmbient result.Should().NotContain("owner-token"); } + [Fact] + public async Task UseSkillTool_WhenChannelDefaultSkillIsNotFound_ShouldExplainOrnnSetup() + { + var tool = new UseSkillTool(new LocalSkillCatalog()); + + using var _ = BeginDefaultSkillBindingScope("test-default-skill"); + var result = await tool.ExecuteAsync("""{"skill":"test-default-skill"}"""); + + ExtractLoaded(result).Should().BeFalse(); + ExtractStatus(result).Should().Be("not_found"); + ExtractText(result).Should().Contain("Channel default skill 'test-default-skill' was not found in Ornn"); + ExtractText(result).Should().Contain("Create and publish an Ornn skill named 'test-default-skill'"); + ExtractText(result).Should().Contain("update the channel registration default_skill_name"); + } + [Fact] public async Task UseSkillTool_LocalSkillDoesNotCallRemoteFetcher() { @@ -581,6 +596,21 @@ private static IDisposable BeginTokenScope(string token) }); } + private static IDisposable BeginDefaultSkillBindingScope(string skillName) + { + var previous = AgentToolRequestContext.Current; + AgentToolRequestContext.Current = AgentToolExecutionContext.Empty with + { + SkillRecovery = AgentSkillRecoveryContext.Empty with + { + CommandName = skillName, + PrimarySkillName = skillName, + FromChannelDefaultSkillBinding = true, + }, + }; + return new RestoreContextScope(previous); + } + private static IDisposable BeginMetadataScope( IReadOnlyDictionary metadata, string? senderNyxUserId = null, diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelConversationTurnRunnerTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelConversationTurnRunnerTests.cs index f0e3ff531..8d412f192 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelConversationTurnRunnerTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelConversationTurnRunnerTests.cs @@ -2570,6 +2570,8 @@ public async Task RunInboundAsync_ShouldRoutePlainTextThroughDefaultSkillBinding result.LlmReplyRequest!.Activity.Content.Text.Should().Contain("bound to the `whatsapp-reply-draft` skill"); result.LlmReplyRequest.Activity.Content.Text.Should().Contain("use_skill"); result.LlmReplyRequest.Activity.Content.Text.Should().Contain("do not call `ornn_search_skills`"); + result.LlmReplyRequest.Activity.Content.Text.Should().Contain("create and publish an Ornn skill with that exact name"); + result.LlmReplyRequest.Activity.Content.Text.Should().Contain("update the channel registration `default_skill_name`"); var recovery = AgentToolExecutionContextMapper.FromPayload(result.LlmReplyRequest.ToolContext).SkillRecovery; recovery.RequireInitialOrnnSearch.Should().BeFalse(); recovery.RequireOrnnSearchOnBlocker.Should().BeFalse(); From 9a3d9aa20fafb39e0b992ed0b9894e069c8d9977 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Tue, 15 Sep 2026 12:47:03 +0800 Subject: [PATCH 2/3] Preserve Ornn proxy missing-service failures. Co-Authored-By: Claude Opus 4.6 --- src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillClient.cs | 3 --- .../OrnnSkillClientTests.cs | 6 ++++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillClient.cs b/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillClient.cs index aeb9dba5b..6870f60a9 100644 --- a/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillClient.cs +++ b/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillClient.cs @@ -240,9 +240,6 @@ private async Task> GetExactAsync( if (proxyError.Status == 403) throw RemoteSkillFetchException.AccessDenied(idOrName, proxyError.Detail, proxyError.Status); - if (proxyError.Status == 404) - return null; - throw RemoteSkillFetchException.Unavailable( idOrName, proxyError.Detail, diff --git a/test/Aevatar.AI.ToolProviders.Ornn.Tests/OrnnSkillClientTests.cs b/test/Aevatar.AI.ToolProviders.Ornn.Tests/OrnnSkillClientTests.cs index 48d05d17e..ae7b6f0cd 100644 --- a/test/Aevatar.AI.ToolProviders.Ornn.Tests/OrnnSkillClientTests.cs +++ b/test/Aevatar.AI.ToolProviders.Ornn.Tests/OrnnSkillClientTests.cs @@ -443,7 +443,7 @@ public async Task RemoteSkillFetcher_DefaultsWorkflowIdToFirstSortedWorkflowFile } [Fact] - public async Task UseSkillTool_WhenNyxIdProxyReportsNotFound_ProducesNotFoundReceipt() + public async Task UseSkillTool_WhenNyxIdProxyReportsNotFound_ProducesLoadFailedReceipt() { var handler = OrnnTestHttpMessageHandler.ReturningJson( """{ "error": "missing" }""", @@ -454,8 +454,10 @@ public async Task UseSkillTool_WhenNyxIdProxyReportsNotFound_ProducesNotFoundRec var result = await tool.ExecuteAsync(arguments); var receipt = tool.CreateResultReceipt("call-missing", tool.Name, arguments, result); + result.Should().Contain("Ornn skill API not reachable"); + result.Should().Contain("nyxid_services action=create"); receipt.Should().NotBeNull(); - receipt!.ErrorCode.Should().Be("USE_SKILL_NOT_FOUND"); + receipt!.ErrorCode.Should().Be("USE_SKILL_LOAD_FAILED"); } [Fact] From 31c2e76060b6dd886cc7c0ca0f2266bf6e0b18f1 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Tue, 15 Sep 2026 17:13:11 +0800 Subject: [PATCH 3/3] Expose use_skill for channel default skill recovery. Co-Authored-By: Claude Opus 4.6 --- .../ConversationReplyGenerator.cs | 33 +++++++----- .../ConversationReplyGeneratorTests.cs | 50 +++++++++++++++++++ 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs b/agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs index 9225bbae2..55f76f920 100644 --- a/agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs @@ -370,7 +370,7 @@ private async Task BuildStepPlanCoreAsync( isChannelRelayTurn, effectiveToolContext, ct) - : BuildProfileTools(disableTools, effectiveTurnCatalog); + : BuildProfileTools(disableTools, effectiveTurnCatalog, effectiveToolContext); var input = await BuildUserInputPartsAsync( activity, provider, @@ -440,13 +440,18 @@ private async Task BuildStepPlanCoreAsync( ownerFallbackToolContext); } - private static ToolManager BuildProfileTools( + private ToolManager BuildProfileTools( bool disableTools, - AgentTurnToolCatalog turnCatalog) + AgentTurnToolCatalog turnCatalog, + AgentToolExecutionContext toolContext) { var tools = new ToolManager(); if (!disableTools) + { tools.Register(turnCatalog.ExactTools.Values); + if (toolContext.SkillRecovery is { } && tools.Get("use_skill") is null) + RegisterUseSkillTool(tools); + } return tools; } @@ -524,19 +529,23 @@ private async Task BuildTurnToolsAsync( // Refactor (iter27/cluster-027-skill-registry-remote-skill-process-state): // Old pattern: SkillRegistry 暴露混合 local + remote skill 注册并用 5min TTL process-wide cache 缓存 remote skill,违反读写分离 + 多用户 token 共享 + 进程内事实状态 // New principle: 删 SkillRegistry + TTL tests + 5min cache;新建 local-only LocalSkillCatalog;remote skill 每次 use_skill 调用 IRemoteSkillFetcher.FetchSkillAsync(currentToken, ...) 不缓存;docs/canon factual sync - if (!IsNyxIdChatTurn(discoveryContext) && - (_localSkillCatalog is not null || _remoteSkillFetcher is not null) && - tools.Get("use_skill") is null) - { - tools.Register(new UseSkillTool( - _localSkillCatalog ?? new LocalSkillCatalog(), - _remoteSkillFetcher, - remoteAccessTokenResolver: _remoteSkillAccessTokenResolver)); - } + if (!IsNyxIdChatTurn(discoveryContext) && tools.Get("use_skill") is null) + RegisterUseSkillTool(tools); return tools; } + private void RegisterUseSkillTool(ToolManager tools) + { + if (_localSkillCatalog is null && _remoteSkillFetcher is null) + return; + + tools.Register(new UseSkillTool( + _localSkillCatalog ?? new LocalSkillCatalog(), + _remoteSkillFetcher, + remoteAccessTokenResolver: _remoteSkillAccessTokenResolver)); + } + private static AgentToolExecutionContext BuildEffectiveToolContext( IReadOnlyDictionary metadata, LLMControlContext control, diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ConversationReplyGeneratorTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ConversationReplyGeneratorTests.cs index 8c0e64ad5..e742466e5 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ConversationReplyGeneratorTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ConversationReplyGeneratorTests.cs @@ -1019,6 +1019,56 @@ public async Task BuildStepPlanAsync_WithChannelRegistrationDefaultSkill_KeepsSe plan.ToolContext.ToolVisibility.IsRestricted.Should().BeFalse(); } + [Fact] + public async Task BuildStepPlanAsync_WithEmptyChannelRegistrationDefaultSkillCatalog_OffersUseSkillRecoveryTool() + { + var localSkillCatalog = new LocalSkillCatalog(); + IAgentRunStepConversationReplyGenerator generator = new NyxIdConversationReplyGenerator( + new RecordingProviderFactory { Capabilities = MultimodalCapabilities }, + BuiltInPromptFloorProvider, + localSkillCatalog: localSkillCatalog); + var catalog = new AgentTurnToolCatalog( + [], + new ProfileRoutingPromptLayer( + "registration-runtime-route", + new ProfileRoutingPromptProvenance("channel-registration"), + new PromptLayerBounds(1024, 256)), + selectedSkillPromptLayer: null, + selectedIntentId: "missing-default-skill", + candidateIntentId: "missing-default-skill", + exactTools: []); + var toolContext = AgentToolExecutionContext.Empty with + { + Channel = new AgentToolChannelContext("telegram", "8823472623", "scope-1", "msg-runtime", null), + CredentialSource = AgentToolCredentialSource.ChannelRegistration, + SkillRecovery = AgentSkillRecoveryContext.Empty with + { + PrimarySkillName = "missing-default-skill", + FromChannelDefaultSkillBinding = true, + }, + }; + + var plan = await generator.BuildStepPlanAsync( + new ChatActivity + { + Id = "msg-runtime", + ChannelId = ChannelId.From("telegram"), + Conversation = new ConversationReference { CanonicalKey = "telegram:dm:8823472623" }, + Content = new MessageContent { Text = "hello" }, + }, + new Dictionary(), + Control(token: "registration-agent-key"), + toolContext, + priorHistory: null, + attachmentContext: null, + forceDisableTools: false, + ct: CancellationToken.None, + turnCatalog: catalog); + + OfferedToolNames(plan).Should().ContainSingle().Which.Should().Be("use_skill"); + plan.ToolContext.ToolVisibility.AllowedToolNames.Should().BeEquivalentTo("use_skill"); + } + [Fact] public async Task BuildStepPlanAsync_InNyxIdChatTurn_UsesPinnedSourceAndAllowsHumanSessionReads() {