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
17 changes: 17 additions & 0 deletions docs/canon/connector.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,23 @@ Actor audit facts. Recovery then revokes both protected request and completion m
`host_callback.algorithm_version`,并由 `ConnectorCallModule` 复制到 `StepCompletedEvent.Annotations`;
- 需要并行保留旧语义时,注册新的 algorithm id(例如后缀 `_v2`),不在运行时按版本分支。

Mainnet Host 默认启用一个 Host-owned 确定性计算 connector:

- connector name:`deterministic_compute`
- type:`host_callback`
- handler:`deterministic_compute`
- allowed operations:`sha256_utf8`
- allowed input keys:`text`

该默认项由 Mainnet 组合层同时注册到运行时 `IConnectorRegistry`,并作为 Host-owned default 发布到每个
Studio scope 的 connector catalog;因此生产镜像不依赖节点本地 `~/.aevatar/connectors.json` 才能暴露该
内建能力。运行时注册仍通过 `HostCallbackConnectorBuilder`,若上述 handler/operation 与已注册的
`DeterministicAlgorithmDescriptor` 不精确一致,Host 启动失败且 catalog 不会形成一个可运行的弱契约。
Studio catalog GET、workflow capability source 与 scheduled authorization evidence 共用同一个 Host-default
connector-name authority。scope PUT 只持久化 scope-owned connector,忽略同名 Host-owned 项并返回组合后的
catalog view;因此不依赖客户端先 GET 再 PUT 来发布默认项。catalog `Version` / ETag 只描述可写的 scope
catalog actor version,Host-owned defaults 随 Host 组合发布且不属于该并发控制边界。

## 3.4 Host 责任边界

以下职责明确属于 host,而不是 workflow engine:
Expand Down
6 changes: 3 additions & 3 deletions docs/contracts/nyxid-assistant-conformance/v1/sources.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"schema_version": 1,
"aevatar": {
"repository": "https://github.com/AevatarAI/aevatar.git",
"revision": "d062400830041ed2c13ebe13a64ee161c6466666",
"contract_files_sha256": "17f41ef766c3a125ea0d56ea44d38be3e03012c0258a73898885eb652abe7d0f",
"revision": "4f5066066786faed3dba5f7410f090c1fbddcb17",
"contract_files_sha256": "616e3dc31d4a1b0dde898f8a5029d21e967c475b596dfefba33d261c354d75f6",
"files": {
"agents/Aevatar.GAgents.NyxidChat/NyxIdActionPostconditionPort.cs": "7791de469b567dcde70a0f8e2a88cc818972ca557617a2538294e8ccabd5bda0",
"agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionRegistry.cs": "60e6f67c94ae11b1bf0dac036ad8ac0c35901e31787b1f0c8173964f6a12d263",
Expand All @@ -17,7 +17,7 @@
"src/Aevatar.AI.ToolProviders.NyxId/NyxIdAssistantToolSource.cs": "1b033df9cb55c741e9b52054cbd4a91067f03c8c3797bd076a7e3d6133eb0fcb",
"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": "f15f082ce76f375fcead9504d9306cbdd05339f49f67c74b7a8f8c9268b81cbb"
"src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs": "2f07e4ab798bc5ed30fa7a93b735465c3c5b9fae8ba886b00bd4186d1e2049f2"
}
},
"nyxid": {
Expand Down
128 changes: 128 additions & 0 deletions src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
using Aevatar.Audit.Core.DependencyInjection;
using Aevatar.Audit.Hosting;
using Aevatar.BackendConsole.Hosting;
using Aevatar.Bootstrap.Connectors;
using Aevatar.Bootstrap.Extensions.AI;
using Aevatar.Bootstrap.Hosting;
using Aevatar.ChatRouting.Core;
Expand Down Expand Up @@ -201,6 +202,12 @@ public static WebApplicationBuilder AddAevatarMainnetHost(
serviceProvider.GetRequiredService<AgentProfileApplicationService>());
builder.Services.AddAIWorkspace(builder.Configuration);
builder.AddStudioCapability();
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<
IHostConnectorCatalogDefaults,
MainnetDeterministicComputeConnectorCatalogDefaults>());
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<
IHostedService,
MainnetDeterministicComputeConnectorHostedService>());
builder.Services.AddAuditTrailCore(builder.Configuration);
builder.AddAuditTrailCapabilityBundle();
builder.Services.AddBackendConsoleStaticAssets(builder.Configuration);
Expand Down Expand Up @@ -881,3 +888,124 @@ private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptionsWithDir
};
}
}

