Merge branch 'master' into routing-utility

This commit is contained in:
Haiping Chen 2025-02-26 11:52:41 -06:00
commit 20563d28f5
17 changed files with 218 additions and 144 deletions

View file

@ -15,4 +15,5 @@ public class StateConst
public const string LANGUAGE = "language";
public const string SUB_CONVERSATION_ID = "sub_conversation_id";
public const string ORIGIN_CONVERSATION_ID = "origin_conversation_id";
}

View file

@ -4,6 +4,7 @@ using System;
using BotSharp.Abstraction.Realtime.Models;
using BotSharp.Abstraction.MLTasks;
using BotSharp.Abstraction.Agents.Models;
using BotSharp.Abstraction.Conversations.Enums;
namespace BotSharp.Core.Realtime;
@ -118,7 +119,7 @@ public class RealtimeHub : IRealtimeHub
foreach (var message in messages)
{
// Invoke function
if (message.MessageType == "function_call")
if (message.MessageType == MessageTypeName.FunctionCall)
{
await routing.InvokeFunction(message.FunctionName, message);
message.Role = AgentRole.Function;

View file

@ -500,16 +500,6 @@ public partial class FileRepository
batchSize = batchLimit;
}
if (bufferHours <= 0)
{
bufferHours = 12;
}
if (messageLimit <= 0)
{
messageLimit = 2;
}
foreach (var d in Directory.GetDirectories(dir))
{
var convFile = Path.Combine(d, CONVERSATION_FILE);

View file

@ -466,16 +466,6 @@ public partial class MongoRepository
batchSize = batchLimit;
}
if (bufferHours <= 0)
{
bufferHours = 12;
}
if (messageLimit <= 0)
{
messageLimit = 2;
}
while (true)
{
var skip = (page - 1) * batchSize;

View file

@ -1,3 +1,4 @@
using BotSharp.Abstraction.Conversations.Enums;
using BotSharp.Abstraction.Files.Utilities;
using BotSharp.Abstraction.Functions.Models;
using BotSharp.Abstraction.Realtime.Models;
@ -204,12 +205,18 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
public async Task SendEventToModel(object message)
{
if (_webSocket.State != WebSocketState.Open)
{
return;
}
if (message is not string data)
{
data = JsonSerializer.Serialize(message);
}
var buffer = Encoding.UTF8.GetBytes(data);
await _webSocket.SendAsync(new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, CancellationToken.None);
}
@ -559,7 +566,8 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
CurrentAgentId = conn.EntryAgentId,
FunctionName = output.Name,
FunctionArgs = output.Arguments,
ToolCallId = output.CallId
ToolCallId = output.CallId,
MessageType = MessageTypeName.FunctionCall
});
}
else if (output.Type == "message")

View file

@ -9,15 +9,16 @@
</PropertyGroup>
<ItemGroup>
<None Remove="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\util-twilio-twilio_outbound_phone_call.json" />
<None Remove="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\templates\util-twilio-twilio_outbound_phone_call.fn.liquid" />
</ItemGroup>
<ItemGroup>
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\util-twilio-twilio_outbound_phone_call.json">
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\util-twilio-hangup_phone_call.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\templates\util-twilio-twilio_outbound_phone_call.fn.liquid">
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\util-twilio-outbound_phone_call.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\templates\util-twilio-hangup_phone_call.fn.liquid">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\templates\util-twilio-outbound_phone_call.fn.liquid">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>

View file

@ -2,6 +2,6 @@ namespace BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.Enums
{
public class UtilityName
{
public const string OutboundPhoneCall = "twilio.twilio-outbound-phone-call";
public const string OutboundPhoneCall = "phone.twilio-phone-call";
}
}

View file

