Merge branch 'master' of https://github.com/SciSharp/BotSharp into features/init-reactive-x

This commit is contained in:
Jicheng Lu 2025-07-24 14:46:36 -05:00
commit 35652a7b71
25 changed files with 149 additions and 57 deletions

View file

@ -4,5 +4,6 @@ namespace BotSharp.Abstraction.Browsing;
public interface IWebDriverHook
{
Task<List<string>> GetUploadFiles(MessageInfo message);
Task<List<string>> GetUploadFiles(MessageInfo message) => Task.FromResult(new List<string>());
Task OnLocateElement(MessageInfo message, string content) => Task.CompletedTask;
}

View file

@ -8,20 +8,24 @@ public class MessageState
[JsonPropertyName("active_rounds")]
public int ActiveRounds { get; set; } = -1;
[JsonPropertyName("global")]
public bool Global { get; set; }
public MessageState()
{
}
public MessageState(string key, object value, int activeRounds = -1)
public MessageState(string key, object value, int activeRounds = -1, bool isGlobal = false)
{
Key = key;
Value = value;
ActiveRounds = activeRounds;
Global = isGlobal;
}
public override string ToString()
{
return $"Key: {Key} => Value: {Value}, ActiveRounds: {ActiveRounds}";
return $"Key: {Key} => Value: {Value}, ActiveRounds: {ActiveRounds}, Global: {Global}";
}
}

View file

@ -10,6 +10,7 @@ public class ConversationFilter
public string? Title { get; set; }
public string? TitleAlias { get; set; }
public string? AgentId { get; set; }
public List<string>? AgentIds { get; set; }
public string? Status { get; set; }
public string? Channel { get; set; }
public string? ChannelId { get; set; }

View file

@ -7,6 +7,15 @@ public class SideCarOptions
public static SideCarOptions Empty()
{
return new SideCarOptions();
return new();
}
public static SideCarOptions InheritStates(IEnumerable<string>? targetStates = null)
{
return new()
{
IsInheritStates = true,
InheritStateKeys = targetStates
};
}
}

View file

@ -178,7 +178,7 @@ public partial class ConversationService : IConversationService
{
_conversationId = conversationId;
_state.Load(_conversationId, isReadOnly);
states.ForEach(x => _state.SetState(x.Key, x.Value, activeRounds: x.ActiveRounds, source: StateSource.External));
states.ForEach(x => _state.SetState(x.Key, x.Value, activeRounds: x.ActiveRounds, isNeedVersion: !x.Global, source: StateSource.External));
}
public async Task<Conversation> GetConversationRecordOrCreateNew(string agentId)

View file

@ -475,7 +475,7 @@ namespace BotSharp.Core.Repository
if (!filter.AgentNames.IsNullOrEmpty())
{
query = query.Where(x => filter.AgentNames.Contains(x.Name));
query = query.Where(x => filter.AgentNames.Contains(x.Name, StringComparer.OrdinalIgnoreCase));
}
if (!string.IsNullOrEmpty(filter.SimilarName))

View file