internal static class MainnetDeterministicComputeConnectorDefinition
{
internal const string ConnectorName = "deterministic_compute";

internal static ConnectorConfigEntry CreateRuntimeDefinition() =>
new()
{
Name = ConnectorName,
Type = "host_callback",
Enabled = true,
TimeoutMs = 30_000,
Retry = 0,
HostCallback = new HostCallbackConnectorConfig
{
Handler = SHA256DeterministicComputeHandler.HandlerName,
AllowedOperations = [SHA256DeterministicComputeHandler.OperationId],
AllowedInputKeys = ["text"],
},
};

internal static StoredConnectorDefinition CreateCatalogDefinition() =>
new(
Name: ConnectorName,
Type: "host_callback",
Enabled: true,
TimeoutMs: 30_000,
Retry: 0,
Http: new StoredHttpConnectorConfig(
string.Empty,
[],
[],
[],
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
EmptyAuth()),
Cli: new StoredCliConnectorConfig(
string.Empty,
[],
[],
[],
string.Empty,
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)),
Mcp: new StoredMcpConnectorConfig(
string.Empty,
string.Empty,
string.Empty,
[],
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
EmptyAuth(),
string.Empty,
[],
[]),
HostCallback: new StoredHostCallbackConnectorConfig(
SHA256DeterministicComputeHandler.HandlerName,
[SHA256DeterministicComputeHandler.OperationId],
["text"]));

private static StoredConnectorAuthConfig EmptyAuth() =>
new(
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty);
}

internal sealed class MainnetDeterministicComputeConnectorCatalogDefaults : IHostConnectorCatalogDefaults
{
public IReadOnlyList<StoredConnectorDefinition> Connectors { get; } =
[MainnetDeterministicComputeConnectorDefinition.CreateCatalogDefinition()];
}

internal sealed class MainnetDeterministicComputeConnectorHostedService : IHostedService
{
private readonly IConnectorRegistry _registry;
private readonly IReadOnlyList<IConnectorBuilder> _connectorBuilders;
private readonly ILogger<MainnetDeterministicComputeConnectorHostedService> _logger;

public MainnetDeterministicComputeConnectorHostedService(
IConnectorRegistry registry,
IEnumerable<IConnectorBuilder> connectorBuilders,
ILogger<MainnetDeterministicComputeConnectorHostedService> logger)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_connectorBuilders = (connectorBuilders ?? throw new ArgumentNullException(nameof(connectorBuilders)))
.ToArray();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}

// Implement (issue #3542):
// Behavior: Register Mainnet's deterministic_compute connector independently of a node-local connectors.json.
// Why this shape: The existing builder remains the fail-closed authority for descriptor/config alignment.
public async Task StartAsync(CancellationToken cancellationToken)
{
var builder = _connectorBuilders.FirstOrDefault(static candidate =>
string.Equals(candidate.Type, "host_callback", StringComparison.OrdinalIgnoreCase));
if (builder is null)
throw new InvalidOperationException("Mainnet requires the host_callback connector builder.");

var definition = MainnetDeterministicComputeConnectorDefinition.CreateRuntimeDefinition();
if (!builder.TryBuild(definition, _logger, out var connector) || connector is null)
{
throw new InvalidOperationException(
"Mainnet deterministic_compute connector does not match the registered algorithm descriptor.");
}

await _registry.RegisterAsync(
global::Aevatar.Foundation.Abstractions.Connectors.ConnectorRegistration.Owned(connector),
cancellationToken);
}

public Task StopAsync(CancellationToken cancellationToken)
{
_ = cancellationToken;
return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,28 @@ public interface IConnectorCatalogQueryPort

Task<StoredConnectorDraft> GetConnectorDraftAsync(CancellationToken cancellationToken = default);
}

/// <summary>
/// Connector definitions owned by the composed Host and published in every Studio scope catalog.
/// </summary>
public interface IHostConnectorCatalogDefaults
{
IReadOnlyList<StoredConnectorDefinition> Connectors { get; }
}

public sealed record ConnectorCatalogNameEntry(string Name, bool Enabled);