@ -1,106 +0,0 @@
using BotSharp.Abstraction.Files;
using BotSharp.Abstraction.Infrastructures.Enums;
using BotSharp.Abstraction.Options;
using BotSharp.Abstraction.Routing;
using BotSharp.Core.Infrastructures;
using BotSharp.Plugin.Twilio.Interfaces;
using BotSharp.Plugin.Twilio.Models;
using BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.LlmContexts;
using Twilio.Rest.Api.V2010.Account;
using Twilio.Types;
namespace BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.Functions
{
public class HandleOutboundPhoneCallFn : IFunctionCallback
{
private readonly IServiceProvider _services;
private readonly ILogger<HandleOutboundPhoneCallFn> _logger;
private readonly BotSharpOptions _options;
private readonly TwilioSetting _twilioSetting;
public string Name => "util-twilio-twilio_outbound_phone_call";
public string Indication => "Dialing the number";
public HandleOutboundPhoneCallFn(
IServiceProvider services,
ILogger<HandleOutboundPhoneCallFn> logger,
BotSharpOptions options,
TwilioSetting twilioSetting)
{
_services = services;
_logger = logger;
_options = options;
_twilioSetting = twilioSetting;
}
public async Task<bool> Execute(RoleDialogModel message)
{
var args = JsonSerializer.Deserialize<LlmContextIn>(message.FunctionArgs, _options.JsonSerializerOptions);
if (args.PhoneNumber.Length != 12 || !args.PhoneNumber.StartsWith("+1", StringComparison.OrdinalIgnoreCase))
{
var error = $"Invalid phone number format: {args.PhoneNumber}";
_logger.LogError(error);
message.Content = error;
return false;
}
if (string.IsNullOrWhiteSpace(args.InitialMessage))
{
_logger.LogError("Initial message is empty.");
message.Content = "There is an error when generating phone message.";
return false;
}
var convService = _services.GetRequiredService<IConversationService>();
var convStorage = _services.GetRequiredService<IConversationStorage>();
var routing = _services.GetRequiredService<IRoutingContext>();
var fileStorage = _services.GetRequiredService<IFileStorageService>();
var sessionManager = _services.GetRequiredService<ITwilioSessionManager>();
var states = _services.GetRequiredService<IConversationStateService>();
// Fork conversation
var entryAgentId = routing.EntryAgentId;
var newConv = await convService.NewConversation(new Abstraction.Conversations.Models.Conversation
{
AgentId = entryAgentId,
Channel = ConversationChannel.Phone
});
var conversationId = newConv.Id;
convStorage.Append(conversationId, new List<RoleDialogModel>
{
new RoleDialogModel(AgentRole.User, "Hi")
{
CurrentAgentId = entryAgentId
},
new RoleDialogModel(AgentRole.Assistant, args.InitialMessage)
{
CurrentAgentId = entryAgentId
}
});
states.SetState(StateConst.SUB_CONVERSATION_ID, conversationId);
// Generate audio
var completion = CompletionProvider.GetAudioCompletion(_services, "openai", "tts-1");
var data = await completion.GenerateAudioFromTextAsync(args.InitialMessage);
var fileName = $"intial.mp3";
fileStorage.SaveSpeechFile(conversationId, fileName, data);
// Call phone number
/*await sessionManager.SetAssistantReplyAsync(conversationId, 0, new AssistantMessage
{
Content = args.InitialMessage,
SpeechFileName = fileName
});*/
var call = await CallResource.CreateAsync(
// url: new Uri($"{_twilioSetting.CallbackHost}/twilio/voice/init-call?conversationId={conversationId}"),
url: new Uri($"{_twilioSetting.CallbackHost}/twilio/stream?conversation_id={conversationId}&init_audio_file={fileName}"),
to: new PhoneNumber(args.PhoneNumber),
from: new PhoneNumber(_twilioSetting.PhoneNumber));
message.Content = $"The generated phone message: {args.InitialMessage}." ?? message.Content;
message.StopCompletion = true;
return true;
}
}
}

View file