@ -396,6 +396,12 @@ public partial class FileRepository
Directory.CreateDirectory(dir);
}
if (filter?.AgentId != null)
{
filter.AgentIds ??= [];
filter.AgentIds.Add(filter.AgentId);
}
var totalDirs = Directory.GetDirectories(dir);
foreach (var d in totalDirs)
{
@ -419,9 +425,9 @@ public partial class FileRepository
{
matched = matched && record.TitleAlias.Contains(filter.TitleAlias);
}
if (filter?.AgentId != null)
if (filter?.AgentIds != null && filter.AgentIds.Any())
{
matched = matched && record.AgentId == filter.AgentId;
matched = matched && filter.AgentIds.Contains(record.AgentId);
}
if (filter?.Status != null)
{

View file

@ -4,10 +4,12 @@ using BotSharp.Abstraction.Templating;
using BotSharp.Abstraction.Translation.Models;
using Fluid;
using Fluid.Ast;
using Fluid.Values;
using System.Collections;
using System.IO;
using System.Reflection;
using System.Text.Encodings.Web;
using System.Text.RegularExpressions;
namespace BotSharp.Core.Templating;
@ -36,9 +38,11 @@ public class TemplateRender : ITemplateRender
_options.MemberAccessStrategy.Register<UserIdentity>();
_options.MemberAccessStrategy.Register<TranslationInput>();
_parser.RegisterIdentifierTag("link", (string identifier, TextWriter writer, TextEncoder encoder, TemplateContext context) =>
_options.Filters.AddFilter("from_agent", FromAgentFilter);
_parser.RegisterExpressionTag("link", (Expression expression, TextWriter writer, TextEncoder encoder, TemplateContext context) =>
{
return RenderIdentifierTag("link", identifier, writer, encoder, context);
return RenderTag("link", expression, writer, encoder, context, services);
});
}
@ -82,20 +86,46 @@ public class TemplateRender : ITemplateRender
#region Private methods
private static async ValueTask<Completion> RenderIdentifierTag(string tag, string identifier, TextWriter writer, TextEncoder encoder, TemplateContext context)
private static async ValueTask<Completion> RenderTag(
string tag,
Expression expression,
TextWriter writer,
TextEncoder encoder,
TemplateContext context,
IServiceProvider services)
{
try
{
var value = await context.Model.GetValueAsync(TemplateRenderConstant.RENDER_AGENT, context);
var agent = value?.ToObjectValue() as Agent;
var found = agent?.Templates?.FirstOrDefault(x => x.Name.IsEqualTo(identifier));
var key = $"{agent?.Id} | {tag} | {identifier}";
var value = await expression.EvaluateAsync(context);
var expStr = value?.ToStringValue() ?? string.Empty;
if (found == null || (context.AmbientValues.TryGetValue(key, out var visited) && (bool)visited))
value = await context.Model.GetValueAsync(TemplateRenderConstant.RENDER_AGENT, context);
var agent = value?.ToObjectValue() as Agent;
var splited = Regex.Split(expStr, @"\s*from_agent\s*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.ToArray();
var templateName = splited.ElementAtOrDefault(0);
var agentName = splited.ElementAtOrDefault(1);
if (splited.Length > 1 && !agentName.IsEqualTo(agent?.Name))
{
using var scope = services.CreateScope();
var agentService = scope.ServiceProvider.GetRequiredService<IAgentService>();
var result = await agentService.GetAgents(new() { SimilarName = agentName });
agent = result?.Items?.FirstOrDefault();
}
var template = agent?.Templates?.FirstOrDefault(x => x.Name.IsEqualTo(templateName));
var key = $"{tag} | {agent?.Id} | {templateName}";
if (template == null || (context.AmbientValues.TryGetValue(key, out var visited) && (bool)visited))
{
writer.Write(string.Empty);
}
else if (_parser.TryParse(found.Content, out var t, out _))
else if (_parser.TryParse(template.Content, out var t, out _))
{
context.AmbientValues[key] = true;
var rendered = t.Render(context);
@ -115,6 +145,16 @@ public class TemplateRender : ITemplateRender
return Completion.Normal;
}
private static ValueTask<FluidValue> FromAgentFilter(
FluidValue input,
FilterArguments arguments,
TemplateContext context)
{
var inputStr = input?.ToStringValue() ?? string.Empty;
var fromAgent = arguments.At(0).ToStringValue();
return new StringValue($"{inputStr} from_agent {fromAgent}");
}
private static bool IsStringType(Type type)
{
return type == typeof(string);

View file

@ -5,7 +5,8 @@ Follow these steps to handle user request:
2. Determine which agent is suitable to handle this conversation. Try to minimize the routing of human service.
3. Extract and populate agent required arguments, think carefully, leave it as blank object if user didn't provide the specific arguments.
4. You must include all required args for the selected agent, but you must not make up any parameters when there is no exact value provided, those parameters must set value as null if not declared.
5. If user is greeting, you can call function response_to_user with a greeting message.
5. Call function route_to_agent if user have specific requests and available agent to proceed. Do not ask user to provide any required args by yourself. The requested agent will handle and fill the required args internally.
6. If user is greeting or do not have specific request, then you can call function response_to_user with a greeting message.
{% if routing_requirements and routing_requirements != empty %}
[REQUIREMENTS]

View file

@ -1,9 +1,6 @@
using BotSharp.Abstraction.Agents.Enums;
using BotSharp.Abstraction.Conversations.Enums;
using BotSharp.Abstraction.Repositories.Filters;
using BotSharp.Abstraction.Statistics.Enums;
using BotSharp.Abstraction.Statistics.Models;
using BotSharp.Abstraction.Statistics.Services;
using BotSharp.Abstraction.Users;
namespace BotSharp.Logger.Hooks;

View file

@ -30,7 +30,8 @@ public class InstructModeController : ControllerBase
.SetState("model_id", input.ModelId, source: StateSource.External)
.SetState("instruction", input.Instruction, source: StateSource.External)
.SetState("input_text", input.Text, source: StateSource.External)
.SetState("template_name", input.Template, source: StateSource.External);
.SetState("template_name", input.Template, source: StateSource.External)
.SetState("channel", input.Channel, source: StateSource.External);
var instructor = _services.GetRequiredService<IInstructService>();
var result = await instructor.Execute(agentId,

View file

@ -82,8 +82,8 @@ public class ChatStreamMiddleware
var (eventType, data) = MapEvents(conn, receivedText);
if (eventType == "start")
{
var states = InitStates(data);
await ConnectToModel(hub, webSocket, states);
var request = InitRequest(data);
await ConnectToModel(hub, webSocket, request?.States);
}
else if (eventType == "media")
{
@ -157,16 +157,15 @@ public class ChatStreamMiddleware
});
}
private List<MessageState> InitStates(string data)
private ChatStreamRequest? InitRequest(string data)
{
try
{
var states = JsonSerializer.Deserialize<List<MessageState>>(data, BotSharpOptions.defaultJsonOptions);
return states ?? [];
return JsonSerializer.Deserialize<ChatStreamRequest>(data, BotSharpOptions.defaultJsonOptions);
}
catch
{
return [];
return null;
}
}
}

View file

@ -0,0 +1,10 @@
using BotSharp.Abstraction.Models;
using System.Text.Json.Serialization;
namespace BotSharp.Plugin.ChatHub.Models.Stream;
public class ChatStreamRequest
{
[JsonPropertyName("states")]
public List<MessageState> States { get; set; } = [];
}

View file

@ -346,6 +346,12 @@ public partial class MongoRepository
var convBuilder = Builders<ConversationDocument>.Filter;
var convFilters = new List<FilterDefinition<ConversationDocument>>() { convBuilder.Empty };
if (filter?.AgentId != null)
{
filter.AgentIds ??= [];
filter.AgentIds.Add(filter.AgentId);
}
// Filter conversations
if (!string.IsNullOrEmpty(filter?.Id))
{
@ -359,9 +365,9 @@ public partial class MongoRepository
{
convFilters.Add(convBuilder.Regex(x => x.Title, new BsonRegularExpression(filter.TitleAlias, "i")));
}
if (!string.IsNullOrEmpty(filter?.AgentId))
if (filter?.AgentIds != null && filter.AgentIds.Any())
{
convFilters.Add(convBuilder.Eq(x => x.AgentId, filter.AgentId));
convFilters.Add(convBuilder.In(x => x.AgentId, filter.AgentIds));
}
if (!string.IsNullOrEmpty(filter?.Status))
{

View file

@ -33,7 +33,7 @@ public class ExecuteQueryFn : IFunctionCallback
var results = dbType.ToLower() switch
{
"mysql" => RunQueryInMySql(args.SqlStatements),
"sqlserver" => RunQueryInSqlServer(args.SqlStatements),
"sqlserver" or "mssql" => RunQueryInSqlServer(args.SqlStatements),
"redshift" => RunQueryInRedshift(args.SqlStatements),
_ => throw new NotImplementedException($"Database type {dbType} is not supported.")
};

View file

@ -37,7 +37,7 @@ public class GetTableDefinitionFn : IFunctionCallback
var tableDdls = dbType switch
{
"mysql" => GetDdlFromMySql(tables),
"sqlserver" => GetDdlFromSqlServer(tables),
"sqlserver" or "mssql" => GetDdlFromSqlServer(tables),
"redshift" => GetDdlFromRedshift(tables),
_ => throw new NotImplementedException($"Database type {dbType} is not supported.")
};

View file

@ -32,7 +32,7 @@ public class SqlSelect : IFunctionCallback
var result = dbType switch
{
"mysql" => RunQueryInMySql(args),
"sqlserver" => RunQueryInSqlServer(args),
"sqlserver" or "mssql" => RunQueryInSqlServer(args),
"redshift" => RunQueryInRedshift(args),
_ => throw new NotImplementedException($"Database type {dbType} is not supported.")
};

View file

@ -34,7 +34,7 @@ public class SqlValidateFn : IFunctionCallback
var validateSql = dbType.ToLower() switch
{
"mysql" => $"EXPLAIN\r\n{sql.Replace("SET ", "-- SET ", StringComparison.InvariantCultureIgnoreCase).Replace(";", "; EXPLAIN ").TrimEnd("EXPLAIN ".ToCharArray())}",
"sqlserver" => $"SET PARSEONLY ON;\r\n{sql}\r\nSET PARSEONLY OFF;",
"sqlserver" or "mssql" => $"SET PARSEONLY ON;\r\n{sql}\r\nSET PARSEONLY OFF;",
"redshift" => $"explain\r\n{sql}",
_ => throw new NotImplementedException($"Database type {dbType} is not supported.")
};

View file

@ -31,7 +31,7 @@ public class GetTableDefinitionFn : IFunctionCallback
var tableDdls = dbType switch
{
"mysql" => GetDdlFromMySql(tables),
"sqlserver" => GetDdlFromSqlServer(tables),
"sqlserver" or "mssql" => GetDdlFromSqlServer(tables),
"redshift" => GetDdlFromRedshift(tables,schema),
_ => throw new NotImplementedException($"Database type {dbType} is not supported.")
};

View file

@ -30,7 +30,7 @@ public class SqlSelect : IFunctionCallback
var result = dbType switch
{
"mysql" => RunQueryInMySql(args),
"sqlserver" => RunQueryInSqlServer(args),
"sqlserver" or "mssql" => RunQueryInSqlServer(args),
"redshift" => RunQueryInRedshift(args),
_ => throw new NotImplementedException($"Database type {dbType} is not supported.")
};

View file

@ -166,16 +166,16 @@ public class TwilioInboundController : TwilioController
var states = new List<MessageState>
{
new("channel", ConversationChannel.Phone),
new("calling_phone", request.From),
new("phone_direction", request.Direction),
new("twilio_call_sid", request.CallSid),
new("channel", ConversationChannel.Phone, isGlobal: true),
new("calling_phone", request.From, isGlobal: true),
new("phone_direction", request.Direction, isGlobal: true),
new("twilio_call_sid", request.CallSid, isGlobal: true),
};
if (request.Direction == "inbound")
{
states.Add(new MessageState("calling_phone_from", request.From));
states.Add(new MessageState("calling_phone_to", request.To));
states.Add(new MessageState("calling_phone_from", request.From, isGlobal: true));
states.Add(new MessageState("calling_phone_to", request.To, isGlobal: true));
}
var requestStates = ParseStates(request.States);

View file

@ -9,6 +9,7 @@ using BotSharp.Plugin.Twilio.Interfaces;
using BotSharp.Plugin.Twilio.Models;
using BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.LlmContexts;
using Twilio.Rest.Api.V2010.Account;
using Twilio.TwiML.Messaging;
using Twilio.Types;
using Conversation = BotSharp.Abstraction.Conversations.Models.Conversation;
using Task = System.Threading.Tasks.Task;
@ -176,7 +177,7 @@ public class OutboundPhoneCallFn : IFunctionCallback
});
var utcNow = DateTime.UtcNow;
var excludStates = new List<string>
var excludeStates = new List<string>
{
"provider",
"model",
@ -185,22 +186,34 @@ public class OutboundPhoneCallFn : IFunctionCallback
"llm_total_cost"
};
var curStates = state.GetStates().Select(x => new MessageState(x.Key, x.Value)).ToList();
var curConvStates = state.GetStates().Select(x => new MessageState(x.Key, x.Value)).ToList();
var subConvStates = new List<MessageState>
{
new(StateConst.ORIGIN_CONVERSATION_ID, originConversationId),
new("channel", "phone"),
new("phone_from", call.From),
new("phone_direction", call.Direction),
new("phone_number", call.To),
new("twilio_call_sid", call.Sid)
new(StateConst.ORIGIN_CONVERSATION_ID, originConversationId, isGlobal: true),
new("channel", "phone", isGlobal: true),
new("phone_from", call.From, isGlobal: true),
new("phone_direction", call.Direction, isGlobal: true),
new("phone_number", call.To, isGlobal: true),
new("twilio_call_sid", call.Sid, isGlobal: true)
};
var subStateKeys = subConvStates.Select(x => x.Key).ToList();
var included = curStates.Where(x => !subStateKeys.Contains(x.Key) && !excludStates.Contains(x.Key));
var newStates = subConvStates.Concat(included).Select(x => new StateKeyValue
var included = curConvStates.Where(x => !subStateKeys.Contains(x.Key) && !excludeStates.Contains(x.Key));
var mappedCurConvStates = MapStates(included, messageId, utcNow);
var mappedSubConvStates = MapStates(subConvStates, messageId, utcNow);
var allStates = mappedCurConvStates.Concat(mappedSubConvStates).ToList();
db.UpdateConversationStates(newConversationId, allStates);
}
private IEnumerable<StateKeyValue> MapStates(IEnumerable<MessageState> states, string messageId, DateTime updateTime)
{
if (states.IsNullOrEmpty()) return [];
return states.Select(x => new StateKeyValue
{
Key = x.Key,
Versioning = true,
Versioning = !x.Global,
Values = [
new StateValue
{
@ -209,11 +222,9 @@ public class OutboundPhoneCallFn : IFunctionCallback
Active = true,
ActiveRounds = x.ActiveRounds,
Source = StateSource.Application,
UpdateTime = utcNow
UpdateTime = updateTime
}
]
}).ToList();
db.UpdateConversationStates(newConversationId, newStates);
}
}

View file

@ -114,6 +114,11 @@ public partial class PlaywrightWebDriver
// fix if html has &
result.Body = HttpUtility.HtmlDecode(html);
result.IsSuccess = true;
var hooks = _services.GetServices<IWebDriverHook>();
foreach (var hook in hooks)
{
await hook.OnLocateElement(message, result.Body);
}
}
else if (count > 1)
{

View file

@ -28,7 +28,7 @@ public class UtilWebCloseBrowserFn : IFunctionCallback
ContextId = webDriverService.GetMessageContext(message)
};
await browser.CloseBrowser(message.CurrentAgentId);
await browser.CloseBrowser(msg.ContextId);
message.Content = $"Browser closed.";

View file

@ -30,6 +30,7 @@ public class UtilWebLocateElementFn : IFunctionCallback
MessageId = message.MessageId,
ContextId = webDriverService.GetMessageContext(message)
};
browser.SetServiceProvider(_services);
var result = await browser.LocateElement(msg, locatorArgs);
message.Content = $"Locating element {(result.IsSuccess ? "success" : "failed")}. ";