/// <summary>
/// Owns Host-default connector-name precedence across catalog readers and scope writes.
/// </summary>
public interface IConnectorCatalogNameAuthority
{
IReadOnlyList<StoredConnectorDefinition> ComposeDefinitions(
IReadOnlyList<StoredConnectorDefinition> scopedConnectors);

IReadOnlyList<string> ComposeEnabledNames(
IReadOnlyList<ConnectorCatalogNameEntry> scopedConnectors);

IReadOnlyList<StoredConnectorDefinition> SelectScopeOwnedDefinitions(
IReadOnlyList<StoredConnectorDefinition> requestedConnectors);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public static IServiceCollection AddStudioApplication(this IServiceCollection se
services.AddSingleton<WorkspaceService>();
services.AddSingleton<ExecutionService>();
services.AddSingleton<ConnectorService>();
services.TryAddSingleton<IConnectorCatalogNameAuthority, ConnectorCatalogNameAuthority>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<
IExternalWorkflowCapabilitySource,
ConnectorExternalWorkflowCapabilitySource>());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using Aevatar.Studio.Application.Studio.Abstractions;

namespace Aevatar.Studio.Application.Studio.Services;

internal sealed class ConnectorCatalogNameAuthority : IConnectorCatalogNameAuthority
{
private readonly IReadOnlyList<StoredConnectorDefinition> _hostConnectorDefaults;
private readonly HashSet<string> _hostConnectorNames;

public ConnectorCatalogNameAuthority(IEnumerable<IHostConnectorCatalogDefaults> hostConnectorDefaults)
{
ArgumentNullException.ThrowIfNull(hostConnectorDefaults);

_hostConnectorDefaults = hostConnectorDefaults
.SelectMany(static defaults => defaults.Connectors)
.ToArray();
_hostConnectorNames = _hostConnectorDefaults
.Select(static connector => NormalizeRequiredName(connector.Name))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}

public IReadOnlyList<StoredConnectorDefinition> ComposeDefinitions(
IReadOnlyList<StoredConnectorDefinition> scopedConnectors) =>
ComposeByName(
scopedConnectors,
static connector => connector.Name,
static connector => connector);

public IReadOnlyList<string> ComposeEnabledNames(
IReadOnlyList<ConnectorCatalogNameEntry> scopedConnectors) =>
ComposeByName(
scopedConnectors,
static connector => connector.Name,
static connector => new ConnectorCatalogNameEntry(connector.Name, connector.Enabled))
.Where(static connector => connector.Enabled && !string.IsNullOrWhiteSpace(connector.Name))
.Select(static connector => connector.Name.Trim())
.ToArray();

public IReadOnlyList<StoredConnectorDefinition> SelectScopeOwnedDefinitions(
IReadOnlyList<StoredConnectorDefinition> requestedConnectors)
{
ArgumentNullException.ThrowIfNull(requestedConnectors);

return requestedConnectors
.Where(connector => !_hostConnectorNames.Contains(connector.Name.Trim()))
.ToArray();
}

private IReadOnlyList<T> ComposeByName<T>(
IReadOnlyList<T> scopedConnectors,
Func<T, string> nameSelector,
Func<StoredConnectorDefinition, T> hostConnectorSelector)
{
ArgumentNullException.ThrowIfNull(scopedConnectors);

var merged = scopedConnectors.ToList();
foreach (var hostConnector in _hostConnectorDefaults)
{
var hostConnectorName = NormalizeRequiredName(hostConnector.Name);
var existingIndex = merged.FindIndex(connector =>
string.Equals(
nameSelector(connector).Trim(),
hostConnectorName,
StringComparison.OrdinalIgnoreCase));
if (existingIndex >= 0)
merged[existingIndex] = hostConnectorSelector(hostConnector);
else
merged.Add(hostConnectorSelector(hostConnector));
}

return merged.AsReadOnly();
}

private static string NormalizeRequiredName(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new InvalidOperationException("Host connector catalog defaults require a name.");

return name.Trim();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked;

/// <summary>
/// Actor-backed implementation of connector catalog query and command ports.
/// Reads from the projection document store (CQRS read model).
/// Reads scope-owned definitions from the projection document store (CQRS read model), then
/// composes immutable Host-owned defaults supplied by the deployment Host.
/// Writes send commands to the Write GAgent through CQRS Core dispatch.
/// Local JSON is only an explicit import boundary, never a draft backup.
/// Per-scope isolation: each scope gets its own <c>connector-catalog-{scopeId}</c> actor.
Expand All @@ -28,20 +29,24 @@ internal sealed class ActorBackedConnectorCatalogStore : IConnectorCatalogQueryP
private readonly IStudioLocalConnectorCatalogImportReader _localImportReader;
private readonly IProjectionDocumentReader<ConnectorCatalogCurrentStateDocument, string> _documentReader;
private readonly ILogger<ActorBackedConnectorCatalogStore> _logger;
private readonly IConnectorCatalogNameAuthority _connectorCatalogNameAuthority;

public ActorBackedConnectorCatalogStore(
IStudioActorBootstrap bootstrap,
StudioActorCommandDispatch commandDispatch,
IAppScopeResolver scopeResolver,
IStudioLocalConnectorCatalogImportReader localImportReader,
IProjectionDocumentReader<ConnectorCatalogCurrentStateDocument, string> documentReader,
IConnectorCatalogNameAuthority connectorCatalogNameAuthority,
ILogger<ActorBackedConnectorCatalogStore> logger)
{
_bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap));
_commandDispatch = commandDispatch ?? throw new ArgumentNullException(nameof(commandDispatch));
_scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver));
_localImportReader = localImportReader ?? throw new ArgumentNullException(nameof(localImportReader));
_documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader));
_connectorCatalogNameAuthority = connectorCatalogNameAuthority ??
throw new ArgumentNullException(nameof(connectorCatalogNameAuthority));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}

