From a63449f88d9fbf6ceba0642a0d0b905d60e31f2f Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Fri, 18 Sep 2026 12:43:02 +0800 Subject: [PATCH 1/2] Load NyxID recommended skill refs. Co-Authored-By: Claude Opus 4.6 --- ...yxIdConnectedServiceInventoryToolSource.cs | 265 +++++++++++++++++- .../Skills/system-prompt.md | 2 +- .../Skills/system-skill-overlay-default.md | 2 +- .../v1/sources.json | 6 +- .../NyxIdServiceInstanceClient.cs | 58 ++++ .../nyxid_service_tools.proto | 16 ++ .../NyxIdServiceInstanceClientTests.cs | 31 ++ ...onnectedServiceInventoryToolSourceTests.cs | 190 ++++++++++++- 8 files changed, 552 insertions(+), 18 deletions(-) diff --git a/agents/Aevatar.GAgents.NyxidChat/ChannelNyxIdConnectedServiceInventoryToolSource.cs b/agents/Aevatar.GAgents.NyxidChat/ChannelNyxIdConnectedServiceInventoryToolSource.cs index ab8563246..27fb176a0 100644 --- a/agents/Aevatar.GAgents.NyxidChat/ChannelNyxIdConnectedServiceInventoryToolSource.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ChannelNyxIdConnectedServiceInventoryToolSource.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Aevatar.AI.Abstractions; using Aevatar.AI.Abstractions.ToolProviders; +using Aevatar.AI.Core.AgentProfiles; using Aevatar.AI.ToolProviders.NyxId; using Aevatar.AI.ToolProviders.NyxId.ConnectedServices; using Aevatar.GAgents.Channel.Abstractions; @@ -25,6 +26,7 @@ public sealed class ChannelNyxIdConnectedServiceInventoryToolSource : IAgentTool private readonly NyxIdToolOptions? _options; private readonly INyxIdApiClientFactory? _apiClientFactory; private readonly INyxIdConnectedServiceCapabilityIssuer? _capabilityIssuer; + private readonly IExactRemoteSkillFetcher? _exactSkillFetcher; private readonly ILogger _logger; public ChannelNyxIdConnectedServiceInventoryToolSource( @@ -32,12 +34,14 @@ public ChannelNyxIdConnectedServiceInventoryToolSource( NyxIdToolOptions? options = null, INyxIdApiClientFactory? apiClientFactory = null, INyxIdConnectedServiceCapabilityIssuer? capabilityIssuer = null, - ILogger? logger = null) + ILogger? logger = null, + IExactRemoteSkillFetcher? exactSkillFetcher = null) { _toolExecutionPort = toolExecutionPort ?? throw new ArgumentNullException(nameof(toolExecutionPort)); _options = options; _apiClientFactory = apiClientFactory; _capabilityIssuer = capabilityIssuer; + _exactSkillFetcher = exactSkillFetcher; _logger = logger ?? NullLogger.Instance; } @@ -49,7 +53,10 @@ public Task> DiscoverToolsAsync(CancellationToken ct = if (context is null || bindingId is null) return Task.FromResult>([]); - return Task.FromResult>([new SenderInventoryTool(this)]); + return Task.FromResult>([ + new SenderInventoryTool(this), + new SenderRecommendedSkillTool(this), + ]); } private async Task ExecuteInventoryAsync(string argumentsJson, CancellationToken ct) @@ -179,6 +186,156 @@ private async Task ExecuteWithSenderTokenAsync( } } + private async Task ExecuteRecommendedSkillLoadAsync(string argumentsJson, CancellationToken ct) + { + var arguments = ParseRecommendedSkillArguments(argumentsJson); + if (arguments is null) + return JsonSerializer.Serialize(new { error = "invalid_arguments" }); + + var context = AgentToolRequestContext.Current; + var bindingId = Normalize(context?.SenderBinding.BindingId); + if (context is null || bindingId is null) + return RecommendedSkillFailure("inventory_capability_unavailable"); + if (_capabilityIssuer is null || !TryBuildSubject(context, out var subject)) + return RecommendedSkillFailure("inventory_capability_unavailable"); + if (_exactSkillFetcher is null) + return RecommendedSkillFailure("exact_skill_loader_unavailable"); + + try + { + var capability = await _capabilityIssuer + .IssueByBindingIdAsync(subject, bindingId, ct) + .ConfigureAwait(false); + var token = Normalize(capability.AccessToken); + if (token is null) + return RecommendedSkillFailure("inventory_capability_unavailable"); + + return await ExecuteRecommendedSkillLoadWithSenderTokenAsync(context, token, arguments, ct) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (BindingRevokedException) + { + return RecommendedSkillFailure("inventory_binding_revoked"); + } + catch (BindingScopeMismatchException) + { + return RecommendedSkillFailure("inventory_scope_unavailable"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "NyxID recommended skill capability issue failed"); + return RecommendedSkillFailure("inventory_capability_unavailable"); + } + } + + private async Task ExecuteRecommendedSkillLoadWithSenderTokenAsync( + AgentToolExecutionContext context, + string token, + RecommendedSkillArguments arguments, + CancellationToken ct) + { + if (_options is null || _apiClientFactory is null || _exactSkillFetcher is null) + return RecommendedSkillFailure("inventory_source_unavailable"); + + var reader = new NyxIdConnectedServiceInventoryReader( + new NyxIdServiceInstanceClient(_apiClientFactory.CreateClient())); + var senderContext = context with + { + CredentialSource = AgentToolCredentialSource.BearerToken, + DurableNyxIdCredential = null, + Credentials = new AgentToolCredentials( + token, + token, + SenderNyxIdAccessToken: token, + NyxIdCredentialKind: AgentToolNyxIdCredentialKind.SourceReadableUserBearer, + NyxIdCredentialAuthority: AgentToolNyxIdCredentialAuthority.ToolExecutionContext), + Request = context.Request with + { + CallId = CreateRecommendedSkillLoadCallId(context.Request.CallId), + }, + }; + var outcome = await _toolExecutionPort.ExecuteAsync( + new AgentToolExecutionRequest( + new SenderRecommendedSkillReaderTool(this, reader, _exactSkillFetcher, token, arguments), + "{}", + senderContext, + AgentToolApprovalContinuationMode.None, + ApprovalGrant: null), + ct).ConfigureAwait(false); + return outcome.ResultJson; + } + + private async Task ReadRecommendedSkillAsync( + NyxIdConnectedServiceInventoryReader reader, + IExactRemoteSkillFetcher exactSkillFetcher, + string token, + RecommendedSkillArguments arguments, + CancellationToken ct) + { + var inventory = await reader.ReadAsync(token, organizationToken: null, ct).ConfigureAwait(false); + var service = inventory.Instances.FirstOrDefault(instance => + string.Equals(instance.UserServiceId, arguments.UserServiceId, StringComparison.Ordinal)); + if (service is null) + return RecommendedSkillFailure("service_instance_not_visible"); + var skillRef = service.RecommendedSkillRefs.FirstOrDefault(candidate => + candidate.Source == NyxIdRecommendedSkillSource.Ornn && + string.Equals(candidate.SkillId, arguments.SkillId, StringComparison.Ordinal) && + string.Equals(candidate.LiteralVersion, arguments.LiteralVersion, StringComparison.Ordinal) && + string.Equals(candidate.ManifestDigest, arguments.ManifestDigest, StringComparison.Ordinal)); + if (skillRef is null) + return RecommendedSkillFailure("recommended_skill_ref_not_visible"); + + var fetchResult = await exactSkillFetcher.FetchAsync( + token, + new ExactRemoteSkillRef + { + Guid = skillRef.SkillId, + LiteralVersion = skillRef.LiteralVersion, + }, + ct).ConfigureAwait(false); + if (!fetchResult.IsSuccess) + { + return JsonSerializer.Serialize(new + { + result_type = "nyxid_recommended_skill_load", + status = "failed", + loaded = false, + failure_code = fetchResult.FailureCode?.ToString(), + failure_detail = fetchResult.FailureDetail, + }); + } + + var fetchedDigest = "sha256:" + Convert.ToHexString(fetchResult.SkillSha256!.ToByteArray()).ToLowerInvariant(); + if (!string.Equals(fetchedDigest, skillRef.ManifestDigest, StringComparison.OrdinalIgnoreCase)) + return RecommendedSkillFailure("recommended_skill_digest_mismatch"); + + return JsonSerializer.Serialize(new + { + result_type = "nyxid_recommended_skill_load", + status = "success", + loaded = true, + service_instance_id = service.UserServiceId, + service_label = service.Label, + skill = new + { + source = "ornn", + skill_id = fetchResult.Guid, + literal_version = fetchResult.LiteralVersion, + name = fetchResult.Name, + publisher_id = fetchResult.PublisherId, + manifest_digest = fetchedDigest, + recommended_name = skillRef.RecommendationName, + revision = skillRef.Revision, + }, + main_document = fetchResult.SkillMarkdown, + resources = Array.Empty(), + }); + } + private async Task ReadInventoryAsync( NyxIdConnectedServiceInventoryReader reader, string token, @@ -208,6 +365,48 @@ private async Task ReadInventoryAsync( private static string CreateInventoryReadCallId(string? outerCallId) => $"{Normalize(outerCallId) ?? "missing"}:inventory-read"; + private static string CreateRecommendedSkillLoadCallId(string? outerCallId) => + $"{Normalize(outerCallId) ?? "missing"}:recommended-skill-load"; + + private static RecommendedSkillArguments? ParseRecommendedSkillArguments(string argumentsJson) + { + try + { + using var document = JsonDocument.Parse( + string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + if (document.RootElement.ValueKind != JsonValueKind.Object) + return null; + var userServiceId = ReadRequiredString(document.RootElement, "user_service_id"); + var source = ReadRequiredString(document.RootElement, "source"); + var skillId = ReadRequiredString(document.RootElement, "skill_id"); + var literalVersion = ReadRequiredString(document.RootElement, "literal_version"); + var manifestDigest = ReadRequiredString(document.RootElement, "manifest_digest"); + if (userServiceId is null || source is null || skillId is null || + literalVersion is null || manifestDigest is null || + !string.Equals(source, "ornn", StringComparison.Ordinal)) + { + return null; + } + + return new RecommendedSkillArguments( + userServiceId, + skillId, + literalVersion, + manifestDigest); + } + catch (JsonException) + { + return null; + } + } + + private static string? ReadRequiredString(JsonElement root, string name) + { + if (!root.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.String) + return null; + return Normalize(value.GetString()); + } + private static bool TryBuildSubject(AgentToolExecutionContext context, out ExternalSubjectRef subject) { subject = new ExternalSubjectRef(); @@ -255,6 +454,21 @@ private static string InventoryFailure(string errorCode) => message = "The connected-service inventory for the bound NyxID account is temporarily unavailable. Retry shortly.", }); + private static string RecommendedSkillFailure(string errorCode) => + JsonSerializer.Serialize(new + { + result_type = "nyxid_recommended_skill_load", + status = "failed", + loaded = false, + error = errorCode, + }); + + private sealed record RecommendedSkillArguments( + string UserServiceId, + string SkillId, + string LiteralVersion, + string ManifestDigest); + private sealed class SenderInventoryTool(ChannelNyxIdConnectedServiceInventoryToolSource source) : IAgentTool { private const string Schema = @@ -278,6 +492,53 @@ public Task ExecuteAsync(string argumentsJson, CancellationToken ct = de source.ExecuteInventoryAsync(argumentsJson, ct); } + private sealed class SenderRecommendedSkillTool(ChannelNyxIdConnectedServiceInventoryToolSource source) : IAgentTool + { + private const string Schema = + """ + { + "type":"object", + "properties":{ + "user_service_id":{"type":"string","description":"Exact user_service_id from nyxid_service_inventory."}, + "source":{"type":"string","enum":["ornn"]}, + "skill_id":{"type":"string","description":"Exact skill_id from the selected recommended_skill_refs entry."}, + "literal_version":{"type":"string","description":"Exact literal_version from the selected recommended_skill_refs entry."}, + "manifest_digest":{"type":"string","description":"Exact manifest_digest from the selected recommended_skill_refs entry."} + }, + "required":["user_service_id","source","skill_id","literal_version","manifest_digest"], + "additionalProperties":false + } + """; + + public string Name => "nyxid_load_recommended_skill"; + public string Description => + "Load the main document for a NyxID service-recommended exact Ornn skill ref from the current sender inventory."; + public string ParametersSchema => Schema; + public bool IsReadOnly => true; + public ToolApprovalMode ApprovalMode => ToolApprovalMode.NeverRequire; + + public Task ExecuteAsync(string argumentsJson, CancellationToken ct = default) => + source.ExecuteRecommendedSkillLoadAsync(argumentsJson, ct); + } + + private sealed class SenderRecommendedSkillReaderTool( + ChannelNyxIdConnectedServiceInventoryToolSource source, + NyxIdConnectedServiceInventoryReader reader, + IExactRemoteSkillFetcher exactSkillFetcher, + string token, + RecommendedSkillArguments arguments) : IAgentTool + { + public string Name => "nyxid_recommended_skill_reader"; + public string Description => "Read and load one current sender recommended Ornn skill ref."; + public string ParametersSchema => + """{"type":"object","properties":{},"required":[],"additionalProperties":false}"""; + public bool IsReadOnly => true; + public ToolApprovalMode ApprovalMode => ToolApprovalMode.NeverRequire; + + public Task ExecuteAsync(string argumentsJson, CancellationToken ct = default) => + source.ReadRecommendedSkillAsync(reader, exactSkillFetcher, token, arguments, ct); + } + private sealed class SenderInventoryReaderTool( ChannelNyxIdConnectedServiceInventoryToolSource source, NyxIdConnectedServiceInventoryReader reader, diff --git a/agents/Aevatar.GAgents.NyxidChat/Skills/system-prompt.md b/agents/Aevatar.GAgents.NyxidChat/Skills/system-prompt.md index 7812ab56a..96369d285 100644 --- a/agents/Aevatar.GAgents.NyxidChat/Skills/system-prompt.md +++ b/agents/Aevatar.GAgents.NyxidChat/Skills/system-prompt.md @@ -106,7 +106,7 @@ Delegate a natural-language task to Codex. Use `managed_sandbox` for the fixed i In an unprofiled turn where this broad tool is present, discover live proxyable services before choosing a slug, then make authenticated requests through NyxID. ### NyxID connected-service tools -When present, `nyxid_service_inventory` is a read-only current-caller inventory capability. Request-local `nyxop_*` tools are separately admitted exact connected-service operations; use only the arguments in each tool's frozen schema. Never substitute a display slug, catalog id, label, endpoint id, remembered value, or inventory result for an operation selector. +When present, `nyxid_service_inventory` is a read-only current-caller inventory capability. If an inventory instance contains `recommended_skill_refs`, load exactly one needed ref with `nyxid_load_recommended_skill` by copying `user_service_id`, `source`, `skill_id`, `literal_version`, and `manifest_digest` from that inventory result; do not load by display name or latest version. Request-local `nyxop_*` tools are separately admitted exact connected-service operations; use only the arguments in each tool's frozen schema. Never substitute a display slug, catalog id, label, endpoint id, remembered value, or inventory result for an operation selector. For a read-only request asking which services the caller already has connected, answer with the inventory read present in the final request's tool schemas: when `nyxid_service_inventory` is present, follow the System Skill Overlay's catalog/service-inspection procedure; when it is absent, use a read-only management read such as `nyxid_services`. If inventory returns `NYXID_SERVICE_INVENTORY_CREDENTIAL_DENIED`, report that the credential configuration must be corrected before retrying. For transient inventory failures, report a temporary read failure. Do not claim that the binding is absent or recommend `/init` unless the binding is explicitly missing or revoked. ### `nyxid_require_service` — Report a missing connection diff --git a/agents/Aevatar.GAgents.NyxidChat/Skills/system-skill-overlay-default.md b/agents/Aevatar.GAgents.NyxidChat/Skills/system-skill-overlay-default.md index c925938f3..3eea4abe2 100644 --- a/agents/Aevatar.GAgents.NyxidChat/Skills/system-skill-overlay-default.md +++ b/agents/Aevatar.GAgents.NyxidChat/Skills/system-skill-overlay-default.md @@ -21,7 +21,7 @@ NyxID service procedures and Ornn user manuals live on the Ornn skill platform, Everything in this section presumes the named tools appear in the current request's tool schemas. If this turn exposes no tool schemas at all, none of it applies: never write tool-call syntax such as `use_skill(...)` into your reply as text, say plainly that no tools are available in this turn, and answer only from context. -For a read-only request asking which services the caller already has connected, answer with the inventory read present in the final request's tool schemas. When `nyxid_service_inventory` is present, route the read through the catalog/service-inspection path: first call `use_skill(skill="nyxid-service-discovery")`, then call `nyxid_service_inventory`. This route establishes current sender-specific service facts; execution tools only run supplied work and cannot establish that inventory. The loaded skill supplies current NyxID semantics; treat the typed inventory result as the authority for the current sender. When `nyxid_service_inventory` is absent, answer from the read-only NyxID management read that is present instead, such as `nyxid_services`, without chasing the missing inventory tool. If inventory returns `NYXID_SERVICE_INVENTORY_CREDENTIAL_DENIED`, report that the credential configuration must be corrected before retrying. For transient inventory failures, report a temporary read failure. Do not claim that the binding is absent or recommend `/init` unless the binding is explicitly missing or revoked. +For a read-only request asking which services the caller already has connected, answer with the inventory read present in the final request's tool schemas. When `nyxid_service_inventory` is present, route the read through the catalog/service-inspection path: first call `use_skill(skill="nyxid-service-discovery")`, then call `nyxid_service_inventory`. This route establishes current sender-specific service facts; execution tools only run supplied work and cannot establish that inventory. If the selected service exposes `recommended_skill_refs` and `nyxid_load_recommended_skill` is present, load exactly one needed ref by copying the exact `user_service_id`, `source`, `skill_id`, `literal_version`, and `manifest_digest` from inventory. Treat that loaded main document as operation guidance only; execute reads through admitted connected-service tools and their frozen schemas. The loaded skill supplies current NyxID semantics; treat the typed inventory result as the authority for the current sender. When `nyxid_service_inventory` is absent, answer from the read-only NyxID management read that is present instead, such as `nyxid_services`, without chasing the missing inventory tool. If inventory returns `NYXID_SERVICE_INVENTORY_CREDENTIAL_DENIED`, report that the credential configuration must be corrected before retrying. For transient inventory failures, report a temporary read failure. Do not claim that the binding is absent or recommend `/init` unless the binding is explicitly missing or revoked. `nyxid_require_service` readiness distinguishes two states that must never be conflated: `USER_SERVICE_NOT_VISIBLE` means the service is genuinely not connected and a connect journey is required; `USER_SERVICE_ACCESS_REQUIRED` means the service **is already connected** and only this chat session's one-time authorization is missing — tell the user the service is connected, say the pending step is a service access review approval, and never ask them to connect the service again. diff --git a/docs/contracts/nyxid-assistant-conformance/v1/sources.json b/docs/contracts/nyxid-assistant-conformance/v1/sources.json index d62681616..f5ec7dca4 100644 --- a/docs/contracts/nyxid-assistant-conformance/v1/sources.json +++ b/docs/contracts/nyxid-assistant-conformance/v1/sources.json @@ -2,8 +2,8 @@ "schema_version": 1, "aevatar": { "repository": "https://github.com/AevatarAI/aevatar.git", - "revision": "78e2490bc1ad548ed72f75061d8927f8cf599ac1", - "contract_files_sha256": "8ddfc263edf7bf71f25f23edcc9248a6afdfd4cdbf98a2717fadadd8c740c119", + "revision": "e3fde8e0fa7526d58c8e050fa2a9f71dfbb33349", + "contract_files_sha256": "fd36e48a371db5f74768b6c37d265db184a841feba53966ecf94add17bff1e4c", "files": { "agents/Aevatar.GAgents.NyxidChat/NyxIdActionPostconditionPort.cs": "7791de469b567dcde70a0f8e2a88cc818972ca557617a2538294e8ccabd5bda0", "agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionRegistry.cs": "60e6f67c94ae11b1bf0dac036ad8ac0c35901e31787b1f0c8173964f6a12d263", @@ -17,7 +17,7 @@ "src/Aevatar.AI.ToolProviders.NyxId/NyxIdAssistantToolSource.cs": "e99f2de69d0eb9e0b9dc235e2d568fc66d9dfb79cb0bb1364e01cc210a8c626f", "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyCreateTool.cs": "2c4f2cda99154f2e667c6cfd291497e697ef11df17f081f96ec70070a8af8b8c", "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyRotateTool.cs": "18212bb64644cfbca401065bccce439ea5fa00316deff57d730a0d9ac2650e53", - "src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs": "a6faf5a532b43dd1e1cf56cfdf5a05f473d755073e84d86ea459e4acec7828bd" + "src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs": "030451d96b83849a3dbd0f2c8159f5c4dd72ad79629be4d2183cd9d9fa88d98e" } }, "nyxid": { diff --git a/src/Aevatar.AI.ToolProviders.NyxId/ConnectedServices/NyxIdServiceInstanceClient.cs b/src/Aevatar.AI.ToolProviders.NyxId/ConnectedServices/NyxIdServiceInstanceClient.cs index 23661daf3..c2446c4b5 100644 --- a/src/Aevatar.AI.ToolProviders.NyxId/ConnectedServices/NyxIdServiceInstanceClient.cs +++ b/src/Aevatar.AI.ToolProviders.NyxId/ConnectedServices/NyxIdServiceInstanceClient.cs @@ -321,9 +321,66 @@ private static IReadOnlyList ParseBindings( instance.OpenapiSpecUrl = openApiSpecUrl; if (nodeId is not null) instance.NodeId = nodeId; + instance.RecommendedSkillRefs.Add(ParseRecommendedSkillRefs(item)); return new NyxIdServiceInstanceBinding(instance, token); } + private static IReadOnlyList ParseRecommendedSkillRefs(JsonElement item) + { + if (!item.TryGetProperty("recommended_skill_refs", out var refsElement) || refsElement.ValueKind == JsonValueKind.Null) + return []; + if (refsElement.ValueKind != JsonValueKind.Array) + throw new NyxIdServiceInventoryContractException(); + + var refs = new List(); + foreach (var refElement in refsElement.EnumerateArray()) + { + var skillRef = ParseRecommendedSkillRef(refElement) + ?? throw new NyxIdServiceInventoryContractException(); + refs.Add(skillRef); + } + + return refs; + } + + private static NyxIdRecommendedSkillRef? ParseRecommendedSkillRef(JsonElement item) + { + if (item.ValueKind != JsonValueKind.Object) + return null; + if (!TryParseRecommendedSkillSource(ReadString(item, "source") ?? ReadString(item, "provider"), out var source)) + return null; + var skillId = ReadString(item, "skill_id") ?? ReadString(item, "id") ?? ReadString(item, "guid"); + var literalVersion = ReadString(item, "literal_version") ?? ReadString(item, "version"); + var manifestDigest = ReadString(item, "manifest_digest") ?? ReadString(item, "digest") ?? ReadString(item, "skill_hash"); + if (string.IsNullOrWhiteSpace(skillId) || + string.IsNullOrWhiteSpace(literalVersion) || + string.IsNullOrWhiteSpace(manifestDigest)) + { + return null; + } + + return new NyxIdRecommendedSkillRef + { + Source = source, + SkillId = skillId.Trim(), + LiteralVersion = literalVersion.Trim(), + ManifestDigest = manifestDigest.Trim(), + DisplayName = ReadString(item, "display_name") ?? ReadString(item, "name") ?? string.Empty, + RecommendationName = ReadString(item, "recommendation_name") ?? ReadString(item, "recommended_name") ?? string.Empty, + Revision = ReadString(item, "revision") ?? string.Empty, + }; + } + + private static bool TryParseRecommendedSkillSource(string? value, out NyxIdRecommendedSkillSource source) + { + source = value switch + { + "ornn" => NyxIdRecommendedSkillSource.Ornn, + _ => NyxIdRecommendedSkillSource.Unspecified, + }; + return source != NyxIdRecommendedSkillSource.Unspecified; + } + private static bool TryReadNodeId(JsonElement item, out string? nodeId) { nodeId = null; @@ -445,6 +502,7 @@ private static bool SameAuthority(NyxIdServiceInstance left, NyxIdServiceInstanc string.Equals(left.EndpointUrl, right.EndpointUrl, StringComparison.Ordinal) && string.Equals(left.OpenapiSpecUrl, right.OpenapiSpecUrl, StringComparison.Ordinal) && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) && + left.RecommendedSkillRefs.SequenceEqual(right.RecommendedSkillRefs) && Equals(left.CallerExecutionReadiness, right.CallerExecutionReadiness) && Equals(left.RouteConstraint, right.RouteConstraint); diff --git a/src/Aevatar.AI.ToolProviders.NyxId/ConnectedServices/nyxid_service_tools.proto b/src/Aevatar.AI.ToolProviders.NyxId/ConnectedServices/nyxid_service_tools.proto index 26e090da1..3b09e76ab 100644 --- a/src/Aevatar.AI.ToolProviders.NyxId/ConnectedServices/nyxid_service_tools.proto +++ b/src/Aevatar.AI.ToolProviders.NyxId/ConnectedServices/nyxid_service_tools.proto @@ -36,6 +36,21 @@ enum NyxIdServiceNodeStatus { NYX_ID_SERVICE_NODE_STATUS_INACCESSIBLE = 6; } +enum NyxIdRecommendedSkillSource { + NYX_ID_RECOMMENDED_SKILL_SOURCE_UNSPECIFIED = 0; + NYX_ID_RECOMMENDED_SKILL_SOURCE_ORNN = 1; +} + +message NyxIdRecommendedSkillRef { + NyxIdRecommendedSkillSource source = 1; + string skill_id = 2; + string literal_version = 3; + string manifest_digest = 4; + string display_name = 5; + string recommendation_name = 6; + string revision = 7; +} + message NyxIdServiceCallerExecutionReadiness { NyxIdServiceCredentialStatus credential_status = 1; bool connected = 2; @@ -67,6 +82,7 @@ message NyxIdServiceInstance { optional string catalog_service_slug = 14; NyxIdServiceCallerExecutionReadiness caller_execution_readiness = 15; optional string openapi_spec_url = 16; + repeated NyxIdRecommendedSkillRef recommended_skill_refs = 17; } message NyxIdServiceUpdateRequest { diff --git a/test/Aevatar.AI.Tests/NyxIdServiceInstanceClientTests.cs b/test/Aevatar.AI.Tests/NyxIdServiceInstanceClientTests.cs index ced38d153..632ae9ded 100644 --- a/test/Aevatar.AI.Tests/NyxIdServiceInstanceClientTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdServiceInstanceClientTests.cs @@ -81,6 +81,37 @@ await act.Should().ThrowAsync() .WithMessage("NYXID_SERVICE_INVENTORY_CONTRACT_INVALID"); } + [Fact] + public async Task ReadAsync_RecommendedSkillRefs_MapsExactOrnnReferenceWithoutCatalogBackfill() + { + var handler = new InventoryHandler(); + handler.KeysByToken["user-token"] = Keys(""" + {"id":"us-personal","slug":"calendar","catalog_service_id":"catalog-calendar", + "catalog_service_slug":"api-calendar","is_active":true,"connected":true,"status":"active", + "credential_source":{"type":"personal"}, + "recommended_skill_refs":[{ + "source":"ornn", + "skill_id":"11111111-1111-1111-1111-111111111111", + "literal_version":"1.2", + "manifest_digest":"sha256:000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "display_name":"Calendar Reader", + "recommendation_name":"read-calendar-events", + "revision":"rev-1" + }]} + """); + + var result = await CreateReader(handler).ReadAsync("user-token", organizationToken: null); + + var skillRef = result.Instances.Should().ContainSingle().Subject.RecommendedSkillRefs + .Should().ContainSingle().Subject; + skillRef.Source.Should().Be(NyxIdRecommendedSkillSource.Ornn); + skillRef.SkillId.Should().Be("11111111-1111-1111-1111-111111111111"); + skillRef.LiteralVersion.Should().Be("1.2"); + skillRef.ManifestDigest.Should().StartWith("sha256:"); + skillRef.RecommendationName.Should().Be("read-calendar-events"); + skillRef.Revision.Should().Be("rev-1"); + } + [Fact] public async Task ReadAsync_GenuineEmptyKeys_ReturnsEmptyInventory() { diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelNyxIdConnectedServiceInventoryToolSourceTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelNyxIdConnectedServiceInventoryToolSourceTests.cs index 82ed4c5c3..521512b40 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelNyxIdConnectedServiceInventoryToolSourceTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelNyxIdConnectedServiceInventoryToolSourceTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Aevatar.AI.Abstractions; using Aevatar.AI.Abstractions.ToolProviders; +using Aevatar.AI.Core.AgentProfiles; using Aevatar.AI.Core.Tools; using Aevatar.AI.ToolProviders.NyxId; using Aevatar.AI.ToolProviders.NyxId.ConnectedServices; @@ -13,6 +14,7 @@ using Aevatar.GAgents.Channel.Identity.Abstractions; using Aevatar.GAgents.NyxidChat; using FluentAssertions; +using Google.Protobuf; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Xunit; @@ -34,7 +36,7 @@ public async Task DiscoverToolsAsync_ExposesListOnlySchemaWithoutUnverifiedInsta SenderTenant: "tenant-1"), }); - var tool = (await source.DiscoverToolsAsync()).Should().ContainSingle().Subject; + var tool = InventoryTool(await source.DiscoverToolsAsync()); using var schema = JsonDocument.Parse(tool.ParametersSchema); schema.RootElement.GetProperty("properties").EnumerateObject().Count().Should().Be(0); @@ -67,7 +69,7 @@ public async Task ExecuteAsync_WhenCallerSuppliesUnverifiedInstanceIdentity_Reje NyxUserId: null, SenderTenant: "tenant-1"), }); - var tool = (await source.DiscoverToolsAsync()).Should().ContainSingle().Subject; + var tool = InventoryTool(await source.DiscoverToolsAsync()); var result = await tool.ExecuteAsync("""{"user_service_id":"unverified-service"}"""); @@ -128,7 +130,7 @@ public async Task ExecuteAsync_WhenStrictSenderRouteTokenIsUnavailable_UsesBound Request = new AgentToolRequestIdentity("request-inventory-1", "call-inventory-1"), }); - var tool = (await source.DiscoverToolsAsync()).Should().ContainSingle().Subject; + var tool = InventoryTool(await source.DiscoverToolsAsync()); tool.Name.Should().Be("nyxid_service_inventory"); handler.Authorization.Should().BeNull("tool discovery must not query the sender's live inventory"); @@ -202,7 +204,7 @@ public async Task ExecuteAsync_WhenSenderRouteTokenExists_RevalidatesBindingBefo "ou_sender_1"), }); - var tool = (await source.DiscoverToolsAsync()).Should().ContainSingle().Subject; + var tool = InventoryTool(await source.DiscoverToolsAsync()); tool.Name.Should().Be("nyxid_service_inventory"); handler.Authorization.Should().BeNull("tool discovery must not query the sender's live inventory"); @@ -264,7 +266,7 @@ public async Task ExecuteAsync_WhenBindingChanged_DoesNotReuseStaleSenderToken() "ou_sender_1"), }); - var tool = (await source.DiscoverToolsAsync()).Should().ContainSingle().Subject; + var tool = InventoryTool(await source.DiscoverToolsAsync()); var result = await tool.ExecuteAsync("{}"); @@ -323,7 +325,7 @@ public async Task ExecuteAsync_WhenInventoryCapabilityCannotBeIssued_ReturnsSani "ou_sender_1"), }); - var tool = (await source.DiscoverToolsAsync()).Should().ContainSingle().Subject; + var tool = InventoryTool(await source.DiscoverToolsAsync()); handler.Authorization.Should().BeNull("tool discovery must not query the sender's live inventory"); await issuer.DidNotReceiveWithAnyArgs() @@ -387,7 +389,7 @@ public async Task ExecuteAsync_WhenTypedNyxIdAuthorityIsMissing_FailsClosedWitho SenderTenant: "tenant-1"), }); - var tool = (await source.DiscoverToolsAsync()).Should().ContainSingle().Subject; + var tool = InventoryTool(await source.DiscoverToolsAsync()); var result = await tool.ExecuteAsync("{}"); using var document = JsonDocument.Parse(result); @@ -426,7 +428,7 @@ public async Task ExecuteAsync_WhenCapabilityIssueIsCanceled_PropagatesCancellat "ou_sender_1"), }); - var tool = (await source.DiscoverToolsAsync(cts.Token)).Should().ContainSingle().Subject; + var tool = InventoryTool(await source.DiscoverToolsAsync(cts.Token)); cts.IsCancellationRequested.Should().BeFalse("discovery must not issue a capability"); Func act = () => tool.ExecuteAsync("{}", cts.Token); @@ -470,7 +472,7 @@ public async Task ExecuteAsync_ThroughRealAdmission_UsesSenderInventoryWithSepar }; } using var scope = AgentToolContextScope.Push(outerContext); - var tool = (await source.DiscoverToolsAsync()).Should().ContainSingle().Subject; + var tool = InventoryTool(await source.DiscoverToolsAsync()); var outcome = await executionPort.ExecuteAsync(new AgentToolExecutionRequest( tool, "{}", outerContext, AgentToolApprovalContinuationMode.None, ApprovalGrant: null)); @@ -540,7 +542,7 @@ public async Task ExecuteAsync_MalformedInventory_RecordsContractFailureInsteadO "bot-owner-token", "bot-owner-org-token", "strict-sender-token"), }; using var scope = AgentToolContextScope.Push(context); - var tool = (await source.DiscoverToolsAsync()).Should().ContainSingle().Subject; + var tool = InventoryTool(await source.DiscoverToolsAsync()); var outcome = await executionPort.ExecuteAsync(new AgentToolExecutionRequest( tool, "{}", context, AgentToolApprovalContinuationMode.None, ApprovalGrant: null)); @@ -575,7 +577,7 @@ public async Task ExecuteAsync_GenuineEmptyKeys_ReturnsSuccessfulEmptyInventory( { Credentials = new AgentToolCredentials(null, null, "strict-sender-token"), }); - var tool = (await source.DiscoverToolsAsync()).Should().ContainSingle().Subject; + var tool = InventoryTool(await source.DiscoverToolsAsync()); var result = await tool.ExecuteAsync("{}"); @@ -586,6 +588,126 @@ public async Task ExecuteAsync_GenuineEmptyKeys_ReturnsSuccessfulEmptyInventory( handler.RequestPath.Should().Be("/api/v1/keys"); } + [Fact] + public async Task DiscoverToolsAsync_WithSenderBinding_ExposesInventoryAndRecommendedSkillLoader() + { + var source = new ChannelNyxIdConnectedServiceInventoryToolSource(new RecordingExecutionPort()); + using var context = AgentToolContextScope.Push(AgentToolExecutionContext.Empty with + { + SenderBinding = new AgentToolSenderBindingContext( + "bnd-sender-1", + NyxUserId: null, + SenderTenant: "tenant-1"), + }); + + var tools = await source.DiscoverToolsAsync(); + + tools.Select(static tool => tool.Name).Should().BeEquivalentTo( + "nyxid_service_inventory", + "nyxid_load_recommended_skill"); + RecommendedSkillTool(tools).ParametersSchema.Should().Contain("manifest_digest"); + } + + [Fact] + public async Task LoadRecommendedSkillAsync_WhenRefMatchesCurrentInventory_LoadsExactOrnnMainDocument() + { + var manifestDigest = "sha256:" + new string('0', 64); + var handler = new InventoryHandler { KeysResponse = KeysWithRecommendedSkill(manifestDigest) }; + var options = new NyxIdToolOptions { BaseUrl = "https://nyx.test" }; + var issuer = Substitute.For(); + issuer.IssueByBindingIdAsync( + Arg.Any(), + "bnd-sender-1", + Arg.Any()) + .Returns(new CapabilityHandle { AccessToken = "strict-sender-token", Scope = "proxy" }); + var fetcher = new RecordingExactFetcher(ExactRemoteSkillFetchResult.Success( + "11111111-1111-1111-1111-111111111111", + "1.2", + "calendar-reader", + "publisher-alpha", + ByteString.CopyFrom(new byte[32]), + "# Calendar Reader\n\nUse list events.")); + var executionPort = new RecordingExecutionPort(); + var source = new ChannelNyxIdConnectedServiceInventoryToolSource( + executionPort, + options, + new TestNyxIdApiClientFactory(new NyxIdApiClient(options, new HttpClient(handler))), + issuer, + exactSkillFetcher: fetcher); + using var scope = AgentToolContextScope.Push(CreateRegistrationContext() with + { + Credentials = new AgentToolCredentials(null, null, "strict-sender-token"), + }); + var tool = RecommendedSkillTool(await source.DiscoverToolsAsync()); + + var result = await tool.ExecuteAsync($$""" + { + "user_service_id":"user-service-1", + "source":"ornn", + "skill_id":"11111111-1111-1111-1111-111111111111", + "literal_version":"1.2", + "manifest_digest":"{{manifestDigest}}" + } + """); + + using var document = JsonDocument.Parse(result); + document.RootElement.GetProperty("status").GetString().Should().Be("success"); + document.RootElement.GetProperty("main_document").GetString().Should().Contain("Calendar Reader"); + fetcher.ObservedToken.Should().Be("strict-sender-token"); + fetcher.ObservedRef.Should().BeEquivalentTo(new ExactRemoteSkillRef + { + Guid = "11111111-1111-1111-1111-111111111111", + LiteralVersion = "1.2", + }); + executionPort.Requests.Should().ContainSingle(request => request.Tool.Name == "nyxid_recommended_skill_reader"); + } + + [Fact] + public async Task LoadRecommendedSkillAsync_WhenRefIsNotVisible_DoesNotFetchExactSkill() + { + var handler = new InventoryHandler { KeysResponse = KeysWithRecommendedSkill("sha256:" + new string('0', 64)) }; + var options = new NyxIdToolOptions { BaseUrl = "https://nyx.test" }; + var issuer = Substitute.For(); + issuer.IssueByBindingIdAsync( + Arg.Any(), + "bnd-sender-1", + Arg.Any()) + .Returns(new CapabilityHandle { AccessToken = "strict-sender-token", Scope = "proxy" }); + var fetcher = new RecordingExactFetcher(ExactRemoteSkillFetchResult.Failed( + ExactRemoteSkillFetchFailureCode.Failed)); + var source = new ChannelNyxIdConnectedServiceInventoryToolSource( + new RecordingExecutionPort(), + options, + new TestNyxIdApiClientFactory(new NyxIdApiClient(options, new HttpClient(handler))), + issuer, + exactSkillFetcher: fetcher); + using var scope = AgentToolContextScope.Push(CreateRegistrationContext() with + { + Credentials = new AgentToolCredentials(null, null, "strict-sender-token"), + }); + var tool = RecommendedSkillTool(await source.DiscoverToolsAsync()); + + var result = await tool.ExecuteAsync(""" + { + "user_service_id":"user-service-1", + "source":"ornn", + "skill_id":"22222222-2222-2222-2222-222222222222", + "literal_version":"1.2", + "manifest_digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000" + } + """); + + using var document = JsonDocument.Parse(result); + document.RootElement.GetProperty("error").GetString().Should().Be("recommended_skill_ref_not_visible"); + fetcher.CallCount.Should().Be(0); + } + + private static IAgentTool InventoryTool(IReadOnlyList tools) => + tools.Should().ContainSingle(tool => tool.Name == "nyxid_service_inventory").Subject; + + private static IAgentTool RecommendedSkillTool(IReadOnlyList tools) => + tools.Should().ContainSingle(tool => tool.Name == "nyxid_load_recommended_skill").Subject; + private static AgentToolExecutionContext CreateRegistrationContext() { var reference = new SecretReference @@ -620,6 +742,34 @@ private static AgentToolExecutionContext CreateRegistrationContext() }; } + private static string KeysWithRecommendedSkill(string manifestDigest) => $$""" + { + "keys": [ + { + "id": "user-service-1", + "slug": "calendar", + "catalog_service_id": "catalog-calendar", + "label": "Calendar", + "is_active": true, + "connected": true, + "status": "active", + "credential_source": { "type": "personal" }, + "recommended_skill_refs": [ + { + "source": "ornn", + "skill_id": "11111111-1111-1111-1111-111111111111", + "literal_version": "1.2", + "manifest_digest": "{{manifestDigest}}", + "display_name": "Calendar Reader", + "recommendation_name": "read-calendar-events", + "revision": "rev-1" + } + ] + } + ] + } + """; + private static AdmittedAgentToolExecutor CreateAdmittedExecutionPort(List auditRecords) { var ledger = Substitute.For(); @@ -727,6 +877,24 @@ public async Task ExecuteAsync( } } + private sealed class RecordingExactFetcher(ExactRemoteSkillFetchResult result) : IExactRemoteSkillFetcher + { + public int CallCount { get; private set; } + public string? ObservedToken { get; private set; } + public ExactRemoteSkillRef? ObservedRef { get; private set; } + + public Task FetchAsync( + string accessToken, + ExactRemoteSkillRef skillRef, + CancellationToken ct = default) + { + CallCount++; + ObservedToken = accessToken; + ObservedRef = skillRef.Clone(); + return Task.FromResult(result); + } + } + private sealed class CancelingInventoryCapabilityIssuer(CancellationTokenSource callerCancellation) : INyxIdConnectedServiceCapabilityIssuer { From 8b98b7852b4fae2b58b1bdbbb2308c5742745b28 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Fri, 18 Sep 2026 13:05:16 +0800 Subject: [PATCH 2/2] Preserve NyxID recommended skill load receipts. Co-Authored-By: Claude Opus 4.6 --- ...yxIdConnectedServiceInventoryToolSource.cs | 76 +++++++++++++++++++ ...onnectedServiceInventoryToolSourceTests.cs | 27 +++++-- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/agents/Aevatar.GAgents.NyxidChat/ChannelNyxIdConnectedServiceInventoryToolSource.cs b/agents/Aevatar.GAgents.NyxidChat/ChannelNyxIdConnectedServiceInventoryToolSource.cs index 27fb176a0..49a94ed19 100644 --- a/agents/Aevatar.GAgents.NyxidChat/ChannelNyxIdConnectedServiceInventoryToolSource.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ChannelNyxIdConnectedServiceInventoryToolSource.cs @@ -463,6 +463,68 @@ private static string RecommendedSkillFailure(string errorCode) => error = errorCode, }); + private static AgentToolReceipt? CreateRecommendedSkillReceipt( + string callId, + string toolName, + string resultJson) + { + try + { + using var document = JsonDocument.Parse(resultJson); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("result_type", out var resultType) || + !string.Equals(resultType.GetString(), "nyxid_recommended_skill_load", StringComparison.Ordinal) || + !root.TryGetProperty("status", out var statusValue) || + statusValue.ValueKind != JsonValueKind.String || + !root.TryGetProperty("loaded", out var loadedValue) || + loadedValue.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + { + return null; + } + + var loaded = loadedValue.GetBoolean(); + var status = statusValue.GetString(); + if (loaded && string.Equals(status, "success", StringComparison.Ordinal)) + { + return new AgentToolReceipt + { + CallId = callId ?? string.Empty, + ToolName = toolName ?? string.Empty, + Status = AgentToolReceiptStatus.Success, + ApprovalMode = AgentToolReceiptApprovalMode.NeverRequire, + ResultJson = resultJson ?? string.Empty, + }; + } + + var errorCode = ReadOptionalString(root, "error") ?? + ReadOptionalString(root, "failure_code") ?? + "nyxid_recommended_skill_load_failed"; + const string errorMessage = "The recommended skill could not be loaded."; + return new AgentToolReceipt + { + CallId = callId ?? string.Empty, + ToolName = toolName ?? string.Empty, + Status = AgentToolReceiptStatus.Error, + ApprovalMode = AgentToolReceiptApprovalMode.NeverRequire, + ErrorCode = errorCode, + ErrorMessage = errorMessage, + ResultJson = resultJson ?? string.Empty, + }; + } + catch (JsonException) + { + return null; + } + } + + private static string? ReadOptionalString(JsonElement root, string name) + { + if (!root.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.String) + return null; + return Normalize(value.GetString()); + } + private sealed record RecommendedSkillArguments( string UserServiceId, string SkillId, @@ -517,6 +579,13 @@ private sealed class SenderRecommendedSkillTool(ChannelNyxIdConnectedServiceInve public bool IsReadOnly => true; public ToolApprovalMode ApprovalMode => ToolApprovalMode.NeverRequire; + public AgentToolReceipt? CreateResultReceipt( + string callId, + string toolName, + string argumentsJson, + string resultJson) => + CreateRecommendedSkillReceipt(callId, toolName, resultJson); + public Task ExecuteAsync(string argumentsJson, CancellationToken ct = default) => source.ExecuteRecommendedSkillLoadAsync(argumentsJson, ct); } @@ -535,6 +604,13 @@ private sealed class SenderRecommendedSkillReaderTool( public bool IsReadOnly => true; public ToolApprovalMode ApprovalMode => ToolApprovalMode.NeverRequire; + public AgentToolReceipt? CreateResultReceipt( + string callId, + string toolName, + string argumentsJson, + string resultJson) => + CreateRecommendedSkillReceipt(callId, toolName, resultJson); + public Task ExecuteAsync(string argumentsJson, CancellationToken ct = default) => source.ReadRecommendedSkillAsync(reader, exactSkillFetcher, token, arguments, ct); } diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelNyxIdConnectedServiceInventoryToolSourceTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelNyxIdConnectedServiceInventoryToolSourceTests.cs index 521512b40..b59099343 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelNyxIdConnectedServiceInventoryToolSourceTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelNyxIdConnectedServiceInventoryToolSourceTests.cs @@ -627,20 +627,21 @@ public async Task LoadRecommendedSkillAsync_WhenRefMatchesCurrentInventory_Loads "publisher-alpha", ByteString.CopyFrom(new byte[32]), "# Calendar Reader\n\nUse list events.")); - var executionPort = new RecordingExecutionPort(); + var auditRecords = new List(); + var executionPort = CreateAdmittedExecutionPort(auditRecords); var source = new ChannelNyxIdConnectedServiceInventoryToolSource( executionPort, options, new TestNyxIdApiClientFactory(new NyxIdApiClient(options, new HttpClient(handler))), issuer, exactSkillFetcher: fetcher); - using var scope = AgentToolContextScope.Push(CreateRegistrationContext() with + var context = CreateRegistrationContext() with { Credentials = new AgentToolCredentials(null, null, "strict-sender-token"), - }); + }; + using var scope = AgentToolContextScope.Push(context); var tool = RecommendedSkillTool(await source.DiscoverToolsAsync()); - - var result = await tool.ExecuteAsync($$""" + var arguments = $$""" { "user_service_id":"user-service-1", "source":"ornn", @@ -648,18 +649,28 @@ public async Task LoadRecommendedSkillAsync_WhenRefMatchesCurrentInventory_Loads "literal_version":"1.2", "manifest_digest":"{{manifestDigest}}" } - """); + """; - using var document = JsonDocument.Parse(result); + var outcome = await executionPort.ExecuteAsync(new AgentToolExecutionRequest( + tool, arguments, context, AgentToolApprovalContinuationMode.None, ApprovalGrant: null)); + + using var document = JsonDocument.Parse(outcome.ResultJson); document.RootElement.GetProperty("status").GetString().Should().Be("success"); document.RootElement.GetProperty("main_document").GetString().Should().Contain("Calendar Reader"); + outcome.Receipt.Status.Should().Be(AgentToolReceiptStatus.Success); + outcome.Receipt.ResultJson.Should().Contain("Calendar Reader"); fetcher.ObservedToken.Should().Be("strict-sender-token"); fetcher.ObservedRef.Should().BeEquivalentTo(new ExactRemoteSkillRef { Guid = "11111111-1111-1111-1111-111111111111", LiteralVersion = "1.2", }); - executionPort.Requests.Should().ContainSingle(request => request.Tool.Name == "nyxid_recommended_skill_reader"); + var terminalRecords = auditRecords.Where(record => record.LifecyclePhase == AuditLifecyclePhase.Terminal).ToArray(); + terminalRecords.Should().HaveCount(2); + terminalRecords.Should().OnlyContain(record => record.Outcome == AuditOutcome.Success); + terminalRecords.Select(record => record.OperationName).Should().BeEquivalentTo( + "nyxid_load_recommended_skill", + "nyxid_recommended_skill_reader"); } [Fact]