diff --git a/agents/Aevatar.GAgents.Channel.Identity/Endpoints/IdentityOAuthEndpoints.cs b/agents/Aevatar.GAgents.Channel.Identity/Endpoints/IdentityOAuthEndpoints.cs index 1baf9847c3..13b460c567 100644 --- a/agents/Aevatar.GAgents.Channel.Identity/Endpoints/IdentityOAuthEndpoints.cs +++ b/agents/Aevatar.GAgents.Channel.Identity/Endpoints/IdentityOAuthEndpoints.cs @@ -123,7 +123,7 @@ internal static async Task HandleNyxIdOAuthCallbackAsync( if (!string.IsNullOrWhiteSpace(error)) { logger.LogWarning("OAuth callback received error from NyxID: {Error}", error); - return Results.BadRequest(new { error, detail = "NyxID returned an error on the OAuth callback. Re-run /init from Lark to retry." }); + return Results.BadRequest(new { error, detail = $"NyxID returned an error on the OAuth callback. {RetryInitInstruction(null)}。" }); } if (string.IsNullOrWhiteSpace(code)) return Results.BadRequest(new { error = "code_missing" }); @@ -139,8 +139,8 @@ internal static async Task HandleNyxIdOAuthCallbackAsync( // bootstrap actor isn't ready yet. Surface a specific message so // the user retries instead of suspecting a tampered link. var detail = decode.ErrorCode == "state_client_not_provisioned" - ? "Aevatar 集群正在初始化 NyxID 客户端,请 30 秒后回到 Lark 重新发送 /init。" - : "绑定链接已过期或无效,请回到 Lark 重新发送 /init"; + ? $"Aevatar 集群正在初始化 NyxID 客户端,{RetryInitInstruction(null, "请 30 秒后")}。" + : $"绑定链接已过期或无效,{RetryInitInstruction(null)}。"; return Results.BadRequest(new { error = decode.ErrorCode, @@ -172,7 +172,7 @@ internal static async Task HandleNyxIdOAuthCallbackAsync( return Results.BadRequest(new { error = "client_not_provisioned", - detail = "Aevatar 集群正在初始化 NyxID 客户端,请 30 秒后回到 Lark 重新发送 /init。", + detail = $"Aevatar 集群正在初始化 NyxID 客户端,{RetryInitInstruction(subject.Platform, "请 30 秒后")}。", }); } catch (NyxIdRequiredServiceAccessException ex) @@ -184,7 +184,7 @@ internal static async Task HandleNyxIdOAuthCallbackAsync( return Results.Json(new { error = "required_service_access_missing", - detail = "NyxID 授权未包含 Aevatar、默认 LLM、Ornn service 或 Sandbox service。请回到 Lark 重新发送 /init,并在授权页保留这些必需 services。", + detail = $"NyxID 授权未包含 Aevatar、默认 LLM、Ornn service 或 Sandbox service。{RetryInitInstruction(subject.Platform)},并在授权页保留这些必需 services。", }, statusCode: StatusCodes.Status409Conflict); } // RFC 6749 §5.2: the token endpoint answers 400 for a bad grant @@ -196,7 +196,7 @@ internal static async Task HandleNyxIdOAuthCallbackAsync( return OAuthCallbackProblem( StatusCodes.Status400BadRequest, "authorization_code_rejected", - "NyxID 拒绝了本次授权码,绑定链接可能已过期。请回到 Lark 重新发送 /init。"); + $"NyxID 拒绝了本次授权码,绑定链接可能已过期。{RetryInitInstruction(subject.Platform)}。"); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -229,7 +229,7 @@ internal static async Task HandleNyxIdOAuthCallbackAsync( return Results.Json(new { error = "binding_changed_during_review", - detail = "Lark 中的 NyxID 绑定在授权期间发生了变化。请回到 Lark 重新发送 /init。", + detail = $"{ChannelDisplayName(subject.Platform)} 中的 NyxID 绑定在授权期间发生了变化。{RetryInitInstruction(subject.Platform)}。", }, statusCode: StatusCodes.Status409Conflict); } @@ -241,14 +241,14 @@ internal static async Task HandleNyxIdOAuthCallbackAsync( ct) .ConfigureAwait(false); if (updatedBindingProbe != IssuedBindingProbeResult.Usable) - return BuildIssuedBindingProbeError(updatedBindingProbe); + return BuildIssuedBindingProbeError(updatedBindingProbe, subject.Platform); logger.LogInformation( "Updated NyxID service grant in place for {Platform}:{Tenant}:{User}; binding_id remained unchanged", subject.Platform, subject.Tenant, subject.ExternalUserId); - return RenderBindingGrantUpdated(format); + return RenderBindingGrantUpdated(format, subject.Platform); } // Defensive: NyxID returned no binding_id even though authorization-code @@ -295,7 +295,7 @@ internal static async Task HandleNyxIdOAuthCallbackAsync( return Results.Json(new { error = "binding_changed_during_review", - detail = "Lark 中的 NyxID 绑定在授权期间发生了变化。请回到 Lark 重新发送 /init。", + detail = $"{ChannelDisplayName(subject.Platform)} 中的 NyxID 绑定在授权期间发生了变化。{RetryInitInstruction(subject.Platform)}。", }, statusCode: StatusCodes.Status409Conflict); } @@ -318,7 +318,7 @@ internal static async Task HandleNyxIdOAuthCallbackAsync( return Results.Json(new { error = "binding_owner_lookup_failed", - detail = "Aevatar 暂时无法核对当前 NyxID 账号。请稍后回到 Lark 重新发送 /init。", + detail = $"Aevatar 暂时无法核对当前 NyxID 账号。{RetryInitInstruction(subject.Platform, "请稍后")}。", }, statusCode: StatusCodes.Status503ServiceUnavailable); } @@ -340,7 +340,7 @@ internal static async Task HandleNyxIdOAuthCallbackAsync( return Results.Json(new { error = "binding_owner_lookup_failed", - detail = "Aevatar 暂时无法核对当前 NyxID 账号。请稍后回到 Lark 重新发送 /init。", + detail = $"Aevatar 暂时无法核对当前 NyxID 账号。{RetryInitInstruction(subject.Platform, "请稍后")}。", }, statusCode: StatusCodes.Status503ServiceUnavailable); } } @@ -356,7 +356,7 @@ internal static async Task HandleNyxIdOAuthCallbackAsync( return Results.Json(new { error = "binding_owner_missing", - detail = "当前绑定缺少可验证的 NyxID 账号归属。请先在 Lark 发送 /unbind,再发送 /init 重新绑定。", + detail = $"当前绑定缺少可验证的 NyxID 账号归属。请先在 {ChannelDisplayName(subject.Platform)} 发送 /unbind,再发送 /init 重新绑定。", }, statusCode: StatusCodes.Status409Conflict); } @@ -370,7 +370,7 @@ internal static async Task HandleNyxIdOAuthCallbackAsync( return Results.Json(new { error = "binding_owner_mismatch", - detail = "当前 Lark 身份已绑定另一个 NyxID 账号。如需切换账号,请先在 Lark 发送 /unbind,再发送 /init。", + detail = $"当前 {ChannelDisplayName(subject.Platform)} 身份已绑定另一个 NyxID 账号。如需切换账号,请先在 {ChannelDisplayName(subject.Platform)} 发送 /unbind,再发送 /init。" }, statusCode: StatusCodes.Status409Conflict); } } @@ -385,7 +385,7 @@ internal static async Task HandleNyxIdOAuthCallbackAsync( if (issuedBindingProbe != IssuedBindingProbeResult.Usable) { await TryRevokeOrphanBindingAsync(brokerCallback, exchange.BindingId, logger, ct).ConfigureAwait(false); - return BuildIssuedBindingProbeError(issuedBindingProbe); + return BuildIssuedBindingProbeError(issuedBindingProbe, subject.Platform); } CommandDispatchResult accepted; @@ -463,7 +463,7 @@ await brokerCapabilityDispatch subject.ExternalUserId, accepted.Receipt.CommandId); - return RenderBindingAccepted(displayName, accepted.Receipt, format); + return RenderBindingAccepted(displayName, accepted.Receipt, format, subject.Platform); } private enum IssuedBindingProbeResult @@ -553,21 +553,37 @@ await capabilityBroker private static string BindingDigest(string bindingId) => NyxIdRemoteCapabilityBroker.BindingDigest(bindingId); - private static IResult BuildIssuedBindingProbeError(IssuedBindingProbeResult probeResult) => + private static string ChannelDisplayName(string? platform) + { + if (string.IsNullOrWhiteSpace(platform)) + return "当前聊天应用"; + + return platform.Trim().ToLowerInvariant() switch + { + "lark" => "Lark", + "telegram" => "Telegram", + _ => platform.Trim(), + }; + } + + private static string RetryInitInstruction(string? platform, string prefix = "请") => + $"{prefix}回到 {ChannelDisplayName(platform)} 重新发送 /init"; + + private static IResult BuildIssuedBindingProbeError(IssuedBindingProbeResult probeResult, string? platform) => probeResult switch { IssuedBindingProbeResult.MissingRequiredAccess => OAuthCallbackProblem( StatusCodes.Status409Conflict, "required_service_access_missing", - "NyxID 授权没有覆盖 Aevatar 所需的 scope 或 services。请回到 Lark 重新发送 /init,并在授权页保留所有必需 services。"), + $"NyxID 授权没有覆盖 Aevatar 所需的 scope 或 services。{RetryInitInstruction(platform)},并在授权页保留所有必需 services。"), IssuedBindingProbeResult.Invalid => OAuthCallbackProblem( StatusCodes.Status503ServiceUnavailable, "issued_binding_invalid", - "NyxID 新授权在 Aevatar 接管前已失效。请回到 Lark 重新发送 /init。"), + $"NyxID 新授权在 Aevatar 接管前已失效。{RetryInitInstruction(platform)}。"), _ => OAuthCallbackProblem( StatusCodes.Status503ServiceUnavailable, "issued_binding_probe_failed", - "Aevatar 暂时无法验证新的 NyxID 服务授权。请稍后回到 Lark 重新发送 /init。"), + $"Aevatar 暂时无法验证新的 NyxID 服务授权。{RetryInitInstruction(platform, "请稍后")}。"), }; // Callback failure branches must never answer with 502/504: Cloudflare @@ -1315,13 +1331,14 @@ private static byte[] Base64UrlDecode(string value) /// /// Render the user-facing success page returned in the OAuth-callback /// response. Issue #513 phase 1 asked for a "callback success → please pick - /// a model" prompt. The full version is a card update pushed back into - /// Lark, which requires capturing the /init card's adapter-owned message - /// id and passing it through the OAuth state token — substantial new - /// design surface left as a follow-up. This page is the browser-side - /// substitute the user sees immediately after the OAuth redirect, and it - /// names the next-step commands (/model, /whoami) explicitly - /// so the user is not left guessing what to type back in Lark. + /// a model" prompt. The full version is a card update pushed back into the + /// originating chat channel, which requires capturing the /init card's + /// adapter-owned message id and passing it through the OAuth state token — + /// substantial new design surface left as a follow-up. This page is the + /// browser-side substitute the user sees immediately after the OAuth + /// redirect, and it names the next-step commands (/model, + /// /whoami) explicitly so the user is not left guessing what to type + /// back in chat. /// /// /// Display name comes from the id_token "name" / sub claim; HTML-encoded @@ -1329,8 +1346,8 @@ private static byte[] Base64UrlDecode(string value) /// Other error paths in the callback intentionally keep returning JSON for /// ops/programmatic consumers. /// - internal static IResult RenderBoundSuccessHtml(string? displayName, bool alreadyBound) => - RenderBoundSuccess(displayName, alreadyBound, format: null); + internal static IResult RenderBoundSuccessHtml(string? displayName, bool alreadyBound, string? platform = null) => + RenderBoundSuccess(displayName, alreadyBound, format: null, platform); /// /// Render the post-binding success response. Default is the HTML browser page that @@ -1339,7 +1356,11 @@ internal static IResult RenderBoundSuccessHtml(string? displayName, bool already /// ?format=json on the callback URL — the same shape the endpoint returned /// before the HTML render landed (PR #570 review #24). /// - internal static IResult RenderBoundSuccess(string? displayName, bool alreadyBound, string? format) + internal static IResult RenderBoundSuccess( + string? displayName, + bool alreadyBound, + string? format, + string? platform = null) { if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)) { @@ -1351,13 +1372,14 @@ internal static IResult RenderBoundSuccess(string? displayName, bool alreadyBoun }); } - return RenderBoundSuccessHtmlInternal(displayName, alreadyBound); + return RenderBoundSuccessHtmlInternal(displayName, alreadyBound, platform); } internal static IResult RenderBindingAccepted( string? displayName, ChannelIdentityOAuthAcceptedReceipt receipt, - string? format) + string? format, + string? platform = null) { // Refactor (iter27/cluster-028-identity-oauth-endpoint): // Old pattern: IdentityOAuthEndpoints + AevatarOAuthClientBootstrapService 直接构造 EventEnvelope 投递,然后在 endpoint 内同步等 projection readiness / rebuild observation / readmodel polling (3-15s timeout + 50-250ms polling),违反 ACK 协议 + query-time projection priming @@ -1372,14 +1394,14 @@ internal static IResult RenderBindingAccepted( correlation_id = receipt.CorrelationId, display_name = string.IsNullOrWhiteSpace(displayName) ? null : displayName, status_url = OAuthClientStatusUrl, - detail = "Binding command accepted for dispatch. Return to Lark and use /whoami to check once projection materializes.", + detail = $"Binding command accepted for dispatch. Return to {ChannelDisplayName(platform)} and use /whoami to check once projection materializes.", }, statusCode: StatusCodes.Status202Accepted); } - return RenderBindingAcceptedHtmlInternal(displayName, receipt); + return RenderBindingAcceptedHtmlInternal(displayName, receipt, platform); } - internal static IResult RenderBindingGrantUpdated(string? format) + internal static IResult RenderBindingGrantUpdated(string? format, string? platform = null) { if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)) { @@ -1390,7 +1412,8 @@ internal static IResult RenderBindingGrantUpdated(string? format) }); } - const string html = """ + var channelName = System.Net.WebUtility.HtmlEncode(ChannelDisplayName(platform)); + var html = """ @@ -1408,24 +1431,25 @@ internal static IResult RenderBindingGrantUpdated(string? format) 已更新

NyxID 服务授权已更新

-

原有 Lark 绑定保持不变。可以关闭此页并回到 Lark 继续对话。

+

原有 {channelName} 绑定保持不变。可以关闭此页并回到 {channelName} 继续对话。

发送 /init 可再次查看服务授权。
- """; + """.Replace("{channelName}", channelName, StringComparison.Ordinal); return Results.Content(html, "text/html; charset=utf-8"); } - internal static IResult RenderBoundSuccessHtmlInternal(string? displayName, bool alreadyBound) + internal static IResult RenderBoundSuccessHtmlInternal(string? displayName, bool alreadyBound, string? platform = null) { + var channelName = System.Net.WebUtility.HtmlEncode(ChannelDisplayName(platform)); var badge = alreadyBound ? "已绑定" : "绑定成功"; var heading = alreadyBound ? "NyxID 账号已绑定" : "已绑定 NyxID 账号"; var displayLine = string.IsNullOrWhiteSpace(displayName) ? string.Empty : $"

账号:{System.Net.WebUtility.HtmlEncode(displayName)}

"; var body = alreadyBound - ? "

当前账号已经完成绑定,无需重复操作。可以关闭此页,回到 Lark 继续对话。

" - : "

可以关闭此页,回到 Lark 继续对话。

"; + ? $"

当前账号已经完成绑定,无需重复操作。可以关闭此页,回到 {channelName} 继续对话。

" + : $"

可以关闭此页,回到 {channelName} 继续对话。

"; var html = $@" @@ -1448,7 +1472,7 @@ internal static IResult RenderBoundSuccessHtmlInternal(string? displayName, bool {body}
下一步
-回到 Lark 后,发送 /model 选择想用的模型,或 /whoami 查看当前绑定状态。 +回到 {channelName} 后,发送 /model 选择想用的模型,或 /whoami 查看当前绑定状态。
"; @@ -1457,11 +1481,13 @@ internal static IResult RenderBoundSuccessHtmlInternal(string? displayName, bool private static IResult RenderBindingAcceptedHtmlInternal( string? displayName, - ChannelIdentityOAuthAcceptedReceipt receipt) + ChannelIdentityOAuthAcceptedReceipt receipt, + string? platform) { // Refactor (iter27/cluster-028-identity-oauth-endpoint): // Old pattern: IdentityOAuthEndpoints + AevatarOAuthClientBootstrapService 直接构造 EventEnvelope 投递,然后在 endpoint 内同步等 projection readiness / rebuild observation / readmodel polling (3-15s timeout + 50-250ms polling),违反 ACK 协议 + query-time projection priming // New principle: 加 module-local CQRS dispatch adapters(ChannelIdentityOAuthCommandDispatch);endpoint inject typed ICommandDispatchService<...>,返回 accepted/pending + status URL,不再等 projection;删 IProjectionReadinessPort/ExternalIdentityBindingProjectionPort/AevatarOAuthClientProjectionPort/AevatarOAuthClientRebuildCoordinator/ProjectionWaitTimeout 等 + var channelName = System.Net.WebUtility.HtmlEncode(ChannelDisplayName(platform)); var displayLine = string.IsNullOrWhiteSpace(displayName) ? string.Empty : $"

账号:{System.Net.WebUtility.HtmlEncode(displayName)}

"; @@ -1484,10 +1510,10 @@ private static IResult RenderBindingAcceptedHtmlInternal( 已受理

NyxID 绑定请求已受理

{displayLine} -

可以关闭此页,回到 Lark 稍后继续对话。请求编号:{commandId}

+

可以关闭此页,回到 {channelName} 稍后继续对话。请求编号:{commandId}

下一步
-回到 Lark 后,发送 /whoami 查看绑定状态。状态可见后,发送 /model 选择想用的模型。 +回到 {channelName} 后,发送 /whoami 查看绑定状态。状态可见后,发送 /model 选择想用的模型。
"; diff --git a/agents/Aevatar.GAgents.Channel.Identity/Slash/InitChannelSlashCommandHandler.cs b/agents/Aevatar.GAgents.Channel.Identity/Slash/InitChannelSlashCommandHandler.cs index 27c1c5b8e8..67e9e84632 100644 --- a/agents/Aevatar.GAgents.Channel.Identity/Slash/InitChannelSlashCommandHandler.cs +++ b/agents/Aevatar.GAgents.Channel.Identity/Slash/InitChannelSlashCommandHandler.cs @@ -8,11 +8,11 @@ namespace Aevatar.GAgents.Channel.Identity.Slash; /// /// /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. /// public sealed class InitChannelSlashCommandHandler : IChannelSlashCommandHandler { @@ -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 }; /// - /// 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 /// being set as the fallback. /// 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 { @@ -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}"; + } } diff --git a/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/AgentTurnToolCatalogMaterializer.cs b/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/AgentTurnToolCatalogMaterializer.cs index 9f403b9a49..bd2d423ceb 100644 --- a/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/AgentTurnToolCatalogMaterializer.cs +++ b/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/AgentTurnToolCatalogMaterializer.cs @@ -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) && @@ -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) diff --git a/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs b/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs index 27038ca9f5..01d2032d0c 100644 --- a/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs @@ -559,7 +559,7 @@ private async Task SendBindingPromptAsync( inbound.Platform, inbound.SenderId, registration.Id); - reply = new MessageContent { Text = "无法识别当前 Lark 用户身份,请稍后重试。" }; + reply = new MessageContent { Text = $"无法识别当前 {inbound.Platform} 用户身份,请稍后重试。" }; } else { @@ -568,6 +568,7 @@ private async Task SendBindingPromptAsync( var challenge = await broker.StartExternalBindingAsync(subject, ct).ConfigureAwait(false); reply = InitChannelSlashCommandHandler.BuildBindingCard( challenge.AuthorizeUrl, + subject.Platform, challenge.RenewsExistingBinding); } catch (AevatarOAuthClientNotProvisionedException ex) diff --git a/src/Aevatar.AI.ToolProviders.AevatarInvocation/AevatarInvocationDispatcher.cs b/src/Aevatar.AI.ToolProviders.AevatarInvocation/AevatarInvocationDispatcher.cs index dde5c6f006..4094613b72 100644 --- a/src/Aevatar.AI.ToolProviders.AevatarInvocation/AevatarInvocationDispatcher.cs +++ b/src/Aevatar.AI.ToolProviders.AevatarInvocation/AevatarInvocationDispatcher.cs @@ -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 diff --git a/src/Aevatar.Mainnet.Host.Api/appsettings.json b/src/Aevatar.Mainnet.Host.Api/appsettings.json index b567f4a5d3..dd1a737ac4 100644 --- a/src/Aevatar.Mainnet.Host.Api/appsettings.json +++ b/src/Aevatar.Mainnet.Host.Api/appsettings.json @@ -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" + ], "Relay": { "EnableDebugDiagnostics": true }, @@ -40,7 +42,7 @@ }, "BackendConsole": { "OidcAuthority": "https://nyx-api.chrono-ai.fun", - "OidcClientId": "a6ff2946-f02f-4c35-8203-1ec46132b660", + "OidcClientId": "8c76ced6-8f5a-4564-bea9-e3d98807f8ba", "OidcScope": "openid profile email offline_access proxy", "NyxApiBaseUrl": "https://nyx-api.chrono-ai.fun", "StorageKey": "aevatar-console:nyxid:pkce", @@ -87,7 +89,7 @@ "Status": { "DefaultIntervalSeconds": 60, "DefaultTimeoutMs": 5000, - "SelfBaseUrl": "http://localhost:5080", + "SelfBaseUrl": "http://127.0.0.1:5107", "UseBuiltInTargets": true } }, @@ -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" } ] }, @@ -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", @@ -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" + ] } ] }, @@ -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" } } diff --git a/src/platform/Aevatar.GAgentService.Abstractions/AgentProfiles/AgentProfilePolicies.cs b/src/platform/Aevatar.GAgentService.Abstractions/AgentProfiles/AgentProfilePolicies.cs index 77bcb6d040..e5fe6bb628 100644 --- a/src/platform/Aevatar.GAgentService.Abstractions/AgentProfiles/AgentProfilePolicies.cs +++ b/src/platform/Aevatar.GAgentService.Abstractions/AgentProfiles/AgentProfilePolicies.cs @@ -158,12 +158,13 @@ private static IReadOnlyList 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))) { @@ -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 && diff --git a/src/platform/Aevatar.GAgentService.Application/Workflows/ScopeWorkflowTemplateEnsureService.cs b/src/platform/Aevatar.GAgentService.Application/Workflows/ScopeWorkflowTemplateEnsureService.cs index 52436f7984..1b2a814815 100644 --- a/src/platform/Aevatar.GAgentService.Application/Workflows/ScopeWorkflowTemplateEnsureService.cs +++ b/src/platform/Aevatar.GAgentService.Application/Workflows/ScopeWorkflowTemplateEnsureService.cs @@ -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; @@ -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 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)); } @@ -41,6 +46,14 @@ public async Task 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, @@ -53,7 +66,7 @@ public async Task EnsureAsync( ExposureDesired: template.ExposureDesired, RevisionId: revisionId) { - CapabilityAdmission = request.CapabilityAdmission, + CapabilityAdmission = capabilityAdmission, }, ct).ConfigureAwait(false); @@ -72,6 +85,52 @@ public async Task EnsureAsync( : "workflow_template_stale"); } + private async Task 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 WaitForRunnableRevisionAsync( string scopeId, string workflowId, diff --git a/test/Aevatar.AI.Tests/NyxIdConnectedServiceToolSourceTests.cs b/test/Aevatar.AI.Tests/NyxIdConnectedServiceToolSourceTests.cs index b886bd021b..a3b0e07ed0 100644 --- a/test/Aevatar.AI.Tests/NyxIdConnectedServiceToolSourceTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdConnectedServiceToolSourceTests.cs @@ -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, @@ -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"); } diff --git a/test/Aevatar.AI.ToolProviders.AevatarInvocation.Tests/AevatarInvocationToolSourceTests.cs b/test/Aevatar.AI.ToolProviders.AevatarInvocation.Tests/AevatarInvocationToolSourceTests.cs index 929cb32d40..6232297695 100644 --- a/test/Aevatar.AI.ToolProviders.AevatarInvocation.Tests/AevatarInvocationToolSourceTests.cs +++ b/test/Aevatar.AI.ToolProviders.AevatarInvocation.Tests/AevatarInvocationToolSourceTests.cs @@ -1902,6 +1902,7 @@ public async Task StartWorkflow_ShouldMergePreferenceContextIntoJsonPromptBefore """ { "location": "Shanghai", + "contact_name": "Louis", "cuisines": ["Italian", "Japanese"], "party_size": 2, "budget_cap": "RMB 800", @@ -1929,6 +1930,7 @@ public async Task StartWorkflow_ShouldMergePreferenceContextIntoJsonPromptBefore using var prompt = JsonDocument.Parse(harness.WorkflowDispatch.Command!.Prompt); var root = prompt.RootElement; root.GetProperty("location").GetString().Should().Be("Shanghai"); + root.GetProperty("contact_name").GetString().Should().Be("Louis"); root.GetProperty("cuisines").EnumerateArray() .Select(static item => item.GetString()) .Should().BeEquivalentTo("Sichuan"); diff --git a/test/Aevatar.GAgentService.Tests/Abstractions/AgentProfileContractsTests.cs b/test/Aevatar.GAgentService.Tests/Abstractions/AgentProfileContractsTests.cs index 1663a6a155..e51eb91ccb 100644 --- a/test/Aevatar.GAgentService.Tests/Abstractions/AgentProfileContractsTests.cs +++ b/test/Aevatar.GAgentService.Tests/Abstractions/AgentProfileContractsTests.cs @@ -118,6 +118,61 @@ public void ValidateDraft_ShouldAcceptDynamicReadOnlyConnectedServiceSelector() AgentProfilePolicies.ValidateDraft(draft).Should().BeEmpty(); } + [Fact] + public void ValidateDraft_ShouldAcceptEndpointOnlyReadConnectedServiceSelector() + { + var draft = new AgentProfileDraft + { + DisplayName = "Research assistant", + Instructions = "Use verified sources.", + RuntimeProfile = new AgentProfileSnapshot + { + AgentKind = AgentProfilePolicies.NyxIdChatAgentKind, + RouteToolSetRef = AgentProfilePolicies.NyxIdChatRouteToolSet, + MaximumToolPolicy = new AgentProfileToolPolicy(), + RecoveryToolPolicy = new AgentProfileToolPolicy(), + Members = { ValidMember("research", "2d05bf2e-88ee-4f76-9998-728ba2f9db10") }, + }, + }; + draft.RuntimeProfile.Members[0].TaskToolPolicy.ConnectedServiceSelectors.Add( + new AgentProfileConnectedServiceSelector + { + EndpointId = "readDiningProfileContext", + AllowedRisks = { AgentToolOperationRiskPayload.ReadOnly }, + }); + + AgentProfilePolicies.ValidateDraft(draft).Should().BeEmpty(); + } + + [Fact] + public void ValidateDraft_ShouldRejectEndpointOnlyWriteConnectedServiceSelector() + { + var draft = new AgentProfileDraft + { + DisplayName = "Research assistant", + Instructions = "Use verified sources.", + RuntimeProfile = new AgentProfileSnapshot + { + AgentKind = AgentProfilePolicies.NyxIdChatAgentKind, + RouteToolSetRef = AgentProfilePolicies.NyxIdChatRouteToolSet, + MaximumToolPolicy = new AgentProfileToolPolicy(), + RecoveryToolPolicy = new AgentProfileToolPolicy(), + Members = { ValidMember("research", "2d05bf2e-88ee-4f76-9998-728ba2f9db10") }, + }, + }; + draft.RuntimeProfile.Members[0].TaskToolPolicy.ConnectedServiceSelectors.Add( + new AgentProfileConnectedServiceSelector + { + EndpointId = "updateDiningProfileContext", + AllowedRisks = { AgentToolOperationRiskPayload.Write }, + }); + + AgentProfilePolicies.ValidateDraft(draft) + .Should().ContainSingle(diagnostic => + diagnostic.Code == "PROFILE_CONNECTED_SERVICE_SLUG_INVALID" && + diagnostic.Field == "runtimeProfile.members[0].taskToolPolicy.connectedServiceSelectors[0].catalogServiceSlug"); + } + [Fact] public void CreateProfileId_ShouldBeStableForTheSameOwnerAndIdempotencyKey() { diff --git a/test/Aevatar.GAgentService.Tests/Application/ScopeWorkflowTemplateEnsureServiceTests.cs b/test/Aevatar.GAgentService.Tests/Application/ScopeWorkflowTemplateEnsureServiceTests.cs index 486aafaad3..e28b9dc875 100644 --- a/test/Aevatar.GAgentService.Tests/Application/ScopeWorkflowTemplateEnsureServiceTests.cs +++ b/test/Aevatar.GAgentService.Tests/Application/ScopeWorkflowTemplateEnsureServiceTests.cs @@ -2,6 +2,7 @@ using Aevatar.GAgentService.Abstractions.Ports; using Aevatar.GAgentService.Application.Workflows; using Aevatar.Workflow.Abstractions; +using Aevatar.Workflow.Application.Abstractions.ExternalCapabilities; using FluentAssertions; using Microsoft.Extensions.Options; @@ -99,6 +100,62 @@ await service.EnsureAsync(new ScopeWorkflowTemplateEnsureRequest("scope-1", "wf- saveAndBindPort.Requests.Should().ContainSingle().Which.CapabilityAdmission.Should().BeSameAs(admission); } + [Fact] + public async Task EnsureAsync_ShouldPreviewExplicitRequestsBeforeSaveAndBind() + { + var queryPort = new RecordingScopeWorkflowQueryPort( + new ScopeWorkflowLookupResult( + ScopeWorkflowLookupStatus.NotFound, + null, + "service_catalog_missing"), + new ScopeWorkflowLookupResult( + ScopeWorkflowLookupStatus.Runnable, + BuildWorkflow("scope-1", "wf-default", "rev-expected"), + "runnable")); + var saveAndBindPort = new RecordingScopeWorkflowSaveAndBindPort(); + var previewService = new RecordingWorkflowExplicitRequestPreviewService( + new WorkflowExplicitRequestPreviewItem( + "wf_default/discover", + "sha256:request-alpha", + "service-alpha", + NyxIdRequestMethod.Post, + "/v1/search", + NyxIdRequestBodyMode.Json, + true, + NyxIdRequestResponseMode.Text, + NyxIdOperationRisk.ReadOnly, + false, + WorkflowExplicitRequestApprovalEnforcement.None, + [ExternalCapabilityExecutionMode.Interactive])); + var service = CreateService(queryPort, saveAndBindPort, previewService, BuildTemplate()); + var admission = new WorkflowCapabilityAdmissionContext( + "caller-1", + NyxIdCallerCredentialSelection.SourceReadableUserBearer("source-token")); + + await service.EnsureAsync(new ScopeWorkflowTemplateEnsureRequest("scope-1", "wf-default") + { + CapabilityAdmission = admission, + }); + + var previewRequest = previewService.Requests.Should().ContainSingle().Subject; + previewRequest.Access.ScopeId.Should().Be("scope-1"); + previewRequest.Access.CallerId.Should().Be("caller-1"); + previewRequest.WorkflowId.Should().Be("wf-default"); + previewRequest.RevisionId.Should().Be("rev-expected"); + previewRequest.ExecutionMode.Should().Be(ExternalCapabilityExecutionMode.Interactive); + var forwardedAdmission = saveAndBindPort.Requests.Should().ContainSingle().Subject.CapabilityAdmission; + forwardedAdmission.Should().NotBeNull(); + forwardedAdmission!.ExplicitRequestConfirmations.Should().ContainSingle().Which.Should().BeEquivalentTo( + new NyxIdExplicitRequestConfirmation + { + CallSiteId = "wf_default/discover", + RequestContractDigest = "sha256:request-alpha", + AttestedRisk = NyxIdOperationRisk.ReadOnly, + WorkflowId = "wf-default", + RevisionId = "rev-expected", + }); + } + [Fact] public async Task EnsureAsync_WhenSaveAndBindReadModelIsNotObserved_ShouldFail() { @@ -179,12 +236,27 @@ private static ScopeWorkflowTemplateEnsureService CreateService( RecordingScopeWorkflowQueryPort queryPort, RecordingScopeWorkflowSaveAndBindPort saveAndBindPort, params ScopeWorkflowConfiguredTemplateOptions[] templates) => - CreateService(queryPort, saveAndBindPort, null, templates); + CreateService(queryPort, saveAndBindPort, new RecordingWorkflowExplicitRequestPreviewService(), null, templates); private static ScopeWorkflowTemplateEnsureService CreateService( RecordingScopeWorkflowQueryPort queryPort, RecordingScopeWorkflowSaveAndBindPort saveAndBindPort, Action? configure, + params ScopeWorkflowConfiguredTemplateOptions[] templates) => + CreateService(queryPort, saveAndBindPort, new RecordingWorkflowExplicitRequestPreviewService(), configure, templates); + + private static ScopeWorkflowTemplateEnsureService CreateService( + RecordingScopeWorkflowQueryPort queryPort, + RecordingScopeWorkflowSaveAndBindPort saveAndBindPort, + RecordingWorkflowExplicitRequestPreviewService previewService, + params ScopeWorkflowConfiguredTemplateOptions[] templates) => + CreateService(queryPort, saveAndBindPort, previewService, null, templates); + + private static ScopeWorkflowTemplateEnsureService CreateService( + RecordingScopeWorkflowQueryPort queryPort, + RecordingScopeWorkflowSaveAndBindPort saveAndBindPort, + RecordingWorkflowExplicitRequestPreviewService previewService, + Action? configure, params ScopeWorkflowConfiguredTemplateOptions[] templates) { var options = new ScopeWorkflowCapabilityOptions @@ -195,6 +267,7 @@ private static ScopeWorkflowTemplateEnsureService CreateService( return new ScopeWorkflowTemplateEnsureService( queryPort, saveAndBindPort, + previewService, Options.Create(options)); } @@ -283,6 +356,23 @@ public Task LookupByWorkflowIdAsync( Task.FromResult(null); } + private sealed class RecordingWorkflowExplicitRequestPreviewService( + params WorkflowExplicitRequestPreviewItem[] items) : IWorkflowExplicitRequestPreviewService + { + public List Requests { get; } = []; + + public Task PreviewAsync( + WorkflowExplicitRequestPreviewRequest request, + CancellationToken cancellationToken = default) + { + Requests.Add(request); + return Task.FromResult(new WorkflowExplicitRequestPreviewResult( + request.WorkflowId ?? string.Empty, + request.RevisionId ?? string.Empty, + items)); + } + } + private sealed class RecordingScopeWorkflowSaveAndBindPort : IScopeWorkflowSaveAndBindPort { public List Requests { get; } = []; diff --git a/test/Aevatar.GAgentService.Tests/Hosting/NyxIdChatSystemAgentProfileDraftFactoryTests.cs b/test/Aevatar.GAgentService.Tests/Hosting/NyxIdChatSystemAgentProfileDraftFactoryTests.cs index edf943675f..48e8dbea23 100644 --- a/test/Aevatar.GAgentService.Tests/Hosting/NyxIdChatSystemAgentProfileDraftFactoryTests.cs +++ b/test/Aevatar.GAgentService.Tests/Hosting/NyxIdChatSystemAgentProfileDraftFactoryTests.cs @@ -27,6 +27,14 @@ public void Create_WhenMembersAreEmpty_ShouldBuildDraftWithConfiguredWorkflowToo "aevatar_observe_run", }, ToolSetRefs = { AgentProfilePolicies.NyxIdChatRouteToolSet }, + ConnectedServiceSelectors = + { + new AgentProfileConnectedServiceSelectorOptions + { + EndpointId = "readDiningProfileContext", + AllowedRisks = { "read_only" }, + }, + }, }, }; @@ -34,6 +42,8 @@ public void Create_WhenMembersAreEmpty_ShouldBuildDraftWithConfiguredWorkflowToo draft.RuntimeProfile.MaximumToolPolicy.ToolNames.Should().Contain("aevatar_start_workflow"); draft.RuntimeProfile.MaximumToolPolicy.ToolSetRefs.Should().Contain(AgentProfilePolicies.NyxIdChatRouteToolSet); + draft.RuntimeProfile.MaximumToolPolicy.ConnectedServiceSelectors.Should().ContainSingle().Which + .EndpointId.Should().Be("readDiningProfileContext"); draft.RuntimeProfile.MaxPlanSteps.Should().Be(AgentProfileValidationLimits.RequiredMaxPlanSteps); draft.RuntimeProfile.MaxOwnedToolCount.Should().Be(AgentProfileValidationLimits.MaximumOwnedToolCount); draft.RuntimeProfile.MaxSchemaBytes.Should().Be(AgentProfileValidationLimits.MaximumSchemaBytes); diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/IdentityOAuthCallbackEndpointTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/IdentityOAuthCallbackEndpointTests.cs index 7b1f486c1b..80370ac717 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/IdentityOAuthCallbackEndpointTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/IdentityOAuthCallbackEndpointTests.cs @@ -342,6 +342,28 @@ public async Task AcceptedPath_RendersHtml_ContentTypeIsTextHtml() text.Should().Contain("/whoami"); } + [Fact] + public async Task AcceptedPath_TelegramHtml_DoesNotMentionLark() + { + var subject = SampleSubject("telegram"); + var broker = NewBroker(subject, "bnd_incoming"); + var queryPort = Substitute.For(); + queryPort.ResolveAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + + var result = await InvokeCallbackAsync( + broker, + queryPort, + new RecordingCommandDispatch(), + new RecordingCommandDispatch()); + var (text, contentType) = await ReadTextWithContentTypeAsync(result); + + contentType.Should().StartWith("text/html"); + text.Should().Contain("Telegram"); + text.Should().NotContain("Lark"); + text.Should().Contain("/whoami"); + } + [Fact] public async Task MissingRequiredService_ReturnsConflictWithoutBindingDispatch() { @@ -761,9 +783,9 @@ private static async Task InvokeCallbackAsync( ct: CancellationToken.None); } - private static ExternalSubjectRef SampleSubject() => new() + private static ExternalSubjectRef SampleSubject(string platform = "lark") => new() { - Platform = "lark", + Platform = platform, Tenant = "ou_tenant_x", ExternalUserId = "ou_user_y", }; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/SlashCommandHandlerTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/SlashCommandHandlerTests.cs index 430e7e4a2e..5b62dc6274 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/SlashCommandHandlerTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/SlashCommandHandlerTests.cs @@ -10,27 +10,28 @@ namespace Aevatar.GAgents.ChannelRuntime.Tests.Identity; /// /// Pins the user-visible behaviour of the per-binding slash command handlers -/// added in issue #513 (Phase 1 /init Lark card, Phase 4 /whoami, Phase 6 +/// added in issue #513 (Phase 1 /init binding card, Phase 4 /whoami, Phase 6 /// RequiresBinding metadata). The handlers run inside the channel turn runner /// and produce ; tests assert on that content /// directly rather than reaching through the runner. /// public sealed class SlashCommandHandlerTests { - private static ExternalSubjectRef Subject() => new() + private static ExternalSubjectRef Subject(string platform = "lark") => new() { - Platform = "lark", + Platform = platform, Tenant = "ou_tenant_x", ExternalUserId = "ou_user_y", }; private static ChannelSlashCommandContext Context( string? bindingValue = null, - bool privateChat = true) => new() + bool privateChat = true, + string platform = "lark") => new() { CommandName = "init", ArgumentText = string.Empty, - Subject = Subject(), + Subject = Subject(platform), BindingIdValue = bindingValue, RegistrationId = "reg-1", RegistrationScopeId = "scope-1", @@ -57,6 +58,25 @@ public async Task Init_ReturnsBindingCard_ForUnboundSenderInPrivateChat() reply.Text.Should().Contain("test-nyxid.local/oauth/authorize"); } + [Fact] + public async Task Init_TelegramReply_DoesNotDuplicateAuthorizeUrlOrMentionLark() + { + var broker = new InMemoryCapabilityBroker(); + var handler = new InitChannelSlashCommandHandler(broker, NullLogger.Instance); + + var reply = await handler.HandleAsync(Context(platform: "telegram"), default); + + reply.Should().NotBeNull(); + reply!.Text.Should().NotContain("test-nyxid.local/oauth/authorize"); + reply.Text.Should().Contain("Telegram"); + reply.Text.Should().NotContain("Lark"); + reply.Cards.Should().ContainSingle().Which.Text.Should().NotContain("Lark"); + reply.Actions.Should().ContainSingle(action => + action.Kind == ActionElementKind.Link && + action.IsPrimary && + action.Value.Contains("test-nyxid.local/oauth/authorize")); + } + [Fact] public async Task Init_RefusesGroupChat() { diff --git a/test/Aevatar.Workflow.Core.Tests/Primitives/DinnerSearchThenCallWorkflowFixtureTests.cs b/test/Aevatar.Workflow.Core.Tests/Primitives/DinnerSearchThenCallWorkflowFixtureTests.cs index 1c2a2b404b..789301b162 100644 --- a/test/Aevatar.Workflow.Core.Tests/Primitives/DinnerSearchThenCallWorkflowFixtureTests.cs +++ b/test/Aevatar.Workflow.Core.Tests/Primitives/DinnerSearchThenCallWorkflowFixtureTests.cs @@ -28,7 +28,7 @@ public void Parse_ShouldUseChatAssembledContextInput() .And.Contain("missing_fields"); var discovery = workflow.Steps.Should().Contain(step => step.Id == "discover_restaurant_candidates").Subject; discovery.Parameters["tool"].Should().Be("nyxid_proxy"); - discovery.Parameters["arguments"].Should().Contain("\"${json(steps.capture_user_choice.json.search_query)}\""); + discovery.Parameters["arguments"].Should().Contain("\"${json(steps.initialize_context.json.search_query)}\""); var holdSelectedOption = workflow.Steps.Should() .Contain(step => step.Id == "hold_selected_option_1") .Subject; @@ -60,6 +60,10 @@ public void Parse_ShouldHoldAllVenuesAfterUserChoiceTimeout() workflow.Steps.Should().Contain(step => step.Id == "hold_candidate_option_1"); workflow.Steps.Should().Contain(step => step.Id == "hold_candidate_option_2"); workflow.Steps.Should().Contain(step => step.Id == "hold_candidate_option_3"); + var optionsShown = workflow.Steps.Should().Contain(step => step.Id == "emit_options_shown").Subject; + optionsShown.Parameters["payload"].Should().Contain("phone_flow") + .And.Contain("timeout_policy") + .And.Contain("hold_all_three_then_wait_for_post_timeout_choice"); workflow.Steps.Should().Contain(step => step.Id == "publish_holds_wait_state"); workflow.Steps.Should().Contain(step => step.Id == "wait_for_post_timeout_choice"); workflow.Steps.Should().Contain(step => step.Id == "release_unselected_after_confirm_option_1"); @@ -92,6 +96,7 @@ public void RenderedParameters_ShouldRemainValidJsonForStructuredInput() ["steps.capture_user_choice.json.cuisines"] = "[\"Japanese\",\"Italian\"]", ["steps.capture_user_choice.json.restaurant_type"] = "romantic dinner", ["steps.capture_user_choice.json.phone_number"] = "+6590000000", + ["steps.capture_user_choice.json.restaurant_phone_number"] = "+6511111111", ["steps.capture_user_choice.json.budget_cap"] = "120", ["steps.capture_user_choice.json.policy"] = "{\"show_options_before_calls\":true,\"money_spend_allowed\":false,\"reservation_calls_auto_allowed\":true}", ["steps.capture_user_choice.json.missing_fields"] = "[]", @@ -101,13 +106,17 @@ public void RenderedParameters_ShouldRemainValidJsonForStructuredInput() var initialize = workflow.Steps.Single(step => step.Id == "initialize_context"); var contextJson = evaluator.Evaluate(initialize.Parameters["value"], variables); + using var contextDocument = JsonDocument.Parse(contextJson); + variables["steps.initialize_context.json.search_query"] = contextDocument.RootElement + .GetProperty("search_query") + .GetString()!; var discovery = workflow.Steps.Single(step => step.Id == "discover_restaurant_candidates"); var argumentsJson = evaluator.Evaluate(discovery.Parameters["arguments"], variables); - using var contextDocument = JsonDocument.Parse(contextJson); contextDocument.RootElement.GetProperty("location").GetString().Should().Be("Keong Saik Duxton Singapore"); contextDocument.RootElement.GetProperty("cuisines").EnumerateArray().Should().HaveCount(2); contextDocument.RootElement.GetProperty("policy").GetProperty("show_options_before_calls").GetBoolean().Should().BeTrue(); + contextDocument.RootElement.GetProperty("restaurant_phone_number").GetString().Should().Be("+6511111111"); using var argumentsDocument = JsonDocument.Parse(argumentsJson); var body = argumentsDocument.RootElement.GetProperty("body"); body.GetProperty("query").GetString().Should() @@ -116,7 +125,7 @@ public void RenderedParameters_ShouldRemainValidJsonForStructuredInput() } [Fact] - public void RenderedCallParameters_ShouldUseConfiguredRestaurantPhoneNumber() + public void RenderedParameters_ShouldBuildSearchQueryWhenChatOmitsExplicitQuery() { var workflow = new WorkflowParser().Parse(File.ReadAllText(Path.Combine( GetRepositoryRoot(), @@ -124,14 +133,100 @@ public void RenderedCallParameters_ShouldUseConfiguredRestaurantPhoneNumber() "dinner_search_then_call.yaml"))); var variables = new Dictionary(StringComparer.Ordinal) { - ["steps.capture_user_choice.json.restaurant_phone_number"] = "+6511111111", + ["steps.capture_user_choice.output"] = "Plan a dinner date with Priya this week.", ["steps.capture_user_choice.json.participant"] = "Priya", + ["steps.capture_user_choice.json.contact_name"] = "Louis", + ["steps.capture_user_choice.json.date_window"] = "this week", ["steps.capture_user_choice.json.party_size"] = "2", - ["steps.capture_user_choice.json.day"] = "Tuesday", - ["steps.capture_user_choice.json.time"] = "19:30", - ["steps.capture_user_choice.json.backup_times"] = "19:45", + ["steps.capture_user_choice.json.location"] = "Keong Saik Duxton Singapore", + ["steps.capture_user_choice.json.preferred_cuisines"] = "[\"Japanese\",\"Italian\"]", + ["steps.capture_user_choice.json.restaurant_type"] = "romantic dinner", ["steps.capture_user_choice.json.phone_number"] = "+6590000000", - ["steps.capture_user_choice.json.special_requests"] = "quiet table", + ["steps.capture_user_choice.json.restaurant_phone_number"] = "+6511111111", + ["steps.capture_user_choice.json.budget_cap"] = "120", + ["steps.capture_user_choice.json.policy"] = "{\"show_options_before_calls\":true,\"money_spend_allowed\":false,\"reservation_calls_auto_allowed\":true}", + }; + var evaluator = new WorkflowExpressionEvaluator(); + + var initialize = workflow.Steps.Single(step => step.Id == "initialize_context"); + var contextJson = evaluator.Evaluate(initialize.Parameters["value"], variables); + using var contextDocument = JsonDocument.Parse(contextJson); + variables["steps.initialize_context.json.search_query"] = contextDocument.RootElement + .GetProperty("search_query") + .GetString()!; + var discovery = workflow.Steps.Single(step => step.Id == "discover_restaurant_candidates"); + var argumentsJson = evaluator.Evaluate(discovery.Parameters["arguments"], variables); + + contextDocument.RootElement.GetProperty("raw_user_request").GetString() + .Should().Be("Plan a dinner date with Priya this week."); + contextDocument.RootElement.GetProperty("participant").GetString().Should().Be("Priya"); + contextDocument.RootElement.GetProperty("contact_name").GetString().Should().Be("Louis"); + contextDocument.RootElement.GetProperty("window").GetString().Should().Be("this week"); + contextDocument.RootElement.GetProperty("cuisines").EnumerateArray() + .Select(element => element.GetString()) + .Should().Equal("Japanese", "Italian"); + contextDocument.RootElement.GetProperty("search_query").GetString() + .Should().Be("Keong Saik Duxton Singapore restaurant reservation"); + using var argumentsDocument = JsonDocument.Parse(argumentsJson); + argumentsDocument.RootElement.GetProperty("body").GetProperty("query").GetString() + .Should().Be("Keong Saik Duxton Singapore restaurant reservation"); + } + + [Fact] + public void RenderedOptionsShownEvent_ShouldDescribePhoneAndTimeoutFlow() + { + var workflow = new WorkflowParser().Parse(File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "workflow-templates", + "dinner_search_then_call.yaml"))); + var variables = new Dictionary(StringComparer.Ordinal) + { + ["steps.build_shortlist_from_candidates.json.option_1_name"] = "Option One", + ["steps.build_shortlist_from_candidates.json.option_1_url"] = "https://example.test/one", + ["steps.build_shortlist_from_candidates.json.option_1_note"] = "first note", + ["steps.build_shortlist_from_candidates.json.option_2_name"] = "Option Two", + ["steps.build_shortlist_from_candidates.json.option_2_url"] = "https://example.test/two", + ["steps.build_shortlist_from_candidates.json.option_2_note"] = "second note", + ["steps.build_shortlist_from_candidates.json.option_3_name"] = "Option Three", + ["steps.build_shortlist_from_candidates.json.option_3_url"] = "https://example.test/three", + ["steps.build_shortlist_from_candidates.json.option_3_note"] = "third note", + }; + var evaluator = new WorkflowExpressionEvaluator(); + + var optionsShown = workflow.Steps.Single(step => step.Id == "emit_options_shown"); + var payloadJson = evaluator.Evaluate(optionsShown.Parameters["payload"], variables); + + using var payloadDocument = JsonDocument.Parse(payloadJson); + payloadDocument.RootElement.GetProperty("workflow_status").GetString() + .Should().Be("waiting_for_user_choice"); + payloadDocument.RootElement.GetProperty("phone_flow").GetString().Should() + .Contain("Timeout starts hold calls for all three shown venues") + .And.Contain("never searched phone numbers"); + payloadDocument.RootElement.GetProperty("timeout_policy").GetProperty("timeout_ms") + .GetInt32().Should().Be(10000); + payloadDocument.RootElement.GetProperty("options").GetProperty("option_1") + .GetProperty("name").GetString().Should().Be("Option One"); + } + + [Fact] + public void RenderedCallParameters_ShouldUseConfiguredReservationDetails() + { + var workflow = new WorkflowParser().Parse(File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "workflow-templates", + "dinner_search_then_call.yaml"))); + var variables = new Dictionary(StringComparer.Ordinal) + { + ["steps.initialize_context.json.restaurant_phone_number"] = "+6511111111", + ["steps.initialize_context.json.participant"] = "Priya", + ["steps.initialize_context.json.contact_name"] = "Louis", + ["steps.initialize_context.json.party_size"] = "2", + ["steps.initialize_context.json.day"] = "Tuesday", + ["steps.initialize_context.json.window"] = "this week", + ["steps.initialize_context.json.time"] = "19:30", + ["steps.initialize_context.json.backup_times"] = "19:45", + ["steps.initialize_context.json.phone_number"] = "+6590000000", + ["steps.initialize_context.json.special_requests"] = "quiet table", ["steps.build_shortlist_from_candidates.json.option_1_name"] = "Option One", ["steps.build_shortlist_from_candidates.json.option_1_url"] = "https://example.test/one", }; @@ -143,6 +238,80 @@ public void RenderedCallParameters_ShouldUseConfiguredRestaurantPhoneNumber() using var argumentsDocument = JsonDocument.Parse(argumentsJson); var body = argumentsDocument.RootElement.GetProperty("body"); body.GetProperty("to_number").GetString().Should().Be("+6511111111"); + var variablesJson = body.GetProperty("conversation_initiation_client_data") + .GetProperty("dynamic_variables"); + variablesJson.GetProperty("restaurant_name").GetString().Should().Be("Option One"); + variablesJson.GetProperty("client_name").GetString().Should().Be("Louis"); + variablesJson.GetProperty("reservation_contact_name").GetString().Should().Be("Louis"); + variablesJson.GetProperty("party_size").GetString().Should().Be("2"); + variablesJson.GetProperty("booking_date").GetString().Should().Be("Tuesday"); + variablesJson.GetProperty("preferred_time").GetString().Should().Be("19:30"); + variablesJson.GetProperty("callback_phone").GetString().Should().Be("+6590000000"); + variablesJson.GetProperty("reservation_details").GetString().Should() + .Contain("Louis") + .And.NotContain("Priya") + .And.Contain("Tuesday") + .And.Contain("19:30") + .And.Contain("party size 2") + .And.Contain("quiet table"); + } + + [Fact] + public void RouteConfiguredContactName_ShouldFailBeforeCallsWhenConfiguredContactNameIsMissing() + { + var workflow = new WorkflowParser().Parse(File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "workflow-templates", + "dinner_search_then_call.yaml"))); + var variables = new Dictionary(StringComparer.Ordinal) + { + ["steps.build_shortlist_from_candidates.json.search_step_success"] = "true", + ["steps.initialize_context.json.restaurant_phone_number"] = "+6511111111", + ["steps.initialize_context.json.contact_name"] = string.Empty, + }; + var evaluator = new WorkflowExpressionEvaluator(); + + var routeShortlist = workflow.Steps.Single(step => step.Id == "route_shortlist_status"); + var shortlistBranch = evaluator.Evaluate(routeShortlist.Parameters["branch.true"], variables); + var phoneRoute = workflow.Steps.Single(step => step.Id == shortlistBranch); + var contactBranch = evaluator.Evaluate(phoneRoute.Parameters["branch.false"], variables); + var contactRoute = workflow.Steps.Single(step => step.Id == contactBranch); + var contactNameMissing = evaluator.Evaluate(contactRoute.Parameters["on"], variables); + + shortlistBranch.Should().Be("route_configured_restaurant_phone"); + contactBranch.Should().Be("route_configured_contact_name"); + contactNameMissing.Should().Be("true"); + contactRoute.Branches.Should().ContainKey("true").WhoseValue.Should() + .Be("final_artifact_contact_name_missing"); + workflow.Steps.Single(step => step.Id == "final_artifact_contact_name_missing") + .Parameters["value"].Should().Contain("prompt_participant_not_used_as_contact_name"); + } + + [Fact] + public void RouteConfiguredRestaurantPhone_ShouldFailBeforeCallsWhenConfiguredPhoneIsMissing() + { + var workflow = new WorkflowParser().Parse(File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "workflow-templates", + "dinner_search_then_call.yaml"))); + var variables = new Dictionary(StringComparer.Ordinal) + { + ["steps.build_shortlist_from_candidates.json.search_step_success"] = "true", + ["steps.initialize_context.json.restaurant_phone_number"] = string.Empty, + }; + var evaluator = new WorkflowExpressionEvaluator(); + + var routeShortlist = workflow.Steps.Single(step => step.Id == "route_shortlist_status"); + var shortlistBranch = evaluator.Evaluate(routeShortlist.Parameters["branch.true"], variables); + var phoneRoute = workflow.Steps.Single(step => step.Id == shortlistBranch); + var phoneMissing = evaluator.Evaluate(phoneRoute.Parameters["on"], variables); + + shortlistBranch.Should().Be("route_configured_restaurant_phone"); + phoneMissing.Should().Be("true"); + phoneRoute.Branches.Should().ContainKey("true").WhoseValue.Should() + .Be("final_artifact_restaurant_phone_missing"); + workflow.Steps.Single(step => step.Id == "final_artifact_restaurant_phone_missing") + .Parameters["value"].Should().Contain("searched_phone_numbers_not_used"); } private static string GetRepositoryRoot() diff --git a/workflow-templates/dinner_search_then_call.yaml b/workflow-templates/dinner_search_then_call.yaml index 1088754ceb..26031018b2 100644 --- a/workflow-templates/dinner_search_then_call.yaml +++ b/workflow-templates/dinner_search_then_call.yaml @@ -42,14 +42,14 @@ steps: type: assign parameters: target: dinner_date_context - value: '{"task_id":"${json(steps.capture_user_choice.json.task_id)}","raw_user_request":"${json(steps.capture_user_choice.json.raw_user_request)}","participant":"${json(steps.capture_user_choice.json.participant)}","window":"${json(steps.capture_user_choice.json.window)}","party_size":${if(isBlank(steps.capture_user_choice.json.party_size), "null", steps.capture_user_choice.json.party_size)},"day":"${json(steps.capture_user_choice.json.day)}","time":"${json(steps.capture_user_choice.json.time)}","location":"${json(steps.capture_user_choice.json.location)}","cuisines":${if(isBlank(steps.capture_user_choice.json.cuisines), "null", steps.capture_user_choice.json.cuisines)},"restaurant_type":"${json(steps.capture_user_choice.json.restaurant_type)}","phone_number":"${json(steps.capture_user_choice.json.phone_number)}","budget_cap":${if(isBlank(steps.capture_user_choice.json.budget_cap), "null", steps.capture_user_choice.json.budget_cap)},"policy":${if(isBlank(steps.capture_user_choice.json.policy), "null", steps.capture_user_choice.json.policy)},"mode":"firecrawl-search-elevenlabs-call-selected","missing_fields":${if(isBlank(steps.capture_user_choice.json.missing_fields), "[]", steps.capture_user_choice.json.missing_fields)}}' + value: '{"task_id":"${json(steps.capture_user_choice.json.task_id)}","raw_user_request":"${json(if(isBlank(steps.capture_user_choice.json.raw_user_request), if(isBlank(steps.capture_user_choice.json.request), steps.capture_user_choice.output, steps.capture_user_choice.json.request), steps.capture_user_choice.json.raw_user_request))}","participant":"${json(if(isBlank(steps.capture_user_choice.json.participant), steps.capture_user_choice.json.companion_name, steps.capture_user_choice.json.participant))}","contact_name":"${json(if(isBlank(steps.capture_user_choice.json.contact_name), if(isBlank(steps.capture_user_choice.json.reservation_contact_name), if(isBlank(steps.capture_user_choice.json.booking_name), if(isBlank(steps.capture_user_choice.json.client_name), if(isBlank(steps.capture_user_choice.json.user_name), if(isBlank(steps.capture_user_choice.json.configured_name), "", steps.capture_user_choice.json.configured_name), steps.capture_user_choice.json.user_name), steps.capture_user_choice.json.client_name), steps.capture_user_choice.json.booking_name), steps.capture_user_choice.json.reservation_contact_name), steps.capture_user_choice.json.contact_name))}","window":"${json(if(isBlank(steps.capture_user_choice.json.window), steps.capture_user_choice.json.date_window, steps.capture_user_choice.json.window))}","party_size":${if(isBlank(steps.capture_user_choice.json.party_size), "null", steps.capture_user_choice.json.party_size)},"day":"${json(if(isBlank(steps.capture_user_choice.json.day), steps.capture_user_choice.json.date, steps.capture_user_choice.json.day))}","time":"${json(steps.capture_user_choice.json.time)}","location":"${json(if(isBlank(steps.capture_user_choice.json.location), steps.capture_user_choice.json.location_context, steps.capture_user_choice.json.location))}","cuisines":${if(isBlank(steps.capture_user_choice.json.cuisines), if(isBlank(steps.capture_user_choice.json.preferred_cuisines), "null", steps.capture_user_choice.json.preferred_cuisines), steps.capture_user_choice.json.cuisines)},"restaurant_type":"${json(steps.capture_user_choice.json.restaurant_type)}","phone_number":"${json(if(isBlank(steps.capture_user_choice.json.phone_number), steps.capture_user_choice.json.contact_phone_number, steps.capture_user_choice.json.phone_number))}","backup_times":"${json(steps.capture_user_choice.json.backup_times)}","special_requests":"${json(steps.capture_user_choice.json.special_requests)}","restaurant_phone_number":"${json(steps.capture_user_choice.json.restaurant_phone_number)}","budget_cap":${if(isBlank(steps.capture_user_choice.json.budget_cap), "null", steps.capture_user_choice.json.budget_cap)},"policy":${if(isBlank(steps.capture_user_choice.json.policy), "null", steps.capture_user_choice.json.policy)},"search_query":"${json(if(isBlank(steps.capture_user_choice.json.search_query), if(isBlank(steps.capture_user_choice.json.location), if(isBlank(steps.capture_user_choice.json.location_context), if(isBlank(steps.capture_user_choice.json.request), steps.capture_user_choice.output, steps.capture_user_choice.json.request), steps.capture_user_choice.json.location_context), steps.capture_user_choice.json.location), steps.capture_user_choice.json.search_query))}${if(isBlank(steps.capture_user_choice.json.search_query), " restaurant reservation", "")}","mode":"firecrawl-search-elevenlabs-call-selected","missing_fields":${if(isBlank(steps.capture_user_choice.json.missing_fields), "[]", steps.capture_user_choice.json.missing_fields)}}' next: discover_restaurant_candidates - id: discover_restaurant_candidates type: tool_call capability: nyxid_request: - user_service_id: "26f76390-2bf2-41f5-aac8-e20ea67233df" + user_service_id: "33dc1c9b-e1f3-4be8-a35e-fef09b999021" method: POST path_template: "/v1/search" query_parameters: [] @@ -60,7 +60,7 @@ steps: risk: read_only parameters: tool: nyxid_proxy - arguments: '{"body":{"query":"${json(steps.capture_user_choice.json.search_query)}","limit":8}}' + arguments: '{"body":{"query":"${json(steps.initialize_context.json.search_query)}","limit":8}}' next: build_shortlist_from_candidates - id: build_shortlist_from_candidates @@ -75,14 +75,52 @@ steps: type: switch parameters: on: "${steps.build_shortlist_from_candidates.json.search_step_success}" - branch.true: mark_options_shown + branch.true: route_configured_restaurant_phone branch.false: final_artifact_search_failed branch._default: final_artifact_search_failed branches: - true: mark_options_shown + true: route_configured_restaurant_phone false: final_artifact_search_failed _default: final_artifact_search_failed + - id: route_configured_restaurant_phone + type: switch + parameters: + on: "${isBlank(steps.initialize_context.json.restaurant_phone_number)}" + branch.true: final_artifact_restaurant_phone_missing + branch.false: route_configured_contact_name + branch._default: final_artifact_restaurant_phone_missing + branches: + true: final_artifact_restaurant_phone_missing + false: route_configured_contact_name + _default: final_artifact_restaurant_phone_missing + + - id: route_configured_contact_name + type: switch + parameters: + on: "${isBlank(steps.initialize_context.json.contact_name)}" + branch.true: final_artifact_contact_name_missing + branch.false: mark_options_shown + branch._default: final_artifact_contact_name_missing + branches: + true: final_artifact_contact_name_missing + false: mark_options_shown + _default: final_artifact_contact_name_missing + + - id: final_artifact_contact_name_missing + type: assign + parameters: + target: dinner_date_artifact + value: '{"workflow_status":"failed","path":"reservation_contact_name_missing","reason":"Configured reservation contact name is required before starting any restaurant call. The dining companion from the prompt is not used as the call contact name.","money_spent":false,"held":false,"success_contract":{"options_shown_before_any_call":false,"restaurant_calls_started":false,"configured_contact_name_required":true,"prompt_participant_not_used_as_contact_name":true,"money_spent":false}}' + next: "" + + - id: final_artifact_restaurant_phone_missing + type: assign + parameters: + target: dinner_date_artifact + value: '{"workflow_status":"failed","path":"restaurant_phone_number_missing","reason":"Configured restaurant phone number is required before starting any restaurant call.","money_spent":false,"held":false,"success_contract":{"options_shown_before_any_call":false,"restaurant_calls_started":false,"configured_restaurant_phone_required":true,"searched_phone_numbers_not_used":true,"money_spent":false}}' + next: "" + - id: final_artifact_search_failed type: assign parameters: @@ -94,14 +132,14 @@ steps: type: assign parameters: target: options_gate - value: '{"options_shown":true,"restaurant_calls_started":false,"money_spent":false,"silence_timeout_ms":10000,"production_timeout":"10s"}' + value: '{"workflow_status":"waiting_for_user_choice","options_shown":true,"restaurant_calls_started":false,"money_spent":false,"silence_timeout_ms":10000,"production_timeout":"10s","phone_flow":"No phone call starts while waiting for the user choice. If the user picks one option, only that selected venue is called using the configured restaurant phone number.","timeout_policy":"If no user choice arrives within 10 seconds, the workflow automatically starts hold calls for all three shown venues using the configured restaurant phone number, then waits for the user to choose which hold to keep."}' next: emit_options_shown - id: emit_options_shown type: emit parameters: event_type: "dinner_date.options_shown" - payload: "${steps.build_shortlist_from_candidates.output}" + payload: '{"workflow_status":"waiting_for_user_choice","message":"Three dinner options are ready. Waiting 10 seconds for the user to choose one before any phone call starts.","phone_flow":"User choice calls only the selected venue. Timeout starts hold calls for all three shown venues using the configured restaurant phone number, never searched phone numbers.","timeout_policy":{"timeout_ms":10000,"on_timeout":"hold_all_three_then_wait_for_post_timeout_choice","next_signal":"dinner_date_user_choice_after_timeout"},"options":{"option_1":{"name":"${json(steps.build_shortlist_from_candidates.json.option_1_name)}","url":"${json(steps.build_shortlist_from_candidates.json.option_1_url)}","note":"${json(steps.build_shortlist_from_candidates.json.option_1_note)}"},"option_2":{"name":"${json(steps.build_shortlist_from_candidates.json.option_2_name)}","url":"${json(steps.build_shortlist_from_candidates.json.option_2_url)}","note":"${json(steps.build_shortlist_from_candidates.json.option_2_note)}"},"option_3":{"name":"${json(steps.build_shortlist_from_candidates.json.option_3_name)}","url":"${json(steps.build_shortlist_from_candidates.json.option_3_url)}","note":"${json(steps.build_shortlist_from_candidates.json.option_3_note)}"}},"success_contract":{"options_shown_before_any_call":true,"configured_restaurant_phone_required":true,"searched_phone_numbers_not_used":true,"timeout_auto_holds_all_three_venues":true,"money_spent":false}}' next: normalize_user_choice - id: normalize_user_choice @@ -148,14 +186,14 @@ steps: response_mode: text parameters: tool: nyxid_proxy - arguments: '{"body":{"agent_id":"agent_6301m11a57yjeattnrb3ffwrbynr","agent_phone_number_id":"phnum_0301m0hkqza9fg48ghjjxtwa33dq","to_number":"${json(steps.capture_user_choice.json.restaurant_phone_number)}","telephony_call_config":{"ringing_timeout_secs":30},"conversation_initiation_client_data":{"dynamic_variables":{"restaurant_name":"${json(steps.build_shortlist_from_candidates.json.option_1_name)}","restaurant_source_url":"${json(steps.build_shortlist_from_candidates.json.option_1_url)}","client_name":"${json(steps.capture_user_choice.json.participant)}","party_size":"${json(steps.capture_user_choice.json.party_size)}","booking_date":"${json(steps.capture_user_choice.json.day)}","preferred_time":"${json(steps.capture_user_choice.json.time)}","backup_times":"${json(steps.capture_user_choice.json.backup_times)}","callback_phone":"${json(steps.capture_user_choice.json.phone_number)}","special_requests":"${json(steps.capture_user_choice.json.special_requests)}"}}}}' + arguments: '{"body":{"agent_id":"agent_6301m11a57yjeattnrb3ffwrbynr","agent_phone_number_id":"phnum_0301m0hkqza9fg48ghjjxtwa33dq","to_number":"${json(steps.initialize_context.json.restaurant_phone_number)}","telephony_call_config":{"ringing_timeout_secs":30},"conversation_initiation_client_data":{"dynamic_variables":{"restaurant_name":"${json(steps.build_shortlist_from_candidates.json.option_1_name)}","restaurant_source_url":"${json(steps.build_shortlist_from_candidates.json.option_1_url)}","client_name":"${json(steps.initialize_context.json.contact_name)}","party_size":"${json(steps.initialize_context.json.party_size)}","booking_date":"${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))}","booking_window":"${json(steps.initialize_context.json.window)}","preferred_time":"${json(steps.initialize_context.json.time)}","backup_times":"${json(steps.initialize_context.json.backup_times)}","callback_phone":"${json(steps.initialize_context.json.phone_number)}","special_requests":"${json(steps.initialize_context.json.special_requests)}","reservation_date":"${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))}","reservation_time":"${json(steps.initialize_context.json.time)}","reservation_party_size":"${json(steps.initialize_context.json.party_size)}","reservation_contact_name":"${json(steps.initialize_context.json.contact_name)}","reservation_details":"Please request a dinner reservation under the name ${json(steps.initialize_context.json.contact_name)}, party size ${json(steps.initialize_context.json.party_size)}, on ${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))} at ${json(steps.initialize_context.json.time)}. If that time is unavailable, ask for backup times ${json(steps.initialize_context.json.backup_times)}. Do not request payment or a deposit. Use callback phone ${json(steps.initialize_context.json.phone_number)}. Special requests: ${json(steps.initialize_context.json.special_requests)}."}}}}' next: verify_selected_hold_option_1 - id: verify_selected_hold_option_1 type: assign parameters: target: selected_hold_verification - value: '{"phone_call_started":"${json(steps.hold_selected_option_1.json.success)}","conversation_id":"${json(steps.hold_selected_option_1.json.conversation_id)}","call_sid":"${json(steps.hold_selected_option_1.json.callSid)}","provider_message":"${json(steps.hold_selected_option_1.json.message)}","restaurant_phone_number":"${json(steps.capture_user_choice.json.restaurant_phone_number)}","payment_requested":false,"result":"provider_response_recorded"}' + value: '{"phone_call_started":"${json(steps.hold_selected_option_1.json.success)}","conversation_id":"${json(steps.hold_selected_option_1.json.conversation_id)}","call_sid":"${json(steps.hold_selected_option_1.json.callSid)}","provider_message":"${json(steps.hold_selected_option_1.json.message)}","restaurant_phone_number":"${json(steps.initialize_context.json.restaurant_phone_number)}","payment_requested":false,"result":"provider_response_recorded"}' next: emit_selected_only_completed_option_1 - id: emit_selected_only_completed_option_1 @@ -169,7 +207,7 @@ steps: type: assign parameters: target: dinner_date_artifact - value: '{"workflow_status":"call_started","path":"selected_venue_phone_call_started","summary":"${json(steps.build_shortlist_from_candidates.json.option_1_name)} call started.","selected_restaurant":"${json(steps.build_shortlist_from_candidates.json.option_1_name)}","kept_option":"option_1","source_url":"${json(steps.build_shortlist_from_candidates.json.option_1_url)}","restaurant_phone_number":"${json(steps.capture_user_choice.json.restaurant_phone_number)}","conversation_id":"${json(steps.hold_selected_option_1.json.conversation_id)}","call_sid":"${json(steps.hold_selected_option_1.json.callSid)}","provider_success":"${json(steps.hold_selected_option_1.json.success)}","provider_message":"${json(steps.hold_selected_option_1.json.message)}","released":[],"money_spent":false,"success_contract":{"options_shown_before_any_call":true,"selected_option_only_called":true,"configured_restaurant_phone_used":true,"money_spent":false}}' + value: '{"workflow_status":"call_started","path":"selected_venue_phone_call_started","summary":"${json(steps.build_shortlist_from_candidates.json.option_1_name)} call started.","selected_restaurant":"${json(steps.build_shortlist_from_candidates.json.option_1_name)}","kept_option":"option_1","source_url":"${json(steps.build_shortlist_from_candidates.json.option_1_url)}","restaurant_phone_number":"${json(steps.initialize_context.json.restaurant_phone_number)}","conversation_id":"${json(steps.hold_selected_option_1.json.conversation_id)}","call_sid":"${json(steps.hold_selected_option_1.json.callSid)}","provider_success":"${json(steps.hold_selected_option_1.json.success)}","provider_message":"${json(steps.hold_selected_option_1.json.message)}","released":[],"money_spent":false,"success_contract":{"options_shown_before_any_call":true,"selected_option_only_called":true,"configured_restaurant_phone_used":true,"money_spent":false}}' next: "" - id: hold_selected_option_2 @@ -184,14 +222,14 @@ steps: response_mode: text parameters: tool: nyxid_proxy - arguments: '{"body":{"agent_id":"agent_6301m11a57yjeattnrb3ffwrbynr","agent_phone_number_id":"phnum_0301m0hkqza9fg48ghjjxtwa33dq","to_number":"${json(steps.capture_user_choice.json.restaurant_phone_number)}","telephony_call_config":{"ringing_timeout_secs":30},"conversation_initiation_client_data":{"dynamic_variables":{"restaurant_name":"${json(steps.build_shortlist_from_candidates.json.option_2_name)}","restaurant_source_url":"${json(steps.build_shortlist_from_candidates.json.option_2_url)}","client_name":"${json(steps.capture_user_choice.json.participant)}","party_size":"${json(steps.capture_user_choice.json.party_size)}","booking_date":"${json(steps.capture_user_choice.json.day)}","preferred_time":"${json(steps.capture_user_choice.json.time)}","backup_times":"${json(steps.capture_user_choice.json.backup_times)}","callback_phone":"${json(steps.capture_user_choice.json.phone_number)}","special_requests":"${json(steps.capture_user_choice.json.special_requests)}"}}}}' + arguments: '{"body":{"agent_id":"agent_6301m11a57yjeattnrb3ffwrbynr","agent_phone_number_id":"phnum_0301m0hkqza9fg48ghjjxtwa33dq","to_number":"${json(steps.initialize_context.json.restaurant_phone_number)}","telephony_call_config":{"ringing_timeout_secs":30},"conversation_initiation_client_data":{"dynamic_variables":{"restaurant_name":"${json(steps.build_shortlist_from_candidates.json.option_2_name)}","restaurant_source_url":"${json(steps.build_shortlist_from_candidates.json.option_2_url)}","client_name":"${json(steps.initialize_context.json.contact_name)}","party_size":"${json(steps.initialize_context.json.party_size)}","booking_date":"${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))}","booking_window":"${json(steps.initialize_context.json.window)}","preferred_time":"${json(steps.initialize_context.json.time)}","backup_times":"${json(steps.initialize_context.json.backup_times)}","callback_phone":"${json(steps.initialize_context.json.phone_number)}","special_requests":"${json(steps.initialize_context.json.special_requests)}","reservation_date":"${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))}","reservation_time":"${json(steps.initialize_context.json.time)}","reservation_party_size":"${json(steps.initialize_context.json.party_size)}","reservation_contact_name":"${json(steps.initialize_context.json.contact_name)}","reservation_details":"Please request a dinner reservation under the name ${json(steps.initialize_context.json.contact_name)}, party size ${json(steps.initialize_context.json.party_size)}, on ${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))} at ${json(steps.initialize_context.json.time)}. If that time is unavailable, ask for backup times ${json(steps.initialize_context.json.backup_times)}. Do not request payment or a deposit. Use callback phone ${json(steps.initialize_context.json.phone_number)}. Special requests: ${json(steps.initialize_context.json.special_requests)}."}}}}' next: verify_selected_hold_option_2 - id: verify_selected_hold_option_2 type: assign parameters: target: selected_hold_verification - value: '{"phone_call_started":"${json(steps.hold_selected_option_2.json.success)}","conversation_id":"${json(steps.hold_selected_option_2.json.conversation_id)}","call_sid":"${json(steps.hold_selected_option_2.json.callSid)}","provider_message":"${json(steps.hold_selected_option_2.json.message)}","restaurant_phone_number":"${json(steps.capture_user_choice.json.restaurant_phone_number)}","payment_requested":false,"result":"provider_response_recorded"}' + value: '{"phone_call_started":"${json(steps.hold_selected_option_2.json.success)}","conversation_id":"${json(steps.hold_selected_option_2.json.conversation_id)}","call_sid":"${json(steps.hold_selected_option_2.json.callSid)}","provider_message":"${json(steps.hold_selected_option_2.json.message)}","restaurant_phone_number":"${json(steps.initialize_context.json.restaurant_phone_number)}","payment_requested":false,"result":"provider_response_recorded"}' next: emit_selected_only_completed_option_2 - id: emit_selected_only_completed_option_2 @@ -205,7 +243,7 @@ steps: type: assign parameters: target: dinner_date_artifact - value: '{"workflow_status":"call_started","path":"selected_venue_phone_call_started","summary":"${json(steps.build_shortlist_from_candidates.json.option_2_name)} call started.","selected_restaurant":"${json(steps.build_shortlist_from_candidates.json.option_2_name)}","kept_option":"option_2","source_url":"${json(steps.build_shortlist_from_candidates.json.option_2_url)}","restaurant_phone_number":"${json(steps.capture_user_choice.json.restaurant_phone_number)}","conversation_id":"${json(steps.hold_selected_option_2.json.conversation_id)}","call_sid":"${json(steps.hold_selected_option_2.json.callSid)}","provider_success":"${json(steps.hold_selected_option_2.json.success)}","provider_message":"${json(steps.hold_selected_option_2.json.message)}","released":[],"money_spent":false,"success_contract":{"options_shown_before_any_call":true,"selected_option_only_called":true,"configured_restaurant_phone_used":true,"money_spent":false}}' + value: '{"workflow_status":"call_started","path":"selected_venue_phone_call_started","summary":"${json(steps.build_shortlist_from_candidates.json.option_2_name)} call started.","selected_restaurant":"${json(steps.build_shortlist_from_candidates.json.option_2_name)}","kept_option":"option_2","source_url":"${json(steps.build_shortlist_from_candidates.json.option_2_url)}","restaurant_phone_number":"${json(steps.initialize_context.json.restaurant_phone_number)}","conversation_id":"${json(steps.hold_selected_option_2.json.conversation_id)}","call_sid":"${json(steps.hold_selected_option_2.json.callSid)}","provider_success":"${json(steps.hold_selected_option_2.json.success)}","provider_message":"${json(steps.hold_selected_option_2.json.message)}","released":[],"money_spent":false,"success_contract":{"options_shown_before_any_call":true,"selected_option_only_called":true,"configured_restaurant_phone_used":true,"money_spent":false}}' next: "" - id: hold_selected_option_3 @@ -220,14 +258,14 @@ steps: response_mode: text parameters: tool: nyxid_proxy - arguments: '{"body":{"agent_id":"agent_6301m11a57yjeattnrb3ffwrbynr","agent_phone_number_id":"phnum_0301m0hkqza9fg48ghjjxtwa33dq","to_number":"${json(steps.capture_user_choice.json.restaurant_phone_number)}","telephony_call_config":{"ringing_timeout_secs":30},"conversation_initiation_client_data":{"dynamic_variables":{"restaurant_name":"${json(steps.build_shortlist_from_candidates.json.option_3_name)}","restaurant_source_url":"${json(steps.build_shortlist_from_candidates.json.option_3_url)}","client_name":"${json(steps.capture_user_choice.json.participant)}","party_size":"${json(steps.capture_user_choice.json.party_size)}","booking_date":"${json(steps.capture_user_choice.json.day)}","preferred_time":"${json(steps.capture_user_choice.json.time)}","backup_times":"${json(steps.capture_user_choice.json.backup_times)}","callback_phone":"${json(steps.capture_user_choice.json.phone_number)}","special_requests":"${json(steps.capture_user_choice.json.special_requests)}"}}}}' + arguments: '{"body":{"agent_id":"agent_6301m11a57yjeattnrb3ffwrbynr","agent_phone_number_id":"phnum_0301m0hkqza9fg48ghjjxtwa33dq","to_number":"${json(steps.initialize_context.json.restaurant_phone_number)}","telephony_call_config":{"ringing_timeout_secs":30},"conversation_initiation_client_data":{"dynamic_variables":{"restaurant_name":"${json(steps.build_shortlist_from_candidates.json.option_3_name)}","restaurant_source_url":"${json(steps.build_shortlist_from_candidates.json.option_3_url)}","client_name":"${json(steps.initialize_context.json.contact_name)}","party_size":"${json(steps.initialize_context.json.party_size)}","booking_date":"${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))}","booking_window":"${json(steps.initialize_context.json.window)}","preferred_time":"${json(steps.initialize_context.json.time)}","backup_times":"${json(steps.initialize_context.json.backup_times)}","callback_phone":"${json(steps.initialize_context.json.phone_number)}","special_requests":"${json(steps.initialize_context.json.special_requests)}","reservation_date":"${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))}","reservation_time":"${json(steps.initialize_context.json.time)}","reservation_party_size":"${json(steps.initialize_context.json.party_size)}","reservation_contact_name":"${json(steps.initialize_context.json.contact_name)}","reservation_details":"Please request a dinner reservation under the name ${json(steps.initialize_context.json.contact_name)}, party size ${json(steps.initialize_context.json.party_size)}, on ${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))} at ${json(steps.initialize_context.json.time)}. If that time is unavailable, ask for backup times ${json(steps.initialize_context.json.backup_times)}. Do not request payment or a deposit. Use callback phone ${json(steps.initialize_context.json.phone_number)}. Special requests: ${json(steps.initialize_context.json.special_requests)}."}}}}' next: verify_selected_hold_option_3 - id: verify_selected_hold_option_3 type: assign parameters: target: selected_hold_verification - value: '{"phone_call_started":"${json(steps.hold_selected_option_3.json.success)}","conversation_id":"${json(steps.hold_selected_option_3.json.conversation_id)}","call_sid":"${json(steps.hold_selected_option_3.json.callSid)}","provider_message":"${json(steps.hold_selected_option_3.json.message)}","restaurant_phone_number":"${json(steps.capture_user_choice.json.restaurant_phone_number)}","payment_requested":false,"result":"provider_response_recorded"}' + value: '{"phone_call_started":"${json(steps.hold_selected_option_3.json.success)}","conversation_id":"${json(steps.hold_selected_option_3.json.conversation_id)}","call_sid":"${json(steps.hold_selected_option_3.json.callSid)}","provider_message":"${json(steps.hold_selected_option_3.json.message)}","restaurant_phone_number":"${json(steps.initialize_context.json.restaurant_phone_number)}","payment_requested":false,"result":"provider_response_recorded"}' next: emit_selected_only_completed_option_3 - id: emit_selected_only_completed_option_3 @@ -241,7 +279,7 @@ steps: type: assign parameters: target: dinner_date_artifact - value: '{"workflow_status":"call_started","path":"selected_venue_phone_call_started","summary":"${json(steps.build_shortlist_from_candidates.json.option_3_name)} call started.","selected_restaurant":"${json(steps.build_shortlist_from_candidates.json.option_3_name)}","kept_option":"option_3","source_url":"${json(steps.build_shortlist_from_candidates.json.option_3_url)}","restaurant_phone_number":"${json(steps.capture_user_choice.json.restaurant_phone_number)}","conversation_id":"${json(steps.hold_selected_option_3.json.conversation_id)}","call_sid":"${json(steps.hold_selected_option_3.json.callSid)}","provider_success":"${json(steps.hold_selected_option_3.json.success)}","provider_message":"${json(steps.hold_selected_option_3.json.message)}","released":[],"money_spent":false,"success_contract":{"options_shown_before_any_call":true,"selected_option_only_called":true,"configured_restaurant_phone_used":true,"money_spent":false}}' + value: '{"workflow_status":"call_started","path":"selected_venue_phone_call_started","summary":"${json(steps.build_shortlist_from_candidates.json.option_3_name)} call started.","selected_restaurant":"${json(steps.build_shortlist_from_candidates.json.option_3_name)}","kept_option":"option_3","source_url":"${json(steps.build_shortlist_from_candidates.json.option_3_url)}","restaurant_phone_number":"${json(steps.initialize_context.json.restaurant_phone_number)}","conversation_id":"${json(steps.hold_selected_option_3.json.conversation_id)}","call_sid":"${json(steps.hold_selected_option_3.json.callSid)}","provider_success":"${json(steps.hold_selected_option_3.json.success)}","provider_message":"${json(steps.hold_selected_option_3.json.message)}","released":[],"money_spent":false,"success_contract":{"options_shown_before_any_call":true,"selected_option_only_called":true,"configured_restaurant_phone_used":true,"money_spent":false}}' next: "" - id: wait_for_user_choice_timeout @@ -274,7 +312,7 @@ steps: response_mode: text parameters: tool: nyxid_proxy - arguments: '{"body":{"agent_id":"agent_6301m11a57yjeattnrb3ffwrbynr","agent_phone_number_id":"phnum_0301m0hkqza9fg48ghjjxtwa33dq","to_number":"${json(steps.capture_user_choice.json.restaurant_phone_number)}","telephony_call_config":{"ringing_timeout_secs":30},"conversation_initiation_client_data":{"dynamic_variables":{"restaurant_name":"${json(steps.build_shortlist_from_candidates.json.option_1_name)}","restaurant_source_url":"${json(steps.build_shortlist_from_candidates.json.option_1_url)}","client_name":"${json(steps.capture_user_choice.json.participant)}","party_size":"${json(steps.capture_user_choice.json.party_size)}","booking_date":"${json(steps.capture_user_choice.json.day)}","preferred_time":"${json(steps.capture_user_choice.json.time)}","backup_times":"${json(steps.capture_user_choice.json.backup_times)}","callback_phone":"${json(steps.capture_user_choice.json.phone_number)}","special_requests":"${json(steps.capture_user_choice.json.special_requests)}"}}}}' + arguments: '{"body":{"agent_id":"agent_6301m11a57yjeattnrb3ffwrbynr","agent_phone_number_id":"phnum_0301m0hkqza9fg48ghjjxtwa33dq","to_number":"${json(steps.initialize_context.json.restaurant_phone_number)}","telephony_call_config":{"ringing_timeout_secs":30},"conversation_initiation_client_data":{"dynamic_variables":{"restaurant_name":"${json(steps.build_shortlist_from_candidates.json.option_1_name)}","restaurant_source_url":"${json(steps.build_shortlist_from_candidates.json.option_1_url)}","client_name":"${json(steps.initialize_context.json.contact_name)}","party_size":"${json(steps.initialize_context.json.party_size)}","booking_date":"${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))}","booking_window":"${json(steps.initialize_context.json.window)}","preferred_time":"${json(steps.initialize_context.json.time)}","backup_times":"${json(steps.initialize_context.json.backup_times)}","callback_phone":"${json(steps.initialize_context.json.phone_number)}","special_requests":"${json(steps.initialize_context.json.special_requests)}","reservation_date":"${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))}","reservation_time":"${json(steps.initialize_context.json.time)}","reservation_party_size":"${json(steps.initialize_context.json.party_size)}","reservation_contact_name":"${json(steps.initialize_context.json.contact_name)}","reservation_details":"Please request a dinner reservation under the name ${json(steps.initialize_context.json.contact_name)}, party size ${json(steps.initialize_context.json.party_size)}, on ${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))} at ${json(steps.initialize_context.json.time)}. If that time is unavailable, ask for backup times ${json(steps.initialize_context.json.backup_times)}. Do not request payment or a deposit. Use callback phone ${json(steps.initialize_context.json.phone_number)}. Special requests: ${json(steps.initialize_context.json.special_requests)}."}}}}' next: hold_candidate_option_2 - id: hold_candidate_option_2 @@ -289,7 +327,7 @@ steps: response_mode: text parameters: tool: nyxid_proxy - arguments: '{"body":{"agent_id":"agent_6301m11a57yjeattnrb3ffwrbynr","agent_phone_number_id":"phnum_0301m0hkqza9fg48ghjjxtwa33dq","to_number":"${json(steps.capture_user_choice.json.restaurant_phone_number)}","telephony_call_config":{"ringing_timeout_secs":30},"conversation_initiation_client_data":{"dynamic_variables":{"restaurant_name":"${json(steps.build_shortlist_from_candidates.json.option_2_name)}","restaurant_source_url":"${json(steps.build_shortlist_from_candidates.json.option_2_url)}","client_name":"${json(steps.capture_user_choice.json.participant)}","party_size":"${json(steps.capture_user_choice.json.party_size)}","booking_date":"${json(steps.capture_user_choice.json.day)}","preferred_time":"${json(steps.capture_user_choice.json.time)}","backup_times":"${json(steps.capture_user_choice.json.backup_times)}","callback_phone":"${json(steps.capture_user_choice.json.phone_number)}","special_requests":"${json(steps.capture_user_choice.json.special_requests)}"}}}}' + arguments: '{"body":{"agent_id":"agent_6301m11a57yjeattnrb3ffwrbynr","agent_phone_number_id":"phnum_0301m0hkqza9fg48ghjjxtwa33dq","to_number":"${json(steps.initialize_context.json.restaurant_phone_number)}","telephony_call_config":{"ringing_timeout_secs":30},"conversation_initiation_client_data":{"dynamic_variables":{"restaurant_name":"${json(steps.build_shortlist_from_candidates.json.option_2_name)}","restaurant_source_url":"${json(steps.build_shortlist_from_candidates.json.option_2_url)}","client_name":"${json(steps.initialize_context.json.contact_name)}","party_size":"${json(steps.initialize_context.json.party_size)}","booking_date":"${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))}","booking_window":"${json(steps.initialize_context.json.window)}","preferred_time":"${json(steps.initialize_context.json.time)}","backup_times":"${json(steps.initialize_context.json.backup_times)}","callback_phone":"${json(steps.initialize_context.json.phone_number)}","special_requests":"${json(steps.initialize_context.json.special_requests)}","reservation_date":"${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))}","reservation_time":"${json(steps.initialize_context.json.time)}","reservation_party_size":"${json(steps.initialize_context.json.party_size)}","reservation_contact_name":"${json(steps.initialize_context.json.contact_name)}","reservation_details":"Please request a dinner reservation under the name ${json(steps.initialize_context.json.contact_name)}, party size ${json(steps.initialize_context.json.party_size)}, on ${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))} at ${json(steps.initialize_context.json.time)}. If that time is unavailable, ask for backup times ${json(steps.initialize_context.json.backup_times)}. Do not request payment or a deposit. Use callback phone ${json(steps.initialize_context.json.phone_number)}. Special requests: ${json(steps.initialize_context.json.special_requests)}."}}}}' next: hold_candidate_option_3 - id: hold_candidate_option_3 @@ -304,7 +342,7 @@ steps: response_mode: text parameters: tool: nyxid_proxy - arguments: '{"body":{"agent_id":"agent_6301m11a57yjeattnrb3ffwrbynr","agent_phone_number_id":"phnum_0301m0hkqza9fg48ghjjxtwa33dq","to_number":"${json(steps.capture_user_choice.json.restaurant_phone_number)}","telephony_call_config":{"ringing_timeout_secs":30},"conversation_initiation_client_data":{"dynamic_variables":{"restaurant_name":"${json(steps.build_shortlist_from_candidates.json.option_3_name)}","restaurant_source_url":"${json(steps.build_shortlist_from_candidates.json.option_3_url)}","client_name":"${json(steps.capture_user_choice.json.participant)}","party_size":"${json(steps.capture_user_choice.json.party_size)}","booking_date":"${json(steps.capture_user_choice.json.day)}","preferred_time":"${json(steps.capture_user_choice.json.time)}","backup_times":"${json(steps.capture_user_choice.json.backup_times)}","callback_phone":"${json(steps.capture_user_choice.json.phone_number)}","special_requests":"${json(steps.capture_user_choice.json.special_requests)}"}}}}' + arguments: '{"body":{"agent_id":"agent_6301m11a57yjeattnrb3ffwrbynr","agent_phone_number_id":"phnum_0301m0hkqza9fg48ghjjxtwa33dq","to_number":"${json(steps.initialize_context.json.restaurant_phone_number)}","telephony_call_config":{"ringing_timeout_secs":30},"conversation_initiation_client_data":{"dynamic_variables":{"restaurant_name":"${json(steps.build_shortlist_from_candidates.json.option_3_name)}","restaurant_source_url":"${json(steps.build_shortlist_from_candidates.json.option_3_url)}","client_name":"${json(steps.initialize_context.json.contact_name)}","party_size":"${json(steps.initialize_context.json.party_size)}","booking_date":"${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))}","booking_window":"${json(steps.initialize_context.json.window)}","preferred_time":"${json(steps.initialize_context.json.time)}","backup_times":"${json(steps.initialize_context.json.backup_times)}","callback_phone":"${json(steps.initialize_context.json.phone_number)}","special_requests":"${json(steps.initialize_context.json.special_requests)}","reservation_date":"${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))}","reservation_time":"${json(steps.initialize_context.json.time)}","reservation_party_size":"${json(steps.initialize_context.json.party_size)}","reservation_contact_name":"${json(steps.initialize_context.json.contact_name)}","reservation_details":"Please request a dinner reservation under the name ${json(steps.initialize_context.json.contact_name)}, party size ${json(steps.initialize_context.json.party_size)}, on ${json(if(isBlank(steps.initialize_context.json.day), steps.initialize_context.json.window, steps.initialize_context.json.day))} at ${json(steps.initialize_context.json.time)}. If that time is unavailable, ask for backup times ${json(steps.initialize_context.json.backup_times)}. Do not request payment or a deposit. Use callback phone ${json(steps.initialize_context.json.phone_number)}. Special requests: ${json(steps.initialize_context.json.special_requests)}."}}}}' next: publish_holds_wait_state - id: publish_holds_wait_state