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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ namespace Aevatar.GAgents.Channel.Identity.Slash;

/// <summary>
/// /init — start a new NyxID OAuth Authorization Code + PKCE binding flow for
/// the inbound sender. Renders the authorize URL as a Lark interactive card
/// (button) when the channel supports cards, with a plain-text fallback for
/// transports that don't. ADR-0018 §Decision: only emits the URL in private
/// chats; group/channel inbound is refused so the sealed state token never
/// reaches a third party.
/// the inbound sender. Renders the authorize URL as an interactive card button
/// when the channel supports cards, with a plain-text fallback for transports
/// that don't. ADR-0018 §Decision: only emits the URL in private chats;
/// group/channel inbound is refused so the sealed state token never reaches a
/// third party.
/// </summary>
public sealed class InitChannelSlashCommandHandler : IChannelSlashCommandHandler
{
Expand Down Expand Up @@ -74,32 +74,31 @@ public InitChannelSlashCommandHandler(
return PlainText("启动 NyxID 绑定时遇到内部错误,请稍后重试 /init。");
}

return BuildBindingCard(challenge.AuthorizeUrl, challenge.RenewsExistingBinding);
return BuildBindingCard(challenge.AuthorizeUrl, context.Subject.Platform, challenge.RenewsExistingBinding);
}

private static MessageContent PlainText(string text) => new() { Text = text };