Expand All @@ -50,20 +55,17 @@ public async Task<StoredConnectorCatalog> GetConnectorCatalogAsync(
{
var state = await ReadProjectedStateAsync(cancellationToken);
var version = state?.LastAppliedEventVersion ?? 0;
if (state is null)
{
return new StoredConnectorCatalog(
HomeDirectory: ActorHomeDirectory,
FilePath: ActorFilePath,
FileExists: false,
Connectors: [],
Version: version);
}

var connectors = state.Connectors
var scopedConnectors = state?.Connectors
.Select(ToStoredConnectorDefinition)
.ToList()
.AsReadOnly();
.ToList() ?? [];

// Implement (issue #3542):
// Behavior: Publish deployment-owned connector defaults in every scope while preserving scope-owned entries.
// Why this shape: Mainnet capabilities remain discoverable without query-time writes or a process-local scope registry.
// Fix (review round 1, F1):
// GET was the only reader that composed Host-owned connector names.
// Delegate composition to the catalog-name authority shared with scheduled evidence.
var connectors = _connectorCatalogNameAuthority.ComposeDefinitions(scopedConnectors);

return new StoredConnectorCatalog(
HomeDirectory: ActorHomeDirectory,
Expand All @@ -78,9 +80,13 @@ public async Task<StoredConnectorCatalog> SaveConnectorCatalogAsync(
long? expectedVersion = null,
CancellationToken cancellationToken = default)
{
// Fix (review round 1, F2):
// GET+PUT could persist Host-owned defaults and PUT returned a different catalog view.
// Persist only scope-owned entries, then return the same composed view exposed by GET.
var scopedConnectors = _connectorCatalogNameAuthority.SelectScopeOwnedDefinitions(catalog.Connectors);
var actor = await EnsureWriteActorAsync(cancellationToken);
var evt = new ConnectorCatalogSavedEvent();
evt.Connectors.AddRange(catalog.Connectors.Select(ToProtoConnectorDefinition));
evt.Connectors.AddRange(scopedConnectors.Select(ToProtoConnectorDefinition));
if (expectedVersion is not null)
evt.ExpectedVersion = expectedVersion.Value;
await _commandDispatch.DispatchAsync(actor, evt, PublisherId, cancellationToken);
Expand All @@ -89,7 +95,7 @@ public async Task<StoredConnectorCatalog> SaveConnectorCatalogAsync(
HomeDirectory: ActorHomeDirectory,
FilePath: ActorFilePath,
FileExists: true,
Connectors: catalog.Connectors,
Connectors: _connectorCatalogNameAuthority.ComposeDefinitions(scopedConnectors),
Version: NextDeterministicVersion(expectedVersion));
}

Expand Down
Loading
Loading