@ -0,0 +1,35 @@
using BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.LlmContexts;
using Twilio.Rest.Api.V2010.Account;
namespace BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.Functions;
public class HangupPhoneCallFn : IFunctionCallback
{
private readonly IServiceProvider _services;
private readonly ILogger<HangupPhoneCallFn> _logger;
public string Name => "util-twilio-hangup_phone_call";
public string Indication => "Hangup";
public HangupPhoneCallFn(
IServiceProvider services,
ILogger<HangupPhoneCallFn> logger)
{
_services = services;
_logger = logger;
}
public async Task<bool> Execute(RoleDialogModel message)
{
var args = JsonSerializer.Deserialize<LlmContextIn>(message.FunctionArgs);
// Have to find the SID by the phone number
var call = CallResource.Update(
status: CallResource.UpdateStatusEnum.Completed,
pathSid: args.CallSid
);
message.Content = "The call has ended.";
return true;
}
}

View file

@ -0,0 +1,125 @@
using BotSharp.Abstraction.Files;
using BotSharp.Abstraction.Infrastructures.Enums;
using BotSharp.Abstraction.Options;
using BotSharp.Abstraction.Routing;
using BotSharp.Core.Infrastructures;
using BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.LlmContexts;
using Twilio.Rest.Api.V2010.Account;
using Twilio.Types;
using Conversation = BotSharp.Abstraction.Conversations.Models.Conversation;
using Task = System.Threading.Tasks.Task;
namespace BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.Functions;
public class OutboundPhoneCallFn : IFunctionCallback
{
private readonly IServiceProvider _services;
private readonly ILogger<OutboundPhoneCallFn> _logger;
private readonly BotSharpOptions _options;
private readonly TwilioSetting _twilioSetting;
public string Name => "util-twilio-outbound_phone_call";
public string Indication => "Dialing the phone number";
public OutboundPhoneCallFn(
IServiceProvider services,
ILogger<OutboundPhoneCallFn> logger,
BotSharpOptions options,
TwilioSetting twilioSetting)
{
_services = services;
_logger = logger;
_options = options;
_twilioSetting = twilioSetting;
}
public async Task<bool> Execute(RoleDialogModel message)
{
var args = JsonSerializer.Deserialize<LlmContextIn>(message.FunctionArgs, _options.JsonSerializerOptions);
if (args.PhoneNumber.Length != 12 || !args.PhoneNumber.StartsWith("+1", StringComparison.OrdinalIgnoreCase))
{
var error = $"Invalid phone number format: {args.PhoneNumber}";
_logger.LogError(error);
message.Content = error;
return false;
}
if (string.IsNullOrWhiteSpace(args.InitialMessage))
{
_logger.LogError("Initial message is empty.");
message.Content = "There is an error when generating phone message.";
return false;
}
var fileStorage = _services.GetRequiredService<IFileStorageService>();
var states = _services.GetRequiredService<IConversationStateService>();
// Fork conversation
var newConversationId = Guid.NewGuid().ToString();
states.SetState(StateConst.SUB_CONVERSATION_ID, newConversationId);
// Generate initial assistant audio
var completion = CompletionProvider.GetAudioCompletion(_services, "openai", "tts-1");
var data = await completion.GenerateAudioFromTextAsync(args.InitialMessage);
var fileName = $"intial.mp3";
fileStorage.SaveSpeechFile(newConversationId, fileName, data);
// Make outbound call
var call = await CallResource.CreateAsync(
url: new Uri($"{_twilioSetting.CallbackHost}/twilio/stream?conversation_id={newConversationId}&init_audio_file={fileName}"),
to: new PhoneNumber(args.PhoneNumber),
from: new PhoneNumber(_twilioSetting.PhoneNumber));
var convService = _services.GetRequiredService<IConversationService>();
var routing = _services.GetRequiredService<IRoutingContext>();
var originConversationId = convService.ConversationId;
var entryAgentId = routing.EntryAgentId;
await ForkConversation(args, entryAgentId, originConversationId, newConversationId, call);
message.Content = $"The generated phone message: \"{args.InitialMessage}.\" [NEW CONVERSATION ID: {newConversationId}, TWILIO CALL SID: {call.Sid}]";
message.StopCompletion = true;
return true;
}
private async Task ForkConversation(LlmContextIn args,
string entryAgentId,
string originConversationId,
string newConversationId,
CallResource resource)
{
// new scope service for isolated conversation
using var scope = _services.CreateScope();
var services = scope.ServiceProvider;
var convService = services.GetRequiredService<IConversationService>();
var convStorage = services.GetRequiredService<IConversationStorage>();
var newConv = await convService.NewConversation(new Conversation
{
Id = newConversationId,
AgentId = entryAgentId,
Channel = ConversationChannel.Phone,
ChannelId = resource.Sid,
Title = args.InitialMessage
});
convStorage.Append(newConversationId, new List<RoleDialogModel>
{
new RoleDialogModel(AgentRole.User, $"[Calling Phone To: {resource.ToFormatted}, Call SID: {resource.Sid}.]")
{
CurrentAgentId = entryAgentId
},
new RoleDialogModel(AgentRole.Assistant, args.InitialMessage)
{
CurrentAgentId = entryAgentId
}
});
convService.SetConversationId(newConversationId,
[
new MessageState(StateConst.ORIGIN_CONVERSATION_ID, originConversationId),
new MessageState("phone_number", resource.To)
]);
convService.SaveStates();
}
}