/// <summary>
/// Build a Lark-friendly card (header + description + primary "open url"
/// Build a channel binding card (header + description + primary "open url"
/// button). Channels without card support degrade to plain text via
/// <see cref="MessageContent.Text"/> being set as the fallback.
/// </summary>
public static MessageContent BuildBindingCard(
string authorizeUrl,
string? platform,
bool renewsExistingBinding = false)
{
var content = new MessageContent
{
Text = renewsExistingBinding
? $"打开此链接重新确认并更新 Lark bot 的 NyxID 服务授权(5 分钟内有效):\n{authorizeUrl}"
: $"打开此链接完成 NyxID 登录并确认服务授权(5 分钟内有效):\n{authorizeUrl}",
Text = BuildTextFallback(authorizeUrl, platform, renewsExistingBinding),
};
content.Cards.Add(new CardBlock
{
Title = renewsExistingBinding ? "更新 NyxID 服务授权" : "完成 NyxID 绑定",
Text = renewsExistingBinding
? "重新确认服务授权;成功后会安全更新当前 Lark 绑定。链接 5 分钟内有效。"
: "登录并确认 Lark bot 可使用的 NyxID 服务。链接 5 分钟内有效。",
? "重新确认服务授权;成功后会安全更新当前会话绑定。链接 5 分钟内有效。"
: "登录并确认当前 bot 可使用的 NyxID 服务。链接 5 分钟内有效。",
});
content.Actions.Add(new ActionElement
{
Expand All @@ -111,4 +110,21 @@ public static MessageContent BuildBindingCard(
});
return content;
}

private static string BuildTextFallback(
string authorizeUrl,
string? platform,
bool renewsExistingBinding)
{
if (string.Equals(platform, "telegram", StringComparison.OrdinalIgnoreCase))
{
return renewsExistingBinding
? "打开下方按钮重新确认并更新当前 Telegram bot 的 NyxID 服务授权(5 分钟内有效)。"
: "打开下方按钮完成 NyxID 登录并确认当前 Telegram bot 的服务授权(5 分钟内有效)。";
}

return renewsExistingBinding
? $"打开此链接重新确认并更新当前 bot 的 NyxID 服务授权(5 分钟内有效):\n{authorizeUrl}"
: $"打开此链接完成 NyxID 登录并确认当前 bot 的服务授权(5 分钟内有效):\n{authorizeUrl}";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2475,7 +2475,8 @@ private static string NormalizeSelectionText(string? value, int maximumLength)
private static bool IsValidConnectedServiceSelector(
AgentProfileConnectedServiceSelector selector) =>
(NyxIdServiceSlugPolicy.IsCanonical(selector.CatalogServiceSlug) ||
IsDynamicReadConnectedServiceSelector(selector)) &&
IsDynamicReadConnectedServiceSelector(selector) ||
IsEndpointOnlyReadConnectedServiceSelector(selector)) &&
(string.IsNullOrEmpty(selector.EndpointId) ||
selector.EndpointId.Length <= 256 &&
string.Equals(selector.EndpointId, selector.EndpointId.Trim(), StringComparison.Ordinal) &&
Expand All @@ -2493,6 +2494,14 @@ selector.Readiness is null &&
selector.AllowedRisks.Count == 1 &&
selector.AllowedRisks[0] == AgentToolOperationRiskPayload.ReadOnly;

private static bool IsEndpointOnlyReadConnectedServiceSelector(
AgentProfileConnectedServiceSelector selector) =>
string.IsNullOrEmpty(selector.CatalogServiceSlug) &&
!string.IsNullOrEmpty(selector.EndpointId) &&
selector.Readiness is null &&
selector.AllowedRisks.Count == 1 &&
selector.AllowedRisks[0] == AgentToolOperationRiskPayload.ReadOnly;

private static bool IsValidReadiness(AgentProfileConnectedServiceReadiness? readiness)
{
if (readiness is null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ private async Task<ConversationTurnResult> SendBindingPromptAsync(
inbound.Platform,
inbound.SenderId,
registration.Id);
reply = new MessageContent { Text = "无法识别当前 Lark 用户身份,请稍后重试。" };
reply = new MessageContent { Text = $"无法识别当前 {inbound.Platform} 用户身份,请稍后重试。" };
}
else
{
Expand All @@ -568,6 +568,7 @@ private async Task<ConversationTurnResult> SendBindingPromptAsync(
var challenge = await broker.StartExternalBindingAsync(subject, ct).ConfigureAwait(false);
reply = InitChannelSlashCommandHandler.BuildBindingCard(
challenge.AuthorizeUrl,
subject.Platform,
challenge.RenewsExistingBinding);
}
catch (AevatarOAuthClientNotProvisionedException ex)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -763,9 +763,10 @@ private static int CountArrayItems(JsonElement root, string propertyName) =>
: 0;

private static bool IsDinnerInputFieldName(string fieldName) =>
fieldName is "participant" or "window" or "party_size" or "day" or "time" or "location" or
"cuisines" or "restaurant_type" or "phone_number" or "budget_cap" or "policy" or
"search_query" or "missing_fields";
fieldName is "participant" or "contact_name" or "window" or "time_window" or "party_size" or "day" or
"time" or "location" or "home_location" or "cuisines" or "preferred_cuisines" or
"restaurant_type" or "phone_number" or "restaurant_phone_number" or "budget_cap" or
"policy" or "search_query" or "missing_fields";

private static bool IsPresent(JsonElement value) =>
value.ValueKind switch
Expand Down
36 changes: 27 additions & 9 deletions src/Aevatar.Mainnet.Host.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
"ApiBaseUrl": "https://nyx-api.chrono-ai.fun",
"GatewayEndpoint": "https://nyx-api.chrono-ai.fun/api/v1/llm/gateway/v1",
"ChronoLlmEndpoint": "https://llm.aelf.dev/v1",
"AdditionalRequiredServiceSlugs": [],
"AdditionalRequiredServiceSlugs": [
"user-context-mock"
],
Comment on lines +21 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep optional dining context out of required OAuth resources

Adding user-context-mock here makes it a global OAuth authorization floor, not an optional profile-context source: NyxIdRemoteCapabilityBroker includes every AdditionalRequiredServiceSlugs entry in RequiredResourceUris() and rejects token issuance when FindMissingRequiredResourcesAsync cannot find it. Consequently, existing users—or users making ordinary chat requests—who have not connected this mock service will hit required_service_access_missing, even though the profile instructions describe dining context as “any available” context. Leave this service optional and let the endpoint-only selector discover it when present.

Useful? React with 👍 / 👎.

"Relay": {
"EnableDebugDiagnostics": true
},
Expand All @@ -40,7 +42,7 @@
},
"BackendConsole": {
"OidcAuthority": "https://nyx-api.chrono-ai.fun",
"OidcClientId": "a6ff2946-f02f-4c35-8203-1ec46132b660",
"OidcClientId": "8c76ced6-8f5a-4564-bea9-e3d98807f8ba",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the provisioned console client invariant synchronized

This change deterministically breaks MainnetBootScriptTests.AppSettings_ShouldUseProvisionedConsoleOAuthClient, which reads this exact setting and asserts that it is a6ff2946-f02f-4c35-8203-1ec46132b660. If this is a newly provisioned client, update the pinned invariant and its provisioning evidence in the same change; otherwise restore the provisioned ID. As committed, the full test suite cannot pass.

AGENTS.md reference: AGENTS.md:L14-L14

Useful? React with 👍 / 👎.

"OidcScope": "openid profile email offline_access proxy",
"NyxApiBaseUrl": "https://nyx-api.chrono-ai.fun",
"StorageKey": "aevatar-console:nyxid:pkce",
Expand Down Expand Up @@ -87,7 +89,7 @@
"Status": {
"DefaultIntervalSeconds": 60,
"DefaultTimeoutMs": 5000,
"SelfBaseUrl": "http://localhost:5080",
"SelfBaseUrl": "http://127.0.0.1:5107",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep status and Studio defaults on the actual listen port

With the normal local startup path, the host still listens on 127.0.0.1:5080 (MainnetHostBuilderExtensions.LocalDevelopmentListenUrl and boot.sh both retain that default), so changing the built-in self probe to 5107 makes the status dashboard probe an unused port. The same commit also changes Studio.Storage.DefaultLocalRuntimeBaseUrl to 5107, causing newly initialized Studio settings to target the wrong server unless the developer supplies a matching override. The one-off 5107 command used in the test plan does not change these repository defaults.

AGENTS.md reference: AGENTS.md:L147-L147

Useful? React with 👍 / 👎.

"UseBuiltInTargets": true
}
},
Expand All @@ -98,10 +100,10 @@
"ConfiguredTemplates": [
{
"WorkflowId": "dinner_date",
"RevisionId": "dinner-date-mock-v9",
"WorkflowYamlPath": "workflow-templates/dinner_date_mock.yaml",
"WorkflowName": "dinner_date_mock",
"DisplayName": "Dinner Date Mock"
"RevisionId": "dinner-date-then-call-v5",
"WorkflowYamlPath": "workflow-templates/dinner_search_then_call.yaml",
"WorkflowName": "dinner_search_then_call",
"DisplayName": "Dinner Date Then Call"
}
]
},
Expand All @@ -112,7 +114,7 @@
"DisplayName": "NyxID Chat Default",
"Purpose": "Default public NyxID chat surface with managed workflow execution.",
"Instructions": "Help users through ordinary chat. Do not start a managed workflow for ordinary questions, small talk, general information requests, weather questions, explanations, or troubleshooting. Start a managed workflow only when the current user request clearly asks for a dinner reservation, dinner date, restaurant booking, or restaurant-selection task that matches the configured dinner_date workflow. Before starting that workflow, use any available current-user read-only profile, preference, or context tool that is relevant to the dinner task. Interpret recovered context semantically against the selected workflow's expected input: the current user message always overrides recovered defaults for the same meaning, recovered context fills only missing task inputs, and the assistant asks the user only for inputs still missing after applying the current message, recovered context, and obvious conversational defaults. For dinner reservation or dinner date requests, start the configured managed workflow directly with the current request and any recovered semantic values instead of asking for planning details up front; the workflow start dispatcher may enrich a sparse JSON object with recovered context before execution. If the user names one companion and no party size is otherwise available, use party_size 2. The exact configured workflow_id is dinner_date; do not use policy revision ids, template revision ids, display names, or workflow names as workflow_id. Start dinner_date with aevatar_start_workflow and build the workflow input according to the published dinner_date input contract. Map semantic values from the current request and recovered context into the selected workflow's contract fields when those fields are known; preserve nested contract object structure only when that nesting exists in the published contract shape; do not create new grouping objects outside the contract shape; do not wrap them in a new schema, invent source-specific preference field names, or copy raw preference text into workflow evidence fields.",
"PolicyRevision": "nyxid-chat-managed-workflow-v2",
"PolicyRevision": "nyxid-chat-managed-workflow-v5",
"MaximumToolPolicy": {
"ToolNames": [
"ask_user",
Expand All @@ -122,9 +124,25 @@
],
"ConnectedServiceSelectors": [
{
"CatalogServiceSlug": "",
"EndpointId": "readDiningProfileContext",
"AllowedRisks": [
"read_only"
]
},
{
"CatalogServiceSlug": "api-firecrawl",
"EndpointId": "095bdb84-688e-4a0f-b3f0-c6b6a8d398e1",
"AllowedRisks": [
"read_only"
]
},
{
"CatalogServiceSlug": "api-elevenlabs",
"EndpointId": "b3e5c77c-0c75-44d8-b555-8d445dbce1c9",
"AllowedRisks": [
"write"
]
}
]
},
Expand Down Expand Up @@ -154,7 +172,7 @@
},
"Studio": {
"Storage": {
"DefaultLocalRuntimeBaseUrl": "http://127.0.0.1:5080",
"DefaultLocalRuntimeBaseUrl": "http://127.0.0.1:5107",
"DefaultRemoteRuntimeBaseUrl": "https://aevatar-console-backend-api.aevatar.ai"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,13 @@ private static IReadOnlyList<AgentProfileDiagnostic> ValidateToolPolicy(
var selector = policy.ConnectedServiceSelectors[index];
var selectorField = $"{field}.connectedServiceSelectors[{index}]";
if (!NyxIdServiceSlugPolicy.IsCanonical(selector.CatalogServiceSlug) &&
!IsDynamicReadConnectedServiceSelector(selector))
!IsDynamicReadConnectedServiceSelector(selector) &&
!IsEndpointOnlyReadConnectedServiceSelector(selector))
{
diagnostics.Add(Diagnostic(
"PROFILE_CONNECTED_SERVICE_SLUG_INVALID",
$"{selectorField}.catalogServiceSlug",
"Connected-service catalog slug must be canonical, or empty for dynamic read-only selection."));
"Connected-service catalog slug must be canonical, or empty for read-only dynamic or endpoint-only selection."));
}
else if (!seenSelectors.Add(SelectorKey(selector)))
{
Expand Down Expand Up @@ -231,6 +232,13 @@ selector.Readiness is null &&
selector.AllowedRisks.Count == 1 &&
selector.AllowedRisks[0] == AgentToolOperationRiskPayload.ReadOnly;

private static bool IsEndpointOnlyReadConnectedServiceSelector(AgentProfileConnectedServiceSelector selector) =>
string.IsNullOrEmpty(selector.CatalogServiceSlug) &&
!string.IsNullOrEmpty(selector.EndpointId) &&
selector.Readiness is null &&
selector.AllowedRisks.Count == 1 &&
selector.AllowedRisks[0] == AgentToolOperationRiskPayload.ReadOnly;

private static bool IsValidEndpointId(string? endpointId) =>
string.IsNullOrEmpty(endpointId) ||
endpointId.Length <= 256 &&
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using Aevatar.GAgentService.Abstractions;
using Aevatar.GAgentService.Abstractions.Ports;
using Aevatar.Workflow.Abstractions;
using Aevatar.Workflow.Application.Abstractions.ExternalCapabilities;
using Microsoft.Extensions.Options;

namespace Aevatar.GAgentService.Application.Workflows;
Expand All @@ -8,15 +10,18 @@ public sealed class ScopeWorkflowTemplateEnsureService : IScopeWorkflowTemplateE
{
private readonly IScopeWorkflowQueryPort _workflowQueryPort;
private readonly IScopeWorkflowSaveAndBindPort _saveAndBindPort;
private readonly IWorkflowExplicitRequestPreviewService _explicitRequestPreviewService;
private readonly ScopeWorkflowCapabilityOptions _options;

public ScopeWorkflowTemplateEnsureService(
IScopeWorkflowQueryPort workflowQueryPort,
IScopeWorkflowSaveAndBindPort saveAndBindPort,
IWorkflowExplicitRequestPreviewService explicitRequestPreviewService,
IOptions<ScopeWorkflowCapabilityOptions> options)
{
_workflowQueryPort = workflowQueryPort ?? throw new ArgumentNullException(nameof(workflowQueryPort));
_saveAndBindPort = saveAndBindPort ?? throw new ArgumentNullException(nameof(saveAndBindPort));
_explicitRequestPreviewService = explicitRequestPreviewService ?? throw new ArgumentNullException(nameof(explicitRequestPreviewService));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
}

Expand All @@ -41,6 +46,14 @@ public async Task<ScopeWorkflowTemplateEnsureResult> EnsureAsync(
}

var workflowYaml = ResolveWorkflowYaml(template);
var capabilityAdmission = await BuildCapabilityAdmissionAsync(
request.CapabilityAdmission,
scopeId,
workflowYaml,
workflowId,
revisionId,
ct)
.ConfigureAwait(false);
var result = await _saveAndBindPort.SaveAndBindAsync(
new ScopeWorkflowSaveAndBindRequest(
scopeId,
Expand All @@ -53,7 +66,7 @@ public async Task<ScopeWorkflowTemplateEnsureResult> EnsureAsync(
ExposureDesired: template.ExposureDesired,
RevisionId: revisionId)
{
CapabilityAdmission = request.CapabilityAdmission,
CapabilityAdmission = capabilityAdmission,
},
ct).ConfigureAwait(false);

Expand All @@ -72,6 +85,52 @@ public async Task<ScopeWorkflowTemplateEnsureResult> EnsureAsync(
: "workflow_template_stale");
}

private async Task<WorkflowCapabilityAdmissionContext?> BuildCapabilityAdmissionAsync(
WorkflowCapabilityAdmissionContext? admission,
string scopeId,
string workflowYaml,
string workflowId,
string revisionId,
CancellationToken ct)
{
if (admission is null)
return null;
if (admission.ExplicitRequestConfirmations.Count > 0)
return admission;

var preview = await _explicitRequestPreviewService.PreviewAsync(
new WorkflowExplicitRequestPreviewRequest(
new ExternalWorkflowCapabilityAccessContext(
scopeId,
admission.CallerId,
admission.NyxIdCallerCredential,
admission.NyxIdOrganizationBearerToken),
workflowYaml,
InlineWorkflowYamls: null,
admission.ExecutionMode,
workflowId,
revisionId),
ct)
.ConfigureAwait(false);
if (preview.Items.Count == 0)
return admission;

return new WorkflowCapabilityAdmissionContext(
admission.CallerId,
admission.NyxIdCallerCredential,
admission.NyxIdOrganizationBearerToken,
admission.ExecutionMode,
admission.ExistingPlan,
preview.Items.Select(item => new NyxIdExplicitRequestConfirmation
{
CallSiteId = item.CallSiteId,
RequestContractDigest = item.RequestContractDigest,
AttestedRisk = item.EffectiveRisk,
WorkflowId = preview.WorkflowId,
RevisionId = preview.RevisionId,
}));
}

private async Task<ScopeWorkflowSummary?> WaitForRunnableRevisionAsync(
string scopeId,
string workflowId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,7 @@ public async Task WorkflowInputPreferenceContextProvider_ShouldIgnoreGenericCont
McpService("aevatar-context", "aevatar", ContextEndpoint("get_api_app_context", "/api/app/context")));
handler.OpenApiResponsesByPath["/api/v1/proxy/s/user-context-mock/openapi.json"] = CustomOpenApi;
handler.ProxyResponseBody =
"""{"preferred_cuisines":["Italian","Japanese"],"budget_cap":200}""";
"""{"contact_name":"Louis","preferred_cuisines":["Italian","Japanese"],"budget_cap":200}""";
var source = CreateSource(handler);
var provider = new NyxIdWorkflowInputPreferenceContextProvider(
source,
Expand All @@ -522,7 +522,8 @@ public async Task WorkflowInputPreferenceContextProvider_ShouldIgnoreGenericCont
var sourceContext = context.Sources.Should().ContainSingle().Subject;
sourceContext.OperationId.Should().Be("readDiningProfileContext");
sourceContext.PathTemplate.Should().Be("/profile/dining");
sourceContext.DataJson.Should().Contain("preferred_cuisines");
sourceContext.DataJson.Should().Contain("preferred_cuisines")
.And.Contain("contact_name");
handler.ProxyRequests.Should().ContainSingle();
handler.ProxyRequests.Single().Path.Should().Contain("/profile/dining");
}
Expand Down
Loading