diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto b/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto index 7eac46f31d..2fcd8db0b4 100644 --- a/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto +++ b/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto @@ -48,6 +48,12 @@ message McpConnectorConfigEntry { repeated string allowed_input_keys = 10; } +message HostCallbackConnectorConfigEntry { + string handler = 1; + repeated string allowed_operations = 2; + repeated string allowed_input_keys = 3; +} + // ─── State ─── message ConnectorDefinitionEntry { @@ -59,6 +65,7 @@ message ConnectorDefinitionEntry { HttpConnectorConfigEntry http = 6; CliConnectorConfigEntry cli = 7; McpConnectorConfigEntry mcp = 8; + HostCallbackConnectorConfigEntry host_callback = 9; } message ConnectorDraftEntry { diff --git a/docs/canon/connector.md b/docs/canon/connector.md index 9f490a9c58..44a9e1e355 100644 --- a/docs/canon/connector.md +++ b/docs/canon/connector.md @@ -121,6 +121,13 @@ Connector 是否需要认证不改变它的所有权。只要 operation 由部 Workflow authoring 只消费 `ConnectorExternalWorkflowCapabilitySource` 从 `IConnectorCatalogQueryPort` 读取的 typed descriptor。每个 descriptor 使用三元组 `connector_capability_ref + operation_id + contract_digest` 标识一个 exact operation;digest 是安全的 contract fingerprint,不包含 secret value。只有 connector 当前存在、启用、operation 仍在 allowlist 且 digest 匹配时,point-in-time readiness 才是 `READY`。缺失、禁用和 contract drift 返回 typed blocker 与 `studio:connectors` trusted remediation,不在 Chat 中接收凭据。 +`host_callback` 只有在 handler 实现 `IDeterministicComputeHandler`,且 catalog 的 `handler + allowedOperations` +与宿主已注册的 `DeterministicAlgorithmDescriptor` 精确对齐时才会发布 typed descriptor。缺失 handler、 +空 allowlist、operation 缺失/多余、重复或无效算法签名均不发布弱 descriptor。其 contract digest 除公共 connector +字段外,还绑定 `host-callback-operation.v1`、handler、algorithm id/version、input/output JSON Schema SHA-256 +digest 与 `allowedInputKeys`;算法版本或 schema 变化因此会在既有 admission/revalidation 主链产生 +`CONNECTOR_CONTRACT_DRIFT`,消费方必须显式重新准入。 + 所有普通 Workflow write 仍由服务器端 `IWorkflowExternalCapabilityAdmissionService` 重新解析 YAML 和校验 readiness。Chat 的 `list_external_workflow_capabilities` / `inspect_external_workflow_capability_readiness` 只负责只读引导,不是安全边界,也不创建或刷新 Connector。Definition actor 会独立重算 capability tuple,并把 definition 与 admission digest 在同一个 actor transition 中提交。 --- @@ -313,6 +320,16 @@ Actor audit facts. Recovery then revokes both protected request and completion m - 同时把稳定字段展平到 metadata,例如 `host_callback.result.route=phase9-router` - 这类 connector 适合“host 已拥有的 published surface”,例如 GitHub label/merge/close 分类、phase9-router entry routing、vibe-map closure 等宿主职责;引擎不为这些场景新增内置控制器能力。 +确定性计算是 `host_callback` 的受治理子集,而不是新的 workflow primitive: + +- handler 实现 `IDeterministicComputeHandler`,每个 operation 声明稳定 algorithm id、正整数 version 以及 + canonical input/output JSON Schema 的 SHA-256 digest; +- handler 必须是纯函数,禁止读取 clock、random、环境变量、文件、网络或其他外部状态;同一输入必须得到同一输出; +- deterministic connector 的 `allowedOperations` 必须非空,并与 handler descriptors 精确对齐,否则 builder fail closed; +- 成功或失败响应都沿普通 connector 结果链返回;已识别算法的 metadata 包含 + `host_callback.algorithm_version`,并由 `ConnectorCallModule` 复制到 `StepCompletedEvent.Annotations`; +- 需要并行保留旧语义时,注册新的 algorithm id(例如后缀 `_v2`),不在运行时按版本分支。 + ## 3.4 Host 责任边界 以下职责明确属于 host,而不是 workflow engine: @@ -327,6 +344,15 @@ Actor audit facts. Recovery then revokes both protected request and completion m - `published-surfaces-only`:workflow 只能消费 host 已正式发布的 surface,例如 `host_callback` handler 契约;不能要求 host 暴露新的内部 runtime 侧读接口。 - `no-new-aevatar-endpoints`:这类能力不通过新增 Aevatar endpoint 进入 engine 主链。 +## 3.5 确定性能力选型 + +1. 通用、内容无关且小而稳定的文本、JSON、算术、聚合或模板操作:演进现有 typed `transform_operation`。 +2. 由宿主或领域拥有、需要版本化算法签名且不应经过 LLM 的历法、编号规则、哈希派生等纯换算:使用 + deterministic `host_callback`,经 connector catalog 与统一 admission 主链发布。 +3. HTTP、CLI、MCP 服务或其他外部能力调用:使用对应 connector;authority 由用户/org credential、OAuth + connection、NyxID UserService 或 local Node 拥有时继续走 NyxID capability,不能改写成 host callback。 +4. 用户上传代码:不通过 deterministic host callback 提供;继续遵守 scripting / dynamic workflow 的既有边界。 + --- ## 4. “Agent 如何使用”的关键说明 diff --git a/docs/contracts/nyxid-assistant-conformance/v1/sources.json b/docs/contracts/nyxid-assistant-conformance/v1/sources.json index 9e1c419c2d..db66da6649 100644 --- a/docs/contracts/nyxid-assistant-conformance/v1/sources.json +++ b/docs/contracts/nyxid-assistant-conformance/v1/sources.json @@ -2,17 +2,17 @@ "schema_version": 1, "aevatar": { "repository": "https://github.com/AevatarAI/aevatar.git", - "revision": "0c5d4fbdb8e50037f78c5faa5e631d94e8dd30d7", - "contract_files_sha256": "15f3dd552af0845d784803a35646e162e2ed221bbedc74ef13203917f82f1d9e", + "revision": "37569d7ba08f6598bde4739cc7c6bc802827f969", + "contract_files_sha256": "f918094381f74fd9d839d0d22a7970f967ff9e64ad2d5a999b13c888a76f40ec", "files": { "agents/Aevatar.GAgents.NyxidChat/NyxIdActionPostconditionPort.cs": "7791de469b567dcde70a0f8e2a88cc818972ca557617a2538294e8ccabd5bda0", "agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionRegistry.cs": "60e6f67c94ae11b1bf0dac036ad8ac0c35901e31787b1f0c8173964f6a12d263", "agents/Aevatar.GAgents.NyxidChat/NyxIdChatBrowserActions.cs": "83c193f0330cf8499f819dd02f33a5fa1d7cdabbf6fa58dd96c86535cca4e18b", "agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationAguiFrameBuilder.cs": "3609d79404e47f6deb91d564fec3b8cfe468c275684de56eae27e5e8cfb9e8ed", "agents/Aevatar.GAgents.NyxidChat/protos/nyxid_chat_recovery_secret.proto": "07dbc449a732df6c7a6d0a97054ddbe35a517f4af4c5670b05ed0bea0bf2011a", - "agents/Aevatar.GAgents.NyxidChat/protos/nyxid_chat_task.proto": "523b8182bdd0a30224e001a7257ad42450226f42a84542a363e97816e34f23d9", + "agents/Aevatar.GAgents.NyxidChat/protos/nyxid_chat_task.proto": "8badff0b887a90765c1cd3fb9304c0310fab7178b601c04e08e6136e501b5b6f", "docs/adr/0048-nyxid-assistant-operation-class-boundary.md": "884aca09774e773e68154c923fec8078610b2cf8e97f581fedc36e10451ccec3", - "src/Aevatar.AI.Abstractions/ai_messages.proto": "7ca08d69adbd97d82b89fc041d8d0eebd81e3ca125e2c4798bf012f1c29dc26d", + "src/Aevatar.AI.Abstractions/ai_messages.proto": "2aed90b870f4fd02e6cad50ddfc9eacef0b851581f5a111b150158d8c3518c5d", "src/Aevatar.AI.ToolProviders.NyxId/NyxIdApiAccessContracts.cs": "e1fecf525f067ede853ffa84d1e19b110576e95f00fbdd6d840aaef496c2573f", "src/Aevatar.AI.ToolProviders.NyxId/NyxIdAssistantToolSource.cs": "1b033df9cb55c741e9b52054cbd4a91067f03c8c3797bd076a7e3d6133eb0fcb", "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyCreateTool.cs": "2c4f2cda99154f2e667c6cfd291497e697ef11df17f081f96ec70070a8af8b8c", diff --git a/src/Aevatar.Bootstrap/Connectors/HostCallbackConnector.cs b/src/Aevatar.Bootstrap/Connectors/HostCallbackConnector.cs index 326ea463b3..0ef9bcbf93 100644 --- a/src/Aevatar.Bootstrap/Connectors/HostCallbackConnector.cs +++ b/src/Aevatar.Bootstrap/Connectors/HostCallbackConnector.cs @@ -14,6 +14,7 @@ public sealed class HostCallbackConnector : IConnector private readonly IHostCallbackConnectorHandler _handler; private readonly HashSet _allowedOperations; private readonly HashSet _allowedInputKeys; + private readonly IReadOnlyDictionary _algorithmVersions; public HostCallbackConnector( string name, @@ -32,6 +33,7 @@ public HostCallbackConnector( _handler = handler ?? throw new ArgumentNullException(nameof(handler)); _allowedOperations = new HashSet(allowedOperations ?? [], StringComparer.OrdinalIgnoreCase); _allowedInputKeys = new HashSet(allowedInputKeys ?? [], StringComparer.OrdinalIgnoreCase); + _algorithmVersions = BuildAlgorithmVersions(handler); } public string Name { get; } @@ -49,10 +51,7 @@ public async Task ExecuteAsync(ConnectorRequest request, Canc { Success = false, Error = "operation is required for host_callback", - Metadata = new Dictionary - { - ["host_callback.handler"] = _handlerName, - }, + Metadata = BuildMetadata(operation), }; } @@ -62,11 +61,7 @@ public async Task ExecuteAsync(ConnectorRequest request, Canc { Success = false, Error = $"operation '{operation}' is not allowed", - Metadata = new Dictionary - { - ["host_callback.handler"] = _handlerName, - ["host_callback.operation"] = operation, - }, + Metadata = BuildMetadata(operation), }; } } @@ -77,11 +72,7 @@ public async Task ExecuteAsync(ConnectorRequest request, Canc { Success = false, Error = schemaError, - Metadata = new Dictionary - { - ["host_callback.handler"] = _handlerName, - ["host_callback.operation"] = operation, - }, + Metadata = BuildMetadata(operation), }; } @@ -102,12 +93,7 @@ public async Task ExecuteAsync(ConnectorRequest request, Canc }, ct); - var metadata = new Dictionary(response.Metadata, StringComparer.Ordinal) - { - ["host_callback.handler"] = _handlerName, - }; - if (!string.IsNullOrWhiteSpace(operation)) - metadata["host_callback.operation"] = operation; + var metadata = BuildMetadata(operation, response.Metadata); var output = string.Empty; if (response.Result != null) @@ -130,15 +116,37 @@ public async Task ExecuteAsync(ConnectorRequest request, Canc { Success = false, Error = ex.Message, - Metadata = new Dictionary - { - ["host_callback.handler"] = _handlerName, - ["host_callback.operation"] = operation, - }, + Metadata = BuildMetadata(operation), }; } } + // Implement (issue #3526): + // Behavior: Stamp every response for a recognized deterministic operation with its exact algorithm version. + // Why this shape: One metadata path keeps success, validation failure, and handler failure observability consistent. + private Dictionary BuildMetadata( + string operation, + IEnumerable>? responseMetadata = null) + { + var metadata = new Dictionary(StringComparer.Ordinal); + if (responseMetadata is not null) + { + foreach (var (key, value) in responseMetadata) + metadata[key] = value; + } + + metadata["host_callback.handler"] = _handlerName; + if (!string.IsNullOrWhiteSpace(operation)) + metadata["host_callback.operation"] = operation; + if (_algorithmVersions.TryGetValue(operation, out var algorithmVersion)) + { + metadata["host_callback.algorithm_version"] = + algorithmVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + return metadata; + } + private static void FlattenResult( JsonNode node, string path, @@ -196,6 +204,28 @@ private static string FlattenScalar(JsonValue value) return value.ToJsonString(JsonOptions); } + private static IReadOnlyDictionary BuildAlgorithmVersions( + IHostCallbackConnectorHandler handler) + { + if (handler is not IDeterministicComputeHandler deterministicHandler) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + var versions = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var algorithm in deterministicHandler.Algorithms) + { + if (string.IsNullOrWhiteSpace(algorithm.AlgorithmId) || + algorithm.AlgorithmVersion <= 0 || + !versions.TryAdd(algorithm.AlgorithmId.Trim(), algorithm.AlgorithmVersion)) + { + throw new ArgumentException( + "deterministic handler algorithms must have unique ids and positive versions", + nameof(handler)); + } + } + + return versions; + } + private static bool TryValidatePayloadKeys(string payload, HashSet allowedKeys, out string error) { error = string.Empty; diff --git a/src/Aevatar.Bootstrap/Connectors/HostCallbackConnectorBuilder.cs b/src/Aevatar.Bootstrap/Connectors/HostCallbackConnectorBuilder.cs index 361bba7e07..49eaafd829 100644 --- a/src/Aevatar.Bootstrap/Connectors/HostCallbackConnectorBuilder.cs +++ b/src/Aevatar.Bootstrap/Connectors/HostCallbackConnectorBuilder.cs @@ -40,6 +40,21 @@ public bool TryBuild(ConnectorConfigEntry entry, ILogger logger, out IConnector? return false; } + // Implement (issue #3526): + // Behavior: A deterministic connector is buildable only when its non-empty allowlist exactly matches signed algorithms. + // Why this shape: Deployment configuration and the handler descriptor become one fail-closed runtime contract. + if (handler is IDeterministicComputeHandler deterministicHandler && + !HasExactDeterministicOperationContract( + entry.HostCallback.AllowedOperations, + deterministicHandler.Algorithms)) + { + logger.LogWarning( + "Skip connector {Name}: deterministic host callback operations do not match handler {HandlerName}", + entry.Name, + handlerName); + return false; + } + connector = new HostCallbackConnector( entry.Name, handlerName, @@ -48,4 +63,45 @@ public bool TryBuild(ConnectorConfigEntry entry, ILogger logger, out IConnector? entry.HostCallback.AllowedInputKeys); return true; } + + private static bool HasExactDeterministicOperationContract( + IEnumerable allowedOperations, + IReadOnlyList algorithms) + { + var allowed = allowedOperations + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (allowed.Count == 0 || algorithms.Count == 0) + return false; + + var algorithmIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var algorithm in algorithms) + { + if (!IsValidAlgorithmDescriptor(algorithm) || !algorithmIds.Add(algorithm.AlgorithmId.Trim())) + return false; + } + + return allowed.SetEquals(algorithmIds); + } + + private static bool IsValidAlgorithmDescriptor(DeterministicAlgorithmDescriptor? algorithm) => + algorithm is { AlgorithmVersion: > 0 } && + !string.IsNullOrWhiteSpace(algorithm.AlgorithmId) && + IsSHA256Digest(algorithm.InputSchemaDigest) && + IsSHA256Digest(algorithm.OutputSchemaDigest); + + private static bool IsSHA256Digest(string? value) + { + if (value is not { Length: 71 } || !value.StartsWith("sha256:", StringComparison.Ordinal)) + return false; + + foreach (var character in value.AsSpan(7)) + { + if (character is not (>= '0' and <= '9') and not (>= 'a' and <= 'f')) + return false; + } + + return true; + } } diff --git a/src/Aevatar.Bootstrap/Connectors/SHA256DeterministicComputeHandler.cs b/src/Aevatar.Bootstrap/Connectors/SHA256DeterministicComputeHandler.cs new file mode 100644 index 0000000000..d5b2305cb8 --- /dev/null +++ b/src/Aevatar.Bootstrap/Connectors/SHA256DeterministicComputeHandler.cs @@ -0,0 +1,95 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Aevatar.Foundation.Abstractions.Connectors; + +namespace Aevatar.Bootstrap.Connectors; + +/// Computes a lowercase SHA-256 digest over the UTF-8 bytes of an input string. +public sealed class SHA256DeterministicComputeHandler : IDeterministicComputeHandler +{ + public const string HandlerName = "deterministic_compute"; + public const string OperationId = "sha256_utf8"; + public const int Version = 1; + + private const string InputSchemaDigest = + "sha256:54669b5e6a1bfebb4d15788d41b5cd5fb8e51fc2d982eb2383a42262c748c90a"; + private const string OutputSchemaDigest = + "sha256:6ece1c260f47c45b60dcac33ad9b45ad37e52e71a09c7f9924478d4c4b347852"; + + private static readonly IReadOnlyList AlgorithmDescriptors = + [ + new(OperationId, Version, InputSchemaDigest, OutputSchemaDigest), + ]; + + public string Name => HandlerName; + + public IReadOnlyList Algorithms => AlgorithmDescriptors; + + // Implement (issue #3526): + // Behavior: Execute one versioned, pure UTF-8 SHA-256 conversion with a schema-checked JSON boundary. + // Why this shape: The host callback stays deterministic and reusable without adding a workflow primitive or deployment unit. + public Task HandleAsync( + HostCallbackConnectorRequest request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + ct.ThrowIfCancellationRequested(); + + if (!string.Equals(request.Operation, OperationId, StringComparison.OrdinalIgnoreCase)) + return Task.FromResult(Failure($"unsupported deterministic algorithm '{request.Operation}'")); + + if (!TryReadText(request.Payload, out var text, out var error)) + return Task.FromResult(Failure(error)); + + var digest = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text))); + return Task.FromResult(new HostCallbackConnectorResponse + { + Success = true, + Result = new JsonObject + { + ["sha256"] = digest, + }, + }); + } + + private static HostCallbackConnectorResponse Failure(string error) => + new() + { + Success = false, + Error = error, + }; + + private static bool TryReadText(string payload, out string text, out string error) + { + text = string.Empty; + error = string.Empty; + try + { + using var document = JsonDocument.Parse(payload); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + error = "payload schema violation: expected JSON object"; + return false; + } + + var properties = document.RootElement.EnumerateObject().ToArray(); + if (properties.Length != 1 || + !string.Equals(properties[0].Name, "text", StringComparison.Ordinal) || + properties[0].Value.ValueKind != JsonValueKind.String) + { + error = "payload schema violation: expected exactly one string property 'text'"; + return false; + } + + text = properties[0].Value.GetString() ?? string.Empty; + return true; + } + catch (JsonException) + { + error = "payload schema violation: invalid JSON"; + return false; + } + } +} diff --git a/src/Aevatar.Bootstrap/ServiceCollectionExtensions.cs b/src/Aevatar.Bootstrap/ServiceCollectionExtensions.cs index f574184d43..2e606e88ed 100644 --- a/src/Aevatar.Bootstrap/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Bootstrap/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Aevatar.Bootstrap.Connectors; using Aevatar.Configuration; +using Aevatar.Foundation.Abstractions.Connectors; using Aevatar.Foundation.Runtime.Hosting.DependencyInjection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -29,6 +30,9 @@ public static IServiceCollection AddAevatarBootstrap( private static void RegisterConnectorBuilders(IServiceCollection services) { + services.TryAddEnumerable(ServiceDescriptor.Singleton< + IHostCallbackConnectorHandler, + SHA256DeterministicComputeHandler>()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); diff --git a/src/Aevatar.Foundation.Abstractions/Connectors/IHostCallbackConnectorHandler.cs b/src/Aevatar.Foundation.Abstractions/Connectors/IHostCallbackConnectorHandler.cs index a037df436d..2cc7527911 100644 --- a/src/Aevatar.Foundation.Abstractions/Connectors/IHostCallbackConnectorHandler.cs +++ b/src/Aevatar.Foundation.Abstractions/Connectors/IHostCallbackConnectorHandler.cs @@ -53,3 +53,26 @@ Task HandleAsync( HostCallbackConnectorRequest request, CancellationToken ct = default); } + +// Implement (issue #3526): +// Behavior: Give deterministic host operations a typed, versioned signature for admission and drift detection. +// Why this shape: The signature extends the existing host callback contract instead of creating another workflow primitive. +/// +/// Versioned signature for one pure deterministic algorithm exposed as a host callback operation. +/// Schema digests are SHA-256 fingerprints of canonical JSON Schema documents. +/// +public sealed record DeterministicAlgorithmDescriptor( + string AlgorithmId, + int AlgorithmVersion, + string InputSchemaDigest, + string OutputSchemaDigest); + +/// +/// Host callback restricted to pure deterministic computation. Implementations must not read the +/// clock, randomness, environment, network, file system, or any other external state. +/// +public interface IDeterministicComputeHandler : IHostCallbackConnectorHandler +{ + /// Exact versioned algorithm signatures owned by this handler. + IReadOnlyList Algorithms { get; } +} diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/StudioWorkspaceRecords.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/StudioWorkspaceRecords.cs index 47cc80b6e5..3185f2757d 100644 --- a/src/Aevatar.Studio.Application/Studio/Abstractions/StudioWorkspaceRecords.cs +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/StudioWorkspaceRecords.cs @@ -53,7 +53,8 @@ public sealed record StoredConnectorDefinition( int Retry, StoredHttpConnectorConfig Http, StoredCliConnectorConfig Cli, - StoredMcpConnectorConfig Mcp); + StoredMcpConnectorConfig Mcp, + StoredHostCallbackConnectorConfig HostCallback); public sealed record StoredHttpConnectorConfig( string BaseUrl, @@ -83,6 +84,11 @@ public sealed record StoredMcpConnectorConfig( IReadOnlyList AllowedTools, IReadOnlyList AllowedInputKeys); +public sealed record StoredHostCallbackConnectorConfig( + string Handler, + IReadOnlyList AllowedOperations, + IReadOnlyList AllowedInputKeys); + public sealed record StoredConnectorAuthConfig( string Type, string TokenUrl, diff --git a/src/Aevatar.Studio.Application/Studio/Contracts/ConnectorContracts.cs b/src/Aevatar.Studio.Application/Studio/Contracts/ConnectorContracts.cs index e080bef1f8..4d5ca32dbe 100644 --- a/src/Aevatar.Studio.Application/Studio/Contracts/ConnectorContracts.cs +++ b/src/Aevatar.Studio.Application/Studio/Contracts/ConnectorContracts.cs @@ -40,7 +40,8 @@ public sealed record ConnectorDefinitionDto( int Retry, HttpConnectorDefinitionDto Http, CliConnectorDefinitionDto Cli, - McpConnectorDefinitionDto Mcp); + McpConnectorDefinitionDto Mcp, + HostCallbackConnectorDefinitionDto? HostCallback); public sealed record HttpConnectorDefinitionDto( string BaseUrl, @@ -70,6 +71,11 @@ public sealed record McpConnectorDefinitionDto( IReadOnlyList AllowedTools, IReadOnlyList AllowedInputKeys); +public sealed record HostCallbackConnectorDefinitionDto( + string Handler, + IReadOnlyList AllowedOperations, + IReadOnlyList AllowedInputKeys); + public sealed record ConnectorAuthDefinitionDto( string Type, string TokenUrl, diff --git a/src/Aevatar.Studio.Application/Studio/Services/ConnectorExternalWorkflowCapabilitySource.cs b/src/Aevatar.Studio.Application/Studio/Services/ConnectorExternalWorkflowCapabilitySource.cs index 8743e7405e..edef5f7935 100644 --- a/src/Aevatar.Studio.Application/Studio/Services/ConnectorExternalWorkflowCapabilitySource.cs +++ b/src/Aevatar.Studio.Application/Studio/Services/ConnectorExternalWorkflowCapabilitySource.cs @@ -1,4 +1,5 @@ using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Foundation.Abstractions.Connectors; using Aevatar.Workflow.Abstractions; using Aevatar.Workflow.Application.Abstractions.ExternalCapabilities; using Google.Protobuf.WellKnownTypes; @@ -10,13 +11,24 @@ public sealed class ConnectorExternalWorkflowCapabilitySource : IExternalWorkflo private static readonly TimeSpan FreshnessWindow = TimeSpan.FromMinutes(5); private readonly IConnectorCatalogQueryPort _catalogQueryPort; + private readonly IReadOnlyDictionary _deterministicHandlersByName; private readonly TimeProvider _timeProvider; public ConnectorExternalWorkflowCapabilitySource( IConnectorCatalogQueryPort catalogQueryPort, + IEnumerable? hostCallbackHandlers = null, TimeProvider? timeProvider = null) { _catalogQueryPort = catalogQueryPort; + _deterministicHandlersByName = (hostCallbackHandlers ?? []) + .OfType() + .Where(static handler => !string.IsNullOrWhiteSpace(handler.Name)) + .GroupBy(static handler => handler.Name.Trim(), StringComparer.OrdinalIgnoreCase) + .Where(static group => group.Count() == 1) + .ToDictionary( + static group => group.Key, + static group => group.Single(), + StringComparer.OrdinalIgnoreCase); _timeProvider = timeProvider ?? TimeProvider.System; } @@ -153,7 +165,7 @@ private ExternalCapabilitySourceStamp BuildSourceStamp(StoredConnectorCatalog ca }; } - private static ExternalWorkflowCapabilityDescriptor BuildDescriptor( + private ExternalWorkflowCapabilityDescriptor BuildDescriptor( StoredConnectorDefinition connector, ConnectorOperation operation, ExternalCapabilitySourceStamp source) @@ -207,9 +219,15 @@ private static ExternalCapabilityReadiness Failure( return result; } - private static IReadOnlyList EnumerateOperations(StoredConnectorDefinition connector) + // Implement (issue #3526): + // Behavior: Publish deterministic host callbacks only when deployment allowlists and registered signatures exactly align. + // Why this shape: Catalog listing and readiness share the existing connector admission path without publishing a weak contract. + private IReadOnlyList EnumerateOperations(StoredConnectorDefinition connector) { var type = connector.Type?.Trim().ToLowerInvariant(); + if (string.Equals(type, "host_callback", StringComparison.Ordinal)) + return EnumerateHostCallbackOperations(connector); + var operationIds = type switch { "http" => connector.Http.AllowedMethods, @@ -228,7 +246,48 @@ private static IReadOnlyList EnumerateOperations(StoredConne .ToArray(); } - private static string BuildContractDigest( + private IReadOnlyList EnumerateHostCallbackOperations( + StoredConnectorDefinition connector) + { + var handlerName = connector.HostCallback.Handler?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(handlerName)) + return []; + + if (!_deterministicHandlersByName.TryGetValue(handlerName, out var handler)) + return []; + + var allowedOperations = connector.HostCallback.AllowedOperations + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (allowedOperations.Length == 0 || handler.Algorithms.Count == 0) + return []; + + var algorithms = new Dictionary( + StringComparer.OrdinalIgnoreCase); + foreach (var algorithm in handler.Algorithms) + { + if (!IsValidAlgorithmDescriptor(algorithm) || + !algorithms.TryAdd(algorithm.AlgorithmId.Trim(), algorithm)) + { + return []; + } + } + + if (!algorithms.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase) + .SetEquals(allowedOperations)) + { + return []; + } + + return algorithms.Values + .OrderBy(static algorithm => algorithm.AlgorithmId, StringComparer.OrdinalIgnoreCase) + .Select(static algorithm => new ConnectorOperation(algorithm.AlgorithmId.Trim(), algorithm)) + .ToArray(); + } + + private string BuildContractDigest( StoredConnectorDefinition connector, ConnectorOperation operation) { @@ -283,16 +342,53 @@ private static string BuildContractDigest( connector.Mcp.Auth.HeaderName?.Trim(), ]); break; + case "host_callback": + var algorithm = operation.Algorithm ?? throw new InvalidOperationException( + "A host callback capability requires a deterministic algorithm signature."); + components.AddRange([ + "host-callback-operation.v1", + connector.HostCallback.Handler?.Trim(), + algorithm.AlgorithmId.Trim(), + algorithm.AlgorithmVersion.ToString(System.Globalization.CultureInfo.InvariantCulture), + algorithm.InputSchemaDigest, + algorithm.OutputSchemaDigest, + Join(connector.HostCallback.AllowedInputKeys), + ]); + break; + default: + throw new InvalidOperationException( + $"Unsupported connector type '{connector.Type}' cannot publish a capability descriptor."); } return ExternalWorkflowCapabilityContractDigest.Compute(components); } private static bool IsReadOnly(string? connectorType, string operationId) => - string.Equals(connectorType?.Trim(), "http", StringComparison.OrdinalIgnoreCase) && - (operationId.Equals("GET", StringComparison.OrdinalIgnoreCase) || - operationId.Equals("HEAD", StringComparison.OrdinalIgnoreCase) || - operationId.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)); + string.Equals(connectorType?.Trim(), "host_callback", StringComparison.OrdinalIgnoreCase) || + (string.Equals(connectorType?.Trim(), "http", StringComparison.OrdinalIgnoreCase) && + (operationId.Equals("GET", StringComparison.OrdinalIgnoreCase) || + operationId.Equals("HEAD", StringComparison.OrdinalIgnoreCase) || + operationId.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase))); + + private static bool IsValidAlgorithmDescriptor(DeterministicAlgorithmDescriptor? algorithm) => + algorithm is { AlgorithmVersion: > 0 } && + !string.IsNullOrWhiteSpace(algorithm.AlgorithmId) && + IsSHA256Digest(algorithm.InputSchemaDigest) && + IsSHA256Digest(algorithm.OutputSchemaDigest); + + private static bool IsSHA256Digest(string? value) + { + if (value is not { Length: 71 } || !value.StartsWith("sha256:", StringComparison.Ordinal)) + return false; + + foreach (var character in value.AsSpan(7)) + { + if (character is not (>= '0' and <= '9') and not (>= 'a' and <= 'f')) + return false; + } + + return true; + } private static string Join(IEnumerable values) => string.Join( @@ -303,5 +399,7 @@ private static string Join(IEnumerable values) => .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)); - private sealed record ConnectorOperation(string OperationId); + private sealed record ConnectorOperation( + string OperationId, + DeterministicAlgorithmDescriptor? Algorithm = null); } diff --git a/src/Aevatar.Studio.Application/Studio/Services/ConnectorService.cs b/src/Aevatar.Studio.Application/Studio/Services/ConnectorService.cs index 924f3093ae..5828b0f1b0 100644 --- a/src/Aevatar.Studio.Application/Studio/Services/ConnectorService.cs +++ b/src/Aevatar.Studio.Application/Studio/Services/ConnectorService.cs @@ -14,6 +14,7 @@ public sealed class ConnectorService "http", "cli", "mcp", + "host_callback", }; private readonly IConnectorCatalogQueryPort _queryPort; @@ -193,6 +194,17 @@ private static void EnsureConnectorConfig(ConnectorDefinitionDto connector, stri return; } + if (string.Equals(normalizedType, "host_callback", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(connector.HostCallback?.Handler)) + { + throw new InvalidOperationException( + $"Connector '{connector.Name}' requires hostCallback.handler."); + } + + return; + } + var mcpCommand = connector.Mcp?.Command?.Trim() ?? string.Empty; var mcpUrl = connector.Mcp?.Url?.Trim() ?? string.Empty; var hasMcpCommand = !string.IsNullOrWhiteSpace(mcpCommand); @@ -251,7 +263,8 @@ private static StoredConnectorDefinition ToStoredConnector(ConnectorDefinitionDt Auth: ToStoredAuth(connector.Mcp.Auth), DefaultTool: connector.Mcp.DefaultTool?.Trim() ?? string.Empty, AllowedTools: NormalizeList(connector.Mcp.AllowedTools), - AllowedInputKeys: NormalizeList(connector.Mcp.AllowedInputKeys))); + AllowedInputKeys: NormalizeList(connector.Mcp.AllowedInputKeys)), + HostCallback: ToStoredHostCallback(connector.HostCallback)); private static StoredConnectorDefinition ToStoredConnectorDraft(ConnectorDefinitionDto connector) => new( @@ -284,7 +297,8 @@ private static StoredConnectorDefinition ToStoredConnectorDraft(ConnectorDefinit Auth: ToStoredAuth(connector.Mcp.Auth), DefaultTool: connector.Mcp.DefaultTool?.Trim() ?? string.Empty, AllowedTools: NormalizeList(connector.Mcp.AllowedTools), - AllowedInputKeys: NormalizeList(connector.Mcp.AllowedInputKeys))); + AllowedInputKeys: NormalizeList(connector.Mcp.AllowedInputKeys)), + HostCallback: ToStoredHostCallback(connector.HostCallback)); private static ConnectorCatalogResponse ToResponse(StoredConnectorCatalog catalog) => new( @@ -344,7 +358,11 @@ private static ConnectorDefinitionDto ToDto(StoredConnectorDefinition connector) ToDto(connector.Mcp.Auth), connector.Mcp.DefaultTool, connector.Mcp.AllowedTools.ToList(), - connector.Mcp.AllowedInputKeys.ToList())); + connector.Mcp.AllowedInputKeys.ToList()), + new HostCallbackConnectorDefinitionDto( + connector.HostCallback.Handler, + connector.HostCallback.AllowedOperations.ToList(), + connector.HostCallback.AllowedInputKeys.ToList())); private static void EnsureAuthConfig(string connectorName, string fieldName, ConnectorAuthDefinitionDto? auth) { @@ -440,6 +458,13 @@ private static StoredConnectorAuthConfig ToStoredAuth(ConnectorAuthDefinitionDto auth?.HeaderName?.Trim() ?? string.Empty, auth?.HeaderValuePrefix?.Trim() ?? string.Empty); + private static StoredHostCallbackConnectorConfig ToStoredHostCallback( + HostCallbackConnectorDefinitionDto? hostCallback) => + new( + hostCallback?.Handler?.Trim() ?? string.Empty, + NormalizeList(hostCallback?.AllowedOperations ?? []), + NormalizeList(hostCallback?.AllowedInputKeys ?? [])); + private static ConnectorAuthDefinitionDto ToDto(StoredConnectorAuthConfig auth) => new( auth.Type, diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs index 90636afd7d..02be91c0d3 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs @@ -218,7 +218,10 @@ private static StoredConnectorDefinition ToStoredConnectorDefinition(ConnectorDe Retry: entry.Retry, Http: entry.Http is not null ? ToStoredHttpConfig(entry.Http) : EmptyHttpConfig(), Cli: entry.Cli is not null ? ToStoredCliConfig(entry.Cli) : EmptyCliConfig(), - Mcp: entry.Mcp is not null ? ToStoredMcpConfig(entry.Mcp) : EmptyMcpConfig()); + Mcp: entry.Mcp is not null ? ToStoredMcpConfig(entry.Mcp) : EmptyMcpConfig(), + HostCallback: entry.HostCallback is not null + ? ToStoredHostCallbackConfig(entry.HostCallback) + : EmptyHostCallbackConfig()); private static StoredHttpConnectorConfig ToStoredHttpConfig(HttpConnectorConfigEntry entry) => new( @@ -251,6 +254,13 @@ private static StoredMcpConnectorConfig ToStoredMcpConfig(McpConnectorConfigEntr AllowedTools: entry.AllowedTools.ToList().AsReadOnly(), AllowedInputKeys: entry.AllowedInputKeys.ToList().AsReadOnly()); + private static StoredHostCallbackConnectorConfig ToStoredHostCallbackConfig( + HostCallbackConnectorConfigEntry entry) => + new( + Handler: entry.Handler, + AllowedOperations: entry.AllowedOperations.ToList().AsReadOnly(), + AllowedInputKeys: entry.AllowedInputKeys.ToList().AsReadOnly()); + private static StoredConnectorAuthConfig ToStoredAuthConfig(ConnectorAuthEntry entry) => new( Type: entry.Type, @@ -274,6 +284,7 @@ private static ConnectorDefinitionEntry ToProtoConnectorDefinition(StoredConnect Http = ToProtoHttpConfig(def.Http), Cli = ToProtoCliConfig(def.Cli), Mcp = ToProtoMcpConfig(def.Mcp), + HostCallback = ToProtoHostCallbackConfig(def.HostCallback), }; return entry; } @@ -328,6 +339,18 @@ private static McpConnectorConfigEntry ToProtoMcpConfig(StoredMcpConnectorConfig return entry; } + private static HostCallbackConnectorConfigEntry ToProtoHostCallbackConfig( + StoredHostCallbackConnectorConfig config) + { + var entry = new HostCallbackConnectorConfigEntry + { + Handler = config.Handler, + }; + entry.AllowedOperations.AddRange(config.AllowedOperations); + entry.AllowedInputKeys.AddRange(config.AllowedInputKeys); + return entry; + } + private static ConnectorAuthEntry ToProtoAuthConfig(StoredConnectorAuthConfig config) => new() { @@ -353,6 +376,9 @@ private static StoredMcpConnectorConfig EmptyMcpConfig() => new Dictionary(StringComparer.OrdinalIgnoreCase), EmptyAuthConfig(), string.Empty, [], []); + private static StoredHostCallbackConnectorConfig EmptyHostCallbackConfig() => + new(string.Empty, [], []); + private static StoredConnectorAuthConfig EmptyAuthConfig() => new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty); } diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ConnectorCatalogImportParser.cs b/src/Aevatar.Studio.Infrastructure/Storage/ConnectorCatalogImportParser.cs index a8ac97d838..0db587de27 100644 --- a/src/Aevatar.Studio.Infrastructure/Storage/ConnectorCatalogImportParser.cs +++ b/src/Aevatar.Studio.Infrastructure/Storage/ConnectorCatalogImportParser.cs @@ -103,7 +103,12 @@ private static IReadOnlyList ParseConnectors(JsonElem Retry: Math.Clamp(ReadInt(connectorNode, "retry", 0), 0, 5), Http: TryGetPropertyIgnoreCase(connectorNode, "http", out var httpNode) ? ParseHttpConfig(httpNode) : EmptyHttpConfig(), Cli: TryGetPropertyIgnoreCase(connectorNode, "cli", out var cliNode) ? ParseCliConfig(cliNode) : EmptyCliConfig(), - Mcp: TryGetPropertyIgnoreCase(connectorNode, "mcp", out var mcpNode) ? ParseMcpConfig(mcpNode) : EmptyMcpConfig()); + Mcp: TryGetPropertyIgnoreCase(connectorNode, "mcp", out var mcpNode) ? ParseMcpConfig(mcpNode) : EmptyMcpConfig(), + HostCallback: TryGetPropertyIgnoreCase(connectorNode, "host_callback", out var hostCallbackNode) + ? ParseHostCallbackConfig(hostCallbackNode) + : TryGetPropertyIgnoreCase(connectorNode, "hostCallback", out hostCallbackNode) + ? ParseHostCallbackConfig(hostCallbackNode) + : EmptyHostCallbackConfig()); } private static StoredHttpConnectorConfig ParseHttpConfig(JsonElement node) => @@ -143,6 +148,14 @@ private static StoredMcpConnectorConfig ParseMcpConfig(JsonElement node) => AllowedTools: ReadStringArray(node, "allowedTools"), AllowedInputKeys: ReadStringArray(node, "allowedInputKeys")); + private static StoredHostCallbackConnectorConfig ParseHostCallbackConfig(JsonElement node) => + node.ValueKind != JsonValueKind.Object + ? EmptyHostCallbackConfig() + : new StoredHostCallbackConnectorConfig( + Handler: ReadString(node, "handler"), + AllowedOperations: ReadStringArray(node, "allowedOperations"), + AllowedInputKeys: ReadStringArray(node, "allowedInputKeys")); + private static StoredConnectorAuthConfig ParseAuthConfig(JsonElement node) => node.ValueKind != JsonValueKind.Object ? EmptyAuthConfig() @@ -175,6 +188,9 @@ private static StoredMcpConnectorConfig EmptyMcpConfig() => [], []); + private static StoredHostCallbackConnectorConfig EmptyHostCallbackConfig() => + new(string.Empty, [], []); + private static StoredConnectorAuthConfig EmptyAuthConfig() => new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty); diff --git a/test/Aevatar.Bootstrap.Tests/BootstrapServiceCollectionExtensionsTests.cs b/test/Aevatar.Bootstrap.Tests/BootstrapServiceCollectionExtensionsTests.cs index 6ff0b324c9..85fd567d41 100644 --- a/test/Aevatar.Bootstrap.Tests/BootstrapServiceCollectionExtensionsTests.cs +++ b/test/Aevatar.Bootstrap.Tests/BootstrapServiceCollectionExtensionsTests.cs @@ -3,6 +3,7 @@ using Aevatar.Bootstrap.Hosting; using Aevatar.Configuration; using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Connectors; using Aevatar.Capabilities; using FluentAssertions; using Microsoft.AspNetCore.Builder; @@ -36,6 +37,8 @@ public void AddAevatarBootstrap_ShouldRegisterRuntimeSecretsAndConnectorBuilders connectorBuilders.Should().ContainSingle(x => x.GetType() == typeof(CliConnectorBuilder)); connectorBuilders.Should().ContainSingle(x => x.GetType() == typeof(HostCallbackConnectorBuilder)); connectorBuilders.Should().ContainSingle(x => x.GetType() == typeof(TelegramUserConnectorBuilder)); + provider.GetServices() + .Should().ContainSingle(static handler => handler is SHA256DeterministicComputeHandler); } [Fact] @@ -108,6 +111,8 @@ public void AddAevatarBootstrap_WhenCalledTwice_ShouldNotDuplicateConnectorBuild connectorBuilders.Should().ContainSingle(x => x.GetType() == typeof(CliConnectorBuilder)); connectorBuilders.Should().ContainSingle(x => x.GetType() == typeof(HostCallbackConnectorBuilder)); connectorBuilders.Should().ContainSingle(x => x.GetType() == typeof(TelegramUserConnectorBuilder)); + provider.GetServices() + .Should().ContainSingle(static handler => handler is SHA256DeterministicComputeHandler); } [Fact] diff --git a/test/Aevatar.Bootstrap.Tests/DeterministicComputeConnectorTests.cs b/test/Aevatar.Bootstrap.Tests/DeterministicComputeConnectorTests.cs new file mode 100644 index 0000000000..f6a39e72b5 --- /dev/null +++ b/test/Aevatar.Bootstrap.Tests/DeterministicComputeConnectorTests.cs @@ -0,0 +1,98 @@ +using Aevatar.Bootstrap.Connectors; +using Aevatar.Configuration; +using Aevatar.Foundation.Abstractions.Connectors; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.Bootstrap.Tests; + +// Implement (issue #3526): +// Behavior: Verify the built-in versioned algorithm and reject catalog configurations that drift from its descriptor. +// Why this shape: Behavior-focused tests keep deterministic admission coverage outside the frozen legacy coverage bucket. +public sealed class DeterministicComputeConnectorTests +{ + [Fact] + public async Task SHA256DeterministicComputeHandler_ShouldMatchGoldenVector_AndExposeVersion() + { + var handler = new SHA256DeterministicComputeHandler(); + var descriptor = handler.Algorithms.Should().ContainSingle().Subject; + descriptor.AlgorithmId.Should().Be(SHA256DeterministicComputeHandler.OperationId); + descriptor.AlgorithmVersion.Should().Be(1); + descriptor.InputSchemaDigest.Should().Be( + "sha256:54669b5e6a1bfebb4d15788d41b5cd5fb8e51fc2d982eb2383a42262c748c90a"); + descriptor.OutputSchemaDigest.Should().Be( + "sha256:6ece1c260f47c45b60dcac33ad9b45ad37e52e71a09c7f9924478d4c4b347852"); + + var connector = new HostCallbackConnector( + "deterministic-hash", + handler.Name, + handler, + [SHA256DeterministicComputeHandler.OperationId], + ["text"]); + var response = await connector.ExecuteAsync(new ConnectorRequest + { + Operation = SHA256DeterministicComputeHandler.OperationId, + Payload = """{"text":"abc"}""", + }); + + response.Success.Should().BeTrue(); + response.Output.Should().Be( + """{"sha256":"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"}"""); + response.Metadata["host_callback.algorithm_version"].Should().Be("1"); + response.Metadata["host_callback.result.sha256"].Should().Be( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + + var invalid = await connector.ExecuteAsync(new ConnectorRequest + { + Operation = SHA256DeterministicComputeHandler.OperationId, + Payload = """{"text":42}""", + }); + invalid.Success.Should().BeFalse(); + invalid.Metadata["host_callback.algorithm_version"].Should().Be("1"); + } + + [Fact] + public void HostCallbackConnectorBuilder_ShouldFailClosedOnDeterministicDescriptorDrift() + { + var deterministicHandler = new SHA256DeterministicComputeHandler(); + var builder = new HostCallbackConnectorBuilder([deterministicHandler]); + + var deterministicWithoutOperations = CreateConfig(deterministicHandler.Name, []); + builder.TryBuild( + deterministicWithoutOperations, + NullLogger.Instance, + out var deterministicWithoutOperationsConnector).Should().BeFalse(); + deterministicWithoutOperationsConnector.Should().BeNull(); + + var deterministicMismatchedOperations = CreateConfig(deterministicHandler.Name, ["different_algorithm"]); + builder.TryBuild( + deterministicMismatchedOperations, + NullLogger.Instance, + out var deterministicMismatchedConnector).Should().BeFalse(); + deterministicMismatchedConnector.Should().BeNull(); + + var deterministicValid = CreateConfig( + deterministicHandler.Name, + [SHA256DeterministicComputeHandler.OperationId]); + builder.TryBuild( + deterministicValid, + NullLogger.Instance, + out var deterministicConnector).Should().BeTrue(); + deterministicConnector.Should().NotBeNull(); + } + + private static ConnectorConfigEntry CreateConfig(string handler, IReadOnlyList allowedOperations) + { + return new ConnectorConfigEntry + { + Name = "deterministic-hash", + Type = "host_callback", + HostCallback = new HostCallbackConnectorConfig + { + Handler = handler, + AllowedOperations = [.. allowedOperations], + AllowedInputKeys = ["text"], + }, + }; + } +} diff --git a/test/Aevatar.Integration.Tests/Aevatar.Integration.Tests.csproj b/test/Aevatar.Integration.Tests/Aevatar.Integration.Tests.csproj index 2d15323214..f7ad16eece 100644 --- a/test/Aevatar.Integration.Tests/Aevatar.Integration.Tests.csproj +++ b/test/Aevatar.Integration.Tests/Aevatar.Integration.Tests.csproj @@ -42,6 +42,7 @@ + diff --git a/test/Aevatar.Integration.Tests/DeterministicHostCallbackWorkflowTests.cs b/test/Aevatar.Integration.Tests/DeterministicHostCallbackWorkflowTests.cs new file mode 100644 index 0000000000..4ad0544436 --- /dev/null +++ b/test/Aevatar.Integration.Tests/DeterministicHostCallbackWorkflowTests.cs @@ -0,0 +1,130 @@ +using Aevatar.Bootstrap.Connectors; +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Connectors; +using Aevatar.Foundation.Core; +using Aevatar.Workflow.Abstractions.Execution; +using Aevatar.Workflow.Core; +using Aevatar.Workflow.Core.Connectors; +using Aevatar.Workflow.Core.Modules; +using Aevatar.Workflow.Core.Primitives; +using FluentAssertions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.Integration.Tests; + +[Trait("Category", "Integration")] +[Trait("Feature", "DeterministicHostCallback")] +// Implement (issue #3526): +// Behavior: Execute a versioned inline algorithm and pass its stable output to the next workflow step. +// Why this shape: The integration test crosses connector execution, annotations, and workflow continuation without polling. +public sealed class DeterministicHostCallbackWorkflowTests +{ + [Fact] + public async Task HandleAsync_WhenDeterministicHostCallbackRuns_ShouldExposeVersionAndFeedNextStep() + { + var registry = new ConfiguredConnectorRegistry(); + var handler = new SHA256DeterministicComputeHandler(); + await registry.RegisterAsync(ConnectorRegistration.External(new HostCallbackConnector( + "deterministic-hash", + handler.Name, + handler, + [SHA256DeterministicComputeHandler.OperationId], + ["text"]))); + var module = new ConnectorCallModule(new RegistryBackedWorkflowConnectorResolver(registry)); + var ctx = CreateContext(); + ctx.SetNextElapsedTime(TimeSpan.FromMilliseconds(88)); + var request = new StepRequestEvent + { + StepId = "hash", + RunId = "run-deterministic-hash", + StepType = "connector_call", + Input = """{"text":"abc"}""", + Parameters = + { + ["connector"] = "deterministic-hash", + ["operation"] = SHA256DeterministicComputeHandler.OperationId, + }, + }; + + await HandleAndDrainAsync(module, Envelope(request), ctx); + + var completed = ctx.Published.Should().ContainSingle().Subject.evt.Should().BeOfType().Subject; + completed.Success.Should().BeTrue(); + completed.Output.Should().Be( + """{"sha256":"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"}"""); + completed.Annotations["host_callback.algorithm_version"].Should().Be("1"); + completed.Annotations["host_callback.result.sha256"].Should().Be( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + completed.Annotations["connector.type"].Should().Be("host_callback"); + completed.Annotations["connector.duration_ms"].Should().Be("88.00"); + + var loop = new WorkflowLoopModule(); + loop.SetWorkflow(new WorkflowDefinition + { + Name = "deterministic-workflow", + Roles = [], + Steps = + [ + new StepDefinition { Id = "hash", Type = "connector_call" }, + new StepDefinition { Id = "consume", Type = "transform" }, + ], + }); + var loopContext = CreateContext(); + await loop.HandleAsync( + Envelope(new StartWorkflowEvent + { + RunId = request.RunId, + Input = request.Input, + }), + loopContext, + CancellationToken.None); + loopContext.Published.Clear(); + + await loop.HandleAsync(Envelope(completed), loopContext, CancellationToken.None); + + var consumer = loopContext.Published.Should().ContainSingle().Subject.evt + .Should().BeOfType().Subject; + consumer.StepId.Should().Be("consume"); + consumer.Input.Should().Be(completed.Output); + } + + private static TestEventHandlerContext CreateContext() + { + return new TestEventHandlerContext( + new ServiceCollection().BuildServiceProvider(), + new TestAgent("deterministic-host-callback-test-agent"), + NullLogger.Instance); + } + + private static EventEnvelope Envelope(IMessage evt) + { + return new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), + Payload = Any.Pack(evt), + Route = EnvelopeRouteSemantics.CreateTopologyPublication("test-publisher", TopologyAudience.Self), + Propagation = new EnvelopePropagation(), + }; + } + + private static async Task HandleAndDrainAsync( + ConnectorCallModule module, + EventEnvelope envelope, + TestEventHandlerContext ctx) + { + await module.HandleAsync(envelope, ctx, CancellationToken.None); + for (var index = 0; index < ctx.Published.Count; index++) + { + if (ctx.Published[index].evt is not WorkflowConnectorAttemptCompletedEvent completed) + continue; + + ctx.Published.RemoveAt(index); + index--; + await module.HandleAsync(Envelope(completed), ctx, CancellationToken.None); + } + } +} diff --git a/test/Aevatar.Studio.Tests/Aevatar.Studio.Tests.csproj b/test/Aevatar.Studio.Tests/Aevatar.Studio.Tests.csproj index b3eec87fff..1c9089865c 100644 --- a/test/Aevatar.Studio.Tests/Aevatar.Studio.Tests.csproj +++ b/test/Aevatar.Studio.Tests/Aevatar.Studio.Tests.csproj @@ -42,6 +42,7 @@ + diff --git a/test/Aevatar.Studio.Tests/ConnectorExternalWorkflowCapabilitySourceTests.cs b/test/Aevatar.Studio.Tests/ConnectorExternalWorkflowCapabilitySourceTests.cs index fd42feaea5..f195d0184e 100644 --- a/test/Aevatar.Studio.Tests/ConnectorExternalWorkflowCapabilitySourceTests.cs +++ b/test/Aevatar.Studio.Tests/ConnectorExternalWorkflowCapabilitySourceTests.cs @@ -1,8 +1,13 @@ using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Application.Studio.Services; using Aevatar.Studio.Application.Studio.DependencyInjection; +using Aevatar.Foundation.Abstractions.Connectors; using Aevatar.Workflow.Abstractions; using Aevatar.Workflow.Application.Abstractions.ExternalCapabilities; +using Aevatar.Workflow.Application.Abstractions.Runs; +using Aevatar.Workflow.Application.ExternalCapabilities; +using Aevatar.Workflow.Core; +using Aevatar.Workflow.Core.Primitives; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; @@ -37,6 +42,7 @@ public async Task ListAsync_ShouldKeepAllConfiguredAuthModesHostOwned() Version: 17); var source = new ConnectorExternalWorkflowCapabilitySource( new StubCatalogQueryPort(catalog), + [], new FixedTimeProvider()); var discovery = await source.ListAsync(Access(), CancellationToken.None); @@ -78,7 +84,7 @@ public async Task InspectAsync_ShouldFailClosed_ForMissingOrDisabledConnector( { var connectors = omitFromCatalog ? Array.Empty() - : [Connector(connectorRef, enabled: false)]; + : [DeterministicConnector(connectorRef, enabled: false)]; var source = new ConnectorExternalWorkflowCapabilitySource( new StubCatalogQueryPort(new StoredConnectorCatalog( string.Empty, @@ -86,11 +92,12 @@ public async Task InspectAsync_ShouldFailClosed_ForMissingOrDisabledConnector( true, connectors, Version: 4)), + [new TestDeterministicComputeHandler()], new FixedTimeProvider()); var result = await source.InspectAsync( Access(), - HostRef(connectorRef, "GET", "untrusted-digest"), + HostRef(connectorRef, TestDeterministicComputeHandler.OperationId, "untrusted-digest"), ExternalCapabilityExecutionMode.Interactive, CancellationToken.None); @@ -103,7 +110,7 @@ public async Task InspectAsync_ShouldFailClosed_ForMissingOrDisabledConnector( [Fact] public async Task InspectAsync_ShouldRejectContractDrift() { - var connector = Connector("connector-home-alpha"); + var connector = DeterministicConnector("connector-home-alpha"); var source = new ConnectorExternalWorkflowCapabilitySource( new StubCatalogQueryPort(new StoredConnectorCatalog( string.Empty, @@ -111,6 +118,7 @@ public async Task InspectAsync_ShouldRejectContractDrift() true, [connector], Version: 8)), + [new TestDeterministicComputeHandler()], new FixedTimeProvider()); var descriptor = (await source.ListAsync(Access(), CancellationToken.None)) .Capabilities.Single(); @@ -127,6 +135,116 @@ public async Task InspectAsync_ShouldRejectContractDrift() result.Blockers.Should().ContainSingle().Which.Code.Should().Be("CONNECTOR_CONTRACT_DRIFT"); } + [Fact] + public async Task ListAsync_ShouldPublishAlignedDeterministicHostCallback_AsReadOnly() + { + var handler = new TestDeterministicComputeHandler(); + var source = DeterministicSource(handler); + + var descriptor = (await source.ListAsync(Access(), CancellationToken.None)) + .Capabilities.Should().ContainSingle().Subject; + + descriptor.Selector.HostConnector.OperationId.Should().Be(TestDeterministicComputeHandler.OperationId); + descriptor.Selector.HostConnector.ContractDigest.Should().MatchRegex("^[0-9a-f]{64}$"); + descriptor.ReadOnly.Should().BeTrue(); + descriptor.Destructive.Should().BeFalse(); + var readiness = await source.InspectAsync( + Access(), + descriptor.Selector, + ExternalCapabilityExecutionMode.Interactive, + CancellationToken.None); + readiness.Status.Should().Be(ExternalCapabilityReadinessStatus.Ready); + } + + [Theory] + [InlineData("different_algorithm")] + [InlineData("sha256_utf8,different_algorithm")] + public async Task ListAsync_ShouldNotPublish_WhenCatalogAndRegisteredAlgorithmsDiffer( + string configuredOperations) + { + var handler = new TestDeterministicComputeHandler(); + var connector = DeterministicConnector( + "deterministic-hash", + allowedOperations: configuredOperations.Split(',')); + var source = new ConnectorExternalWorkflowCapabilitySource( + new StubCatalogQueryPort(Catalog(connector)), + [handler], + new FixedTimeProvider()); + + var discovery = await source.ListAsync(Access(), CancellationToken.None); + + discovery.Capabilities.Should().BeEmpty(); + discovery.CandidateCount.Should().Be(0); + } + + [Fact] + public async Task ListAsync_ShouldNotPublish_WhenDeterministicHandlerNameIsMissing() + { + var handler = new TestDeterministicComputeHandler(); + var connector = DeterministicConnector("deterministic-hash", handlerName: string.Empty); + var source = new ConnectorExternalWorkflowCapabilitySource( + new StubCatalogQueryPort(Catalog(connector)), + [handler], + new FixedTimeProvider()); + + var discovery = await source.ListAsync(Access(), CancellationToken.None); + + discovery.Capabilities.Should().BeEmpty(); + discovery.CandidateCount.Should().Be(0); + } + + [Fact] + public async Task InspectAsync_ShouldReportContractDrift_AfterAlgorithmVersionBump() + { + var versionOne = DeterministicSource(new TestDeterministicComputeHandler(version: 1)); + var savedSelector = (await versionOne.ListAsync(Access(), CancellationToken.None)) + .Capabilities.Single().Selector; + var versionTwo = DeterministicSource(new TestDeterministicComputeHandler(version: 2)); + + var readiness = await versionTwo.InspectAsync( + Access(), + savedSelector, + ExternalCapabilityExecutionMode.Durable, + CancellationToken.None); + + readiness.Status.Should().Be(ExternalCapabilityReadinessStatus.ContractDrift); + readiness.Blockers.Should().ContainSingle().Which.Code.Should().Be("CONNECTOR_CONTRACT_DRIFT"); + } + + [Fact] + public async Task WorkflowAdmission_ShouldAcceptListedDeterministicHostCallback() + { + var source = DeterministicSource(new TestDeterministicComputeHandler()); + var selector = (await source.ListAsync(Access(), CancellationToken.None)) + .Capabilities.Single().Selector.HostConnector; + var yaml = $$""" + name: deterministic-workflow + steps: + - id: hash + type: connector_call + parameters: + connector: {{selector.ConnectorCapabilityRef}} + operation: {{selector.OperationId}} + contract_digest: {{selector.ContractDigest}} + """; + var readiness = new ExternalWorkflowCapabilityReadinessService([source]); + var admission = new WorkflowExternalCapabilityAdmissionService( + new RealWorkflowDefinitionParser(), + readiness, + new FixedTimeProvider()); + + var plan = await admission.AdmitAsync(new WorkflowExternalCapabilityAdmissionRequest( + Access(), + yaml, + new Dictionary(), + "deterministic-test", + ExternalCapabilityExecutionMode.Interactive)); + + var invocation = plan.InvocationAdmissions.Should().ContainSingle().Subject; + invocation.CallSiteId.Should().Be("deterministic-workflow/hash"); + invocation.Capability.HostConnector.Should().BeEquivalentTo(selector); + } + private static ExternalWorkflowCapabilityAccessContext Access() => new("scope-alpha", "caller-alpha"); @@ -186,7 +304,59 @@ private static StoredConnectorDefinition Connector( new StoredConnectorAuthConfig("", "", "", "", "", "", "", ""), string.Empty, [], - [])); + []), + new StoredHostCallbackConnectorConfig(string.Empty, [], [])); + + private static ConnectorExternalWorkflowCapabilitySource DeterministicSource( + IDeterministicComputeHandler handler) => + new( + new StubCatalogQueryPort(Catalog(DeterministicConnector("deterministic-hash"))), + [handler], + new FixedTimeProvider()); + + private static StoredConnectorCatalog Catalog(params StoredConnectorDefinition[] connectors) => + new(string.Empty, string.Empty, true, connectors, Version: 19); + + private static StoredConnectorDefinition DeterministicConnector( + string name, + bool enabled = true, + IReadOnlyList? allowedOperations = null, + string? handlerName = TestDeterministicComputeHandler.HandlerName) => + new( + name, + "host_callback", + enabled, + 30_000, + 0, + new StoredHttpConnectorConfig( + string.Empty, + [], + [], + [], + new Dictionary(), + new StoredConnectorAuthConfig("", "", "", "", "", "", "", "")), + new StoredCliConnectorConfig( + string.Empty, + [], + [], + [], + string.Empty, + new Dictionary()), + new StoredMcpConnectorConfig( + string.Empty, + string.Empty, + string.Empty, + [], + new Dictionary(), + new Dictionary(), + new StoredConnectorAuthConfig("", "", "", "", "", "", "", ""), + string.Empty, + [], + []), + new StoredHostCallbackConnectorConfig( + handlerName ?? string.Empty, + allowedOperations ?? [TestDeterministicComputeHandler.OperationId], + ["text"])); private sealed class StubCatalogQueryPort(StoredConnectorCatalog catalog) : IConnectorCatalogQueryPort { @@ -202,4 +372,60 @@ private sealed class FixedTimeProvider : TimeProvider public override DateTimeOffset GetUtcNow() => new(2026, 7, 21, 10, 0, 0, TimeSpan.Zero); } + + private sealed class TestDeterministicComputeHandler(int version = 1) : IDeterministicComputeHandler + { + public const string HandlerName = "deterministic-test"; + public const string OperationId = "sha256_utf8"; + + public string Name => HandlerName; + + public IReadOnlyList Algorithms { get; } = + [ + new( + OperationId, + version, + $"sha256:{new string('a', 64)}", + $"sha256:{new string('b', 64)}"), + ]; + + public Task HandleAsync( + HostCallbackConnectorRequest request, + CancellationToken ct = default) => + throw new NotSupportedException(); + } + + private sealed class RealWorkflowDefinitionParser : IWorkflowDefinitionParser + { + private readonly WorkflowParser _parser = new(); + + public Task ParseWorkflowYamlAsync( + string workflowYaml, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + try + { + var workflow = _parser.Parse(workflowYaml); + return Task.FromResult(WorkflowYamlParseResult.Success( + workflow.Name, + WorkflowAuthorizationDependencyEvaluator.Evaluate(workflow))); + } + catch (WorkflowExternalCapabilityValidationException exception) + { + return Task.FromResult(WorkflowYamlParseResult.Invalid( + exception.Message, + exception.Readiness)); + } + catch (Exception exception) + { + return Task.FromResult(WorkflowYamlParseResult.Invalid(exception.Message)); + } + } + + public Task ParseInlineWorkflowBundleAsync( + IReadOnlyList inlineWorkflowDocuments, + CancellationToken ct = default) => + throw new NotSupportedException(); + } } diff --git a/test/Aevatar.Studio.Tests/ConnectorServiceTests.cs b/test/Aevatar.Studio.Tests/ConnectorServiceTests.cs index 3630557539..4ff9bc5c90 100644 --- a/test/Aevatar.Studio.Tests/ConnectorServiceTests.cs +++ b/test/Aevatar.Studio.Tests/ConnectorServiceTests.cs @@ -25,6 +25,38 @@ await action.Should().ThrowAsync() commandPort.SaveAttempts.Should().Be(0); } + [Fact] + public async Task SaveCatalogAsync_ShouldNormalizeHostCallbackConfiguration() + { + var commandPort = new RecordingConnectorCatalogCommandPort(); + var service = new ConnectorService(null!, commandPort, null!); + + var response = await service.SaveCatalogAsync(new SaveConnectorCatalogRequest( + [CreateHostCallbackConnector()])); + + response.Connectors.Should().ContainSingle(); + var stored = commandPort.LastCatalog!.Connectors.Should().ContainSingle().Subject; + stored.Type.Should().Be("host_callback"); + stored.HostCallback.Handler.Should().Be("deterministic_compute"); + stored.HostCallback.AllowedOperations.Should().Equal("sha256_utf8"); + stored.HostCallback.AllowedInputKeys.Should().Equal("text"); + } + + [Fact] + public async Task SaveCatalogAsync_ShouldAcceptNonHostConnector_WhenHostCallbackConfigurationIsOmitted() + { + var commandPort = new RecordingConnectorCatalogCommandPort(); + var service = new ConnectorService(null!, commandPort, null!); + var connector = CreateMcpConnector("npx", string.Empty) with { HostCallback = null }; + + var response = await service.SaveCatalogAsync(new SaveConnectorCatalogRequest([connector])); + + response.Connectors.Should().ContainSingle(); + commandPort.LastCatalog!.Connectors.Should().ContainSingle() + .Which.HostCallback.Should().BeEquivalentTo( + new StoredHostCallbackConnectorConfig(string.Empty, [], [])); + } + private static ConnectorDefinitionDto CreateMcpConnector(string command, string url) => new( "mcp-canary", @@ -56,7 +88,48 @@ private static ConnectorDefinitionDto CreateMcpConnector(string command, string EmptyAuth(), string.Empty, [], - [])); + []), + EmptyHostCallback()); + + private static ConnectorDefinitionDto CreateHostCallbackConnector() => + new( + "deterministic-hash", + "host_callback", + true, + 30_000, + 0, + new HttpConnectorDefinitionDto( + string.Empty, + [], + [], + [], + new Dictionary(), + EmptyAuth()), + new CliConnectorDefinitionDto( + string.Empty, + [], + [], + [], + string.Empty, + new Dictionary()), + new McpConnectorDefinitionDto( + string.Empty, + string.Empty, + string.Empty, + [], + new Dictionary(), + new Dictionary(), + EmptyAuth(), + string.Empty, + [], + []), + new HostCallbackConnectorDefinitionDto( + " deterministic_compute ", + [" sha256_utf8 ", "sha256_utf8"], + [" text "])); + + private static HostCallbackConnectorDefinitionDto EmptyHostCallback() => + new(string.Empty, [], []); private static ConnectorAuthDefinitionDto EmptyAuth() => new( @@ -72,6 +145,7 @@ private static ConnectorAuthDefinitionDto EmptyAuth() => private sealed class RecordingConnectorCatalogCommandPort : IConnectorCatalogCommandPort { public int SaveAttempts { get; private set; } + public StoredConnectorCatalog? LastCatalog { get; private set; } public Task SaveConnectorCatalogAsync( StoredConnectorCatalog catalog, @@ -79,6 +153,7 @@ public Task SaveConnectorCatalogAsync( CancellationToken cancellationToken = default) { SaveAttempts++; + LastCatalog = catalog; return Task.FromResult(catalog); } diff --git a/test/Aevatar.Studio.Tests/StudioCatalogImportParserTests.cs b/test/Aevatar.Studio.Tests/StudioCatalogImportParserTests.cs index f7cce63242..5f0828b7aa 100644 --- a/test/Aevatar.Studio.Tests/StudioCatalogImportParserTests.cs +++ b/test/Aevatar.Studio.Tests/StudioCatalogImportParserTests.cs @@ -101,6 +101,59 @@ public void ConnectorAuthEntry_ShouldRoundTripSecretRefHeaderFields() .And.Contain(field => field.FieldNumber == 8 && field.Name == "header_value_prefix"); } + [Fact] + public async Task ConnectorImportParser_ShouldParseHostCallbackConfiguration() + { + const string json = """ + { + "connectors": [ + { + "name": "deterministic-hash", + "type": "host_callback", + "host_callback": { + "handler": "deterministic_compute", + "allowedOperations": ["sha256_utf8"], + "allowedInputKeys": ["text"] + } + } + ] + } + """; + + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var result = await new ConnectorCatalogImportParser() + .ParseCatalogAsync(stream, CancellationToken.None); + + var hostCallback = result.Should().ContainSingle().Subject.HostCallback; + hostCallback.Handler.Should().Be("deterministic_compute"); + hostCallback.AllowedOperations.Should().Equal("sha256_utf8"); + hostCallback.AllowedInputKeys.Should().Equal("text"); + } + + [Fact] + public void ConnectorDefinitionEntry_ShouldRoundTripHostCallbackFields() + { + var entry = new ConnectorDefinitionEntry + { + Name = "deterministic-hash", + Type = "host_callback", + HostCallback = new HostCallbackConnectorConfigEntry + { + Handler = "deterministic_compute", + AllowedOperations = { "sha256_utf8" }, + AllowedInputKeys = { "text" }, + }, + }; + + var parsed = ConnectorDefinitionEntry.Parser.ParseFrom(entry.ToByteArray()); + + parsed.HostCallback.Handler.Should().Be("deterministic_compute"); + parsed.HostCallback.AllowedOperations.Should().Equal("sha256_utf8"); + parsed.HostCallback.AllowedInputKeys.Should().Equal("text"); + ConnectorDefinitionEntry.Descriptor.Fields.InDeclarationOrder() + .Should().Contain(field => field.FieldNumber == 9 && field.Name == "host_callback"); + } + [Fact] public async Task Role_import_parser_keeps_json_catalog_boundary() { diff --git a/tools/ci/architecture_guards.sh b/tools/ci/architecture_guards.sh index d766a79173..63f79206c6 100755 --- a/tools/ci/architecture_guards.sh +++ b/tools/ci/architecture_guards.sh @@ -1472,6 +1472,9 @@ if [ -n "${reducer_test_coverage_violations}" ]; then exit 1 fi +echo "Running deterministic compute handler guard..." +bash tools/ci/deterministic_compute_handler_guard.sh + stateful_replay_contract_requirements=( "WorkflowGAgent:test/Aevatar.Integration.Tests/WorkflowGAgentReplayContractTests.cs" "RoleGAgent:test/Aevatar.AI.Tests/RoleGAgentReplayContractTests.cs" diff --git a/tools/ci/deterministic_compute_handler_guard.sh b/tools/ci/deterministic_compute_handler_guard.sh new file mode 100755 index 0000000000..7da314b292 --- /dev/null +++ b/tools/ci/deterministic_compute_handler_guard.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +implementation_lines="$( + rg -n \ + '^\s*(public|internal)\s+(sealed\s+)?class\s+[A-Za-z0-9_]+\s*:\s*[^\n]*\bIDeterministicComputeHandler\b' \ + src agents \ + -g '*.cs' \ + -g '!**/bin/**' \ + -g '!**/obj/**' || true +)" + +if [ -z "${implementation_lines}" ]; then + echo "Deterministic compute handler guard failed: at least one production IDeterministicComputeHandler is required." + exit 1 +fi + +violations="" +while IFS= read -r implementation_line; do + [ -z "${implementation_line}" ] && continue + file="${implementation_line%%:*}" + remainder="${implementation_line#*:}" + line_no="${remainder%%:*}" + declaration="${remainder#*:}" + class_name="$(echo "${declaration}" | sed -E 's/.*class[[:space:]]+([A-Za-z0-9_]+).*/\1/')" + + if [ -z "${class_name}" ] || ! rg -n "\b${class_name}\b" test -g '*Tests.cs' >/dev/null; then + violations="${violations}${file}:${line_no}:${class_name}\n" + fi +done <<< "${implementation_lines}" + +if [ -n "${violations}" ]; then + printf '%b' "${violations}" + echo "Deterministic compute handler guard failed: every registered handler must be referenced by a golden-vector test." + exit 1 +fi + +echo "Deterministic compute handler guard passed."