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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -59,6 +65,7 @@ message ConnectorDefinitionEntry {
HttpConnectorConfigEntry http = 6;
CliConnectorConfigEntry cli = 7;
McpConnectorConfigEntry mcp = 8;
HostCallbackConnectorConfigEntry host_callback = 9;
}

message ConnectorDraftEntry {
Expand Down
26 changes: 26 additions & 0 deletions docs/canon/connector.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 中提交。

---
Expand Down Expand Up @@ -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:
Expand All @@ -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 如何使用”的关键说明
Expand Down
8 changes: 4 additions & 4 deletions docs/contracts/nyxid-assistant-conformance/v1/sources.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
80 changes: 55 additions & 25 deletions src/Aevatar.Bootstrap/Connectors/HostCallbackConnector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public sealed class HostCallbackConnector : IConnector
private readonly IHostCallbackConnectorHandler _handler;
private readonly HashSet<string> _allowedOperations;
private readonly HashSet<string> _allowedInputKeys;
private readonly IReadOnlyDictionary<string, int> _algorithmVersions;

public HostCallbackConnector(
string name,
Expand All @@ -32,6 +33,7 @@ public HostCallbackConnector(
_handler = handler ?? throw new ArgumentNullException(nameof(handler));
_allowedOperations = new HashSet<string>(allowedOperations ?? [], StringComparer.OrdinalIgnoreCase);
_allowedInputKeys = new HashSet<string>(allowedInputKeys ?? [], StringComparer.OrdinalIgnoreCase);
_algorithmVersions = BuildAlgorithmVersions(handler);
}

public string Name { get; }
Expand All @@ -49,10 +51,7 @@ public async Task<ConnectorResponse> ExecuteAsync(ConnectorRequest request, Canc
{
Success = false,
Error = "operation is required for host_callback",
Metadata = new Dictionary<string, string>
{
["host_callback.handler"] = _handlerName,
},
Metadata = BuildMetadata(operation),
};
}

Expand All @@ -62,11 +61,7 @@ public async Task<ConnectorResponse> ExecuteAsync(ConnectorRequest request, Canc
{
Success = false,
Error = $"operation '{operation}' is not allowed",
Metadata = new Dictionary<string, string>
{
["host_callback.handler"] = _handlerName,
["host_callback.operation"] = operation,
},
Metadata = BuildMetadata(operation),
};
}
}
Expand All @@ -77,11 +72,7 @@ public async Task<ConnectorResponse> ExecuteAsync(ConnectorRequest request, Canc
{
Success = false,
Error = schemaError,
Metadata = new Dictionary<string, string>
{
["host_callback.handler"] = _handlerName,
["host_callback.operation"] = operation,
},
Metadata = BuildMetadata(operation),
};
}

Expand All @@ -102,12 +93,7 @@ public async Task<ConnectorResponse> ExecuteAsync(ConnectorRequest request, Canc
},
ct);

var metadata = new Dictionary<string, string>(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)
Expand All @@ -130,15 +116,37 @@ public async Task<ConnectorResponse> ExecuteAsync(ConnectorRequest request, Canc
{
Success = false,
Error = ex.Message,
Metadata = new Dictionary<string, string>
{
["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<string, string> BuildMetadata(
string operation,
IEnumerable<KeyValuePair<string, string>>? responseMetadata = null)
{
var metadata = new Dictionary<string, string>(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,
Expand Down Expand Up @@ -196,6 +204,28 @@ private static string FlattenScalar(JsonValue value)
return value.ToJsonString(JsonOptions);
}

private static IReadOnlyDictionary<string, int> BuildAlgorithmVersions(
IHostCallbackConnectorHandler handler)
{
if (handler is not IDeterministicComputeHandler deterministicHandler)
return new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);

var versions = new Dictionary<string, int>(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<string> allowedKeys, out string error)
{
error = string.Empty;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -48,4 +63,45 @@ public bool TryBuild(ConnectorConfigEntry entry, ILogger logger, out IConnector?
entry.HostCallback.AllowedInputKeys);
return true;
}

private static bool HasExactDeterministicOperationContract(
IEnumerable<string> allowedOperations,
IReadOnlyList<DeterministicAlgorithmDescriptor> 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<string>(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;
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Computes a lowercase SHA-256 digest over the UTF-8 bytes of an input string.</summary>
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<DeterministicAlgorithmDescriptor> AlgorithmDescriptors =
[
new(OperationId, Version, InputSchemaDigest, OutputSchemaDigest),
];

public string Name => HandlerName;

public IReadOnlyList<DeterministicAlgorithmDescriptor> 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<HostCallbackConnectorResponse> 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;
}
}
}
Loading
Loading