Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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" +
Expand Down
33 changes: 21 additions & 12 deletions agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ private async Task<AgentRunReplyStepPlan> BuildStepPlanCoreAsync(
isChannelRelayTurn,
effectiveToolContext,
ct)
: BuildProfileTools(disableTools, effectiveTurnCatalog);
: BuildProfileTools(disableTools, effectiveTurnCatalog, effectiveToolContext);
var input = await BuildUserInputPartsAsync(
activity,
provider,
Expand Down Expand Up @@ -440,13 +440,18 @@ private async Task<AgentRunReplyStepPlan> 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;
}

Expand Down Expand Up @@ -524,19 +529,23 @@ private async Task<ToolManager> 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<string, string> metadata,
LLMControlContext control,
Expand Down
3 changes: 0 additions & 3 deletions src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,6 @@ private async Task<OrnnExactSkillReadResult<T>> GetExactAsync<T>(
if (proxyError.Status == 403)
throw RemoteSkillFetchException.AccessDenied(idOrName, proxyError.Detail, proxyError.Status);

if (proxyError.Status == 404)
return null;

throw RemoteSkillFetchException.Unavailable(
idOrName,
proxyError.Detail,
Expand Down
27 changes: 25 additions & 2 deletions src/Aevatar.AI.ToolProviders.Skills/UseSkillTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -340,14 +340,37 @@ public async Task<string> 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."
Comment thread
louis4li marked this conversation as resolved.
: $"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<string> BuildLoadResultAsync(
string? skillName,
bool loaded,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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<string, string> metadata,
string? senderNyxUserId = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }""",
Expand All @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>(),
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()
{
Expand Down
Loading