diff --git a/src/AgentCore.Application/Conversation/Actions/CallTransferOutcome.cs b/src/AgentCore.Application/Conversation/Actions/CallTransferOutcome.cs deleted file mode 100644 index 3491d31..0000000 --- a/src/AgentCore.Application/Conversation/Actions/CallTransferOutcome.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace AgentCore.Application.Conversation.Actions -{ - /// How an went. A channel reads every value it does not know as . - public enum CallTransferOutcome - { - /// The line did not take the call; the caller is still on this call. - NotTaken = 0, - - /// The line took the call; this side hangs its leg up. - Taken = 1, - } -} diff --git a/src/AgentCore.Application/Conversation/Actions/ConversationAction.cs b/src/AgentCore.Application/Conversation/Actions/ConversationAction.cs deleted file mode 100644 index 4a92ab3..0000000 --- a/src/AgentCore.Application/Conversation/Actions/ConversationAction.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace AgentCore.Application.Conversation.Actions -{ - /// - /// The base of every action a conversation can be asked for. Its constructor is closed to other assemblies: every - /// channel must know what each action means, and an action a host made up would reach channels that cannot read it. - /// - public abstract record ConversationAction - { - private protected ConversationAction() - { - } - } -} diff --git a/src/AgentCore.Application/Conversation/Actions/ConversationActionResult.cs b/src/AgentCore.Application/Conversation/Actions/ConversationActionResult.cs deleted file mode 100644 index c2739a3..0000000 --- a/src/AgentCore.Application/Conversation/Actions/ConversationActionResult.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace AgentCore.Application.Conversation.Actions -{ - /// - /// The answer to an action request. It is a value and not an exception, so the tool can turn a refusal into words - /// for the model; AgentCore writes no text for the model. - /// - public enum ConversationActionResult - { - /// Accepted. It runs after the current answer was delivered, so a caller hears the answer first. - Scheduled = 0, - - /// The channel has no way to do it, such as a web chat asked to transfer a call. The tool picks the fallback. - NotSupported = 1, - - /// Refused: an end was already asked for, and a later action would contradict it. - Ending = 2, - } -} diff --git a/src/AgentCore.Application/Conversation/Actions/EndConversationAction.cs b/src/AgentCore.Application/Conversation/Actions/EndConversationAction.cs deleted file mode 100644 index 4e354bd..0000000 --- a/src/AgentCore.Application/Conversation/Actions/EndConversationAction.cs +++ /dev/null @@ -1,11 +0,0 @@ -using AgentCore.Domain.Audit; - -namespace AgentCore.Application.Conversation.Actions -{ - /// - /// The same end as ConversationSession.EndConversation, as an action, so a tool ends its conversation through - /// the one door it uses for every other action. - /// - /// Stored as the conversation.ended token, so it becomes part of the audit record. - public sealed record EndConversationAction(ConversationEndReason Reason) : ConversationAction; -} diff --git a/src/AgentCore.Application/Conversation/Actions/ICallTransfer.cs b/src/AgentCore.Application/Conversation/Actions/ICallTransfer.cs deleted file mode 100644 index 56a4c45..0000000 --- a/src/AgentCore.Application/Conversation/Actions/ICallTransfer.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace AgentCore.Application.Conversation.Actions -{ - /// - /// A host that moves a call to another line itself, such as through its phone system's API. A phone channel that can - /// transfer (GPT-Live today) hands a registered one each in place of its own transfer, - /// and takes its answer as the outcome: the vendor's own transfer may report none (OpenAI's SIP refer, probe T1). - /// Register it as a singleton: a call outlives the request that started it. - /// - public interface ICallTransfer - { - /// Moves the call. It runs once the caller heard the answer that asked for it. - /// The call and its target. - /// Cancels the move. - /// Whether the line took the call. - Task TransferAsync(CallTransfer transfer, CancellationToken cancellationToken); - } -} diff --git a/src/AgentCore.Application/Conversation/Actions/IConversationControl.cs b/src/AgentCore.Application/Conversation/Actions/IConversationControl.cs deleted file mode 100644 index 7596656..0000000 --- a/src/AgentCore.Application/Conversation/Actions/IConversationControl.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace AgentCore.Application.Conversation.Actions -{ - /// - /// One door for every action, so a new action needs no new method here and no new parameter on a tool. - /// - public interface IConversationControl - { - /// Returns at once and runs nothing inline, because an action must wait until the current answer was delivered. - /// One of the actions AgentCore defines. - /// Whether the action will run, and if not, why, so the tool can tell the model. - /// is . - ConversationActionResult Request(ConversationAction action); - } -} diff --git a/src/AgentCore.Application/Conversation/Commands/AddVoiceContextCommand.cs b/src/AgentCore.Application/Conversation/Commands/AddVoiceContextCommand.cs new file mode 100644 index 0000000..0b9a6cb --- /dev/null +++ b/src/AgentCore.Application/Conversation/Commands/AddVoiceContextCommand.cs @@ -0,0 +1,22 @@ +namespace AgentCore.Application.Conversation.Commands +{ + /// + /// A fact for a voice that speaks for itself, for example a note a host looked up after the call was answered. + /// + public sealed record AddVoiceContextCommand : ChannelCommand + { + private const int MaxLength = 1000; + + /// Keeps the fact, cut to its first 1,000 characters. + /// The fact, as the voice should read it. + /// is , empty, or white space. + public AddVoiceContextCommand(string text) + { + ArgumentException.ThrowIfNullOrWhiteSpace(text); + Text = text.Length > MaxLength ? text[..MaxLength] : text; + } + + /// Gets the fact. + public string Text { get; } + } +} diff --git a/src/AgentCore.Application/Conversation/Commands/CallTransferOutcome.cs b/src/AgentCore.Application/Conversation/Commands/CallTransferOutcome.cs new file mode 100644 index 0000000..e09a1f0 --- /dev/null +++ b/src/AgentCore.Application/Conversation/Commands/CallTransferOutcome.cs @@ -0,0 +1,15 @@ +namespace AgentCore.Application.Conversation.Commands +{ + /// + /// How a host's transfer went, as its answers it. A channel + /// reads every value it does not know as . + /// + public enum CallTransferOutcome + { + /// The line did not take the call; the caller is still on this call. + NotTaken = 0, + + /// The line took the call; this side hangs its leg up. + Taken = 1, + } +} diff --git a/src/AgentCore.Application/Conversation/Commands/ChannelCommand.cs b/src/AgentCore.Application/Conversation/Commands/ChannelCommand.cs new file mode 100644 index 0000000..6118564 --- /dev/null +++ b/src/AgentCore.Application/Conversation/Commands/ChannelCommand.cs @@ -0,0 +1,12 @@ +namespace AgentCore.Application.Conversation.Commands +{ + /// + /// The base of every command a conversation's live channel. + /// + public abstract record ChannelCommand + { + private protected ChannelCommand() + { + } + } +} diff --git a/src/AgentCore.Application/Conversation/Actions/CallTransfer.cs b/src/AgentCore.Application/Conversation/Commands/ChannelCommandContext.cs similarity index 51% rename from src/AgentCore.Application/Conversation/Actions/CallTransfer.cs rename to src/AgentCore.Application/Conversation/Commands/ChannelCommandContext.cs index 00c9f18..a4e537e 100644 --- a/src/AgentCore.Application/Conversation/Actions/CallTransfer.cs +++ b/src/AgentCore.Application/Conversation/Commands/ChannelCommandContext.cs @@ -1,14 +1,11 @@ -namespace AgentCore.Application.Conversation.Actions +namespace AgentCore.Application.Conversation.Commands { - /// One transfer an carries out. - public sealed record CallTransfer + /// The conversation an acts on. + public sealed record ChannelCommandContext { - /// Gets the conversation the call carries. + /// Gets the conversation the channel carries. public required string ConversationId { get; init; } - /// Gets the line, as the named it. - public required Uri Target { get; init; } - /// /// Gets the call's own transport headers, such as SIP headers, by name, case-insensitive. A host finds its phone /// system's id for the call here when the system that placed the call put it in. diff --git a/src/AgentCore.Application/Conversation/Commands/ChannelCommandResult.cs b/src/AgentCore.Application/Conversation/Commands/ChannelCommandResult.cs new file mode 100644 index 0000000..e64b292 --- /dev/null +++ b/src/AgentCore.Application/Conversation/Commands/ChannelCommandResult.cs @@ -0,0 +1,17 @@ +namespace AgentCore.Application.Conversation.Commands +{ + /// + /// The answer to a command. + /// + public enum ChannelCommandResult + { + /// Accepted. It runs when its command says, such as after the current answer was delivered. + Scheduled = 0, + + /// The channel has no way to do it, such as a web chat asked to transfer a call. The tool picks the fallback. + NotSupported = 1, + + /// Refused: an end was already asked for, and a later command would contradict it. + Ending = 2, + } +} diff --git a/src/AgentCore.Application/Conversation/Commands/EndCommand.cs b/src/AgentCore.Application/Conversation/Commands/EndCommand.cs new file mode 100644 index 0000000..7b8e1d4 --- /dev/null +++ b/src/AgentCore.Application/Conversation/Commands/EndCommand.cs @@ -0,0 +1,10 @@ +using AgentCore.Domain.Audit; + +namespace AgentCore.Application.Conversation.Commands +{ + /// + /// The same end as ConversationSession.EndConversation, as a command. + /// + /// Stored as the conversation.ended token, so it becomes part of the audit record. + public sealed record EndCommand(ConversationEndReason Reason) : ChannelCommand; +} diff --git a/src/AgentCore.Application/Conversation/Commands/IChannelCommandHandler.cs b/src/AgentCore.Application/Conversation/Commands/IChannelCommandHandler.cs new file mode 100644 index 0000000..5c8ad5c --- /dev/null +++ b/src/AgentCore.Application/Conversation/Commands/IChannelCommandHandler.cs @@ -0,0 +1,18 @@ +namespace AgentCore.Application.Conversation.Commands +{ + /// + /// A host that carries out one command itself. + /// + /// The command the host carries out. + /// What the channel needs to know of how it went, such as . + public interface IChannelCommandHandler + where TCommand : ChannelCommand + { + /// Carries out the command. A transfer runs once the caller heard the answer that asked for it. + /// The command, as the tool sent it. + /// The conversation the command acts on. + /// Cancels the command. + /// How it went. + Task HandleAsync(TCommand command, ChannelCommandContext context, CancellationToken cancellationToken); + } +} diff --git a/src/AgentCore.Application/Conversation/Commands/IChannelControl.cs b/src/AgentCore.Application/Conversation/Commands/IChannelControl.cs new file mode 100644 index 0000000..f8f4bde --- /dev/null +++ b/src/AgentCore.Application/Conversation/Commands/IChannelControl.cs @@ -0,0 +1,14 @@ +namespace AgentCore.Application.Conversation.Commands +{ + /// + /// One door for every command. + /// + public interface IChannelControl + { + /// Returns at once and runs nothing inline: each command runs when it says, such as after the current answer was delivered. + /// One of the commands AgentCore defines. + /// Whether the command will run, and if not, why, so the tool can tell the model. + /// is . + ChannelCommandResult Send(ChannelCommand command); + } +} diff --git a/src/AgentCore.Application/Conversation/Actions/TransferAction.cs b/src/AgentCore.Application/Conversation/Commands/TransferCommand.cs similarity index 62% rename from src/AgentCore.Application/Conversation/Actions/TransferAction.cs rename to src/AgentCore.Application/Conversation/Commands/TransferCommand.cs index f7f4756..512279b 100644 --- a/src/AgentCore.Application/Conversation/Actions/TransferAction.cs +++ b/src/AgentCore.Application/Conversation/Commands/TransferCommand.cs @@ -1,16 +1,15 @@ -namespace AgentCore.Application.Conversation.Actions +namespace AgentCore.Application.Conversation.Commands { /// - /// Hands a phone call to another line. It waits until the caller heard the current answer, because a transfer that - /// cuts the reply loses what the caller was just told. + /// Hands a phone call to another line. /// - public sealed record TransferAction : ConversationAction + public sealed record TransferCommand : ChannelCommand { /// Checks the target here, so a bad target fails in the tool that made it and not later on the call. /// A tel:, sip: or sips: URI, the forms a SIP REFER can carry. /// is . /// is not an absolute tel, sip or sips URI. - public TransferAction(Uri target) + public TransferCommand(Uri target) { ArgumentNullException.ThrowIfNull(target); if (!target.IsAbsoluteUri || target.Scheme is not ("tel" or "sip" or "sips")) @@ -25,10 +24,7 @@ public TransferAction(Uri target) public Uri Target { get; } /// - /// Gets the instruction the voice follows when the line did not take the call, after which the call goes on. The - /// host writes it, because AgentCore writes no text for the model. Without it a failed transfer hangs up. A voice - /// may keep it for the rest of the call (GPT-Live appends it to its session instructions, as it does the - /// greeting), so word it as one past event, such as "the transfer you just started did not go through". + /// Gets the instruction the voice follows when the line did not take the call, after which the call goes on. /// public string? IfFailed { get; init; } } diff --git a/src/AgentCore.Application/Hooks/BuiltIn/CallBriefHook.cs b/src/AgentCore.Application/Hooks/BuiltIn/CallBriefHook.cs index 88d9c11..a0db560 100644 --- a/src/AgentCore.Application/Hooks/BuiltIn/CallBriefHook.cs +++ b/src/AgentCore.Application/Hooks/BuiltIn/CallBriefHook.cs @@ -6,7 +6,6 @@ namespace AgentCore.Application.Hooks.BuiltIn { /// /// Adds an accepted call's brief to every run of the call's conversation, as instructions. - /// AgentCore never reads, wraps, or changes the brief; the host writes all of it. /// internal sealed class CallBriefHook : AgentHook { diff --git a/src/AgentCore.Application/Hooks/Gates/CallGate.cs b/src/AgentCore.Application/Hooks/Gates/CallGate.cs index 682d91d..ad029aa 100644 --- a/src/AgentCore.Application/Hooks/Gates/CallGate.cs +++ b/src/AgentCore.Application/Hooks/Gates/CallGate.cs @@ -60,6 +60,7 @@ public void Accept(string conversationId, string? brief = null) ArgumentException.ThrowIfNullOrEmpty(conversationId); string? kept = brief is { Length: > MaxBriefLength } ? brief[..MaxBriefLength] : brief; + Stage(terminal: true, () => { AcceptedConversationId = conversationId; diff --git a/src/AgentCore.Application/PublicAPI.Unshipped.txt b/src/AgentCore.Application/PublicAPI.Unshipped.txt index 0cb2fd6..e7934d9 100644 --- a/src/AgentCore.Application/PublicAPI.Unshipped.txt +++ b/src/AgentCore.Application/PublicAPI.Unshipped.txt @@ -3114,72 +3114,81 @@ override sealed AgentCore.Application.Hooks.Notices.CallEnded.Equals(AgentCore.A static AgentCore.Application.Hooks.Notices.CallEnded.operator !=(AgentCore.Application.Hooks.Notices.CallEnded? left, AgentCore.Application.Hooks.Notices.CallEnded? right) -> bool static AgentCore.Application.Hooks.Notices.CallEnded.operator ==(AgentCore.Application.Hooks.Notices.CallEnded? left, AgentCore.Application.Hooks.Notices.CallEnded? right) -> bool virtual AgentCore.Application.Hooks.AgentHook.OnCallEndedAsync(AgentCore.Application.Hooks.Notices.CallEnded! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Conversation.Actions.ConversationAction -AgentCore.Application.Conversation.Actions.ConversationAction.ConversationAction(AgentCore.Application.Conversation.Actions.ConversationAction! original) -> void -AgentCore.Application.Conversation.Actions.ConversationActionResult -AgentCore.Application.Conversation.Actions.ConversationActionResult.Ending = 2 -> AgentCore.Application.Conversation.Actions.ConversationActionResult -AgentCore.Application.Conversation.Actions.ConversationActionResult.NotSupported = 1 -> AgentCore.Application.Conversation.Actions.ConversationActionResult -AgentCore.Application.Conversation.Actions.ConversationActionResult.Scheduled = 0 -> AgentCore.Application.Conversation.Actions.ConversationActionResult -AgentCore.Application.Conversation.Actions.EndConversationAction -AgentCore.Application.Conversation.Actions.EndConversationAction.Deconstruct(out AgentCore.Domain.Audit.ConversationEndReason Reason) -> void -AgentCore.Application.Conversation.Actions.EndConversationAction.EndConversationAction(AgentCore.Domain.Audit.ConversationEndReason Reason) -> void -AgentCore.Application.Conversation.Actions.EndConversationAction.Equals(AgentCore.Application.Conversation.Actions.EndConversationAction? other) -> bool -AgentCore.Application.Conversation.Actions.EndConversationAction.Reason.get -> AgentCore.Domain.Audit.ConversationEndReason -AgentCore.Application.Conversation.Actions.EndConversationAction.Reason.init -> void -AgentCore.Application.Conversation.Actions.IConversationControl -AgentCore.Application.Conversation.Actions.IConversationControl.Request(AgentCore.Application.Conversation.Actions.ConversationAction! action) -> AgentCore.Application.Conversation.Actions.ConversationActionResult -AgentCore.Application.Conversation.Actions.TransferAction -AgentCore.Application.Conversation.Actions.TransferAction.Equals(AgentCore.Application.Conversation.Actions.TransferAction? other) -> bool -AgentCore.Application.Conversation.Actions.TransferAction.Target.get -> System.Uri! -AgentCore.Application.Conversation.Actions.TransferAction.TransferAction(System.Uri! target) -> void -AgentCore.Application.Runtime.Session.ConversationSession.Request(AgentCore.Application.Conversation.Actions.ConversationAction! action) -> AgentCore.Application.Conversation.Actions.ConversationActionResult -AgentCore.Application.Tools.ToolCallScope.Conversation.get -> AgentCore.Application.Conversation.Actions.IConversationControl! -AgentCore.Application.Tools.ToolCallScope.Conversation.init -> void -abstract AgentCore.Application.Conversation.Actions.ConversationAction.$() -> AgentCore.Application.Conversation.Actions.ConversationAction! -override AgentCore.Application.Conversation.Actions.ConversationAction.Equals(object? obj) -> bool -override AgentCore.Application.Conversation.Actions.ConversationAction.GetHashCode() -> int -override AgentCore.Application.Conversation.Actions.ConversationAction.ToString() -> string! -override AgentCore.Application.Conversation.Actions.EndConversationAction.$() -> AgentCore.Application.Conversation.Actions.EndConversationAction! -override AgentCore.Application.Conversation.Actions.EndConversationAction.Equals(object? obj) -> bool -override AgentCore.Application.Conversation.Actions.EndConversationAction.GetHashCode() -> int -override AgentCore.Application.Conversation.Actions.EndConversationAction.ToString() -> string! -override AgentCore.Application.Conversation.Actions.TransferAction.$() -> AgentCore.Application.Conversation.Actions.TransferAction! -override AgentCore.Application.Conversation.Actions.TransferAction.Equals(object? obj) -> bool -override AgentCore.Application.Conversation.Actions.TransferAction.GetHashCode() -> int -override AgentCore.Application.Conversation.Actions.TransferAction.ToString() -> string! -override sealed AgentCore.Application.Conversation.Actions.EndConversationAction.Equals(AgentCore.Application.Conversation.Actions.ConversationAction? other) -> bool -override sealed AgentCore.Application.Conversation.Actions.TransferAction.Equals(AgentCore.Application.Conversation.Actions.ConversationAction? other) -> bool -static AgentCore.Application.Conversation.Actions.ConversationAction.operator ==(AgentCore.Application.Conversation.Actions.ConversationAction? left, AgentCore.Application.Conversation.Actions.ConversationAction? right) -> bool -static AgentCore.Application.Conversation.Actions.ConversationAction.operator !=(AgentCore.Application.Conversation.Actions.ConversationAction? left, AgentCore.Application.Conversation.Actions.ConversationAction? right) -> bool -static AgentCore.Application.Conversation.Actions.EndConversationAction.operator ==(AgentCore.Application.Conversation.Actions.EndConversationAction? left, AgentCore.Application.Conversation.Actions.EndConversationAction? right) -> bool -static AgentCore.Application.Conversation.Actions.EndConversationAction.operator !=(AgentCore.Application.Conversation.Actions.EndConversationAction? left, AgentCore.Application.Conversation.Actions.EndConversationAction? right) -> bool -static AgentCore.Application.Conversation.Actions.TransferAction.operator ==(AgentCore.Application.Conversation.Actions.TransferAction? left, AgentCore.Application.Conversation.Actions.TransferAction? right) -> bool -static AgentCore.Application.Conversation.Actions.TransferAction.operator !=(AgentCore.Application.Conversation.Actions.TransferAction? left, AgentCore.Application.Conversation.Actions.TransferAction? right) -> bool -virtual AgentCore.Application.Conversation.Actions.ConversationAction.EqualityContract.get -> System.Type! -virtual AgentCore.Application.Conversation.Actions.ConversationAction.Equals(AgentCore.Application.Conversation.Actions.ConversationAction? other) -> bool -virtual AgentCore.Application.Conversation.Actions.ConversationAction.PrintMembers(System.Text.StringBuilder! builder) -> bool const AgentCore.Application.Secrets.KnownSecrets.TelnyxRelayKeyName = "telnyx-relay-key" -> string! const AgentCore.Application.Secrets.KnownSecrets.TelnyxRelayKeyVariable = "TELNYX_RELAY_KEY" -> string! static readonly AgentCore.Application.Secrets.KnownSecrets.TelnyxRelay -> AgentCore.Application.Secrets.SecretName! -AgentCore.Application.Conversation.Actions.TransferAction.IfFailed.get -> string? -AgentCore.Application.Conversation.Actions.TransferAction.IfFailed.init -> void -AgentCore.Application.Conversation.Actions.CallTransfer -AgentCore.Application.Conversation.Actions.CallTransfer.$() -> AgentCore.Application.Conversation.Actions.CallTransfer! -AgentCore.Application.Conversation.Actions.CallTransfer.CallTransfer() -> void -AgentCore.Application.Conversation.Actions.CallTransfer.ConversationId.get -> string! -AgentCore.Application.Conversation.Actions.CallTransfer.ConversationId.init -> void -AgentCore.Application.Conversation.Actions.CallTransfer.Equals(AgentCore.Application.Conversation.Actions.CallTransfer? other) -> bool -AgentCore.Application.Conversation.Actions.CallTransfer.Headers.get -> System.Collections.Generic.IReadOnlyDictionary! -AgentCore.Application.Conversation.Actions.CallTransfer.Headers.init -> void -AgentCore.Application.Conversation.Actions.CallTransferOutcome -AgentCore.Application.Conversation.Actions.CallTransferOutcome.NotTaken = 0 -> AgentCore.Application.Conversation.Actions.CallTransferOutcome -AgentCore.Application.Conversation.Actions.CallTransferOutcome.Taken = 1 -> AgentCore.Application.Conversation.Actions.CallTransferOutcome -AgentCore.Application.Conversation.Actions.CallTransfer.Target.get -> System.Uri! -AgentCore.Application.Conversation.Actions.CallTransfer.Target.init -> void -AgentCore.Application.Conversation.Actions.ICallTransfer -AgentCore.Application.Conversation.Actions.ICallTransfer.TransferAsync(AgentCore.Application.Conversation.Actions.CallTransfer! transfer, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -override AgentCore.Application.Conversation.Actions.CallTransfer.Equals(object? obj) -> bool -override AgentCore.Application.Conversation.Actions.CallTransfer.GetHashCode() -> int -override AgentCore.Application.Conversation.Actions.CallTransfer.ToString() -> string! -static AgentCore.Application.Conversation.Actions.CallTransfer.operator ==(AgentCore.Application.Conversation.Actions.CallTransfer? left, AgentCore.Application.Conversation.Actions.CallTransfer? right) -> bool -static AgentCore.Application.Conversation.Actions.CallTransfer.operator !=(AgentCore.Application.Conversation.Actions.CallTransfer? left, AgentCore.Application.Conversation.Actions.CallTransfer? right) -> bool +abstract AgentCore.Application.Conversation.Commands.ChannelCommand.$() -> AgentCore.Application.Conversation.Commands.ChannelCommand! +AgentCore.Application.Conversation.Commands.AddVoiceContextCommand +AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.AddVoiceContextCommand(string! text) -> void +AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.Equals(AgentCore.Application.Conversation.Commands.AddVoiceContextCommand? other) -> bool +AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.Text.get -> string! +AgentCore.Application.Conversation.Commands.CallTransferOutcome +AgentCore.Application.Conversation.Commands.CallTransferOutcome.NotTaken = 0 -> AgentCore.Application.Conversation.Commands.CallTransferOutcome +AgentCore.Application.Conversation.Commands.CallTransferOutcome.Taken = 1 -> AgentCore.Application.Conversation.Commands.CallTransferOutcome +AgentCore.Application.Conversation.Commands.ChannelCommand +AgentCore.Application.Conversation.Commands.ChannelCommand.ChannelCommand(AgentCore.Application.Conversation.Commands.ChannelCommand! original) -> void +AgentCore.Application.Conversation.Commands.ChannelCommandContext +AgentCore.Application.Conversation.Commands.ChannelCommandContext.$() -> AgentCore.Application.Conversation.Commands.ChannelCommandContext! +AgentCore.Application.Conversation.Commands.ChannelCommandContext.ChannelCommandContext() -> void +AgentCore.Application.Conversation.Commands.ChannelCommandContext.ConversationId.get -> string! +AgentCore.Application.Conversation.Commands.ChannelCommandContext.ConversationId.init -> void +AgentCore.Application.Conversation.Commands.ChannelCommandContext.Equals(AgentCore.Application.Conversation.Commands.ChannelCommandContext? other) -> bool +AgentCore.Application.Conversation.Commands.ChannelCommandContext.Headers.get -> System.Collections.Generic.IReadOnlyDictionary! +AgentCore.Application.Conversation.Commands.ChannelCommandContext.Headers.init -> void +AgentCore.Application.Conversation.Commands.ChannelCommandResult +AgentCore.Application.Conversation.Commands.ChannelCommandResult.Ending = 2 -> AgentCore.Application.Conversation.Commands.ChannelCommandResult +AgentCore.Application.Conversation.Commands.ChannelCommandResult.NotSupported = 1 -> AgentCore.Application.Conversation.Commands.ChannelCommandResult +AgentCore.Application.Conversation.Commands.ChannelCommandResult.Scheduled = 0 -> AgentCore.Application.Conversation.Commands.ChannelCommandResult +AgentCore.Application.Conversation.Commands.EndCommand +AgentCore.Application.Conversation.Commands.EndCommand.Deconstruct(out AgentCore.Domain.Audit.ConversationEndReason Reason) -> void +AgentCore.Application.Conversation.Commands.EndCommand.EndCommand(AgentCore.Domain.Audit.ConversationEndReason Reason) -> void +AgentCore.Application.Conversation.Commands.EndCommand.Equals(AgentCore.Application.Conversation.Commands.EndCommand? other) -> bool +AgentCore.Application.Conversation.Commands.EndCommand.Reason.get -> AgentCore.Domain.Audit.ConversationEndReason +AgentCore.Application.Conversation.Commands.EndCommand.Reason.init -> void +AgentCore.Application.Conversation.Commands.IChannelCommandHandler +AgentCore.Application.Conversation.Commands.IChannelCommandHandler.HandleAsync(TCommand! command, AgentCore.Application.Conversation.Commands.ChannelCommandContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +AgentCore.Application.Conversation.Commands.IChannelControl +AgentCore.Application.Conversation.Commands.IChannelControl.Send(AgentCore.Application.Conversation.Commands.ChannelCommand! command) -> AgentCore.Application.Conversation.Commands.ChannelCommandResult +AgentCore.Application.Conversation.Commands.TransferCommand +AgentCore.Application.Conversation.Commands.TransferCommand.Equals(AgentCore.Application.Conversation.Commands.TransferCommand? other) -> bool +AgentCore.Application.Conversation.Commands.TransferCommand.IfFailed.get -> string? +AgentCore.Application.Conversation.Commands.TransferCommand.IfFailed.init -> void +AgentCore.Application.Conversation.Commands.TransferCommand.Target.get -> System.Uri! +AgentCore.Application.Conversation.Commands.TransferCommand.TransferCommand(System.Uri! target) -> void +AgentCore.Application.Runtime.Session.ConversationSession.Send(AgentCore.Application.Conversation.Commands.ChannelCommand! command) -> AgentCore.Application.Conversation.Commands.ChannelCommandResult +AgentCore.Application.Tools.ToolCallScope.Channel.get -> AgentCore.Application.Conversation.Commands.IChannelControl! +AgentCore.Application.Tools.ToolCallScope.Channel.init -> void +override AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.$() -> AgentCore.Application.Conversation.Commands.AddVoiceContextCommand! +override AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.Equals(object? obj) -> bool +override AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.GetHashCode() -> int +override AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.ToString() -> string! +override AgentCore.Application.Conversation.Commands.ChannelCommandContext.Equals(object? obj) -> bool +override AgentCore.Application.Conversation.Commands.ChannelCommandContext.GetHashCode() -> int +override AgentCore.Application.Conversation.Commands.ChannelCommandContext.ToString() -> string! +override AgentCore.Application.Conversation.Commands.ChannelCommand.Equals(object? obj) -> bool +override AgentCore.Application.Conversation.Commands.ChannelCommand.GetHashCode() -> int +override AgentCore.Application.Conversation.Commands.ChannelCommand.ToString() -> string! +override AgentCore.Application.Conversation.Commands.EndCommand.$() -> AgentCore.Application.Conversation.Commands.EndCommand! +override AgentCore.Application.Conversation.Commands.EndCommand.Equals(object? obj) -> bool +override AgentCore.Application.Conversation.Commands.EndCommand.GetHashCode() -> int +override AgentCore.Application.Conversation.Commands.EndCommand.ToString() -> string! +override AgentCore.Application.Conversation.Commands.TransferCommand.$() -> AgentCore.Application.Conversation.Commands.TransferCommand! +override AgentCore.Application.Conversation.Commands.TransferCommand.Equals(object? obj) -> bool +override AgentCore.Application.Conversation.Commands.TransferCommand.GetHashCode() -> int +override AgentCore.Application.Conversation.Commands.TransferCommand.ToString() -> string! +override sealed AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.Equals(AgentCore.Application.Conversation.Commands.ChannelCommand? other) -> bool +override sealed AgentCore.Application.Conversation.Commands.EndCommand.Equals(AgentCore.Application.Conversation.Commands.ChannelCommand? other) -> bool +override sealed AgentCore.Application.Conversation.Commands.TransferCommand.Equals(AgentCore.Application.Conversation.Commands.ChannelCommand? other) -> bool +static AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.operator ==(AgentCore.Application.Conversation.Commands.AddVoiceContextCommand? left, AgentCore.Application.Conversation.Commands.AddVoiceContextCommand? right) -> bool +static AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.operator !=(AgentCore.Application.Conversation.Commands.AddVoiceContextCommand? left, AgentCore.Application.Conversation.Commands.AddVoiceContextCommand? right) -> bool +static AgentCore.Application.Conversation.Commands.ChannelCommandContext.operator ==(AgentCore.Application.Conversation.Commands.ChannelCommandContext? left, AgentCore.Application.Conversation.Commands.ChannelCommandContext? right) -> bool +static AgentCore.Application.Conversation.Commands.ChannelCommandContext.operator !=(AgentCore.Application.Conversation.Commands.ChannelCommandContext? left, AgentCore.Application.Conversation.Commands.ChannelCommandContext? right) -> bool +static AgentCore.Application.Conversation.Commands.ChannelCommand.operator ==(AgentCore.Application.Conversation.Commands.ChannelCommand? left, AgentCore.Application.Conversation.Commands.ChannelCommand? right) -> bool +static AgentCore.Application.Conversation.Commands.ChannelCommand.operator !=(AgentCore.Application.Conversation.Commands.ChannelCommand? left, AgentCore.Application.Conversation.Commands.ChannelCommand? right) -> bool +static AgentCore.Application.Conversation.Commands.EndCommand.operator ==(AgentCore.Application.Conversation.Commands.EndCommand? left, AgentCore.Application.Conversation.Commands.EndCommand? right) -> bool +static AgentCore.Application.Conversation.Commands.EndCommand.operator !=(AgentCore.Application.Conversation.Commands.EndCommand? left, AgentCore.Application.Conversation.Commands.EndCommand? right) -> bool +static AgentCore.Application.Conversation.Commands.TransferCommand.operator ==(AgentCore.Application.Conversation.Commands.TransferCommand? left, AgentCore.Application.Conversation.Commands.TransferCommand? right) -> bool +static AgentCore.Application.Conversation.Commands.TransferCommand.operator !=(AgentCore.Application.Conversation.Commands.TransferCommand? left, AgentCore.Application.Conversation.Commands.TransferCommand? right) -> bool +virtual AgentCore.Application.Conversation.Commands.ChannelCommand.EqualityContract.get -> System.Type! +virtual AgentCore.Application.Conversation.Commands.ChannelCommand.Equals(AgentCore.Application.Conversation.Commands.ChannelCommand? other) -> bool +virtual AgentCore.Application.Conversation.Commands.ChannelCommand.PrintMembers(System.Text.StringBuilder! builder) -> bool diff --git a/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs b/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs index a83a1a3..edadbf3 100644 --- a/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs +++ b/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs @@ -62,9 +62,11 @@ internal void SetTimeZone(TimeZoneInfo zone) internal string? ClockLine() { AgentsConfiguration agents = _session.Compiled.Configuration.Agents; + string? agentId = _session.Policy is { } policy ? policy.CurrentAgentId : _session.Compiled.Configuration.Entries.GetValueOrDefault(_session.Compiled.EntryName)?.Agent; + bool clock = agents.Items.FirstOrDefault(agent => agent.Id == agentId) is { } named ? AgentHarness.Compose(agents.Defaults, named).Clock : agents.Defaults?.Clock ?? true; @@ -143,7 +145,7 @@ internal ConversationTurn BeginTurn(ChatMessage userInput, AgentSession session, // An edit that withdraws the turn that asked leaves its requests unanswered: the edit drops them. IReadOnlySet withdrawn = TurnApprovalAnswers.Withdrawn(_session.History, session, origin); - + ChatMessage spoken = !userInput.Contents.OfType().Any() && PendingApprovalQueue.Open(session).Where(request => !withdrawn.Contains(request.RequestId)).ToList() is { Count: > 0 } open ? TurnApprovalAnswers.MovedOn(userInput, open) @@ -265,7 +267,7 @@ internal TurnInvocation TurnInvocationOf(ConversationTurn turn, ITurnCompleter c ToolRuns = _session.ToolRuns, Notices = new TurnNotices(), Hooks = _session.Hooks, - Control = _session.Actions, + Channel = _session.Commands, RefusalReply = _session.Compiled.RefusalReply, Items = new ConcurrentDictionary(StringComparer.Ordinal), Rounds = new TurnRounds(), diff --git a/src/AgentCore.Application/Runtime/Harness/BackgroundChildAgent.cs b/src/AgentCore.Application/Runtime/Harness/BackgroundChildAgent.cs index 5adc44a..71415d7 100644 --- a/src/AgentCore.Application/Runtime/Harness/BackgroundChildAgent.cs +++ b/src/AgentCore.Application/Runtime/Harness/BackgroundChildAgent.cs @@ -55,7 +55,7 @@ private static TurnInvocation Shared(TurnInvocation parent) TimeZone = parent.TimeZone, ToolStop = parent.ToolStop, ToolRuns = parent.ToolRuns, - Control = parent.Control, + Channel = parent.Channel, Nested = true, }; } diff --git a/src/AgentCore.Application/Runtime/Session/ChannelCommands.cs b/src/AgentCore.Application/Runtime/Session/ChannelCommands.cs new file mode 100644 index 0000000..ff69309 --- /dev/null +++ b/src/AgentCore.Application/Runtime/Session/ChannelCommands.cs @@ -0,0 +1,37 @@ +using AgentCore.Application.Conversation.Commands; + +namespace AgentCore.Application.Runtime.Session +{ + /// + /// The end is handled here and not by the channel, because every channel already follows an end the engine asks for. + /// Only a command the transport must carry out itself goes to the channel. + /// + internal sealed class ChannelCommands(ConversationSession session) : IChannelControl + { + private IConversationChannel? _channel; + + /// Replaces any earlier channel, because a call that takes a conversation over carries its commands from then on. + internal void Attach(IConversationChannel channel) + { + ArgumentNullException.ThrowIfNull(channel); + Volatile.Write(ref _channel, channel); + } + + /// + public ChannelCommandResult Send(ChannelCommand command) + { + ArgumentNullException.ThrowIfNull(command); + + if (session.IsComplete || session.Lifetime.Ending.Requested) + { + return ChannelCommandResult.Ending; + } + + return command switch + { + EndCommand end => session.Lifetime.EndConversation(end.Reason) ? ChannelCommandResult.Scheduled : ChannelCommandResult.Ending, + _ => Volatile.Read(ref _channel)?.Send(command) ?? ChannelCommandResult.NotSupported, + }; + } + } +} diff --git a/src/AgentCore.Application/Runtime/Session/ConversationActions.cs b/src/AgentCore.Application/Runtime/Session/ConversationActions.cs deleted file mode 100644 index 0aefe8c..0000000 --- a/src/AgentCore.Application/Runtime/Session/ConversationActions.cs +++ /dev/null @@ -1,36 +0,0 @@ -using AgentCore.Application.Conversation.Actions; - -namespace AgentCore.Application.Runtime.Session -{ - /// - /// The end is handled here and not by the channel, because every channel already follows an end the engine asks for. - /// Only an action the transport must perform itself goes to the channel. - /// - internal sealed class ConversationActions(ConversationSession session) : IConversationControl - { - private IConversationChannel? _channel; - - /// Replaces any earlier channel, because a call that takes a conversation over carries its actions from then on. - internal void Attach(IConversationChannel channel) - { - ArgumentNullException.ThrowIfNull(channel); - Volatile.Write(ref _channel, channel); - } - - /// - public ConversationActionResult Request(ConversationAction action) - { - ArgumentNullException.ThrowIfNull(action); - if (session.IsComplete || session.Lifetime.Ending.Requested) - { - return ConversationActionResult.Ending; - } - - return action switch - { - EndConversationAction end => session.Lifetime.EndConversation(end.Reason) ? ConversationActionResult.Scheduled : ConversationActionResult.Ending, - _ => Volatile.Read(ref _channel)?.Request(action) ?? ConversationActionResult.NotSupported, - }; - } - } -} diff --git a/src/AgentCore.Application/Runtime/Session/ConversationSession.cs b/src/AgentCore.Application/Runtime/Session/ConversationSession.cs index bad6675..f7561bf 100644 --- a/src/AgentCore.Application/Runtime/Session/ConversationSession.cs +++ b/src/AgentCore.Application/Runtime/Session/ConversationSession.cs @@ -3,7 +3,7 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Conversation; -using AgentCore.Application.Conversation.Actions; +using AgentCore.Application.Conversation.Commands; using AgentCore.Application.Hooks.Engine; using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Policy; @@ -103,7 +103,7 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable internal FinishedToolPairs ToolPairs { get; } - internal ConversationActions Actions { get; } + internal ChannelCommands Commands { get; } /// /// Creates the session of one conversation. @@ -146,7 +146,7 @@ internal ConversationSession( Busy = new ConversationBusyMark(this); ToolPairs = new FinishedToolPairs(this); ToolRuns = new ConversationToolRuns(ToolPairs.Keep); - Actions = new ConversationActions(this); + Commands = new ChannelCommands(this); } /// @@ -354,10 +354,10 @@ public bool EndConversation(ConversationEndReason reason) return Lifetime.EndConversation(reason); } - /// - public ConversationActionResult Request(ConversationAction action) + /// + public ChannelCommandResult Send(ChannelCommand command) { - return Actions.Request(action); + return Commands.Send(command); } /// diff --git a/src/AgentCore.Application/Runtime/Session/IConversationChannel.cs b/src/AgentCore.Application/Runtime/Session/IConversationChannel.cs index 7d0ffd0..621506d 100644 --- a/src/AgentCore.Application/Runtime/Session/IConversationChannel.cs +++ b/src/AgentCore.Application/Runtime/Session/IConversationChannel.cs @@ -1,11 +1,11 @@ -using AgentCore.Application.Conversation.Actions; +using AgentCore.Application.Conversation.Commands; namespace AgentCore.Application.Runtime.Session { - /// Implemented by the transport, because only the transport can perform an action such as a transfer. + /// Implemented by the transport, because only the transport can carry out a command such as a transfer. internal interface IConversationChannel { - /// Must not block: a tool call is waiting on it, and the action itself runs after the answer was delivered. - ConversationActionResult Request(ConversationAction action); + /// Must not block: a tool call is waiting on it, and the command itself runs when it says. + ChannelCommandResult Send(ChannelCommand command); } } diff --git a/src/AgentCore.Application/Runtime/Session/UnattachedChannel.cs b/src/AgentCore.Application/Runtime/Session/UnattachedChannel.cs new file mode 100644 index 0000000..24294a6 --- /dev/null +++ b/src/AgentCore.Application/Runtime/Session/UnattachedChannel.cs @@ -0,0 +1,21 @@ +using AgentCore.Application.Conversation.Commands; + +namespace AgentCore.Application.Runtime.Session +{ + /// Keeps ToolCallScope.Channel non-null, so a tool never checks for null; outside a turn there is nothing to act on. + internal sealed class UnattachedChannel : IChannelControl + { + internal static readonly UnattachedChannel Instance = new(); + + private UnattachedChannel() + { + } + + /// + public ChannelCommandResult Send(ChannelCommand command) + { + ArgumentNullException.ThrowIfNull(command); + return ChannelCommandResult.NotSupported; + } + } +} diff --git a/src/AgentCore.Application/Runtime/Session/UnattachedConversationControl.cs b/src/AgentCore.Application/Runtime/Session/UnattachedConversationControl.cs deleted file mode 100644 index 2417b3e..0000000 --- a/src/AgentCore.Application/Runtime/Session/UnattachedConversationControl.cs +++ /dev/null @@ -1,21 +0,0 @@ -using AgentCore.Application.Conversation.Actions; - -namespace AgentCore.Application.Runtime.Session -{ - /// Keeps ToolCallScope.Conversation non-null, so a tool never checks for null; outside a turn there is nothing to act on. - internal sealed class UnattachedConversationControl : IConversationControl - { - internal static readonly UnattachedConversationControl Instance = new(); - - private UnattachedConversationControl() - { - } - - /// - public ConversationActionResult Request(ConversationAction action) - { - ArgumentNullException.ThrowIfNull(action); - return ConversationActionResult.NotSupported; - } - } -} diff --git a/src/AgentCore.Application/Runtime/ToolCalls/ToolCallScopes.cs b/src/AgentCore.Application/Runtime/ToolCalls/ToolCallScopes.cs index 5faf88c..16fb1c2 100644 --- a/src/AgentCore.Application/Runtime/ToolCalls/ToolCallScopes.cs +++ b/src/AgentCore.Application/Runtime/ToolCalls/ToolCallScopes.cs @@ -24,7 +24,7 @@ internal static ToolCallScope From(TurnInvocation invocation) return new ToolCallScope(invocation.ConversationId, invocation.TurnIndex, invocation.Stage, invocation.Workspace) { Items = invocation.Items, - Conversation = invocation.Control ?? UnattachedConversationControl.Instance, + Channel = invocation.Channel ?? UnattachedChannel.Instance, }; } } diff --git a/src/AgentCore.Application/Runtime/Turn/TurnInvocation.cs b/src/AgentCore.Application/Runtime/Turn/TurnInvocation.cs index 92f5d3a..7e1863f 100644 --- a/src/AgentCore.Application/Runtime/Turn/TurnInvocation.cs +++ b/src/AgentCore.Application/Runtime/Turn/TurnInvocation.cs @@ -1,5 +1,5 @@ using System.Collections.Concurrent; -using AgentCore.Application.Conversation.Actions; +using AgentCore.Application.Conversation.Commands; using AgentCore.Application.Hooks.Engine; using AgentCore.Application.Runtime.Harness; using AgentCore.Application.State; @@ -79,7 +79,7 @@ internal sealed record TurnInvocation public SessionHooks? Hooks { get; init; } /// Carried here so that a tool's scope and a background child's tools reach the same session door. - public IConversationControl? Control { get; init; } + public IChannelControl? Channel { get; init; } /// /// Gets the turn's shared state, which gates expose as Items and tools as , diff --git a/src/AgentCore.Application/Tools/ToolCallScope.cs b/src/AgentCore.Application/Tools/ToolCallScope.cs index 8687db1..7ade9fc 100644 --- a/src/AgentCore.Application/Tools/ToolCallScope.cs +++ b/src/AgentCore.Application/Tools/ToolCallScope.cs @@ -1,4 +1,4 @@ -using AgentCore.Application.Conversation.Actions; +using AgentCore.Application.Conversation.Commands; using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tools @@ -17,9 +17,10 @@ public sealed record ToolCallScope(string ConversationId, int TurnIndex, string public IDictionary? Items { get; init; } /// - /// Gets the conversation's action door. It sits on the scope because the binder already hands every tool the - /// scope, so a tool needs no new parameter. Outside a turn it refuses every action: there is no conversation. + /// Gets the door to the conversation's live channel, its call or its chat connection. It sits on the scope because + /// the binder already hands every tool the scope, so a tool needs no new parameter. Outside a turn it refuses + /// every command: there is no conversation. /// - public IConversationControl Conversation { get; init; } = UnattachedConversationControl.Instance; + public IChannelControl Channel { get; init; } = UnattachedChannel.Instance; } } diff --git a/src/AgentCore.AspNetCore/Calls/CallBrief.cs b/src/AgentCore.AspNetCore/Calls/CallBrief.cs index 303fc8e..3f85ec2 100644 --- a/src/AgentCore.AspNetCore/Calls/CallBrief.cs +++ b/src/AgentCore.AspNetCore/Calls/CallBrief.cs @@ -24,7 +24,10 @@ internal void File() } } - internal void Clear() => host.Briefs?.Clear(conversationId); + internal void Clear() + { + host.Briefs?.Clear(conversationId); + } // A newer session that another call holds carries that call's brief, so only then is it left in place. internal void Forget(ConversationSession? holder, ConversationSession own) diff --git a/src/AgentCore.AspNetCore/Calls/CallChannel.cs b/src/AgentCore.AspNetCore/Calls/CallChannel.cs index 6e3962e..1a126d2 100644 --- a/src/AgentCore.AspNetCore/Calls/CallChannel.cs +++ b/src/AgentCore.AspNetCore/Calls/CallChannel.cs @@ -3,8 +3,8 @@ namespace AgentCore.AspNetCore.Calls { /// - /// The vendor's actions, such as a transfer, on the call's conversation. It follows the call to a session reopened - /// after an idle unload, because the tools of the next ask run on that session. + /// The vendor's channel, which carries out commands such as a transfer, on the call's conversation. It follows the + /// call to a session reopened after an idle unload, because the tools of the next ask run on that session. /// internal sealed class CallChannel { @@ -15,14 +15,14 @@ internal void Use(IConversationChannel channel, ConversationSession session) ArgumentNullException.ThrowIfNull(channel); ArgumentNullException.ThrowIfNull(session); Volatile.Write(ref _channel, channel); - session.Actions.Attach(channel); + session.Commands.Attach(channel); } internal void Follow(ConversationSession reopened) { if (Volatile.Read(ref _channel) is { } channel) { - reopened.Actions.Attach(channel); + reopened.Commands.Attach(channel); } } } diff --git a/src/AgentCore.AspNetCore/Calls/PhoneCall.cs b/src/AgentCore.AspNetCore/Calls/PhoneCall.cs index 8b411ad..f641786 100644 --- a/src/AgentCore.AspNetCore/Calls/PhoneCall.cs +++ b/src/AgentCore.AspNetCore/Calls/PhoneCall.cs @@ -161,6 +161,7 @@ internal async ValueTask StartAsync(CancellationToken cancellationToken) _keptStart = kept; Volatile.Write(ref _session, session); + Channel.Follow(session); } // ConversationStarted is raised when the store opens, so it always comes before CallStarted. diff --git a/src/AgentCore.AspNetCore/Calls/PhoneCallHost.cs b/src/AgentCore.AspNetCore/Calls/PhoneCallHost.cs index 00100e3..98d68f0 100644 --- a/src/AgentCore.AspNetCore/Calls/PhoneCallHost.cs +++ b/src/AgentCore.AspNetCore/Calls/PhoneCallHost.cs @@ -1,4 +1,5 @@ using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Conversation.Commands; using AgentCore.Application.Hooks; using AgentCore.Application.Hooks.BuiltIn; using AgentCore.Application.Hooks.Engine; @@ -31,6 +32,19 @@ internal sealed record PhoneCallHost( /// internal bool FrontVoice { get; init; } + /// Gets the root services, where the host's command handlers are found, or for none. + internal IServiceProvider? Services { get; init; } + + /// + /// Gets the host's handler for one command, or when it registered none. It comes from the + /// root services: a call outlives the request that started it, so a scoped one would be used after its scope ended. + /// + internal IChannelCommandHandler? HandlerFor() + where TCommand : ChannelCommand + { + return Services?.GetService>(); + } + /// Reads the host's shared parts from a started container. internal static PhoneCallHost From(IServiceProvider services, ConversationProviderConfiguration? configuration) { @@ -42,7 +56,10 @@ internal static PhoneCallHost From(IServiceProvider services, ConversationProvid boot.Hooks, services.GetRequiredService(), services.GetRequiredService().CreateLogger("AgentCore.Calls"), - configuration?.AnswerSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : GatePoint.BeforeCall.Deadline); + configuration?.AnswerSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : GatePoint.BeforeCall.Deadline) + { + Services = boot.Services, + }; } } } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/ILiveTransferLine.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/ILiveTransferLine.cs index 0d9f8c4..c016e77 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/ILiveTransferLine.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/ILiveTransferLine.cs @@ -1,3 +1,5 @@ +using AgentCore.Application.Conversation.Commands; + namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call { /// @@ -6,10 +8,10 @@ namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call /// internal interface ILiveTransferLine { - /// The line. + /// The command, as the tool sent it. /// Waits for the peer to close the AI leg, for a line whose only sign of success is that close. /// The read loop's receive still open, or . /// Cancels the hand-over. - Task HandOverAsync(Uri target, LivePeerClose peer, Task? receiving, CancellationToken cancellationToken); + Task HandOverAsync(TransferCommand transfer, LivePeerClose peer, Task? receiving, CancellationToken cancellationToken); } } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveChannel.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveChannel.cs new file mode 100644 index 0000000..4e672d0 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveChannel.cs @@ -0,0 +1,97 @@ +using AgentCore.Application.Conversation.Commands; +using AgentCore.Application.Runtime.Session; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// + /// GPT-Live's channel. The webhook attaches it as soon as the call is admitted, before CallStarted, so a host + /// can send a command from that notice on. A transfer is the 's, once the call runs. Voice + /// context goes out as silent context at once, which in a probe never made the voice speak or cut its speech + /// (docs/probes/live-late-brief); context sent before session.started waits for it, as OpenAI's guide sends + /// nothing before it. + /// + internal sealed class LiveChannel(string callId, ILogger logger) : IConversationChannel + { + private readonly Lock _gate = new(); + + private readonly List _waiting = []; + + private LiveTransfer? _transfer; + + private Func? _tell; + + private CancellationToken _stopping; + + public ChannelCommandResult Send(ChannelCommand command) + { + ArgumentNullException.ThrowIfNull(command); + return command switch + { + TransferCommand transfer => Volatile.Read(ref _transfer)?.Send(transfer) ?? ChannelCommandResult.NotSupported, + AddVoiceContextCommand context => Add(context.Text), + _ => ChannelCommandResult.NotSupported, + }; + } + + /// Lets the call transfer. A transfer sent before this answers as not supported. + internal void Transfers(LiveTransfer transfer) => Volatile.Write(ref _transfer, transfer); + + /// On session.started, before the greeting: tells the context that waited, then each later one at once. + /// Sends one piece of context to GPT-Live. + /// The call's token. Later context goes out under it too. + internal async ValueTask OpenAsync(Func tell, CancellationToken cancellationToken) + { + string[] waiting; + lock (_gate) + { + _stopping = cancellationToken; + _tell = tell; + waiting = [.. _waiting]; + _waiting.Clear(); + } + + foreach (string text in waiting) + { + await tell(text, cancellationToken).ConfigureAwait(false); + } + } + + private ChannelCommandResult Add(string text) + { + Func? tell; + lock (_gate) + { + tell = _tell; + if (tell is null) + { + _waiting.Add(text); + return ChannelCommandResult.Scheduled; + } + } + + _ = TellAsync(tell, text); + return ChannelCommandResult.Scheduled; + } + + private async Task TellAsync(Func tell, string text) + { + try + { + await tell(text, _stopping).ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + // The call ended and let go of its socket: there is no voice left to tell. + } + catch (OperationCanceledException) when (_stopping.IsCancellationRequested) + { + // The host is stopping; OpenAiLiveCalls ends the call itself. + } + catch (Exception fault) when (fault is not OutOfMemoryException) + { + OpenAiLiveLog.VoiceContextFailed(logger, callId, fault); + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHostTransfer.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHostTransfer.cs index 36d2845..6e7fc55 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHostTransfer.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHostTransfer.cs @@ -1,22 +1,26 @@ -using AgentCore.Application.Conversation.Actions; +using AgentCore.Application.Conversation.Commands; using Microsoft.Extensions.Logging; namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call { /// - /// The host's own transfer, through its . Its answer is the outcome, so nothing waits for - /// the peer's close, and the AI leg is hung up from this side once the line took the call. + /// The host's own transfer, through its . Its answer is the + /// outcome, so nothing waits for the peer's close, and the AI leg is hung up from this side once the line took the call. /// internal sealed class LiveHostTransfer( - ICallTransfer host, string conversationId, IReadOnlyDictionary headers, ILogger logger, string callId) : ILiveTransferLine + IChannelCommandHandler host, + string conversationId, + IReadOnlyDictionary headers, + ILogger logger, + string callId) : ILiveTransferLine { - public async Task HandOverAsync(Uri target, LivePeerClose peer, Task? receiving, CancellationToken cancellationToken) + public async Task HandOverAsync(TransferCommand transfer, LivePeerClose peer, Task? receiving, CancellationToken cancellationToken) { CallTransferOutcome outcome; try { - outcome = await host.TransferAsync( - new CallTransfer { ConversationId = conversationId, Target = target, Headers = headers }, cancellationToken).ConfigureAwait(false); + outcome = await host.HandleAsync( + transfer, new ChannelCommandContext { ConversationId = conversationId, Headers = headers }, cancellationToken).ConfigureAwait(false); } catch (Exception fault) when (fault is not OperationCanceledException) { diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveReferTransfer.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveReferTransfer.cs index 2edc608..4d1c52e 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveReferTransfer.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveReferTransfer.cs @@ -1,3 +1,4 @@ +using AgentCore.Application.Conversation.Commands; using Microsoft.Extensions.Logging; namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call @@ -9,12 +10,12 @@ namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call /// internal sealed class LiveReferTransfer(Func> refer, TimeSpan wait, ILogger logger, string callId) : ILiveTransferLine { - public async Task HandOverAsync(Uri target, LivePeerClose peer, Task? receiving, CancellationToken cancellationToken) + public async Task HandOverAsync(TransferCommand transfer, LivePeerClose peer, Task? receiving, CancellationToken cancellationToken) { bool sent; try { - sent = await refer(target, cancellationToken).ConfigureAwait(false); + sent = await refer(transfer.Target, cancellationToken).ConfigureAwait(false); } catch (Exception fault) when (fault is not OperationCanceledException) { @@ -28,6 +29,7 @@ public async Task HandOverAsync(Uri target, LivePeerClose peer, Ta } (bool closed, receiving) = await peer.WaitAsync(wait, receiving, cancellationToken).ConfigureAwait(false); + return closed ? new LiveHandover(LiveHandoverOutcome.TakenAndClosed, null) : new LiveHandover(LiveHandoverOutcome.NotTaken, receiving, "the call was still up when the transfer wait ran out"); diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTransfer.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTransfer.cs index 4197562..a105840 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTransfer.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTransfer.cs @@ -1,43 +1,41 @@ -using AgentCore.Application.Conversation.Actions; -using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Conversation.Commands; namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call { /// - /// GPT-Live's side of . A request only records the action: the call's read loop hands the + /// GPT-Live's side of . A send only records the command: the call's read loop hands the /// caller over through once the caller heard the answer, so the transfer never cuts a reply. /// - internal sealed class LiveTransfer(ILiveTransferLine line, Func leaving, Action scheduled) : IConversationChannel + internal sealed class LiveTransfer(ILiveTransferLine line, Func leaving, Action scheduled) { - private TransferAction? _pending; + private TransferCommand? _pending; internal ILiveTransferLine Line => line; - internal Uri? Target => Volatile.Read(ref _pending)?.Target; + internal TransferCommand? Pending => Volatile.Read(ref _pending); - internal string? IfFailed => Volatile.Read(ref _pending)?.IfFailed; + internal Uri? Target => Pending?.Target; - public ConversationActionResult Request(ConversationAction action) - { - ArgumentNullException.ThrowIfNull(action); - if (action is not TransferAction transfer) - { - return ConversationActionResult.NotSupported; - } + internal string? IfFailed => Pending?.IfFailed; + internal ChannelCommandResult Send(TransferCommand transfer) + { // A call that is leaving has no line left to hand over, and a second transfer would contradict the first, // which may already be on the line. if (leaving() || Interlocked.CompareExchange(ref _pending, transfer, null) is not null) { - return ConversationActionResult.Ending; + return ChannelCommandResult.Ending; } // A transfer asked outside a delegation has no answer of its own to leave after. scheduled(); - return ConversationActionResult.Scheduled; + return ChannelCommandResult.Scheduled; } /// The line did not take the call and the call goes on, so the caller's next words start turns again. - internal void Failed() => Volatile.Write(ref _pending, null); + internal void Failed() + { + Volatile.Write(ref _pending, null); + } } } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs index 77dda4e..121a220 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs @@ -1,11 +1,10 @@ using System.Globalization; using System.Text.Json.Nodes; +using AgentCore.Application.Conversation.Commands; using AgentCore.Application.Hooks.Notices; -using AgentCore.Application.Transcript; using AgentCore.AspNetCore.Calls; using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; using AgentCore.Domain.Audit; -using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call @@ -43,9 +42,9 @@ internal sealed class OpenAiLiveCall : IDisposable private readonly PhoneCall _call; - private readonly ILiveSideband _sideband; + private readonly LiveChannel _channel; - private readonly ILogger _logger; + private readonly ILiveSideband _sideband; private readonly string? _greeting; @@ -68,12 +67,16 @@ internal sealed class OpenAiLiveCall : IDisposable private long _eventIds; + private bool _started; + /// - /// A greeting leaves GPT-Live silent until the caller speaks. A + /// is the call's channel, already attached to its conversation. A + /// greeting leaves GPT-Live silent until the caller speaks. A /// transfer line leaves the call unable to transfer, so its conversation answers a transfer as not supported. /// internal OpenAiLiveCall( PhoneCall call, + LiveChannel channel, ILiveSideband sideband, Func> hangUp, ILogger logger, @@ -81,8 +84,9 @@ internal OpenAiLiveCall( ILiveTransferLine? transferLine = null) { _call = call; + _channel = channel; _sideband = sideband; - _logger = logger; + Logger = logger; _greeting = greeting; _end = new LiveCallEnd(call.Host.Time, call.CallId, logger); _hearing = new LiveHearing(call); @@ -91,11 +95,15 @@ internal OpenAiLiveCall( : new LiveTransfer(transferLine, () => _end.Finished || call.HasEnded, () => _ = TransferAfterAnswersAsync()); _answers = new LiveAnswers(call, _end, SendAsync, NextEventId, () => call.EngineEnded || _transfer?.Target is not null, logger); _finish = new LiveCallFinish(call, _end, _answers, hangUp, logger); + if (_transfer is not null) + { + channel.Transfers(_transfer); + } } internal string CallId => _call.CallId; - internal ILogger Logger => _logger; + internal ILogger Logger { get; } /// Disposes the send lock and the end timers. calls this once every answer is done. public void Dispose() @@ -107,41 +115,32 @@ public void Dispose() internal async Task RunAsync(CancellationToken cancellationToken) { Task? receiving = null; + Volatile.Write(ref _looping, 1); + _ = HangUpAfterAnyEngineEndAsync(); - if (_transfer is not null) - { - _call.Channel.Use(_transfer, _call.Session); - } try { - // GPT-Live knows no date of its own: on a real call it named the wrong weekday. - if (_call.Session.Runner.ClockLine() is { } clock) - { - await SendAsync(OpenAiLiveWire.SessionFact(NextEventId(), clock), cancellationToken).ConfigureAwait(false); - } - - if (_greeting is not null) - { - await SendAsync(OpenAiLiveWire.GreetFirst(NextEventId(), _greeting), cancellationToken).ConfigureAwait(false); - } - while (true) { receiving ??= _sideband.ReceiveAsync(cancellationToken).AsTask(); + _ = await Task.WhenAny(receiving, _end.Requested).ConfigureAwait(false); + if (_end.Requested.IsCompleted) { (ConversationEndReason reason, string cause) = await _end.Requested.ConfigureAwait(false); + await HearReceivedAsync(receiving).ConfigureAwait(false); + await _hearing.FlushAsync().ConfigureAwait(false); // The quiet wait after the last answer ends in the transfer when one waits, in place of the hang-up. - if (cause == AgentEndedCause && _transfer is { Target: { } target }) + if (cause == AgentEndedCause && _transfer is { Pending: { } pending }) { (receiving, bool goesOn) = await TransferAsync( - _transfer, target, receiving is { IsCompleted: true } ? null : receiving, cancellationToken).ConfigureAwait(false); + _transfer, pending, receiving is { IsCompleted: true } ? null : receiving, cancellationToken).ConfigureAwait(false); if (goesOn) { @@ -151,13 +150,17 @@ internal async Task RunAsync(CancellationToken cancellationToken) return; } - OpenAiLiveLog.HungUpBecause(_logger, CallId, cause); + OpenAiLiveLog.HungUpBecause(Logger, CallId, cause); + await _finish.HangUpAndFinishAsync(reason, cause, cancellationToken).ConfigureAwait(false); + return; } string? json = await receiving.ConfigureAwait(false); + receiving = null; + if (json is null) { break; @@ -170,6 +173,7 @@ internal async Task RunAsync(CancellationToken cancellationToken) } await _hearing.FlushAsync().ConfigureAwait(false); + await _finish.FinishAsync(ConversationEndReason.Faulted, SidebandClosedCause).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -178,15 +182,20 @@ internal async Task RunAsync(CancellationToken cancellationToken) } catch (Exception fault) when (fault is not OperationCanceledException) { - OpenAiLiveLog.SidebandFaulted(_logger, CallId, fault); + OpenAiLiveLog.SidebandFaulted(Logger, CallId, fault); + await _hearing.FlushAsync().ConfigureAwait(false); + await _finish.FinishAsync(ConversationEndReason.Faulted, SidebandFailedCause).ConfigureAwait(false); } finally { _ = _loopLeft.TrySetResult(); + await _answers.WhenAnsweredAsync().ConfigureAwait(false); + Dispose(); + await _sideband.DisposeAsync().ConfigureAwait(false); // A receive left behind by an end ends with the socket; nothing reads its result. @@ -202,6 +211,7 @@ internal async Task RunAsync(CancellationToken cancellationToken) internal async Task EndAsync(ConversationEndReason reason, string cause, CancellationToken cancellationToken) { _end.Now(reason, cause); + if (Volatile.Read(ref _looping) == 1) { try @@ -228,6 +238,7 @@ private async ValueTask HandleAsync(LiveEvent liveEvent, CancellationToken } await _hearing.HearAsync(delta).ConfigureAwait(false); + return false; case LiveEvent.Delegation delegation: @@ -241,17 +252,28 @@ private async ValueTask HandleAsync(LiveEvent liveEvent, CancellationToken return false; + case LiveEvent.Started when !_started: + _started = true; + + await OpenAsync(cancellationToken).ConfigureAwait(false); + + return false; + case LiveEvent.Appended { EventId: { } eventId }: _end.Acked(eventId); + return false; case LiveEvent.Closed end: await _hearing.FlushAsync().ConfigureAwait(false); + await _finish.FinishAsync(ConversationEndReason.CallerHungUp, end.Reason ?? SidebandClosedCause).ConfigureAwait(false); + return true; case LiveEvent.Failed failed: - OpenAiLiveLog.LiveError(_logger, CallId, failed.Code ?? "unknown", failed.Message ?? string.Empty); + OpenAiLiveLog.LiveError(Logger, CallId, failed.Code ?? "unknown", failed.Message ?? string.Empty); + return false; default: @@ -259,13 +281,36 @@ private async ValueTask HandleAsync(LiveEvent liveEvent, CancellationToken } } + private async ValueTask OpenAsync(CancellationToken cancellationToken) + { + // GPT-Live knows no date of its own: on a real call it named the wrong weekday. + if (_call.Session.Runner.ClockLine() is { } clock) + { + await SendAsync(OpenAiLiveWire.SessionFact(NextEventId(), clock), cancellationToken).ConfigureAwait(false); + } + + await _channel.OpenAsync(TellAsync, cancellationToken).ConfigureAwait(false); + + if (_greeting is not null) + { + await SendAsync(OpenAiLiveWire.GreetFirst(NextEventId(), _greeting), cancellationToken).ConfigureAwait(false); + } + } + + private ValueTask TellAsync(string context, CancellationToken cancellationToken) + { + return SendAsync(OpenAiLiveEvents.SessionThinking(context, NextEventId()), cancellationToken); + } + // An end from outside every delegation (an operator's, the host's) has no answer of its own to hang up after. // Once the delegations in flight are answered, a call none of them set to end hangs up when GPT-Live's speech // has been quiet for EndQuietWait. The session is read once: an unload during the call is not followed. private async Task HangUpAfterAnyEngineEndAsync() { await _call.Session.Lifetime.Ending.WhenRequested.ConfigureAwait(false); + await _answers.WhenAnsweredAsync().ConfigureAwait(false); + _end.WhenQuiet(); } @@ -274,15 +319,18 @@ private async Task HangUpAfterAnyEngineEndAsync() private async Task TransferAfterAnswersAsync() { await _answers.WhenAnsweredAsync().ConfigureAwait(false); + _end.WhenQuiet(); } // A message that arrived together with the end is still part of the call: its words are heard. A delegation in // it is not answered, and the ledger flush after this closes the line it would have closed. - private ValueTask HearReceivedAsync(Task? receiving) => - receiving is { IsCompletedSuccessfully: true, Result: { } json } && LiveEventReader.Read(json) is LiveEvent.Transcript delta + private ValueTask HearReceivedAsync(Task? receiving) + { + return receiving is { IsCompletedSuccessfully: true, Result: { } json } && LiveEventReader.Read(json) is LiveEvent.Transcript delta ? _hearing.HearAsync(delta) : ValueTask.CompletedTask; + } private async ValueTask SendAsync(JsonObject liveEvent, CancellationToken cancellationToken) { @@ -302,25 +350,29 @@ private async ValueTask SendAsync(JsonObject liveEvent, CancellationToken cancel } private async Task<(Task? Receiving, bool GoesOn)> TransferAsync( - LiveTransfer transfer, Uri target, Task? receiving, CancellationToken cancellationToken) + LiveTransfer transfer, TransferCommand pending, Task? receiving, CancellationToken cancellationToken) { - OpenAiLiveLog.Transferring(_logger, CallId); + OpenAiLiveLog.Transferring(Logger, CallId); + LiveHandover handover = await transfer.Line - .HandOverAsync(target, new LivePeerClose(_sideband, _hearing, _call.Host.Time), receiving, cancellationToken).ConfigureAwait(false); + .HandOverAsync(pending, new LivePeerClose(_sideband, _hearing, _call.Host.Time), receiving, cancellationToken).ConfigureAwait(false); switch (handover.Outcome) { case LiveHandoverOutcome.Taken: await _finish.HangUpAndFinishAsync(ConversationEndReason.TransferredToHuman, TransferredCause, cancellationToken).ConfigureAwait(false); + return (handover.Receiving, false); case LiveHandoverOutcome.TakenAndClosed: await _finish.FinishAsync(ConversationEndReason.TransferredToHuman, TransferredCause).ConfigureAwait(false); + return (null, false); case LiveHandoverOutcome.NotTaken: default: bool goesOn = await FailedTransferAsync(transfer, handover.Why ?? "the line did not take the call", cancellationToken).ConfigureAwait(false); + return (handover.Receiving, goesOn); } } @@ -331,15 +383,21 @@ private async Task FailedTransferAsync(LiveTransfer transfer, string why, { if (transfer.IfFailed is not { } words || _call.HasEnded) { - OpenAiLiveLog.TransferFailed(_logger, CallId, why); + OpenAiLiveLog.TransferFailed(Logger, CallId, why); + await _finish.HangUpAndFinishAsync(ConversationEndReason.Faulted, TransferFailedCause, cancellationToken).ConfigureAwait(false); + return false; } - OpenAiLiveLog.TransferFailedGoingOn(_logger, CallId, why); + OpenAiLiveLog.TransferFailedGoingOn(Logger, CallId, why); + transfer.Failed(); + _end.Rearm(); + await SendAsync(OpenAiLiveWire.SayNow(NextEventId(), words), cancellationToken).ConfigureAwait(false); + if (_call.EngineEnded) { _end.WhenQuiet(); @@ -348,6 +406,9 @@ private async Task FailedTransferAsync(LiveTransfer transfer, string why, return true; } - private string NextEventId() => "ac" + Interlocked.Increment(ref _eventIds).ToString(CultureInfo.InvariantCulture); + private string NextEventId() + { + return "ac" + Interlocked.Increment(ref _eventIds).ToString(CultureInfo.InvariantCulture); + } } } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs index e454b11..fa9dda7 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs @@ -1,5 +1,4 @@ using System.Collections.Concurrent; -using AgentCore.Application.Conversation.Actions; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.Vendors.OpenAiLive.Call; using AgentCore.Domain.Audit; @@ -26,12 +25,6 @@ internal sealed class OpenAiLiveCalls(IServiceProvider services) : IHostedServic private readonly CancellationTokenSource _stopping = new(); - /// - /// Gets the host's own transfer, or when it registered none. It comes from the root - /// services: a call outlives the request that started it, so a scoped one would be used after its scope ended. - /// - internal ICallTransfer? HostTransfer => services.GetService(); - private readonly Lock _gate = new(); private bool _stopped; @@ -65,12 +58,21 @@ internal bool TryClaimWebhook(string webhookId, DateTimeOffset now) return _webhooks.TryAdd(webhookId, now); } - internal bool TryClaimCall(string callId) => _claimed.TryAdd(callId, 0); + internal bool TryClaimCall(string callId) + { + return _claimed.TryAdd(callId, 0); + } - internal void ReleaseCall(string callId) => _claimed.TryRemove(callId, out _); + internal void ReleaseCall(string callId) + { + _claimed.TryRemove(callId, out _); + } /// Forgets a webhook id whose request failed, so the retry OpenAI sends under the same id is processed. - internal void ReleaseWebhook(string webhookId) => _webhooks.TryRemove(webhookId, out _); + internal void ReleaseWebhook(string webhookId) + { + _webhooks.TryRemove(webhookId, out _); + } /// /// Runs a call to its end. A call that arrives after the host began to stop is hung up and ended with cause diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs index 3e23c05..b75a311 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs @@ -56,5 +56,8 @@ internal static partial class OpenAiLiveLog [LoggerMessage(EventId = 327, Level = LogLevel.Warning, Message = "transferring the call {CallId} failed: {Why}. The caller is told, and the call goes on.")] internal static partial void TransferFailedGoingOn(ILogger logger, string callId, string why); + + [LoggerMessage(EventId = 328, Level = LogLevel.Warning, Message = "a fact for the voice of the call {CallId} could not be sent, so the voice goes on without it.")] + internal static partial void VoiceContextFailed(ILogger logger, string callId, Exception fault); } } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs index 4413285..c7aaa3e 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs @@ -1,4 +1,5 @@ using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Conversation.Commands; using AgentCore.Application.Hooks.Gates; using AgentCore.Application.Hooks.Layers; using AgentCore.AspNetCore.Calls; @@ -49,6 +50,7 @@ internal async Task HandleAsync(HttpContext http) string? webhookId = Header(http, "webhook-id"); http.Response.StatusCode = StatusCodes.Status200OK; + if (!calls.TryClaimWebhook(webhookId!, time.GetUtcNow()) || OpenAiLiveWire.ReadIncomingCall(body) is not { } incoming || !calls.TryClaimCall(incoming.CallId)) @@ -95,6 +97,10 @@ private async Task AnswerAsync(HttpContext http, LiveIncomingCall incoming return false; } + // Attached before the call starts, so a command a hook sends from CallStarted on reaches the call. + LiveChannel channel = new(incoming.CallId, logger); + call.Channel.Use(channel, call.Session); + if (!await AcceptAndStartAsync(call, incoming.CallId, logger, stopping).ConfigureAwait(false)) { return false; @@ -108,23 +114,29 @@ private async Task AnswerAsync(HttpContext http, LiveIncomingCall incoming catch (Exception fault) when (fault is not OutOfMemoryException) { OpenAiLiveLog.AttachFailed(logger, incoming.CallId, fault); + await HangUpAsync(incoming.CallId, logger).ConfigureAwait(false); + await call.EndAsync(ConversationEndReason.Faulted, OpenAiLiveCall.AttachFailedCause).ConfigureAwait(false); + await call.CloseAsync().ConfigureAwait(false); + return false; } - ILiveTransferLine transferLine = calls.HostTransfer is { } hostTransfer + ILiveTransferLine transferLine = host.HandlerFor() is { } hostTransfer ? new LiveHostTransfer(hostTransfer, call.ConversationId, incoming.Headers, logger, incoming.CallId) : new LiveReferTransfer((target, token) => control.ReferAsync(incoming.CallId, target, token), settings.TransferWait, logger, incoming.CallId); calls.Run(new OpenAiLiveCall( call, + channel, sideband, _ => control.HangupQuietlyAsync(incoming.CallId), logger, settings.Greeting, transferLine)); + return true; } @@ -141,7 +153,9 @@ private async Task AcceptAndStartAsync(PhoneCall call, string callId, ILog } await call.StartAsync(stopping).ConfigureAwait(false); + started = true; + return true; } catch (Exception) @@ -149,6 +163,7 @@ private async Task AcceptAndStartAsync(PhoneCall call, string callId, ILog // A thrown accept may still have landed, and a failed start follows an accept that did: either way // GPT-Live would be left with a call and no sideband. await HangUpAsync(callId, logger).ConfigureAwait(false); + throw; } finally @@ -166,12 +181,14 @@ private async Task AcceptedAsync(PhoneCall call, string callId, ILogger lo callId, OpenAiLiveWire.AcceptBody(settings.Model, settings.Voice, LiveInstructions.Build(settings.Instructions, call.Brief)), stopping).ConfigureAwait(false); + if (outcome == AcceptOutcome.Accepted) { return true; } OpenAiLiveLog.AcceptRefused(logger, callId, outcome); + if (outcome == AcceptOutcome.Failed && !await control.RejectQuietlyAsync(callId, CallRefusal.Unavailable).ConfigureAwait(false)) { OpenAiLiveLog.RejectFailed(logger, callId); @@ -188,18 +205,22 @@ private async Task HangUpAsync(string callId, ILogger logger) } } - private static string? Header(HttpContext http, string name) => - http.Request.Headers.TryGetValue(name, out StringValues value) ? value.ToString() : null; + private static string? Header(HttpContext http, string name) + { + return http.Request.Headers.TryGetValue(name, out StringValues value) ? value.ToString() : null; + } /// Leaves the body buffered, because reads it again. internal async ValueTask IsOpenAiAsync(HttpContext http) { OpenAiLiveCredentials keys = await credentials.ConfigureAwait(false); + http.Request.EnableBuffering(); byte[]? body = await ReadBodyAsync(http.Request, http.RequestAborted).ConfigureAwait(false); http.Request.Body.Position = 0; TimeProvider time = http.RequestServices.GetRequiredService(); + if (body is not null && StandardWebhookSignature.Verify(keys.WebhookKey, Header(http, "webhook-id"), Header(http, "webhook-timestamp"), body, Header(http, "webhook-signature"), time.GetUtcNow())) { @@ -207,6 +228,7 @@ internal async ValueTask IsOpenAiAsync(HttpContext http) } OpenAiLiveLog.SignatureRefused(http.RequestServices.GetRequiredService().CreateLogger("AgentCore.OpenAiLive")); + return false; } @@ -215,6 +237,7 @@ internal async ValueTask IsOpenAiAsync(HttpContext http) using MemoryStream body = new(); byte[] buffer = new byte[8192]; int read; + while ((read = await request.Body.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) { if (body.Length + read > MaxBodyBytes) diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEvent.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEvent.cs index ca4b7ac..cde9631 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEvent.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEvent.cs @@ -9,6 +9,9 @@ private protected LiveEvent() { } + /// The session is ready for session-wide instructions. + internal sealed record Started : LiveEvent; + /// A piece of what one side said. Times are milliseconds from the session's start; start_ms of the last word, not its end. internal sealed record Transcript(Speaker Speaker, string Delta, int StartMs, int EndMs) : LiveEvent; diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEventReader.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEventReader.cs index 5da5b24..c87d162 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEventReader.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEventReader.cs @@ -24,6 +24,7 @@ internal static LiveEvent Read(string json) return type switch { + OpenAiLiveEvents.Started => new LiveEvent.Started(), OpenAiLiveEvents.InputTranscriptDelta => Transcript(root, Speaker.Caller, type), OpenAiLiveEvents.OutputTranscriptDelta => Transcript(root, Speaker.Agent, type), OpenAiLiveEvents.DelegationCreated => root.TryGetProperty("delegation", out JsonElement delegation) && Text(delegation, "id") is { Length: > 0 } id diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveEvents.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveEvents.cs index 6059dd0..a559707 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveEvents.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveEvents.cs @@ -8,6 +8,8 @@ namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Wire /// internal static class OpenAiLiveEvents { + internal const string Started = "session.started"; + internal const string InputTranscriptDelta = "session.input_transcript.delta"; internal const string OutputTranscriptDelta = "session.output_transcript.delta"; @@ -23,17 +25,35 @@ internal static class OpenAiLiveEvents internal const string Closed = "session.closed"; /// Silent context for the open delegation. - internal static JsonObject Thinking(string delegationId, string content, string eventId) => Append(ThinkingAppend, delegationId, content, eventId); + internal static JsonObject Thinking(string delegationId, string content, string eventId) + { + return Append(ThinkingAppend, delegationId, content, eventId); + } + + /// + /// Silent context for the whole session, under no delegation. In a probe it never made GPT-Live speak or cut its + /// speech; session.instructions.append cut the greeting (docs/probes/live-late-brief). + /// + internal static JsonObject SessionThinking(string content, string eventId) + { + return Append(ThinkingAppend, delegationId: null, content, eventId); + } /// Words GPT-Live speaks for the open delegation, lightly paraphrased. At most 500 tokens. - internal static JsonObject Commentary(string delegationId, string content, string eventId) => Append(CommentaryAppend, delegationId, content, eventId); + internal static JsonObject Commentary(string delegationId, string content, string eventId) + { + return Append(CommentaryAppend, delegationId, content, eventId); + } - private static JsonObject Append(string type, string delegationId, string content, string eventId) => new() + private static JsonObject Append(string type, string? delegationId, string content, string eventId) { - ["type"] = type, - ["delegation_id"] = delegationId, - ["content"] = content, - ["event_id"] = eventId, - }; + return new() + { + ["type"] = type, + ["delegation_id"] = delegationId, + ["content"] = content, + ["event_id"] = eventId, + }; + } } } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs index 2c8c29a..9a732bf 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs @@ -70,12 +70,20 @@ internal static Uri AttachUri(Uri apiBase, string callId) }; /// - /// The event that makes GPT-Live greet the caller instead of waiting for the caller's voice: instructions with no - /// delegation, from OpenAI's live-conversations guide. A real SIP call without it stayed silent until the caller spoke. + /// The event that makes GPT-Live greet the caller instead of waiting for the caller's voice: speakable commentary + /// with no delegation. A real SIP call without it stayed silent until the caller spoke. OpenAI's live-conversations + /// guide sends the greeting as instructions, but GPT-Live spoke those in only 5 of 10 probe sessions (2026-10-06); + /// commentary, in 8 of 8, within about a second. /// - internal static JsonObject GreetFirst(string eventId, string greeting) => SessionFact(eventId, greeting); + internal static JsonObject GreetFirst(string eventId, string greeting) => new() + { + ["type"] = OpenAiLiveEvents.CommentaryAppend, + ["event_id"] = eventId, + ["delegation_id"] = null, + ["content"] = greeting, + }; - /// Words GPT-Live says at once, outside every delegation: the event that makes it greet first. + /// Words GPT-Live says at once, outside every delegation. internal static JsonObject SayNow(string eventId, string words) => SessionFact(eventId, words); /// diff --git a/tests/AgentCore.Application.Tests/Conversation/ConversationActionsTests.cs b/tests/AgentCore.Application.Tests/Conversation/ChannelCommandsTests.cs similarity index 56% rename from tests/AgentCore.Application.Tests/Conversation/ConversationActionsTests.cs rename to tests/AgentCore.Application.Tests/Conversation/ChannelCommandsTests.cs index 23b18e8..5dca130 100644 --- a/tests/AgentCore.Application.Tests/Conversation/ConversationActionsTests.cs +++ b/tests/AgentCore.Application.Tests/Conversation/ChannelCommandsTests.cs @@ -1,4 +1,4 @@ -using AgentCore.Application.Conversation.Actions; +using AgentCore.Application.Conversation.Commands; using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Runtime.Session; using AgentCore.Application.Tests.Fakes; @@ -10,34 +10,34 @@ namespace AgentCore.Application.Tests.Conversation { - public sealed class ConversationActionsTests + public sealed class ChannelCommandsTests { private static readonly Uri Staff = new("tel:+15550002222"); [Fact] - public async Task AnEndActionEndsTheConversationWithItsReason() + public async Task AnEndCommandEndsTheConversationWithItsReason() { RecordingHook hook = new(); using ScriptedChatClient reply = new("hello"); ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook]); - Assert.Equal(ConversationActionResult.Scheduled, session.Request(new EndConversationAction(ConversationEndReason.TransferredToHuman))); + Assert.Equal(ChannelCommandResult.Scheduled, session.Send(new EndCommand(ConversationEndReason.TransferredToHuman))); await session.FlushNoticesAsync(); Assert.Equal(ConversationEndReason.TransferredToHuman, Assert.Single(hook.Of()).Reason); } [Fact] - public void AnEndingConversationTakesNoActionAndNeverAsksTheChannel() + public void AnEndingConversationTakesNoCommandAndNeverAsksTheChannel() { using ScriptedChatClient reply = new("hello"); ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply); - RecordingChannel channel = new(ConversationActionResult.Scheduled); - session.Actions.Attach(channel); + RecordingChannel channel = new(ChannelCommandResult.Scheduled); + session.Commands.Attach(channel); _ = session.EndConversation(ConversationEndReason.CallerHungUp); - Assert.Equal(ConversationActionResult.Ending, session.Request(new EndConversationAction(ConversationEndReason.AgentCompleted))); - Assert.Equal(ConversationActionResult.Ending, session.Request(new TransferAction(Staff))); + Assert.Equal(ChannelCommandResult.Ending, session.Send(new EndCommand(ConversationEndReason.AgentCompleted))); + Assert.Equal(ChannelCommandResult.Ending, session.Send(new TransferCommand(Staff))); Assert.Empty(channel.Seen); } @@ -47,7 +47,7 @@ public void ATransferWithNoChannelIsNotSupportedAndLeavesTheConversationOpen() using ScriptedChatClient reply = new("hello"); ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply); - Assert.Equal(ConversationActionResult.NotSupported, session.Request(new TransferAction(Staff))); + Assert.Equal(ChannelCommandResult.NotSupported, session.Send(new TransferCommand(Staff))); Assert.False(session.IsComplete); } @@ -56,26 +56,26 @@ public void TheAttachedChannelTakesATransfer() { using ScriptedChatClient reply = new("hello"); ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply); - RecordingChannel channel = new(ConversationActionResult.Scheduled); - TransferAction transfer = new(Staff); + RecordingChannel channel = new(ChannelCommandResult.Scheduled); + TransferCommand transfer = new(Staff); - session.Actions.Attach(channel); - Assert.Equal(ConversationActionResult.Scheduled, session.Request(transfer)); + session.Commands.Attach(channel); + Assert.Equal(ChannelCommandResult.Scheduled, session.Send(transfer)); Assert.Same(transfer, Assert.Single(channel.Seen)); } - // A newer call that takes the conversation over carries its actions from then on. + // A newer call that takes the conversation over carries its commands from then on. [Fact] public void ANewerChannelReplacesTheOlderOne() { using ScriptedChatClient reply = new("hello"); ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply); - RecordingChannel older = new(ConversationActionResult.Scheduled); - RecordingChannel newer = new(ConversationActionResult.Scheduled); - session.Actions.Attach(older); - session.Actions.Attach(newer); + RecordingChannel older = new(ChannelCommandResult.Scheduled); + RecordingChannel newer = new(ChannelCommandResult.Scheduled); + session.Commands.Attach(older); + session.Commands.Attach(newer); - Assert.Equal(ConversationActionResult.Scheduled, session.Request(new TransferAction(Staff))); + Assert.Equal(ChannelCommandResult.Scheduled, session.Send(new TransferCommand(Staff))); Assert.Single(newer.Seen); Assert.Empty(older.Seen); } @@ -85,7 +85,7 @@ public void AScopeBuiltOutsideATurnCanDoNothing() { ToolCallScope scope = new("c-1", 0, string.Empty); - Assert.Equal(ConversationActionResult.NotSupported, scope.Conversation.Request(new EndConversationAction(ConversationEndReason.AgentCompleted))); + Assert.Equal(ChannelCommandResult.NotSupported, scope.Channel.Send(new EndCommand(ConversationEndReason.AgentCompleted))); } [Theory] @@ -94,7 +94,7 @@ public void AScopeBuiltOutsideATurnCanDoNothing() [InlineData("sips:agent@example.com")] public void ATransferTakesTelSipAndSipsTargets(string target) { - Assert.Equal(new Uri(target), new TransferAction(new Uri(target)).Target); + Assert.Equal(new Uri(target), new TransferCommand(new Uri(target)).Target); } [Theory] @@ -102,16 +102,23 @@ public void ATransferTakesTelSipAndSipsTargets(string target) [InlineData("mailto:staff@example.com")] public void ATransferRefusesAnyOtherTarget(string target) { - _ = Assert.Throws(() => new TransferAction(new Uri(target))); + _ = Assert.Throws(() => new TransferCommand(new Uri(target))); } - private sealed class RecordingChannel(ConversationActionResult answer) : IConversationChannel + // GPT-Live takes at most 500 tokens in one update. + [Fact] + public void AVoiceFactIsCutToItsFirstThousandCharacters() + { + Assert.Equal(new string('a', 1000), new AddVoiceContextCommand(new string('a', 1000) + "b").Text); + } + + private sealed class RecordingChannel(ChannelCommandResult answer) : IConversationChannel { - public List Seen { get; } = []; + public List Seen { get; } = []; - public ConversationActionResult Request(ConversationAction action) + public ChannelCommandResult Send(ChannelCommand command) { - Seen.Add(action); + Seen.Add(command); return answer; } } diff --git a/tests/AgentCore.Application.Tests/Tools/ToolCallScopeTests.cs b/tests/AgentCore.Application.Tests/Tools/ToolCallScopeTests.cs index 98725f0..340b0b0 100644 --- a/tests/AgentCore.Application.Tests/Tools/ToolCallScopeTests.cs +++ b/tests/AgentCore.Application.Tests/Tools/ToolCallScopeTests.cs @@ -1,6 +1,6 @@ using System.Runtime.CompilerServices; using AgentCore.Application.Configuration.Compilation; -using AgentCore.Application.Conversation.Actions; +using AgentCore.Application.Conversation.Commands; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; @@ -63,16 +63,16 @@ public async Task ABoundTool_ReceivesTheRunningConversationsScope() [Fact] public async Task ABoundToolAsksItsOwnConversationForAnActionThroughTheScope() { - List answers = []; + List answers = []; ToolBindingRegistry bindings = new(); _ = bindings.Register( "RequestHuman", - (string reason, ToolCallScope scope) => answers.Add(scope.Conversation.Request(new EndConversationAction(ConversationEndReason.TransferredToHuman)))); + (string reason, ToolCallScope scope) => answers.Add(scope.Channel.Send(new EndCommand(ConversationEndReason.TransferredToHuman)))); ConversationSession session = await CreateAsync(bindings, "conversation-scope-2"); _ = await session.RunTurnAsync("I need a person", TestContext.Current.CancellationToken); - Assert.Equal(ConversationActionResult.Scheduled, Assert.Single(answers)); + Assert.Equal(ChannelCommandResult.Scheduled, Assert.Single(answers)); Assert.True(session.IsComplete); } diff --git a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallChannelTests.cs b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallChannelTests.cs index 426d566..3676e89 100644 --- a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallChannelTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallChannelTests.cs @@ -1,4 +1,4 @@ -using AgentCore.Application.Conversation.Actions; +using AgentCore.Application.Conversation.Commands; using AgentCore.Application.Runtime.Session; using AgentCore.AspNetCore.Calls; using AgentCore.AspNetCore.Tests.Fakes; @@ -8,11 +8,11 @@ namespace AgentCore.AspNetCore.Tests.Calls { public sealed class PhoneCallChannelTests { - private static readonly TransferAction Transfer = new(new Uri("tel:+15550002222")); + private static readonly TransferCommand Transfer = new(new Uri("tel:+15550002222")); private static CancellationToken Ct => TestContext.Current.CancellationToken; - // The tools of the next ask run on the session reopened after an idle unload, so the vendor's actions must be there. + // The tools of the next ask run on the session reopened after an idle unload, so the vendor's channel must be there. [Fact] public async Task TheChannelFollowsTheCallToASessionReopenedAfterAnUnload() { @@ -20,17 +20,17 @@ public async Task TheChannelFollowsTheCallToASessionReopenedAfterAnUnload() PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct)).Call!; await call.StartAsync(Ct); call.Channel.Use(new AcceptingChannel(), call.Session); - Assert.Equal(ConversationActionResult.Scheduled, call.Session.Request(Transfer)); + Assert.Equal(ChannelCommandResult.Scheduled, call.Session.Send(Transfer)); await harness.Sessions.CloseAsync("main", "call-7", Ct); ConversationSession reopened = await call.LiveSessionAsync(Ct); - Assert.Equal(ConversationActionResult.Scheduled, reopened.Request(Transfer)); + Assert.Equal(ChannelCommandResult.Scheduled, reopened.Send(Transfer)); } private sealed class AcceptingChannel : IConversationChannel { - public ConversationActionResult Request(ConversationAction action) => ConversationActionResult.Scheduled; + public ChannelCommandResult Send(ChannelCommand command) => ChannelCommandResult.Scheduled; } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs index 2fc8cee..30286e7 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs @@ -78,13 +78,15 @@ private OpenAiLiveHost(WebApplication app, HttpClient client, AgentCoreHttpClien /// Starts the host. /// Names the fault the sideband attach of a call throws, or for none. /// Anything else the test binds on the options, such as a conversation store. + /// Holds each sideband attach until it completes, or to attach at once. public static async Task StartAsync( IChatClient model, IReadOnlyList hooks, MapSecretResolver? secrets = null, string yaml = LiveYaml, Func? attachFault = null, - Action? configure = null) + Action? configure = null, + Task? attachHeldUntil = null) { WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(); _ = builder.WebHost.UseUrls("http://127.0.0.1:0"); @@ -97,21 +99,28 @@ public static async Task StartAsync( OpenAiLiveConversationAdapter adapter = new( pipeline, secrets ?? new MapSecretResolver().With("openai-api-key", ApiKey).With("openai-webhook-secret", WebhookSecret), - (attach, token) => + async (attach, token) => { if (attachFault?.Invoke(attach) is { } fault) { throw fault; } + if (attachHeldUntil is not null) + { + await attachHeldUntil.WaitAsync(token); + } + + // A real sideband opens with session.started (docs/probes/live-transfer-t1/t1-none.log, 0 ms after attach). FakeSideband sideband = new(); + sideband.Push([RunningLiveCall.Started]); lock (attached) { attached.Add(sideband); } _ = host?.FirstAttach.TrySetResult(sideband); - return ValueTask.FromResult(sideband); + return (ILiveSideband)sideband; }, new Uri("https://api.openai.test/")); diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs index 1f165f9..1f0cd3b 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs @@ -1,5 +1,5 @@ using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Conversation.Actions; +using AgentCore.Application.Conversation.Commands; using AgentCore.Application.Hooks; using AgentCore.AspNetCore.Calls; using AgentCore.AspNetCore.Vendors.OpenAiLive; @@ -22,6 +22,9 @@ internal sealed class RunningLiveCall : IAsyncDisposable public const string Greeting = "Greet the caller, then pause and listen."; + /// The first event every recorded GPT-Live session sent. + public const string Started = """{"type":"session.started"}"""; + private RunningLiveCall(PhoneCallHarness harness, FakeTimeProvider time, PhoneCall call, FakeSideband sideband) { Harness = harness; @@ -48,13 +51,16 @@ private RunningLiveCall(PhoneCallHarness harness, FakeTimeProvider time, PhoneCa public TaskCompletionSource Referred { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); /// - /// Starts the call. With and unset the call cannot - /// transfer. transfers by refer, and each refer answers it; - /// transfers through a host's own , which answers it. + /// Starts the call, which GPT-Live opens with unless is false. + /// With and unset the call cannot transfer. + /// transfers by refer, and each refer answers it; + /// transfers through a host's own , which answers it. + /// The call's channel is attached before the loop runs, as the webhook attaches it. /// public static async Task StartAsync( IChatClient model, IReadOnlyList hooks, string yaml = PhoneCallHarness.OneEntryYaml, ILogger? logger = null, FakeSideband? sideband = null, - string? greeting = Greeting, Func? tools = null, bool? refers = null, CallTransferOutcome? hostAnswers = null) + string? greeting = Greeting, Func? tools = null, bool? refers = null, CallTransferOutcome? hostAnswers = null, + bool started = true) { CancellationToken ct = TestContext.Current.CancellationToken; FakeTimeProvider time = new(Noon) { Zone = TimeZoneInfo.Utc }; @@ -69,7 +75,14 @@ public static async Task StartAsync( (null, { } answer) => new LiveReferTransfer((target, _) => running.ReferAsync(target, answer), OpenAiLiveSettings.DefaultTransferWait, logger, call.CallId), _ => null, }; - OpenAiLiveCall live = new(call, running.Sideband, running.HangUpAsync, logger, greeting, line); + LiveChannel channel = new(call.CallId, logger); + call.Channel.Use(channel, call.Session); + OpenAiLiveCall live = new(call, channel, running.Sideband, running.HangUpAsync, logger, greeting, line); + if (started) + { + running.Sideband.Push([Started]); + } + running.Loop = live.RunAsync(ct); return running; } @@ -93,9 +106,9 @@ private Task HangUpAsync(CancellationToken cancellationToken) return Task.FromResult(true); } - private sealed class AnsweringTransfer(RunningLiveCall running, CallTransferOutcome outcome) : ICallTransfer + private sealed class AnsweringTransfer(RunningLiveCall running, CallTransferOutcome outcome) : IChannelCommandHandler { - public Task TransferAsync(CallTransfer transfer, CancellationToken cancellationToken) + public Task HandleAsync(TransferCommand transfer, ChannelCommandContext context, CancellationToken cancellationToken) { _ = running.Referred.TrySetResult(transfer.Target); return Task.FromResult(outcome); diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveSent.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveSent.cs index e038e42..cf782b1 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveSent.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveSent.cs @@ -30,11 +30,14 @@ internal static class LiveSent - { id: done, agent: only, terminal: true } """; - // Every call opens with the session instructions (see TheCallTellsGptLiveToGreetTheCallerFirst). + // Every call opens with the session instructions and the greeting (see TheCallTellsGptLiveToGreetTheCallerFirst). internal static IEnumerable AfterTheGreeting(RunningLiveCall running) => - running.Sideband.Sent.Where(sent => (string?)sent["type"] != "session.instructions.append"); + running.Sideband.Sent.Where(sent => (string?)sent["type"] != "session.instructions.append" && !IsGreeting(sent)); - internal static bool IsCommentary(JsonObject sent) => (string?)sent["type"] == OpenAiLiveEvents.CommentaryAppend; + // An answer's commentary names its delegation; the greeting's names none. + internal static bool IsCommentary(JsonObject sent) => (string?)sent["type"] == OpenAiLiveEvents.CommentaryAppend && sent["delegation_id"] is not null; + + private static bool IsGreeting(JsonObject sent) => (string?)sent["type"] == OpenAiLiveEvents.CommentaryAppend && sent["delegation_id"] is null; internal static string? TypeOf(string json) => (string?)JsonNode.Parse(json)!["type"]; diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs index 74b24c1..69502a2 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs @@ -145,22 +145,40 @@ public async Task ASidebandThatFailsMidLineStillRaisesTheOpenLine() Assert.Equal((ConversationEndReason.Faulted, "sideband_failed"), (ended.Reason, ended.Call!.Cause)); } - // GPT-Live waits for the caller's voice unless it is told to speak first (OpenAI's live-conversations guide): - // the configured greeting as a session.instructions.append with no delegation, before anything else. + // GPT-Live waits for the caller's voice unless it is told to speak first. The configured greeting goes out before + // anything else as speakable commentary with no delegation: sent as session instructions, GPT-Live spoke it in + // only 5 of 10 probe sessions (2026-10-06); as commentary, in 8 of 8, within about a second. [Fact(Timeout = 30_000)] public async Task TheCallTellsGptLiveToGreetTheCallerFirst() { await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [], EndsAfterTheFirstAskYaml); - JsonObject greet = await running.Sideband.WaitForSentAsync(sent => (string?)sent["type"] == "session.instructions.append"); + JsonObject greet = await running.Sideband.WaitForSentAsync(sent => (string?)sent["content"] == RunningLiveCall.Greeting); Assert.Same(greet, running.Sideband.Sent[0]); + Assert.Equal(OpenAiLiveEvents.CommentaryAppend, (string?)greet["type"]); Assert.True(greet.ContainsKey("delegation_id")); Assert.Null(greet["delegation_id"]); Assert.Equal(RunningLiveCall.Greeting, (string?)greet["content"]); Assert.False(string.IsNullOrWhiteSpace((string?)greet["event_id"])); } + // OpenAI's live-conversations guide: "send greeting instructions after session.started". Instructions sent + // before it may land in a session that is not ready; a real call reported context_injection_incomplete. + [Fact(Timeout = 30_000)] + public async Task TheGreetingWaitsForTheSessionToStart() + { + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [], started: false); + await running.Sideband.WaitForDrainAsync(); + bool sentBeforeTheStart = running.Sideband.Sent.Count > 0; + + running.Sideband.Push([RunningLiveCall.Started, RunningLiveCall.Started]); + await running.Sideband.WaitForDrainAsync(); + + Assert.False(sentBeforeTheStart); + Assert.Single(running.Sideband.Sent, sent => (string?)sent["content"] == RunningLiveCall.Greeting); + } + // A call whose conversation a newer call took is hung up, and says nothing. [Fact(Timeout = 30_000)] public async Task ACallThatLostItsConversationIsHungUpWithNoCommentary() diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallsTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallsTests.cs index 0ee4e02..4b30581 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallsTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallsTests.cs @@ -82,7 +82,7 @@ private static async Task StartedCallAsync( { PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct)).Call!; await call.StartAsync(Ct); - return new OpenAiLiveCall(call, sideband, hangUp, logger ?? NullLogger.Instance); + return new OpenAiLiveCall(call, new LiveChannel(call.CallId, NullLogger.Instance), sideband, hangUp, logger ?? NullLogger.Instance); } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveEndToEndTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveEndToEndTests.cs index 131a594..23e70fc 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveEndToEndTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveEndToEndTests.cs @@ -26,7 +26,7 @@ public async Task TheRecordedP1CallRunsThroughTheRealRoute() int delegation = LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated); sideband.Push(log.Take(delegation + 1)); - JsonObject commentary = await sideband.WaitForSentAsync(sent => (string?)sent["type"] == OpenAiLiveEvents.CommentaryAppend); + JsonObject commentary = await sideband.WaitForSentAsync(LiveSent.IsCommentary); sideband.Push(log.Skip(delegation + 1)); ConversationEnded ended = await hook.WaitForAsync(); diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs index 402acb6..27a6077 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs @@ -1,7 +1,7 @@ using System.Collections.Concurrent; using System.Text.Json.Nodes; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Conversation.Actions; +using AgentCore.Application.Conversation.Commands; using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Tools; using AgentCore.Application.Tools.Binding; @@ -34,7 +34,7 @@ public sealed class OpenAiLiveTransferTests public async Task TheReferWaitsUntilTheAnswerWasHeardAndACloseEndsTheCallAsTransferred() { RecordingHook hook = new(); - ConcurrentQueue answers = new(); + ConcurrentQueue answers = new(); await using RunningLiveCall running = await RunningLiveCall.StartAsync( new GatedToolCallingChatClient(), [hook], tools: TransferTool(answers), refers: true); @@ -51,7 +51,7 @@ public async Task TheReferWaitsUntilTheAnswerWasHeardAndACloseEndsTheCallAsTrans await running.Loop; ConversationEnded ended = await hook.WaitForAsync(); - Assert.Equal([ConversationActionResult.Scheduled], answers); + Assert.Equal([ChannelCommandResult.Scheduled], answers); Assert.False(referredBeforeSpeechStarted); Assert.Equal(Staff, target); Assert.False(running.HungUp.Task.IsCompleted); @@ -153,7 +153,7 @@ public async Task ATransferTheHostCouldNotCarryOutSaysTheHostsWordsAndTheCallGoe [Fact(Timeout = 30_000)] public async Task ACallWithNoReferAnswersATransferAsNotSupportedAndStaysUp() { - ConcurrentQueue answers = new(); + ConcurrentQueue answers = new(); await using RunningLiveCall running = await RunningLiveCall.StartAsync(new GatedToolCallingChatClient(), [], tools: TransferTool(answers)); running.Sideband.Push(Ask("item_1", "Can I talk to a person?", 1000)); @@ -161,16 +161,16 @@ public async Task ACallWithNoReferAnswersATransferAsNotSupportedAndStaysUp() await running.Sideband.WaitForDrainAsync(); running.Time.Advance(OpenAiLiveCall.EndQuietWait); - Assert.Equal([ConversationActionResult.NotSupported], answers); + Assert.Equal([ChannelCommandResult.NotSupported], answers); Assert.False(running.Call.HasEnded); } // Every tool of the harness's document asks for a transfer through the scope the binder fills, as a host's would. - private static Func TransferTool(ConcurrentQueue answers, string? ifFailed = null) => + private static Func TransferTool(ConcurrentQueue answers, string? ifFailed = null) => tool => AIFunctionFactory.Create( (ToolCallScope scope) => { - ConversationActionResult answer = scope.Conversation.Request(new TransferAction(Staff) { IfFailed = ifFailed }); + ChannelCommandResult answer = scope.Channel.Send(new TransferCommand(Staff) { IfFailed = ifFailed }); answers.Enqueue(answer); return answer.ToString(); }, diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveVoiceContextTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveVoiceContextTests.cs new file mode 100644 index 0000000..3ee62d8 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveVoiceContextTests.cs @@ -0,0 +1,93 @@ +using System.Text.Json.Nodes; +using AgentCore.Application.Conversation.Commands; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime.Session; +using AgentCore.AspNetCore.Tests.Fakes; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// + /// A host can tell the voice a fact it looked up after the call was answered. OpenAI's live guide sends one as + /// session.thinking.append with delegation_id: null: "factual context without asking the model to say + /// it immediately". + /// + public sealed class OpenAiLiveVoiceContextTests + { + private const string Fact = "A staff note from this caller's earlier chat, for context only.\n\"Ask first.\""; + + [Fact(Timeout = 30_000)] + public async Task AFactForTheVoiceGoesOutAtOnceAsSilentSessionContext() + { + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), []); + _ = await running.Sideband.WaitForSentAsync(IsGreeting); + + ChannelCommandResult result = running.Call.Session.Send(new AddVoiceContextCommand(Fact)); + JsonObject fact = await running.Sideband.WaitForSentAsync(IsThinking); + + Assert.Equal(ChannelCommandResult.Scheduled, result); + Assert.Null(fact["delegation_id"]); + Assert.Equal(Fact, (string?)fact["content"]); + } + + // The guide sends nothing before session.started, and the voice should know the fact before it greets. + [Fact(Timeout = 30_000)] + public async Task AFactSentBeforeTheSessionStartedWaitsForItAndGoesBeforeTheGreeting() + { + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [], started: false); + ChannelCommandResult result = running.Call.Session.Send(new AddVoiceContextCommand(Fact)); + await running.Sideband.WaitForDrainAsync(); + bool sentBeforeTheStart = running.Sideband.Sent.Count > 0; + + running.Sideband.Push([RunningLiveCall.Started]); + _ = await running.Sideband.WaitForSentAsync(IsGreeting); + + Assert.Equal(ChannelCommandResult.Scheduled, result); + Assert.False(sentBeforeTheStart); + List sent = [.. running.Sideband.Sent]; + Assert.InRange(sent.FindIndex(IsThinking), 0, sent.FindIndex(IsGreeting) - 1); + } + + // CallStarted comes before the sideband is attached, so the call's channel must already be there to take the fact. + [Fact(Timeout = 30_000)] + public async Task AFactAHookSendsFromCallStartedReachesTheVoice() + { + TaskCompletionSource told = new(TaskCreationOptions.RunContinuationsAsynchronously); + TellsOnStart hook = new(told); + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), [hook], attachHeldUntil: told.Task); + hook.Services = host.Services; + + _ = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_123")); + + Assert.Equal(ChannelCommandResult.Scheduled, await told.Task); + FakeSideband sideband = await host.FirstAttach.Task; + Assert.Equal(Fact, (string?)(await sideband.WaitForSentAsync(IsThinking))["content"]); + } + + private static bool IsGreeting(JsonObject sent) => (string?)sent["content"] == RunningLiveCall.Greeting; + + private static bool IsThinking(JsonObject sent) => (string?)sent["type"] == "session.thinking.append"; + + private sealed class TellsOnStart(TaskCompletionSource told) : AgentHook + { + public IServiceProvider? Services { get; set; } + + public override ValueTask BeforeCallAsync(CallGate gate, CancellationToken cancellationToken) + { + gate.Accept("call_" + gate.CallId); + return default; + } + + public override async ValueTask OnCallStartedAsync(CallStarted notice, CancellationToken cancellationToken) + { + IConversationSessions sessions = Services!.GetRequiredService().Sessions; + ConversationSession? session = await sessions.TryGetAsync(notice.Scope.Entry!, notice.Scope.ConversationId!, cancellationToken); + _ = told.TrySetResult(session?.Send(new AddVoiceContextCommand(Fact)) ?? ChannelCommandResult.NotSupported); + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs index 7fe6979..ad08aeb 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs @@ -28,7 +28,7 @@ public async Task AnAcceptedCallIsAcceptedWithItsInstructionsVoiceAndClientDeleg HttpResponseMessage response = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_123")); FakeSideband sideband = await host.FirstAttach.Task; - JsonObject greet = await sideband.WaitForSentAsync(sent => (string?)sent["type"] == "session.instructions.append"); + JsonObject greet = await sideband.WaitForSentAsync(sent => (string?)sent["type"] == "session.commentary.append"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); RecordingLiveControl.Seen accept = Assert.Single(host.Control.Requests);