View file

@ -6,15 +6,24 @@ namespace BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.Hooks;
public class OutboundPhoneCallHandlerUtilityHook : IAgentUtilityHook
{
private static string PREFIX = "util-twilio-";
private static string OUTBOUND_PHONE_CALL_FN = $"{PREFIX}twilio_outbound_phone_call";
private static string OUTBOUND_PHONE_CALL_FN = $"{PREFIX}outbound_phone_call";
private static string HANGUP_PHONE_CALL_FN = $"{PREFIX}hangup_phone_call";
public void AddUtilities(List<AgentUtility> utilities)
{
var utility = new AgentUtility
{
Name = UtilityName.OutboundPhoneCall,
Functions = [new($"{OUTBOUND_PHONE_CALL_FN}")],
Templates = [new($"{OUTBOUND_PHONE_CALL_FN}.fn")]
Functions =
[
new($"{OUTBOUND_PHONE_CALL_FN}"),
new($"{HANGUP_PHONE_CALL_FN}")
],
Templates =
[
new($"{OUTBOUND_PHONE_CALL_FN}.fn"),
new($"{HANGUP_PHONE_CALL_FN}.fn")
]
};
utilities.Add(utility);

View file

@ -9,4 +9,7 @@ public class LlmContextIn
[JsonPropertyName("initial_message")]
public string InitialMessage { get; set; }
[JsonPropertyName("call_sid")]
public string CallSid { get; set; }
}

View file

@ -0,0 +1,15 @@
{
"name": "util-twilio-hangup_phone_call",
"description": "Call this function if the user wants to end the phone call",
"visibility_expression": "{% if states.channel == 'phone' %}visible{% endif %}",
"parameters": {
"type": "object",
"properties": {
"call_sid": {
"type": "string",
"description": "The unique string SID that we created to identify this Call resource."
}
},
"required": [ "call_sid" ]
}
}

View file

@ -1,15 +1,16 @@
{
"name": "util-twilio-twilio_outbound_phone_call",
"name": "util-twilio-outbound_phone_call",
"description": "If the user wants to initiate a phone call, you need to capture the phone number and compose the message the users wants to send. Then call this function to make an outbound call via Twilio.",
"visibility_expression": "{% if states.channel != 'phone' %}visible{% endif %}",
"parameters": {
"type": "object",
"properties": {
"phone_number": {
"to_read": "string",
"type": "string",
"description": "The phone number which will be dialed. It needs to be a valid phone number starting with +1."
},
"initial_message": {
"to_read": "string",
"type": "string",
"description": "The initial message which will be sent."
}
},

View file

@ -0,0 +1 @@
** Please call util-twilio-hangup_phone_call if user wants to end the phone call.

View file

@ -0,0 +1,2 @@
** Please call util-twilio-outbound_phone_call if user wants to make an outbound call.
** For outbound calls, phone number format is "XXX-XXX-XXXX".

View file

@ -1,2 +0,0 @@
** Please take a look at the conversation and decide whether user wants to make an outbound call.
** Please call util-twilio-twilio_outbound_phone_call if user wants to make an outbound call.