diff --git a/Extensions/Signum.Agent/AgentClient.tsx b/Extensions/Signum.Agent/AgentClient.tsx new file mode 100644 index 0000000000..ac66eb860d --- /dev/null +++ b/Extensions/Signum.Agent/AgentClient.tsx @@ -0,0 +1,50 @@ +import * as React from 'react' +import { ajaxGet } from '@framework/Services'; +import { Navigator, EntitySettings } from '@framework/Navigator'; +import * as AppContext from '@framework/AppContext'; +import { TypeContext } from '@framework/TypeContext'; +import { SkillCustomizationEntity } from './Signum.Agent'; + +export namespace AgentClient { + + export function start(options: { routes: unknown[] }): void { + Navigator.addSettings(new EntitySettings(SkillCustomizationEntity, e => import('./Templates/SkillCustomization'))); + AppContext.clearSettingsActions.push(() => propertyValueRegistry.clear()); + } + + export type PropertyValueFactory = ( + ctx: TypeContext, + meta: SkillPropertyMeta + ) => React.ReactElement; + + const propertyValueRegistry = new Map(); + + export function registerPropertyValueControl(attributeName: string, factory: PropertyValueFactory): void { + propertyValueRegistry.set(attributeName, factory); + } + + export function getPropertyValueControl(attributeName: string): PropertyValueFactory | undefined { + return propertyValueRegistry.get(attributeName); + } + + export namespace API { + export function getSkillCodeInfo(skillCode: string): Promise { + return ajaxGet({ url: `/api/agentSkill/skillCodeInfo/${encodeURIComponent(skillCode)}` }); + } + } +} + + +export interface SkillPropertyMeta { + propertyName: string; + attributeName: string; + valueHint: string | null; + propertyType: string; + } + + export interface SkillCodeInfo { + defaultShortDescription: string; + defaultInstructions: string; + properties: SkillPropertyMeta[]; + } + diff --git a/Extensions/Signum.Agent/AgentLogic.cs b/Extensions/Signum.Agent/AgentLogic.cs new file mode 100644 index 0000000000..a58b36a2c4 --- /dev/null +++ b/Extensions/Signum.Agent/AgentLogic.cs @@ -0,0 +1,344 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using Signum.Agent.Skills; +using Signum.Engine.Sync; +using Signum.Utilities.DataStructures; +using Signum.Utilities.Reflection; +using System.Collections.Concurrent; +using System.Collections.Frozen; + +namespace Signum.Agent; + + +public static class AgentLogic +{ + public static readonly AsyncThreadVariable IsMCP = Statics.ThreadVariable("IsMCP"); + + public static Dictionary> RegisteredAgents = new(); + + public static ResetLazy> SkillCodeByAgent = null!; + + public static ResetLazy> SkillCustomizationByAgent = null!; + + + public static void RegisterAgent(AgentSymbol agent, Func factory) + { + RegisteredAgents[agent] = factory; + + using (SkillCodeLogic.AutoRegister()) + factory(); //Check if it works at registration time + } + + public static void Start(SchemaBuilder sb, Func? getChatBot) + { + if (sb.AlreadyDefined(MethodBase.GetCurrentMethod())) + return; + + SkillCodeLogic.Start(sb); + + SkillCodeLogic.Register(); + SkillCodeLogic.Register(); + + if (getChatBot != null) + RegisterAgent(DefaultAgent.Chatbot, getChatBot); + + RegisterAgent(DefaultAgent.QuestionSummarizer, () => new QuestionSumarizerSkill()); + RegisterAgent(DefaultAgent.ConversationSumarizer, () => new ConversationSumarizerSkill()); + + SymbolLogic.Start(sb, () => RegisteredAgents.Keys); + + + sb.Include() + .WithUniqueIndex(a => a.Agent, a => a.Agent != null) + .WithSave(SkillCustomizationOperation.Save) + .WithDelete(SkillCustomizationOperation.Delete) + .WithQuery(() => e => new + { + Entity = e, + e.Id, + e.SkillCode, + e.Agent, + e.ShortDescription, + }); + + new Graph.ConstructFrom(SkillCustomizationOperation.CreateFromAgent) + { + Construct = (agentSymbol, _) => + { + if (!RegisteredAgents.TryGetValue(agentSymbol, out var factory)) + return new SkillCustomizationEntity { Agent = agentSymbol }; + + var code = factory(); + return code.ToCustomizationEntity(agentSymbol); + } + }.Register(); + + sb.Schema.EntityEvents().Saving += entity => + { + if (!entity.IsNew && entity.SubSkills.IsGraphModified) + ValidateNoCircularReferences(entity); + }; + + SkillCodeByAgent = sb.GlobalLazy(() => new ConcurrentDictionary(), + new InvalidateWith(typeof(SkillCustomizationEntity))); + + + SkillCustomizationByAgent = sb.GlobalLazy(() => Database.Query().Where(a=>a.Agent != null).ToFrozenDictionaryEx(a=>a.Agent!), + new InvalidateWith(typeof(SkillCustomizationEntity))); + } + + public static SkillCode ToSkillCode(this SkillCustomizationEntity entity) + { + var code = (SkillCode)Activator.CreateInstance(entity.SkillCode.ToType())!; + code.Customization = entity.ToLite(); + + if (entity.ShortDescription != null) + code.ShortDescription = entity.ShortDescription; + if (entity.Instructions != null) + code.OriginalInstructions = entity.Instructions; + + code.ApplyPropertyOverrides(entity); + + foreach (var ss in entity.SubSkills) + { + SkillCode subCode = + ss.Skill is SkillCustomizationEntity c ? c.ToSkillCode() : + ss.Skill is SkillCodeEntity sc ? (SkillCode)Activator.CreateInstance(sc.ToType())! : + throw new UnexpectedValueException(ss.Skill); + + code.SubSkills.Add((subCode, ss.Activation)); + } + + return code; + } + + static void ValidateNoCircularReferences(SkillCustomizationEntity entity) + { + using (new EntityCache(EntityCacheType.ForceNew)) + { + EntityCache.AddFullGraph(entity); + var allEntities = Database.RetrieveAll(); + + var graph = DirectedGraph.Generate( + allEntities, + e => + { + var subSkills = e.Is(entity) ? entity.SubSkills : e.SubSkills; + return subSkills + .Where(s => s.Skill is SkillCustomizationEntity) + .Select(s => (SkillCustomizationEntity)s.Skill) + .ToList(); + } + ); + + var problems = graph.FeedbackEdgeSet().Edges.ToList(); + if (problems.Count > 0) + throw new ApplicationException( + $"{problems.Count} cycle(s) found in AgentSkill graph:\n" + + problems.ToString(e => $" {e.From} → {e.To}", "\n")); + } + } + + public static SkillCode GetEffectiveSkillCode(this AgentSymbol agentSymbol) + { + return SkillCodeByAgent.Value.GetOrCreate(agentSymbol, s => + { + var skillCustomization = SkillCustomizationByAgent.Value.TryGetC(s); + if(skillCustomization != null) + skillCustomization.ToSkillCode(); + + var def = RegisteredAgents.GetOrThrow(agentSymbol); + + return def(); + }); + + } + + static SkillCustomizationEntity ToCustomizationEntity(this SkillCode code, AgentSymbol? agent) + { + var type = code.GetType(); + + var entity = new SkillCustomizationEntity + { + SkillCode = SkillCodeLogic.ToSkillCodeEntity(type), + Agent = agent, + ShortDescription = code.ShortDescription, + Instructions = code.OriginalInstructions, + }; + + foreach (var pi in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var attr = pi.GetCustomAttribute(); + if (attr == null) continue; + + var currentStr = attr.ConvertValueToString(pi.GetValue(code), pi.PropertyType); + entity.Properties.Add(new SkillPropertyEmbedded + { + PropertyName = pi.Name, + Value = currentStr, + }); + } + + foreach (var (sub, activation) in code.SubSkills) + { + Entity skillLite = !sub.IsDefault() ? sub.ToCustomizationEntity(agent: null) : + SkillCodeLogic.ToSkillCodeEntity(sub.GetType()); + entity.SubSkills.Add(new SubSkillEmbedded { Skill = skillLite, Activation = activation }); + } + + return entity; + } + + +} + +[AttributeUsage(AttributeTargets.Property)] +public class SkillPropertyAttribute : Attribute +{ + public virtual object? ConvertFromString(string? value, Type targetType) + { + if (value == null) + return null; + + return ReflectionTools.ChangeType(value, targetType); + } + + public virtual string? ConvertValueToString(object? value, Type targetType) => value?.ToString(); + + public virtual string? ValidateValue(string? value, Type targetType) => null; + + public virtual string? ValueHint => null; +} + +[AttributeUsage(AttributeTargets.Property)] +public class SkillProperty_QueryListAttribute : SkillPropertyAttribute +{ + public override object? ConvertFromString(string? value, Type targetType) + { + if (value == null) + return null; + + return value + .Split(',') + .Select(k => QueryLogic.ToQueryName(k.Trim())) + .ToHashSet(); + } + + public override string? ConvertValueToString(object? value, Type targetType) + { + if (value is not System.Collections.IEnumerable enumerable) return value?.ToString(); + return enumerable.Cast().Select(q => QueryLogic.GetQueryEntity(q).Key).ToString(", "); + } + + public override string? ValidateValue(string? value, Type targetType) + { + if (value == null) + return null; + + var errors = value.Split(',') + .Select(k => k.Trim()) + .Where(k => k.HasText() && QueryLogic.ToQueryName(k) == null) + .ToList(); + + return errors.Any() + ? $"Unknown query key(s): {errors.ToString(", ")}" + : null; + } + + public override string? ValueHint => "Comma-separated query keys"; +} + +public enum SkillActivation +{ + Eager, + Lazy, +} + +/// +/// Marks a [McpServerTool] as a UI tool: the server never invokes its body. +/// The controller routes the call to the client via the $!AssistantUITool streaming command. +/// The method body must throw InvalidOperationException. +/// +[AttributeUsage(AttributeTargets.Method)] +public class UIToolAttribute : Attribute { } + +public static partial class SignumMcpServerBuilderExtensions +{ + public static IMcpServerBuilder WithSignumSkill(this IMcpServerBuilder builder, AgentSymbol useCase) + { + var sessionActivated = new ConcurrentDictionary>(); + + SkillCode GetRoot() => + AgentLogic.GetEffectiveSkillCode(useCase) + ?? throw new InvalidOperationException($"No active AgentSkillEntity with UseCase = {useCase.Key}"); + + IEnumerable GetActivated(SkillCode code, string? sessionId) => + sessionId != null && sessionActivated.TryGetValue(sessionId, out var s) ? s + : code.GetEagerSkillsRecursive().Select(s => s.Name).ToHashSet(); + + return builder + .WithHttpTransport(options => + { +#pragma warning disable MCPEXP002 + options.RunSessionHandler = async (httpContext, mcpServer, token) => + { + if (mcpServer.SessionId != null) + sessionActivated[mcpServer.SessionId] = GetRoot().GetEagerSkillsRecursive().Select(s => s.Name).ToHashSet(); + try { await mcpServer.RunAsync(token); } + finally + { + if (mcpServer.SessionId != null) + sessionActivated.TryRemove(mcpServer.SessionId, out _); + } + }; +#pragma warning restore MCPEXP002 + }) + .WithListToolsHandler(async (ctx, ct) => + { + var root = GetRoot(); + var activated = GetActivated(root, ctx.Server.SessionId); + var tools = activated + .Select(name => root.FindSkill(name)) + .OfType() + .SelectMany(s => s.GetMcpServerTools()) + .Select(t => t.ProtocolTool) + .ToList(); + + return new ListToolsResult { Tools = tools }; + }) + .WithCallToolHandler(async (ctx, ct) => + { + var toolName = ctx.Params!.Name; + var root = GetRoot(); + var activated = GetActivated(root, ctx.Server.SessionId); + + var tool = activated + .Select(name => root.FindSkill(name)) + .OfType() + .SelectMany(s => s.GetMcpServerTools()) + .FirstOrDefault(t => t.ProtocolTool.Name == toolName) + ?? throw new McpException($"Tool '{toolName}' not found"); + + CallToolResult result; + using (AgentLogic.IsMCP.Override(true)) + result = await tool.InvokeAsync(ctx, ct); + + if (toolName == nameof(IntroductionSkill.Describe) + && ctx.Params.Arguments?.TryGetValue("skillName", out var je) == true + && je.GetString() is { } skillName + && ctx.Server.SessionId is { } sessionId) + { + var newSkill = root.FindSkill(skillName); + if (newSkill != null && sessionActivated.TryGetValue(sessionId, out var skills)) + { + foreach (var s in newSkill.GetEagerSkillsRecursive()) + skills.Add(s.Name); + await ctx.Server.SendNotificationAsync(NotificationMethods.ToolListChangedNotification, ct); + } + } + + return result; + }); + } +} diff --git a/Extensions/Signum.Agent/AgentSkillLogic.cs b/Extensions/Signum.Agent/AgentSkillLogic.cs deleted file mode 100644 index e9a67945a2..0000000000 --- a/Extensions/Signum.Agent/AgentSkillLogic.cs +++ /dev/null @@ -1,275 +0,0 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using Signum.Agent.Skills; -using Signum.API; -using System.Collections.Concurrent; -using System.ComponentModel; -using System.IO; -using System.Text.Json; -using System.Text.Json.Serialization.Metadata; - -namespace Signum.Agent; - -public static class AgentSkillLogic -{ - public static readonly AsyncThreadVariable IsMCP = Statics.ThreadVariable("IsMCP"); - - public static AgentSkill? IntroductionSkill; - - public static ConversationSumarizerSkill ConversationSumarizerSkill = new ConversationSumarizerSkill(); - public static QuestionSumarizerSkill QuestionSumarizerSkill = new QuestionSumarizerSkill(); - - public static void Start(SchemaBuilder sb, AgentSkill? introductionSkill = null) - { - if (sb.AlreadyDefined(MethodBase.GetCurrentMethod())) - return; - - if (introductionSkill != null) - IntroductionSkill = introductionSkill; - } - - public static AgentSkill WithSubSkill(this AgentSkill parent, SkillActivation activation, AgentSkill child) - { - parent.SubSkills[child] = activation; - return parent; - } -} - - -public abstract class AgentSkill -{ - public string Name => this.GetType().Name.Before("Skill"); - public string ShortDescription; - public Func IsAllowed; - public Dictionary>? Replacements; - - public static string SkillsDirectory = Path.Combine(Path.GetDirectoryName(typeof(AgentSkill).Assembly.Location)!, "Skills"); - - string? originalInstructions; - public string OriginalInstructions - { - get { return originalInstructions ??= File.ReadAllText(Path.Combine(SkillsDirectory, this.Name + ".md")); } - set { originalInstructions = value; } - } - - public string GetInstruction(object? context) - { - StringBuilder sb = new StringBuilder(); - if (Replacements.IsNullOrEmpty()) - sb.AppendLineLF(OriginalInstructions); - else - sb.AppendLineLF(OriginalInstructions.Replace(Replacements.SelectDictionary(k => k, v => v(context)))); - - FillSubInstructions(sb); - - return sb.ToString(); - } - - public string FillSubInstructions() - { - var sb = new StringBuilder(); - FillSubInstructions(sb); - return sb.ToString(); - } - - private void FillSubInstructions(StringBuilder sb) - { - foreach (var (skill, activation) in SubSkills) - { - sb.AppendLineLF("# Skill " + skill.Name); - sb.AppendLineLF("**Summary**: " + skill.ShortDescription); - sb.AppendLineLF(); - - if (activation == SkillActivation.Eager) - sb.AppendLineLF(skill.GetInstruction(null)); - else - sb.AppendLineLF("Use the tool 'describe' to get more information about this skill and discover additional tools."); - } - } - - public Dictionary SubSkills = new Dictionary(); - - IEnumerable? chatbotTools; - internal IEnumerable GetTools() - { - return (chatbotTools ??= this.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) - .Where(m => m.GetCustomAttribute() != null) - .Select(m => - { - Type delType = Expression.GetDelegateType(m.GetParameters().Select(a => a.ParameterType).And(m.ReturnType).ToArray()); - Delegate del = m.IsStatic ? - Delegate.CreateDelegate(delType, m) : - Delegate.CreateDelegate(delType, this, m); - - string? description = m.GetCustomAttribute()?.Description; - return (AITool)AIFunctionFactory.Create(del, m.Name, description, GetJsonSerializerOptions()); - }) - .ToList()); - } - - internal IEnumerable GetMcpServerTools() => - GetTools().Select(t => McpServerTool.Create((AIFunction)t, new McpServerToolCreateOptions - { - SerializerOptions = GetJsonSerializerOptions(), - })); - - static JsonSerializerOptions JsonSerializationOptions = new JsonSerializerOptions - { - TypeInfoResolver = new DefaultJsonTypeInfoResolver(), - PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }.AddSignumJsonConverters(); - - public virtual JsonSerializerOptions GetJsonSerializerOptions() => JsonSerializationOptions; - - public IEnumerable GetToolsRecursive() - { - var list = GetTools().ToList(); - - foreach (var (skill, activation) in SubSkills) - { - if (activation == SkillActivation.Eager) - list.AddRange(skill.GetToolsRecursive()); - } - - return list; - } - - public AgentSkill? FindSkill(string name) - { - if (this.Name == name) return this; - foreach (var (skill, _) in SubSkills) - { - var found = skill.FindSkill(name); - if (found != null) return found; - } - return null; - } - - public AITool? FindTool(string name) - { - var tool = GetTools().FirstOrDefault(t => t.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)); - if (tool != null) return tool; - foreach (var (skill, _) in SubSkills) - { - var found = skill.FindTool(name); - if (found != null) return found; - } - return null; - } - - public IEnumerable GetSkillsRecursive() - { - yield return this; - foreach (var (skill, _) in SubSkills) - foreach (var s in skill.GetSkillsRecursive()) - yield return s; - } - - public IEnumerable GetEagerSkillsRecursive() - { - yield return this; - foreach (var (skill, activation) in SubSkills) - { - if (activation == SkillActivation.Eager) - foreach (var s in skill.GetEagerSkillsRecursive()) - yield return s; - } - } -} - -public enum SkillActivation -{ - Eager, - Lazy, -} - -/// -/// Marks a [McpServerTool] method as a UI tool: the server never invokes its body. -/// Instead the controller detects this attribute before calling InvokeAsync and routes -/// the call to the client via the $!AssistantUITool streaming command. -/// The method body must throw InvalidOperationException("This method should not be called on the server"). -/// -[AttributeUsage(AttributeTargets.Method)] -public class UIToolAttribute : Attribute { } - -public static partial class SignumMcpServerBuilderExtensions -{ - public static IMcpServerBuilder WithSignumSkill(this IMcpServerBuilder builder, AgentSkill rootSkill) - { - var allSkillTools = rootSkill.GetSkillsRecursive() - .ToDictionary(s => s.Name, s => s.GetMcpServerTools().ToList()); - - var sessionActivated = new ConcurrentDictionary>(); - - HashSet InitialActivated() => - rootSkill.GetEagerSkillsRecursive().Select(s => s.Name).ToHashSet(); - - IEnumerable GetActivated(string? sessionId) => - sessionId != null && sessionActivated.TryGetValue(sessionId, out var s) - ? s - : InitialActivated(); - - return builder - .WithHttpTransport(options => - { -#pragma warning disable MCPEXP002 - options.RunSessionHandler = async (httpContext, mcpServer, token) => - { - if (mcpServer.SessionId != null) - sessionActivated[mcpServer.SessionId] = InitialActivated(); - try { await mcpServer.RunAsync(token); } - finally - { - if (mcpServer.SessionId != null) - sessionActivated.TryRemove(mcpServer.SessionId, out _); - } - }; -#pragma warning restore MCPEXP002 - }) - .WithListToolsHandler(async (ctx, ct) => - { - var tools = GetActivated(ctx.Server.SessionId) - .Where(allSkillTools.ContainsKey) - .SelectMany(n => allSkillTools[n]) - .Select(t => t.ProtocolTool) - .ToList(); - - return new ListToolsResult { Tools = tools }; - }) - .WithCallToolHandler(async (ctx, ct) => - { - var toolName = ctx.Params!.Name; - var tool = GetActivated(ctx.Server.SessionId) - .Where(allSkillTools.ContainsKey) - .SelectMany(n => allSkillTools[n]) - .FirstOrDefault(t => t.ProtocolTool.Name == toolName) - ?? throw new McpException($"Tool '{toolName}' not found"); - - CallToolResult result; - using (AgentSkillLogic.IsMCP.Override(true)) - result = await tool.InvokeAsync(ctx, ct); - - - // When Describe is called for a Lazy skill, activate it for this session - if (toolName == nameof(IntroductionSkill.Describe) - && ctx.Params.Arguments?.TryGetValue("skillName", out var je) == true - && je.GetString() is { } skillName - && ctx.Server.SessionId is { } sessionId) - { - var newSkill = rootSkill.FindSkill(skillName); - if (newSkill != null && sessionActivated.TryGetValue(sessionId, out var skills)) - { - foreach (var s in newSkill.GetEagerSkillsRecursive()) - skills.Add(s.Name); - await ctx.Server.SendNotificationAsync(NotificationMethods.ToolListChangedNotification, ct); - } - } - - return result; - }); - } -} diff --git a/Extensions/Signum.Agent/ChatbotClient.tsx b/Extensions/Signum.Agent/ChatbotClient.tsx index 6d18f6aecd..ce69f1bebf 100644 --- a/Extensions/Signum.Agent/ChatbotClient.tsx +++ b/Extensions/Signum.Agent/ChatbotClient.tsx @@ -3,7 +3,7 @@ import { RouteObject } from 'react-router' import { ajaxGet, ajaxPost, wrapRequest, AjaxOptions } from '@framework/Services'; import { Navigator, EntitySettings } from '@framework/Navigator' import * as AppContext from '@framework/AppContext' -import { ChatbotLanguageModelEntity, ChatSessionEntity, ChatMessageEntity, LanguageModelProviderSymbol, EmbeddingsLanguageModelEntity, ToolCallEmbedded, UserFeedback } from './Signum.Agent' +import { ChatSessionEntity, ChatMessageEntity, ToolCallEmbedded, UserFeedback } from './Signum.Agent' import { toAbsoluteUrl } from '../../Signum/React/AppContext'; import { Dic } from '@framework/Globals'; import { Finder } from '@framework/Finder'; @@ -14,15 +14,12 @@ const ChatMarkdown = React.lazy(() => import("./Templates/ChatMarkdown")); export namespace ChatbotClient { export let renderMarkdown = (markdown: string): React.JSX.Element => ; - //export let renderMarkdown = (markdown: string): React.JSX.Element => {markdown}; - export function start(options: { routes: RouteObject[] }): void { - Navigator.addSettings(new EntitySettings(ChatbotLanguageModelEntity, e => import('./Templates/ChatbotLanguageModel'))); - Navigator.addSettings(new EntitySettings(EmbeddingsLanguageModelEntity, e => import('./Templates/EmbeddingsLanguageModel'))); + export function start(options: { routes: RouteObject[] }): void { Navigator.addSettings(new EntitySettings(ChatSessionEntity, a => import('./Templates/ChatSession'))); Navigator.addSettings(new EntitySettings(ChatMessageEntity, a => import('./Templates/ChatMessage'))); Finder.registerPropertyFormatter(ChatMessageEntity.tryPropertyRoute(a => a.content), new Finder.CellFormatter((cell, ctx, column) => cell && , true)); - + AppContext.clearSettingsActions.push(() => uiToolRegistry.clear()); } @@ -40,23 +37,23 @@ export namespace ChatbotClient { export namespace API { - export function ask(question: string, optiions: { sessionId?: string | number, callId?: string, toolId?: string, recover?: boolean }, signal?: AbortSignal): Promise { + export function ask(question: string, options: { sessionId?: string | number, callId?: string, toolId?: string, recover?: boolean }, signal?: AbortSignal): Promise { - const options: AjaxOptions = { url: "/api/chatbot/ask", }; + const ajaxOptions: AjaxOptions = { url: "/api/chatbot/ask" }; - return wrapRequest(options, () => { + return wrapRequest(ajaxOptions, () => { const headers = { 'Accept': 'text/plain', 'Content-Type': 'text/plain', - 'X-Chatbot-Session-Id': optiions.sessionId, - 'X-Chatbot-UIReply-CallId': optiions.callId, - 'X-Chatbot-UIReply-ToolId': optiions.toolId, - 'X-Chatbot-Recover': optiions.recover ? 'true' : undefined, - ...options.headers + 'X-Chatbot-Session-Id': options.sessionId, + 'X-Chatbot-UIReply-CallId': options.callId, + 'X-Chatbot-UIReply-ToolId': options.toolId, + 'X-Chatbot-Recover': options.recover ? 'true' : undefined, + ...ajaxOptions.headers } as any; - return fetch(toAbsoluteUrl(options.url), { + return fetch(toAbsoluteUrl(ajaxOptions.url), { method: "POST", credentials: "same-origin", headers: Dic.simplify(headers), @@ -67,15 +64,6 @@ export namespace ChatbotClient { }); } - - export function getModels(provider: LanguageModelProviderSymbol): Promise> { - return ajaxGet({ url: `/api/chatbot/provider/${provider.key}/models` }); - } - - export function getEmbeddingModels(provider: LanguageModelProviderSymbol): Promise> { - return ajaxGet({ url: `/api/chatbot/provider/${provider.key}/embeddingModels` }); - } - export function getMessagesBySessionId(sessionId: string | number | undefined): Promise> { return ajaxGet({ url: "/api/chatbot/messages/" + sessionId }); } @@ -84,7 +72,6 @@ export namespace ChatbotClient { return ajaxPost({ url: "/api/chatbot/feedback/" + messageId }, { feedback, message }); } } - } // UI Tool registry diff --git a/Extensions/Signum.Agent/ChatbotController.cs b/Extensions/Signum.Agent/ChatbotController.cs index b99511e12d..34c3afbf8a 100644 --- a/Extensions/Signum.Agent/ChatbotController.cs +++ b/Extensions/Signum.Agent/ChatbotController.cs @@ -5,7 +5,6 @@ using Signum.API; using Signum.API.Filters; using Signum.Authorization; -using System.Data; using System.Diagnostics; using System.Reflection; using System.Text.Json; @@ -14,55 +13,35 @@ namespace Signum.Agent; public class ChatbotController : Controller { - [HttpGet("api/chatbot/provider/{providerKey}/models")] - public async Task> GetModels(string providerKey, CancellationToken token) - { - var symbol = SymbolLogic.ToSymbol(providerKey); - - var list = await ChatbotLogic.GetModelNamesAsync(symbol, token); - - return list.Order().ToList(); - } - - [HttpGet("api/chatbot/provider/{providerKey}/embeddingModels")] - public async Task> GetEmbeddingModels(string providerKey, CancellationToken token) - { - var symbol = SymbolLogic.ToSymbol(providerKey); - - var list = await ChatbotLogic.GetEmbeddingModelNamesAsync(symbol, token); - - return list.Order().ToList(); - } + [HttpGet("api/agentSkill/skillCodeInfo/{skillCodeName}")] + public DefaultSkillCodeInfo GetSkillCodeInfo(string skillCodeName) => + SkillCodeLogic.GetDefaultSkillCodeInfo(skillCodeName); [HttpPost("api/chatbot/feedback/{messageId}")] public void SetFeedback(int messageId, [FromBody] SetFeedbackRequest request) { var message = Database.Retrieve(messageId); - if (message.Role != ChatMessageRole.Assistant) throw new InvalidOperationException("Feedback can only be set on Assistant messages."); - message.UserFeedback = request.Feedback; message.UserFeedbackMessage = request.Feedback == UserFeedback.Negative ? request.Message : null; message.Save(); } [HttpGet("api/chatbot/messages/{sessionID}")] - public List GetMessagesBySessionId(int sessionID) - { - var messages = Database.Query().Where(m => m.ChatSession.Id == sessionID).OrderBy(cm => cm.CreationDate).ToList(); - - return messages; - } + public List GetMessagesBySessionId(int sessionID) => + Database.Query() + .Where(m => m.ChatSession.Id == sessionID) + .OrderBy(cm => cm.CreationDate) + .ToList(); [HttpPost("api/chatbot/ask")] public async Task AskQuestionAsync(CancellationToken ct) { var resp = this.HttpContext.Response; + var output = new HttpAgentOutput(resp); try { - var context = this.HttpContext; - string sessionID = HttpContext.Request.Headers["X-Chatbot-Session-Id"].ToString(); string question = Encoding.UTF8.GetString(HttpContext.Request.Body.ReadAllBytesAsync().Result); @@ -72,17 +51,13 @@ public async Task AskQuestionAsync(CancellationToken ct) if (sessionID.HasText() == false || sessionID == "undefined") { - await resp.WriteAsync(UINotification(ChatbotUICommand.SessionId, session.Id.ToString()), ct); + await resp.WriteAsync(output.Notification(ChatbotUICommand.SessionId, session.Id.ToString()), ct); await resp.Body.FlushAsync(); history = CreateNewConversationHistory(session); var init = history.Messages.SingleEx(); - - await resp.WriteAsync(UINotification(ChatbotUICommand.System), ct); - await resp.WriteAsync(init.Content!, ct); - await resp.WriteAsync("\n", ct); - await resp.WriteAsync(UINotification(ChatbotUICommand.MessageId, init.Id.ToString()), ct); + await output.OnSystemMessageAsync(init, ct); } else { @@ -97,7 +72,7 @@ public async Task AskQuestionAsync(CancellationToken ct) .OrderBy(a => a.CreationDate) .ToList(); - var lastSystemDate = systemAndSummaries.Max(a => a.CreationDate); //Inial or Summary + var lastSystemDate = systemAndSummaries.Max(a => a.CreationDate); var remainingMessages = Database.Query() .Where(c => c.ChatSession.Is(session)) @@ -111,360 +86,198 @@ public async Task AskQuestionAsync(CancellationToken ct) Session = session.ToLite(), SessionTitle = session.Title, LanguageModel = session.LanguageModel.RetrieveFromCache(), + RootSkill = AgentLogic.GetEffectiveSkillCode(DefaultAgent.Chatbot), Messages = systemAndSummaries.Concat(remainingMessages).ToList(), }; } } - // If this request is a UI reply, inject the tool result into history and skip adding a new user message string? uiReplyCallId = HttpContext.Request.Headers["X-Chatbot-UIReply-CallId"].ToString().DefaultToNull(); string? uiReplyToolId = HttpContext.Request.Headers["X-Chatbot-UIReply-ToolId"].ToString().DefaultToNull(); bool isRecover = HttpContext.Request.Headers["X-Chatbot-Recover"].ToString() == "true"; if (uiReplyCallId != null && uiReplyToolId != null) { - // Create the Tool message now that we have the client's result - var toolMsg = new ChatMessageEntity() + var toolMsg = new ChatMessageEntity { ChatSession = session.ToLite(), Role = ChatMessageRole.Tool, ToolCallID = uiReplyCallId, ToolID = uiReplyToolId, - Content = question, // request body carries the JSON result + Content = question, }.Save(); - await resp.WriteAsync(UINotification(ChatbotUICommand.Tool, uiReplyToolId + "/" + uiReplyCallId), ct); + await resp.WriteAsync(output.Notification(ChatbotUICommand.Tool, uiReplyToolId + "/" + uiReplyCallId), ct); await resp.WriteAsync(question, ct); await resp.WriteAsync("\n"); - await resp.WriteAsync(UINotification(ChatbotUICommand.MessageId, toolMsg.Id.ToString()), ct); + await resp.WriteAsync(output.Notification(ChatbotUICommand.MessageId, toolMsg.Id.ToString()), ct); await resp.Body.FlushAsync(); history.Messages.Add(toolMsg); } else if (isRecover) { - // Recover mode: body must be empty — we resume from the last saved state if (question.HasText()) throw new InvalidOperationException("Recover requests must have an empty body."); - // Case C: last message in history is an Assistant with an unresponded regular tool call. - // Re-execute that tool call so the agent loop can continue. var lastAssistant = history.Messages.LastOrDefault(m => m.Role == ChatMessageRole.Assistant); if (lastAssistant != null) { var pendingToolCall = lastAssistant.ToolCalls - .Select(tc => tc) .FirstOrDefault(tc => !tc.IsUITool && !history.Messages.Any(m => m.Role == ChatMessageRole.Tool && m.ToolCallID == tc.CallId)); if (pendingToolCall != null) { var parsedArgs = JsonSerializer.Deserialize>(pendingToolCall.Arguments) ?? new(); - await ExecuteToolAsync(history, pendingToolCall.ToolId, pendingToolCall.CallId, parsedArgs, ct); + await ChatbotLogic.ExecuteToolAsync(history, pendingToolCall.ToolId, pendingToolCall.CallId, parsedArgs, output, ct); } } - // Case D (User or Tool as last message): history is already correct — fall through to the agent loop. } else { - ChatMessageEntity userQuestion = NewChatMessage(session.ToLite(), question, ChatMessageRole.User).Save(); - - history.Messages.Add(userQuestion); - - await resp.WriteAsync(UINotification(ChatbotUICommand.QuestionId, userQuestion.Id.ToString()), ct); - await resp.Body.FlushAsync(); - } - - var client = ChatbotLogic.GetChatClient(history.LanguageModel); - while (true) - { - if (history.LanguageModel.MaxTokens != null && history.Messages.Skip(1).LastOrDefault()?.InputTokens > history.LanguageModel.MaxTokens * 0.8) - { - var systemMsg = history.Messages.FirstEx(); - if (systemMsg.Role != ChatMessageRole.System) - throw new InvalidOperationException("First message is expected to be system"); - var normalMessages = history.Messages.Skip(1).ToList(); - var toKeepIndex = normalMessages.FindLastIndex(a => a.InputTokens < history.LanguageModel.MaxTokens * 0.5).NotFoundToNull() ?? (normalMessages.Count - 1); - var toSumarize = normalMessages.Take(toKeepIndex).ToList(); - var toKeep = normalMessages.Skip(toKeepIndex).ToList(); - - var summaryContent = await ChatbotLogic.SumarizeConversation(toSumarize, history.LanguageModel, ct); - - var summary = new ChatMessageEntity - { - ChatSession = history.Session, - Role = ChatMessageRole.System, - Content = $"## Summary of earlier conversation\n{summaryContent}\n\n---\nRecent messages follow:", - }.Save(); - - await resp.WriteAsync(UINotification(ChatbotUICommand.System), ct); - await resp.WriteAsync(summary.Content!, ct); - await resp.WriteAsync("\n", ct); - await resp.WriteAsync(UINotification(ChatbotUICommand.MessageId, summary.Id.ToString()), ct); - - history.Messages = [systemMsg, summary, .. toKeep]; - } - - var tools = history.GetTools(); - var options = ChatbotLogic.ChatOptions(history.LanguageModel, tools); - - var messages = history.GetMessages(); - ChatbotLogic.GetProvider(history.LanguageModel).CustomizeMessagesAndOptions(messages, options); - List updates = []; - var sw = Stopwatch.StartNew(); - await foreach (var update in client.GetStreamingResponseAsync(messages, options, ct)) - { - if (updates.Count == 0) - await resp.WriteAsync(UINotification(ChatbotUICommand.AssistantAnswer), ct); - - updates.Add(update); - var text = update.Text; - if (text.HasText()) - { - await resp.WriteAsync(text); - await resp.Body.FlushAsync(); - } - } - sw.Stop(); - - var response = updates.ToChatResponse(); - var responseMsg = response.Messages.SingleEx(); - - - var notSupported = responseMsg.Contents.Where(a => !(a is FunctionCallContent or Microsoft.Extensions.AI.TextContent)).ToList(); - - if (notSupported.Any()) - throw new InvalidOperationException("Unexpected response" + notSupported.ToString(a => a.GetType().Name, ", ")); - - var usage = response.Usage; - - var toolCalls = responseMsg.Contents.OfType().ToList(); - - // Detect UITool calls — the server never invokes their bodies - var uiToolCalls = toolCalls.Where(fc => + var userQuestion = new ChatMessageEntity { - var tool = AgentSkillLogic.IntroductionSkill?.FindTool(fc.Name) - ?? throw new InvalidOperationException($"Tool '{fc.Name}' not found"); - return ((AIFunction)tool).UnderlyingMethod?.GetCustomAttribute() != null; - }).ToList(); - - if (uiToolCalls.Count > 1) - throw new InvalidOperationException($"The LLM invoked more than one UITool in a single response ({string.Join(", ", uiToolCalls.Select(t => t.Name))}). Only one UITool can be active at a time."); - - var answer = new ChatMessageEntity - { - ChatSession = history.Session, - Role = ChatMessageRole.Assistant, - Content = responseMsg.Text, - LanguageModel = session.LanguageModel, - InputTokens = (int?)usage?.InputTokenCount, - CachedInputTokens = (int?)usage?.CachedInputTokenCount, - OutputTokens = (int?)usage?.OutputTokenCount, - ReasoningOutputTokens = (int?)usage?.ReasoningTokenCount, - Duration = sw.Elapsed, - ToolCalls = toolCalls.Select(fc => new ToolCallEmbedded - { - ToolId = fc.Name, - CallId = fc.CallId, - Arguments = JsonSerializer.Serialize(fc.Arguments), - IsUITool = uiToolCalls.Any(u => u.CallId == fc.CallId), - }).ToMList() + ChatSession = session.ToLite(), + Role = ChatMessageRole.User, + Content = question, }.Save(); - - - Expression> NullableAdd = (a, b) => a == null && b == null ? null : (a ?? 0) + (b ?? 0); - - history.Session.InDB().UnsafeUpdate() - .Set(a => a.TotalInputTokens, a => NullableAdd.Evaluate(a.TotalInputTokens, answer.InputTokens)) - .Set(a => a.TotalCachedInputTokens, a => NullableAdd.Evaluate(a.TotalCachedInputTokens, answer.CachedInputTokens)) - .Set(a => a.TotalOutputTokens, a => NullableAdd.Evaluate(a.TotalOutputTokens, answer.OutputTokens)) - .Set(a => a.TotalReasoningOutputTokens, a => NullableAdd.Evaluate(a.TotalReasoningOutputTokens, answer.ReasoningOutputTokens)) - .Set(a => a.TotalToolCalls, a => a.TotalToolCalls + answer.ToolCalls.Count) - .Execute(); - - foreach (var item in answer.ToolCalls) - { - await resp.WriteAsync("\n"); - var cmd = item.IsUITool ? ChatbotUICommand.AssistantUITool : ChatbotUICommand.AssistantTool; - await resp.WriteAsync(UINotification(cmd, item.ToolId + "/" + item.CallId), ct); - await resp.WriteAsync(item.Arguments, ct); - } - - await resp.WriteAsync("\n"); - await resp.WriteAsync(UINotification(ChatbotUICommand.MessageId, answer.Id.ToString()), ct); - await resp.Body.FlushAsync(); - - history.Messages.Add(answer); - - if (toolCalls.IsEmpty()) - break; - - // If a UITool was invoked, close the stream — the client will resume via a new ask request - if (uiToolCalls.Any()) - goto doneWithTools; - - foreach (var funCall in toolCalls) - await ExecuteToolAsync(history, funCall.Name, funCall.CallId, funCall.Arguments!, ct); + history.Messages.Add(userQuestion); + await output.OnUserQuestionAsync(userQuestion, ct); } - doneWithTools: - - if (history.SessionTitle == null || history.SessionTitle.StartsWith("!*$")) - { - history.SessionTitle = history.Session.InDB(a => a.Title); - if (history.SessionTitle == null || history.SessionTitle.StartsWith("!*$")) - { - string title = await ChatbotLogic.SumarizeTitle(history, ct); - if (title.HasText() && title.ToLower() != "pending") - { - history.Session.InDB().UnsafeUpdate(a => a.Title, a => title); - await resp.WriteAsync(UINotification(ChatbotUICommand.SessionTitle, title), ct); - } - } - } + await ChatbotLogic.RunAgentLoopAsync(history, output, ct); } catch (Exception e) { var ex = e.LogException().ToLiteFat(); - - await resp.WriteAsync(UINotification(ChatbotUICommand.Exception, ex!.Id.ToString()), ct); + await resp.WriteAsync(output.Notification(ChatbotUICommand.Exception, ex!.Id.ToString()), ct); await resp.WriteAsync(ex!.ToString()!, ct); await resp.WriteAsync("\n"); await resp.Body.FlushAsync(); } } - - async Task ExecuteToolAsync(ConversationHistory history, string toolId, string callId, IDictionary arguments, CancellationToken ct) + ChatSessionEntity GetOrCreateSession(string? sessionID) { - var resp = this.HttpContext.Response; - - await resp.WriteAsync(UINotification(ChatbotUICommand.Tool, toolId + "/" + callId), ct); + return sessionID.HasText() == false || sessionID == "undefined" + ? new ChatSessionEntity + { + LanguageModel = LanguageModelLogic.DefaultLanguageModel.Value + ?? throw new InvalidOperationException($"No default {typeof(ChatbotLanguageModelEntity).Name}"), + User = UserEntity.Current, + StartDate = Clock.Now, + Title = null, + }.Save() + : Database.Query().SingleEx(a => a.Id == PrimaryKey.Parse(sessionID, typeof(ChatSessionEntity))); + } - var toolSw = Stopwatch.StartNew(); - try - { - AITool tool = AgentSkillLogic.IntroductionSkill?.FindTool(toolId) - ?? throw new InvalidOperationException($"Tool '{toolId}' not found"); - var obj = await ((AIFunction)tool).InvokeAsync(new AIFunctionArguments(arguments), ct); - toolSw.Stop(); + ConversationHistory CreateNewConversationHistory(ChatSessionEntity session) + { + var rootSkill = AgentLogic.GetEffectiveSkillCode(DefaultAgent.Chatbot) + ?? throw new InvalidOperationException("No active AgentSkillEntity with UseCase = DefaultChatbot"); - string toolResponse = JsonSerializer.Serialize(obj); - var toolMsg = new ChatMessageEntity() - { - ChatSession = history.Session, - Role = ChatMessageRole.Tool, - ToolCallID = callId, - ToolID = toolId, - Content = toolResponse, - Duration = toolSw.Elapsed, - }.Save(); - - await resp.WriteAsync(toolResponse, ct); - await resp.WriteAsync("\n"); - await resp.WriteAsync(UINotification(ChatbotUICommand.MessageId, toolMsg.Id.ToString()), ct); - await resp.Body.FlushAsync(); - history.Messages.Add(toolMsg); - } - catch (Exception e) + return new ConversationHistory { - toolSw.Stop(); - var errorContent = FormatToolError(toolId, e, arguments); - - ChatMessageEntity toolMsg; - using (AuthLogic.Disable()) + Session = session.ToLite(), + SessionTitle = session.Title, + LanguageModel = session.LanguageModel.RetrieveFromCache(), + RootSkill = rootSkill, + Messages = new List { - toolMsg = new ChatMessageEntity() + new ChatMessageEntity { - ChatSession = history.Session, - Role = ChatMessageRole.Tool, - ToolCallID = callId, - ToolID = toolId, - Content = errorContent, - Exception = e.LogException().ToLiteFat(), - Duration = toolSw.Elapsed, - }.Save(); + Role = ChatMessageRole.System, + ChatSession = session.ToLite(), + Content = rootSkill.GetInstruction(null), + }.Save() } - - await resp.WriteAsync(UINotification(ChatbotUICommand.Exception, toolMsg.Exception!.Id.ToString()), ct); - await resp.WriteAsync(errorContent, ct); - await resp.WriteAsync("\n"); - await resp.WriteAsync(UINotification(ChatbotUICommand.MessageId, toolMsg.Id.ToString()), ct); - await resp.Body.FlushAsync(); - history.Messages.Add(toolMsg); - } + }; } +} + +public class HttpAgentOutput : IAgentOutput +{ + readonly HttpResponse _resp; - static string FormatToolError(string toolName, Exception e, IDictionary? arguments) + public HttpAgentOutput(HttpResponse resp) => _resp = resp; + + public string Notification(ChatbotUICommand cmd, string? payload = null) { - var sb = new StringBuilder(); - sb.AppendLine($"Tool '{toolName}' failed."); - if (arguments != null && arguments.Count > 0) - sb.AppendLine($"Arguments: {JsonSerializer.Serialize(arguments).Etc(300)}"); - sb.AppendLine($"Error: {e.GetType().Name}: {e.Message}"); + if (payload == null) + return "$!" + cmd + "\n"; - if (e.Data["Hint"] is string s) - sb.AppendLine($"Hint: {s}"); + if (payload.Contains('\n')) + throw new InvalidOperationException("Payload has newlines!"); - sb.AppendLine("Please review the error and try again with corrected arguments."); - return sb.ToString(); + return "$!" + cmd + ":" + payload + "\n"; } - string UINotification(ChatbotUICommand commandName, string? payload = null) + public async Task OnSystemMessageAsync(ChatMessageEntity msg, CancellationToken ct) { - if (payload == null) - return "$!" + commandName + "\n"; + await _resp.WriteAsync(Notification(ChatbotUICommand.System), ct); + await _resp.WriteAsync(msg.Content!, ct); + await _resp.WriteAsync("\n", ct); + await _resp.WriteAsync(Notification(ChatbotUICommand.MessageId, msg.Id.ToString()), ct); + } - if (payload.Contains("\n")) - throw new InvalidOperationException("Payload has newlines!"); + public async Task OnUserQuestionAsync(ChatMessageEntity msg, CancellationToken ct) + { + await _resp.WriteAsync(Notification(ChatbotUICommand.QuestionId, msg.Id.ToString()), ct); + await _resp.Body.FlushAsync(ct); + } - return "$!" + commandName + ":" + payload + "\n"; + public async Task OnSummarizationAsync(ChatMessageEntity msg, CancellationToken ct) + { + await _resp.WriteAsync(Notification(ChatbotUICommand.System), ct); + await _resp.WriteAsync(msg.Content!, ct); + await _resp.WriteAsync("\n", ct); + await _resp.WriteAsync(Notification(ChatbotUICommand.MessageId, msg.Id.ToString()), ct); } - ChatSessionEntity GetOrCreateSession(string? sessionID) + public async Task OnAssistantStartedAsync(CancellationToken ct) { - return sessionID.HasText() == false || sessionID == "undefined" ? new ChatSessionEntity - { - LanguageModel = ChatbotLogic.DefaultLanguageModel.Value ?? throw new InvalidOperationException($"No default {typeof(ChatbotLanguageModelEntity).Name}"), - User = UserEntity.Current, - StartDate = Clock.Now, - Title = null, - }.Save() : Database.Query().SingleEx(a => a.Id == PrimaryKey.Parse(sessionID, typeof(ChatSessionEntity))); + await _resp.WriteAsync(Notification(ChatbotUICommand.AssistantAnswer), ct); } - ConversationHistory CreateNewConversationHistory(ChatSessionEntity session) + public async Task OnTextChunkAsync(string chunk, CancellationToken ct) { - var intro = AgentSkillLogic.IntroductionSkill - ?? throw new InvalidOperationException("IntroductionSkill not configured"); + await _resp.WriteAsync(chunk, ct); + await _resp.Body.FlushAsync(ct); + } - var history = new ConversationHistory + public async Task OnAssistantMessageAsync(ChatMessageEntity msg, CancellationToken ct) + { + foreach (var item in msg.ToolCalls) { - Session = session.ToLite(), - SessionTitle = session.Title, - LanguageModel = session.LanguageModel.RetrieveFromCache(), - Messages = new List - { - new ChatMessageEntity - { - Role = ChatMessageRole.System, - ChatSession = session.ToLite(), - Content = intro.GetInstruction(null), - }.Save() - } - }; + await _resp.WriteAsync("\n", ct); + var cmd = item.IsUITool ? ChatbotUICommand.AssistantUITool : ChatbotUICommand.AssistantTool; + await _resp.WriteAsync(Notification(cmd, item.ToolId + "/" + item.CallId), ct); + await _resp.WriteAsync(item.Arguments, ct); + } + await _resp.WriteAsync("\n", ct); + await _resp.WriteAsync(Notification(ChatbotUICommand.MessageId, msg.Id.ToString()), ct); + await _resp.Body.FlushAsync(ct); + } - return history; + public async Task OnToolStartAsync(string toolId, string callId, CancellationToken ct) + { + await _resp.WriteAsync(Notification(ChatbotUICommand.Tool, toolId + "/" + callId), ct); } - ChatMessageEntity NewChatMessage(Lite session, string message, ChatMessageRole role) + public async Task OnToolFinishedAsync(ChatMessageEntity toolMsg, CancellationToken ct) { - var command = new ChatMessageEntity() - { - ChatSession = session, - Role = role, - Content = message, - }; + if (toolMsg.Exception != null) + await _resp.WriteAsync(Notification(ChatbotUICommand.Exception, toolMsg.Exception.Id.ToString()), ct); + + await _resp.WriteAsync(toolMsg.Content!, ct); + await _resp.WriteAsync("\n", ct); + await _resp.WriteAsync(Notification(ChatbotUICommand.MessageId, toolMsg.Id.ToString()), ct); + await _resp.Body.FlushAsync(ct); + } - return command; + public async Task OnTitleUpdatedAsync(string title, CancellationToken ct) + { + await _resp.WriteAsync(Notification(ChatbotUICommand.SessionTitle, title), ct); } } diff --git a/Extensions/Signum.Agent/ChatbotLogic.cs b/Extensions/Signum.Agent/ChatbotLogic.cs index bd063348a9..f98cd804cb 100644 --- a/Extensions/Signum.Agent/ChatbotLogic.cs +++ b/Extensions/Signum.Agent/ChatbotLogic.cs @@ -2,14 +2,12 @@ using Signum.Authorization; using Signum.Authorization.Rules; using Signum.Agent.Skills; -using Signum.Agent.Providers; using Signum.Utilities.Synchronization; using System.Text.Json; -using Pgvector; +using System.Diagnostics; namespace Signum.Agent; - public static class ChatbotLogic { [AutoExpressionField] @@ -37,145 +35,12 @@ public static IQueryable Messages(this ChatSessionEntity sess public static decimal? TotalPrice(this ChatSessionEntity session) => As.Expression(() => session.Messages().Sum(m => m.Price())); - public static ResetLazy, ChatbotLanguageModelEntity>> LanguageModels = null!; - public static ResetLazy?> DefaultLanguageModel = null!; - - public static ResetLazy, EmbeddingsLanguageModelEntity>> EmbeddingsModels = null!; - public static ResetLazy?> DefaultEmbeddingsModel = null!; - - public static Dictionary ChatbotModelProviders = new Dictionary - { - { LanguageModelProviders.OpenAI, new OpenAIProvider()}, - { LanguageModelProviders.Gemini, new GeminiProvider()}, - { LanguageModelProviders.Anthropic, new AnthropicProvider()}, - { LanguageModelProviders.GithubModels, new GithubModelsProvider()}, - { LanguageModelProviders.Mistral, new MistralProvider()}, - { LanguageModelProviders.Ollama, new OllamaProvider()}, - { LanguageModelProviders.DeepSeek, new DeepSeekProvider()}, - }; - - public static Dictionary EmbeddingsProviders = new Dictionary - { - { LanguageModelProviders.OpenAI, new OpenAIProvider()}, - { LanguageModelProviders.Gemini, new GeminiProvider()}, - { LanguageModelProviders.GithubModels, new GithubModelsProvider()}, - { LanguageModelProviders.Mistral, new MistralProvider()}, - { LanguageModelProviders.Ollama, new OllamaProvider()}, - }; - - public static Func GetConfig; - - public static ChatbotLanguageModelEntity RetrieveFromCache(this Lite lite) - { - return LanguageModels.Value.GetOrThrow(lite); - } - - public static EmbeddingsLanguageModelEntity RetrieveFromCache(this Lite lite) - { - return EmbeddingsModels.Value.GetOrThrow(lite); - } - public static void Start(SchemaBuilder sb, Func config) { if (sb.AlreadyDefined(MethodBase.GetCurrentMethod())) return; - GetConfig = config; - - SymbolLogic.Start(sb, () => ChatbotModelProviders.Keys.Union(EmbeddingsProviders.Keys)); - - sb.Include() - .WithSave(ChatbotLanguageModelOperation.Save, (m, args) => - { - if (!m.IsNew && Database.Query().Any(a => a.LanguageModel.Is(m))) - { - var inDb = m.InDB(a => new { a.Model, a.Provider }); - if (inDb.Model != m.Model || !inDb.Provider.Is(m.Provider)) - { - throw new ArgumentNullException(ChatbotMessage.UnableToChangeModelOrProviderOnceUsed.NiceToString()); - } - } - }) - .WithUniqueIndex(a => a.IsDefault, a => a.IsDefault == true) - .WithQuery(() => e => new - { - Entity = e, - e.Id, - e.IsDefault, - e.Provider, - e.Model, - e.Temperature, - e.MaxTokens, - e.PricePerInputToken, - e.PricePerOutputToken, - e.PricePerCachedInputToken, - e.PricePerReasoningOutputToken, - }); - - new Graph.Execute(ChatbotLanguageModelOperation.MakeDefault) - { - CanExecute = a => !a.IsDefault ? null : ValidationMessage._0IsSet.NiceToString(Entity.NicePropertyName(() => a.IsDefault)), - Execute = (e, _) => - { - var other = Database.Query().Where(a => a.IsDefault).SingleOrDefaultEx(); - if (other != null) - { - other.IsDefault = false; - other.Execute(ChatbotLanguageModelOperation.Save); - } - - e.IsDefault = true; - e.Save(); - } - }.Register(); - - - new Graph.Delete(ChatbotLanguageModelOperation.Delete) - { - Delete = (e, _) => { e.Delete(); }, - }.Register(); - - - LanguageModels = sb.GlobalLazy(() => Database.Query().ToDictionary(a => a.ToLite()), new InvalidateWith(typeof(ChatbotLanguageModelEntity))); - DefaultLanguageModel = sb.GlobalLazy(() => LanguageModels.Value.Values.SingleOrDefaultEx(a => a.IsDefault)?.ToLite(), new InvalidateWith(typeof(ChatbotLanguageModelEntity))); - - sb.Include() - .WithSave(EmbeddingsLanguageModelOperation.Save) - .WithUniqueIndex(a => a.IsDefault, a => a.IsDefault == true) - .WithQuery(() => e => new - { - Entity = e, - e.Id, - e.IsDefault, - e.Provider, - e.Model, - e.Dimensions, - }); - - new Graph.Execute(EmbeddingsLanguageModelOperation.MakeDefault) - { - CanExecute = a => !a.IsDefault ? null : ValidationMessage._0IsSet.NiceToString(Entity.NicePropertyName(() => a.IsDefault)), - Execute = (e, _) => - { - var other = Database.Query().Where(a => a.IsDefault).SingleOrDefaultEx(); - if (other != null) - { - other.IsDefault = false; - other.Execute(EmbeddingsLanguageModelOperation.Save); - } - - e.IsDefault = true; - e.Save(); - } - }.Register(); - - new Graph.Delete(EmbeddingsLanguageModelOperation.Delete) - { - Delete = (e, _) => { e.Delete(); }, - }.Register(); - - EmbeddingsModels = sb.GlobalLazy(() => Database.Query().ToDictionary(a => a.ToLite()), new InvalidateWith(typeof(EmbeddingsLanguageModelEntity))); - DefaultEmbeddingsModel = sb.GlobalLazy(() => EmbeddingsModels.Value.Values.SingleOrDefaultEx(a => a.IsDefault)?.ToLite(), new InvalidateWith(typeof(EmbeddingsLanguageModelEntity))); + LanguageModelLogic.Start(sb, config); sb.Include() .WithDelete(ChatSessionOperation.Delete) @@ -213,7 +78,6 @@ public static void Start(SchemaBuilder sb, Func co e.ChatSession, }); - new Graph.Delete(ChatMessageOperation.Delete) { CanDelete = m => m.Is(Database.Query().Where(a => a.ChatSession.Is(m.ChatSession)).OrderByDescending(a => a.CreationDate).Select(a => a.ToLite()).First()) ? null : ChatbotMessage.MessageMustBeTheLastToDelete.NiceToString(), @@ -224,20 +88,6 @@ public static void Start(SchemaBuilder sb, Func co QueryLogic.Expressions.Register((ChatSessionEntity cm) => cm.TotalPrice(), ChatbotMessage.TotalPrice); PermissionLogic.RegisterTypes(typeof(ChatbotPermission)); - - Filter.GetEmbeddingForSmartSearch = (vectorToken, searchString) => - { - // Get the default embeddings model - var modelLite = ChatbotLogic.DefaultEmbeddingsModel.Value; - if (modelLite == null) - throw new InvalidOperationException("No default EmbeddingsLanguageModelEntity configured."); - - // Retrieve and call the embeddings API - var model = modelLite.RetrieveFromCache(); - var embeddings = model.GetEmbeddingsAsync(new[] { searchString }, CancellationToken.None).ResultSafe(); - - return new Vector(embeddings.SingleEx()); - }; } public static void RegisterUserTypeCondition(TypeConditionSymbol userEntities) @@ -266,117 +116,314 @@ public static async Task SumarizeConversation(List me conversationText.AppendLine($"{roleName}: {content}"); } - var skill = AgentSkillLogic.ConversationSumarizerSkill; - var prompt = skill.GetInstruction(conversationText.ToString()); - var client = GetChatClient(languageModel); - var options = ChatOptions(languageModel, []); + var prompt = DefaultAgent.ConversationSumarizer.GetEffectiveSkillCode().GetInstruction(conversationText.ToString()); + var client = LanguageModelLogic.GetChatClient(languageModel); + var options = LanguageModelLogic.ChatOptions(languageModel, []); var cr = await client.GetResponseAsync(prompt, options, cancellationToken: ct); return cr.Text; } public static async Task SumarizeTitle(ConversationHistory history, CancellationToken ct) { - var prompt = AgentSkillLogic.QuestionSumarizerSkill.GetInstruction(history); - var client = GetChatClient(history.LanguageModel); - var options = ChatbotLogic.ChatOptions(history.LanguageModel, []); + var prompt = DefaultAgent.QuestionSummarizer.GetEffectiveSkillCode().GetInstruction(history); + var client = LanguageModelLogic.GetChatClient(history.LanguageModel); + var options = LanguageModelLogic.ChatOptions(history.LanguageModel, []); var cr = await client.GetResponseAsync(prompt, options, cancellationToken: ct); return cr.Text; } - public static void RegisterChatbotModelProvider(LanguageModelProviderSymbol symbol, IChatbotModelProvider provider) + public static async Task RunAgentLoopAsync(ConversationHistory history, IAgentOutput output, CancellationToken ct) { - ChatbotModelProviders.Add(symbol, provider); - } + var client = LanguageModelLogic.GetChatClient(history.LanguageModel); - public static void RegisterEmbeddingsProvider(LanguageModelProviderSymbol symbol, IEmbeddingsProvider provider) - { - EmbeddingsProviders.Add(symbol, provider); - } + while (true) + { + if (history.LanguageModel.MaxTokens != null && + history.Messages.Skip(1).LastOrDefault()?.InputTokens > history.LanguageModel.MaxTokens * 0.8) + { + var systemMsg = history.Messages.FirstEx(); + if (systemMsg.Role != ChatMessageRole.System) + throw new InvalidOperationException("First message is expected to be system"); + var normalMessages = history.Messages.Skip(1).ToList(); + var toKeepIndex = normalMessages.FindLastIndex(a => a.InputTokens < history.LanguageModel.MaxTokens * 0.5) + .NotFoundToNull() ?? (normalMessages.Count - 1); - public static Task> GetModelNamesAsync(LanguageModelProviderSymbol provider, CancellationToken ct) - { - return ChatbotModelProviders.GetOrThrow(provider).GetModelNames(ct); - } + var toSumarize = normalMessages.Take(toKeepIndex).ToList(); + var toKeep = normalMessages.Skip(toKeepIndex).ToList(); - public static Task> GetEmbeddingModelNamesAsync(LanguageModelProviderSymbol provider, CancellationToken ct) - { - return EmbeddingsProviders.GetOrThrow(provider).GetEmbeddingModelNames(ct); - } + var summaryContent = await SumarizeConversation(toSumarize, history.LanguageModel, ct); - public static IChatbotModelProvider GetProvider(ChatbotLanguageModelEntity model) - { - return ChatbotModelProviders.GetOrThrow(model.Provider); - } + var summary = new ChatMessageEntity + { + ChatSession = history.Session, + Role = ChatMessageRole.System, + Content = $"## Summary of earlier conversation\n{summaryContent}\n\n---\nRecent messages follow:", + }.Save(); - public static IChatClient GetChatClient(ChatbotLanguageModelEntity model) - { - return GetProvider(model).CreateChatClient(model); + await output.OnSummarizationAsync(summary, ct); + history.Messages = [systemMsg, summary, .. toKeep]; + } + + var tools = history.GetTools(); + var options = LanguageModelLogic.ChatOptions(history.LanguageModel, tools); + var messages = history.GetMessages(); + LanguageModelLogic.GetProvider(history.LanguageModel).CustomizeMessagesAndOptions(messages, options); + + List updates = []; + var sw = Stopwatch.StartNew(); + bool assistantStarted = false; + + await foreach (var update in client.GetStreamingResponseAsync(messages, options, ct)) + { + if (!assistantStarted) + { + await output.OnAssistantStartedAsync(ct); + assistantStarted = true; + } + updates.Add(update); + if (update.Text.HasText()) + await output.OnTextChunkAsync(update.Text, ct); + } + sw.Stop(); + + var response = updates.ToChatResponse(); + var responseMsg = response.Messages.SingleEx(); + + var notSupported = responseMsg.Contents + .Where(a => a is not FunctionCallContent and not Microsoft.Extensions.AI.TextContent) + .ToList(); + if (notSupported.Any()) + throw new InvalidOperationException("Unexpected response: " + notSupported.ToString(a => a.GetType().Name, ", ")); + + var usage = response.Usage; + var toolCalls = responseMsg.Contents.OfType().ToList(); + + var uiToolCalls = toolCalls.Where(fc => + { + var tool = history.RootSkill?.FindTool(fc.Name) + ?? throw new InvalidOperationException($"Tool '{fc.Name}' not found"); + return ((AIFunction)tool).UnderlyingMethod?.GetCustomAttribute() != null; + }).ToList(); + + if (uiToolCalls.Count > 1) + throw new InvalidOperationException( + $"The LLM invoked more than one UITool in a single response ({string.Join(", ", uiToolCalls.Select(t => t.Name))}). Only one UITool can be active at a time."); + + var answer = new ChatMessageEntity + { + ChatSession = history.Session, + Role = ChatMessageRole.Assistant, + Content = responseMsg.Text, + LanguageModel = history.Session.InDB(s => s.LanguageModel), + InputTokens = (int?)usage?.InputTokenCount, + CachedInputTokens = (int?)usage?.CachedInputTokenCount, + OutputTokens = (int?)usage?.OutputTokenCount, + ReasoningOutputTokens = (int?)usage?.ReasoningTokenCount, + Duration = sw.Elapsed, + ToolCalls = toolCalls.Select(fc => new ToolCallEmbedded + { + ToolId = fc.Name, + CallId = fc.CallId, + Arguments = JsonSerializer.Serialize(fc.Arguments), + IsUITool = uiToolCalls.Any(u => u.CallId == fc.CallId), + }).ToMList() + }.Save(); + + Expression> NullableAdd = (a, b) => + a == null && b == null ? null : (a ?? 0) + (b ?? 0); + + history.Session.InDB().UnsafeUpdate() + .Set(a => a.TotalInputTokens, a => NullableAdd.Evaluate(a.TotalInputTokens, answer.InputTokens)) + .Set(a => a.TotalCachedInputTokens, a => NullableAdd.Evaluate(a.TotalCachedInputTokens, answer.CachedInputTokens)) + .Set(a => a.TotalOutputTokens, a => NullableAdd.Evaluate(a.TotalOutputTokens, answer.OutputTokens)) + .Set(a => a.TotalReasoningOutputTokens, a => NullableAdd.Evaluate(a.TotalReasoningOutputTokens, answer.ReasoningOutputTokens)) + .Set(a => a.TotalToolCalls, a => a.TotalToolCalls + answer.ToolCalls.Count) + .Execute(); + + await output.OnAssistantMessageAsync(answer, ct); + history.Messages.Add(answer); + + if (toolCalls.IsEmpty() || uiToolCalls.Any()) + break; + + foreach (var funCall in toolCalls) + await ExecuteToolAsync(history, funCall.Name, funCall.CallId, funCall.Arguments!, output, ct); + } + + if (history.SessionTitle == null || history.SessionTitle.StartsWith("!*$")) + { + history.SessionTitle = history.Session.InDB(a => a.Title); + if (history.SessionTitle == null || history.SessionTitle.StartsWith("!*$")) + { + string title = await SumarizeTitle(history, ct); + if (title.HasText() && title.ToLower() != "pending") + { + history.Session.InDB().UnsafeUpdate(a => a.Title, a => title); + history.SessionTitle = title; + await output.OnTitleUpdatedAsync(title, ct); + } + } + } } - public static Task> GetEmbeddingsAsync(this EmbeddingsLanguageModelEntity model, string[] inputs, CancellationToken ct) + public static async Task ExecuteToolAsync( + ConversationHistory history, + string toolId, string callId, + IDictionary arguments, + IAgentOutput output, + CancellationToken ct) { - using (HeavyProfiler.Log("GetEmbeddings", () => model.GetMessage() + "\n" + inputs.ToString("\n"))) + await output.OnToolStartAsync(toolId, callId, ct); + var toolSw = Stopwatch.StartNew(); + try { - return EmbeddingsProviders.GetOrThrow(model.Provider).GetEmbeddings(inputs, model, ct); + AITool tool = history.RootSkill?.FindTool(toolId) + ?? throw new InvalidOperationException($"Tool '{toolId}' not found"); + var obj = await ((AIFunction)tool).InvokeAsync(new AIFunctionArguments(arguments), ct); + toolSw.Stop(); + + var toolMsg = new ChatMessageEntity + { + ChatSession = history.Session, + Role = ChatMessageRole.Tool, + ToolCallID = callId, + ToolID = toolId, + Content = JsonSerializer.Serialize(obj), + Duration = toolSw.Elapsed, + }.Save(); + + await output.OnToolFinishedAsync(toolMsg, ct); + history.Messages.Add(toolMsg); + } + catch (Exception e) + { + toolSw.Stop(); + var errorContent = FormatToolError(toolId, e, arguments); + ChatMessageEntity toolMsg; + using (AuthLogic.Disable()) + { + toolMsg = new ChatMessageEntity + { + ChatSession = history.Session, + Role = ChatMessageRole.Tool, + ToolCallID = callId, + ToolID = toolId, + Content = errorContent, + Exception = e.LogException().ToLiteFat(), + Duration = toolSw.Elapsed, + }.Save(); + } + await output.OnToolFinishedAsync(toolMsg, ct); + history.Messages.Add(toolMsg); } } - public static ChatOptions ChatOptions(ChatbotLanguageModelEntity languageModel, List? tools) + public static string FormatToolError(string toolName, Exception e, IDictionary? arguments) { - var opts = new ChatOptions - { - ModelId = languageModel.Model, - }; + var sb = new StringBuilder(); + sb.AppendLine($"Tool '{toolName}' failed."); + if (arguments != null && arguments.Count > 0) + sb.AppendLine($"Arguments: {JsonSerializer.Serialize(arguments).Etc(300)}"); + sb.AppendLine($"Error: {e.GetType().Name}: {e.Message}"); + if (e.Data["Hint"] is string s) + sb.AppendLine($"Hint: {s}"); + sb.AppendLine("Please review the error and try again with corrected arguments."); + return sb.ToString(); + } - if (languageModel.MaxTokens != null) - opts.MaxOutputTokens = languageModel.MaxTokens; - else - opts.MaxOutputTokens = 64000; + public static async Task RunHeadlessAsync( + string prompt, + AgentSymbol useCase, + Lite? languageModel = null, + IAgentOutput? output = null, + CancellationToken ct = default) + { + output ??= NullAgentOutput.Instance; - if (languageModel.Temperature != null) - opts.Temperature = languageModel.Temperature; + var modelLite = languageModel ?? LanguageModelLogic.DefaultLanguageModel.Value + ?? throw new InvalidOperationException($"No default {nameof(ChatbotLanguageModelEntity)} configured."); - if (tools.HasItems()) - opts.Tools = tools; + var rootSkill = AgentLogic.GetEffectiveSkillCode(useCase) + ?? throw new InvalidOperationException($"No active AgentSkillEntity with UseCase = {useCase.Key}."); - return opts; - } -} + var session = new ChatSessionEntity + { + LanguageModel = modelLite, + User = UserEntity.Current, + StartDate = Clock.Now, + }.Save(); + var systemMsg = new ChatMessageEntity + { + Role = ChatMessageRole.System, + ChatSession = session.ToLite(), + Content = rootSkill.GetInstruction(null), + }.Save(); + await output.OnSystemMessageAsync(systemMsg, ct); + var userMsg = new ChatMessageEntity + { + Role = ChatMessageRole.User, + ChatSession = session.ToLite(), + Content = prompt, + }.Save(); -public interface IChatbotModelProvider -{ - Task> GetModelNames(CancellationToken ct); + await output.OnUserQuestionAsync(userMsg, ct); - IChatClient CreateChatClient(ChatbotLanguageModelEntity model); + var history = new ConversationHistory + { + Session = session.ToLite(), + LanguageModel = modelLite.RetrieveFromCache(), + RootSkill = rootSkill, + Messages = [systemMsg, userMsg], + }; - void CustomizeMessagesAndOptions(List messages, ChatOptions options) { } + await RunAgentLoopAsync(history, output, ct); + return history; + } } -public interface IEmbeddingsProvider +public interface IAgentOutput { - Task> GetEmbeddingModelNames(CancellationToken ct); + Task OnSystemMessageAsync(ChatMessageEntity msg, CancellationToken ct); + Task OnUserQuestionAsync(ChatMessageEntity msg, CancellationToken ct); + Task OnSummarizationAsync(ChatMessageEntity summaryMsg, CancellationToken ct); + Task OnAssistantStartedAsync(CancellationToken ct); + Task OnTextChunkAsync(string chunk, CancellationToken ct); + Task OnAssistantMessageAsync(ChatMessageEntity msg, CancellationToken ct); + Task OnToolStartAsync(string toolId, string callId, CancellationToken ct); + Task OnToolFinishedAsync(ChatMessageEntity toolMsg, CancellationToken ct); + Task OnTitleUpdatedAsync(string title, CancellationToken ct); +} - Task> GetEmbeddings(string[] inputs, EmbeddingsLanguageModelEntity model, CancellationToken ct); +public sealed class NullAgentOutput : IAgentOutput +{ + public static readonly NullAgentOutput Instance = new(); + private NullAgentOutput() { } + + public Task OnSystemMessageAsync(ChatMessageEntity msg, CancellationToken ct) => Task.CompletedTask; + public Task OnUserQuestionAsync(ChatMessageEntity msg, CancellationToken ct) => Task.CompletedTask; + public Task OnSummarizationAsync(ChatMessageEntity summaryMsg, CancellationToken ct) => Task.CompletedTask; + public Task OnAssistantStartedAsync(CancellationToken ct) => Task.CompletedTask; + public Task OnTextChunkAsync(string chunk, CancellationToken ct) => Task.CompletedTask; + public Task OnAssistantMessageAsync(ChatMessageEntity msg, CancellationToken ct) => Task.CompletedTask; + public Task OnToolStartAsync(string toolId, string callId, CancellationToken ct) => Task.CompletedTask; + public Task OnToolFinishedAsync(ChatMessageEntity toolMsg, CancellationToken ct) => Task.CompletedTask; + public Task OnTitleUpdatedAsync(string title, CancellationToken ct) => Task.CompletedTask; } public class ConversationHistory { public Lite Session; - public ChatbotLanguageModelEntity LanguageModel; - public List Messages; - public string? SessionTitle { get; internal set; } + public SkillCode? RootSkill { get; set; } - public List GetMessages() - { - return Messages.Select(m => ToChatMessage(m)).ToList(); - } + public List GetMessages() => + Messages.Select(ToChatMessage).ToList(); ChatMessage ToChatMessage(ChatMessageEntity c) { @@ -387,11 +434,7 @@ ChatMessage ToChatMessage(ChatMessageEntity c) : null); if (c.Role == ChatMessageRole.Tool) - { - return new ChatMessage(role, [ - new FunctionResultContent(c.ToolCallID!, content) - ]); - } + return new ChatMessage(role, [new FunctionResultContent(c.ToolCallID!, content)]); if (c.ToolCalls.IsEmpty()) return new ChatMessage(role, content); @@ -409,7 +452,7 @@ ChatMessage ToChatMessage(ChatMessageEntity c) return new ChatMessage(role, contents); } - private static object? CleanValue(object? value) + static object? CleanValue(object? value) { if (value is JsonElement je) { @@ -428,10 +471,11 @@ ChatMessage ToChatMessage(ChatMessageEntity c) public List GetTools() { - var activatedSkills = new HashSet(); + if (RootSkill == null) + return []; - if (AgentSkillLogic.IntroductionSkill != null) - activatedSkills.Add(AgentSkillLogic.IntroductionSkill.Name); + var activatedSkills = new HashSet( + RootSkill.GetEagerSkillsRecursive().Select(s => s.Name)); foreach (var m in Messages) { @@ -445,7 +489,13 @@ public List GetTools() { var args = JsonSerializer.Deserialize>(tc.Arguments); if (args != null && args.TryGetValue("skillName", out var sn)) - activatedSkills.Add(sn.GetString()!); + { + var skillName = sn.GetString()!; + var newSkill = RootSkill.FindSkill(skillName); + if (newSkill != null) + foreach (var s in newSkill.GetEagerSkillsRecursive()) + activatedSkills.Add(s.Name); + } } catch { } } @@ -454,13 +504,13 @@ public List GetTools() } return activatedSkills - .Select(skillName => AgentSkillLogic.IntroductionSkill?.FindSkill(skillName)) - .OfType() - .SelectMany(skill => skill.GetToolsRecursive()) + .Select(name => RootSkill.FindSkill(name)) + .OfType() + .SelectMany(skill => skill.GetTools()) .ToList(); } - private ChatRole ToChatRole(ChatMessageRole role) => role switch + ChatRole ToChatRole(ChatMessageRole role) => role switch { ChatMessageRole.System => ChatRole.System, ChatMessageRole.User => ChatRole.User, diff --git a/Extensions/Signum.Agent/LanguageModelClient.tsx b/Extensions/Signum.Agent/LanguageModelClient.tsx new file mode 100644 index 0000000000..c7df8a7908 --- /dev/null +++ b/Extensions/Signum.Agent/LanguageModelClient.tsx @@ -0,0 +1,21 @@ +import { ajaxGet } from '@framework/Services'; +import { Navigator, EntitySettings } from '@framework/Navigator'; +import { ChatbotLanguageModelEntity, EmbeddingsLanguageModelEntity, LanguageModelProviderSymbol } from './Signum.Agent'; + +export namespace LanguageModelClient { + + export function start(options: { routes: unknown[] }): void { + Navigator.addSettings(new EntitySettings(ChatbotLanguageModelEntity, e => import('./Templates/ChatbotLanguageModel'))); + Navigator.addSettings(new EntitySettings(EmbeddingsLanguageModelEntity, e => import('./Templates/EmbeddingsLanguageModel'))); + } + + export namespace API { + export function getModels(provider: LanguageModelProviderSymbol): Promise { + return ajaxGet({ url: `/api/chatbot/provider/${provider.key}/models` }); + } + + export function getEmbeddingModels(provider: LanguageModelProviderSymbol): Promise { + return ajaxGet({ url: `/api/chatbot/provider/${provider.key}/embeddingModels` }); + } + } +} diff --git a/Extensions/Signum.Agent/LanguageModelController.cs b/Extensions/Signum.Agent/LanguageModelController.cs new file mode 100644 index 0000000000..48c57e4b36 --- /dev/null +++ b/Extensions/Signum.Agent/LanguageModelController.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using Signum.API; +using Signum.API.Filters; + +namespace Signum.Agent; + +public class LanguageModelController : Controller +{ + [HttpGet("api/chatbot/provider/{providerKey}/models")] + public async Task> GetModels(string providerKey, CancellationToken token) + { + var symbol = SymbolLogic.ToSymbol(providerKey); + return (await LanguageModelLogic.GetModelNamesAsync(symbol, token)).Order().ToList(); + } + + [HttpGet("api/chatbot/provider/{providerKey}/embeddingModels")] + public async Task> GetEmbeddingModels(string providerKey, CancellationToken token) + { + var symbol = SymbolLogic.ToSymbol(providerKey); + return (await LanguageModelLogic.GetEmbeddingModelNamesAsync(symbol, token)).Order().ToList(); + } +} diff --git a/Extensions/Signum.Agent/LanguageModelLogic.cs b/Extensions/Signum.Agent/LanguageModelLogic.cs new file mode 100644 index 0000000000..e8d21409f2 --- /dev/null +++ b/Extensions/Signum.Agent/LanguageModelLogic.cs @@ -0,0 +1,199 @@ +using Microsoft.Extensions.AI; +using Signum.Agent.Providers; +using Pgvector; +using Signum.Utilities.Synchronization; + +namespace Signum.Agent; + +public static class LanguageModelLogic +{ + public static Func GetConfig = null!; + + public static ResetLazy, ChatbotLanguageModelEntity>> LanguageModels = null!; + public static ResetLazy?> DefaultLanguageModel = null!; + + public static ResetLazy, EmbeddingsLanguageModelEntity>> EmbeddingsModels = null!; + public static ResetLazy?> DefaultEmbeddingsModel = null!; + + public static Dictionary ChatbotModelProviders = new() + { + { LanguageModelProviders.OpenAI, new OpenAIProvider() }, + { LanguageModelProviders.Gemini, new GeminiProvider() }, + { LanguageModelProviders.Anthropic, new AnthropicProvider() }, + { LanguageModelProviders.GithubModels, new GithubModelsProvider() }, + { LanguageModelProviders.Mistral, new MistralProvider() }, + { LanguageModelProviders.Ollama, new OllamaProvider() }, + { LanguageModelProviders.DeepSeek, new DeepSeekProvider() }, + }; + + public static Dictionary EmbeddingsProviders = new() + { + { LanguageModelProviders.OpenAI, new OpenAIProvider() }, + { LanguageModelProviders.Gemini, new GeminiProvider() }, + { LanguageModelProviders.GithubModels, new GithubModelsProvider() }, + { LanguageModelProviders.Mistral, new MistralProvider() }, + { LanguageModelProviders.Ollama, new OllamaProvider() }, + }; + + public static ChatbotLanguageModelEntity RetrieveFromCache(this Lite lite) => + LanguageModels.Value.GetOrThrow(lite); + + public static EmbeddingsLanguageModelEntity RetrieveFromCache(this Lite lite) => + EmbeddingsModels.Value.GetOrThrow(lite); + + public static void Start(SchemaBuilder sb, Func config) + { + GetConfig = config; + + SymbolLogic.Start(sb, () => ChatbotModelProviders.Keys.Union(EmbeddingsProviders.Keys)); + + sb.Include() + .WithSave(ChatbotLanguageModelOperation.Save, (m, args) => + { + if (!m.IsNew && Database.Query().Any(a => a.LanguageModel.Is(m))) + { + var inDb = m.InDB(a => new { a.Model, a.Provider }); + if (inDb.Model != m.Model || !inDb.Provider.Is(m.Provider)) + throw new ArgumentNullException(ChatbotMessage.UnableToChangeModelOrProviderOnceUsed.NiceToString()); + } + }) + .WithUniqueIndex(a => a.IsDefault, a => a.IsDefault == true) + .WithQuery(() => e => new + { + Entity = e, + e.Id, + e.IsDefault, + e.Provider, + e.Model, + e.Temperature, + e.MaxTokens, + e.PricePerInputToken, + e.PricePerOutputToken, + e.PricePerCachedInputToken, + e.PricePerReasoningOutputToken, + }); + + new Graph.Execute(ChatbotLanguageModelOperation.MakeDefault) + { + CanExecute = a => !a.IsDefault ? null : ValidationMessage._0IsSet.NiceToString(Entity.NicePropertyName(() => a.IsDefault)), + Execute = (e, _) => + { + var other = Database.Query().Where(a => a.IsDefault).SingleOrDefaultEx(); + if (other != null) + { + other.IsDefault = false; + other.Execute(ChatbotLanguageModelOperation.Save); + } + e.IsDefault = true; + e.Save(); + } + }.Register(); + + new Graph.Delete(ChatbotLanguageModelOperation.Delete) + { + Delete = (e, _) => { e.Delete(); }, + }.Register(); + + LanguageModels = sb.GlobalLazy(() => Database.Query().ToDictionary(a => a.ToLite()), new InvalidateWith(typeof(ChatbotLanguageModelEntity))); + DefaultLanguageModel = sb.GlobalLazy(() => LanguageModels.Value.Values.SingleOrDefaultEx(a => a.IsDefault)?.ToLite(), new InvalidateWith(typeof(ChatbotLanguageModelEntity))); + + sb.Include() + .WithSave(EmbeddingsLanguageModelOperation.Save) + .WithUniqueIndex(a => a.IsDefault, a => a.IsDefault == true) + .WithQuery(() => e => new + { + Entity = e, + e.Id, + e.IsDefault, + e.Provider, + e.Model, + e.Dimensions, + }); + + new Graph.Execute(EmbeddingsLanguageModelOperation.MakeDefault) + { + CanExecute = a => !a.IsDefault ? null : ValidationMessage._0IsSet.NiceToString(Entity.NicePropertyName(() => a.IsDefault)), + Execute = (e, _) => + { + var other = Database.Query().Where(a => a.IsDefault).SingleOrDefaultEx(); + if (other != null) + { + other.IsDefault = false; + other.Execute(EmbeddingsLanguageModelOperation.Save); + } + e.IsDefault = true; + e.Save(); + } + }.Register(); + + new Graph.Delete(EmbeddingsLanguageModelOperation.Delete) + { + Delete = (e, _) => { e.Delete(); }, + }.Register(); + + EmbeddingsModels = sb.GlobalLazy(() => Database.Query().ToDictionary(a => a.ToLite()), new InvalidateWith(typeof(EmbeddingsLanguageModelEntity))); + DefaultEmbeddingsModel = sb.GlobalLazy(() => EmbeddingsModels.Value.Values.SingleOrDefaultEx(a => a.IsDefault)?.ToLite(), new InvalidateWith(typeof(EmbeddingsLanguageModelEntity))); + + Filter.GetEmbeddingForSmartSearch = (vectorToken, searchString) => + { + var modelLite = DefaultEmbeddingsModel.Value + ?? throw new InvalidOperationException("No default EmbeddingsLanguageModelEntity configured."); + var embeddings = modelLite.RetrieveFromCache().GetEmbeddingsAsync([searchString], CancellationToken.None).ResultSafe(); + return new Vector(embeddings.SingleEx()); + }; + } + + public static void RegisterChatbotModelProvider(LanguageModelProviderSymbol symbol, IChatbotModelProvider provider) => + ChatbotModelProviders.Add(symbol, provider); + + public static void RegisterEmbeddingsProvider(LanguageModelProviderSymbol symbol, IEmbeddingsProvider provider) => + EmbeddingsProviders.Add(symbol, provider); + + public static Task> GetModelNamesAsync(LanguageModelProviderSymbol provider, CancellationToken ct) => + ChatbotModelProviders.GetOrThrow(provider).GetModelNames(ct); + + public static Task> GetEmbeddingModelNamesAsync(LanguageModelProviderSymbol provider, CancellationToken ct) => + EmbeddingsProviders.GetOrThrow(provider).GetEmbeddingModelNames(ct); + + public static IChatbotModelProvider GetProvider(ChatbotLanguageModelEntity model) => + ChatbotModelProviders.GetOrThrow(model.Provider); + + public static IChatClient GetChatClient(ChatbotLanguageModelEntity model) => + GetProvider(model).CreateChatClient(model); + + public static Task> GetEmbeddingsAsync(this EmbeddingsLanguageModelEntity model, string[] inputs, CancellationToken ct) + { + using (HeavyProfiler.Log("GetEmbeddings", () => model.GetMessage() + "\n" + inputs.ToString("\n"))) + return EmbeddingsProviders.GetOrThrow(model.Provider).GetEmbeddings(inputs, model, ct); + } + + public static ChatOptions ChatOptions(ChatbotLanguageModelEntity languageModel, List? tools) + { + var opts = new ChatOptions + { + ModelId = languageModel.Model, + MaxOutputTokens = languageModel.MaxTokens ?? 64000, + }; + + if (languageModel.Temperature != null) + opts.Temperature = languageModel.Temperature; + + if (tools.HasItems()) + opts.Tools = tools; + + return opts; + } +} + +public interface IChatbotModelProvider +{ + Task> GetModelNames(CancellationToken ct); + IChatClient CreateChatClient(ChatbotLanguageModelEntity model); + void CustomizeMessagesAndOptions(List messages, ChatOptions options) { } +} + +public interface IEmbeddingsProvider +{ + Task> GetEmbeddingModelNames(CancellationToken ct); + Task> GetEmbeddings(string[] inputs, EmbeddingsLanguageModelEntity model, CancellationToken ct); +} diff --git a/Extensions/Signum.Agent/Message.tsx b/Extensions/Signum.Agent/Message.tsx index a82de52be9..0a3a5fb366 100644 --- a/Extensions/Signum.Agent/Message.tsx +++ b/Extensions/Signum.Agent/Message.tsx @@ -27,8 +27,8 @@ export const Message: React.NamedExoticComponent<{ msg: ChatMessageEntity; toolR }, (a, b) => a.msg.id != null && a.toolResponses == b.toolResponses); -export function looksLikeJson(text: string) { - return text && (text.trim().startsWith("{") || text.trim().startsWith("[")); +export function looksLikeJson(text: string) : boolean { + return text != null && (text.trim().startsWith("{") || text.trim().startsWith("[")); } export function SystemMessage(p: { msg: ChatMessageEntity }): React.ReactElement { @@ -204,7 +204,7 @@ export function ToolResponseBlock(p: { msg: ChatMessageEntity }): React.ReactEle ); } -export function MarkdownOrJson(p: { content: string | null | undefined, formatJson?: boolean }) { +export function MarkdownOrJson(p: { content: string | null | undefined, formatJson?: boolean }) : JSX.Element { if (!p.content) return {p.content + ""}; @@ -218,7 +218,7 @@ export function MarkdownOrJson(p: { content: string | null | undefined, formatJs ); } -export function tryParseJsonString(str: string) { +export function tryParseJsonString(str: string): string { try { if (str.startsWith("\"") && str.endsWith("\"")) { return JSON.parse(str); diff --git a/Extensions/Signum.Agent/Providers/AnthropicProvider.cs b/Extensions/Signum.Agent/Providers/AnthropicProvider.cs index fab2bb8f53..4eb027254a 100644 --- a/Extensions/Signum.Agent/Providers/AnthropicProvider.cs +++ b/Extensions/Signum.Agent/Providers/AnthropicProvider.cs @@ -44,7 +44,7 @@ public void CustomizeMessagesAndOptions(List messages, ChatOptions private static string GetApiKey() { - var apiKey = ChatbotLogic.GetConfig().AnthropicAPIKey; + var apiKey = LanguageModelLogic.GetConfig().AnthropicAPIKey; if (apiKey.IsNullOrEmpty()) throw new InvalidOperationException("No API Key for Claude configured!"); diff --git a/Extensions/Signum.Agent/Providers/DeepSeekProvider.cs b/Extensions/Signum.Agent/Providers/DeepSeekProvider.cs index f14da22c42..108b964490 100644 --- a/Extensions/Signum.Agent/Providers/DeepSeekProvider.cs +++ b/Extensions/Signum.Agent/Providers/DeepSeekProvider.cs @@ -49,7 +49,7 @@ public void CustomizeMessagesAndOptions(List> GetEmbeddings(string[] inputs, EmbeddingsLangua static string GetApiKey() { - var apiKey = ChatbotLogic.GetConfig().GeminiAPIKey; + var apiKey = LanguageModelLogic.GetConfig().GeminiAPIKey; if (apiKey.IsNullOrEmpty()) throw new InvalidOperationException("No API Key for Gemini configured!"); diff --git a/Extensions/Signum.Agent/Providers/GithubModelsProvider.cs b/Extensions/Signum.Agent/Providers/GithubModelsProvider.cs index c236df4a0d..e5e9b818bd 100644 --- a/Extensions/Signum.Agent/Providers/GithubModelsProvider.cs +++ b/Extensions/Signum.Agent/Providers/GithubModelsProvider.cs @@ -94,7 +94,7 @@ public async Task> GetEmbeddings(string[] inputs, EmbeddingsLangua static string GetToken() { - var apiKey = ChatbotLogic.GetConfig().GithubModelsToken; + var apiKey = LanguageModelLogic.GetConfig().GithubModelsToken; if (apiKey.IsNullOrEmpty()) throw new InvalidOperationException("No Token for Github Models configured!"); diff --git a/Extensions/Signum.Agent/Providers/MistralProvider.cs b/Extensions/Signum.Agent/Providers/MistralProvider.cs index 884f9c654d..293952a379 100644 --- a/Extensions/Signum.Agent/Providers/MistralProvider.cs +++ b/Extensions/Signum.Agent/Providers/MistralProvider.cs @@ -53,7 +53,7 @@ public async Task> GetEmbeddings(string[] inputs, EmbeddingsLangua static string GetApiKey() { - var apiKey = ChatbotLogic.GetConfig().MistralAPIKey; + var apiKey = LanguageModelLogic.GetConfig().MistralAPIKey; if (apiKey.IsNullOrEmpty()) throw new InvalidOperationException("No API Key for Mistral configured!"); diff --git a/Extensions/Signum.Agent/Providers/OllamaProvider.cs b/Extensions/Signum.Agent/Providers/OllamaProvider.cs index 2b624aadf9..f11ef0b2f0 100644 --- a/Extensions/Signum.Agent/Providers/OllamaProvider.cs +++ b/Extensions/Signum.Agent/Providers/OllamaProvider.cs @@ -51,7 +51,7 @@ public async Task> GetEmbeddings(string[] inputs, EmbeddingsLangua private static string GetOllamaUrl() { - var apiKey = ChatbotLogic.GetConfig().OllamaUrl; + var apiKey = LanguageModelLogic.GetConfig().OllamaUrl; if (apiKey.IsNullOrEmpty()) throw new InvalidOperationException("No Ollama URL configured!"); diff --git a/Extensions/Signum.Agent/Providers/OpenAIProvider.cs b/Extensions/Signum.Agent/Providers/OpenAIProvider.cs index 59f6f68fd6..1d65423d7a 100644 --- a/Extensions/Signum.Agent/Providers/OpenAIProvider.cs +++ b/Extensions/Signum.Agent/Providers/OpenAIProvider.cs @@ -47,7 +47,7 @@ public async Task> GetEmbeddings(string[] inputs, EmbeddingsLangua static string GetApiKey() { - var apiKey = ChatbotLogic.GetConfig().OpenAIAPIKey; + var apiKey = LanguageModelLogic.GetConfig().OpenAIAPIKey; if (apiKey.IsNullOrEmpty()) throw new InvalidOperationException("No API Key for OpenAI configured!"); diff --git a/Extensions/Signum.Agent/Signum.Agent.d.ts b/Extensions/Signum.Agent/Signum.Agent.d.ts deleted file mode 100644 index e77b297c0c..0000000000 --- a/Extensions/Signum.Agent/Signum.Agent.d.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { MessageKey, Type, EnumType } from '../../Signum/React/Reflection'; -import * as Entities from '../../Signum/React/Signum.Entities'; -import * as Basics from '../../Signum/React/Signum.Basics'; -import * as Operations from '../../Signum/React/Signum.Operations'; -import * as Authorization from '../Signum.Authorization/Signum.Authorization'; -export interface ToolCallEmbedded { - _response?: ChatMessageEntity; -} -export declare const ChatbotConfigurationEmbedded: Type; -export interface ChatbotConfigurationEmbedded extends Entities.EmbeddedEntity { - Type: "ChatbotConfigurationEmbedded"; - openAIAPIKey: string | null; - anthropicAPIKey: string | null; - geminiAPIKey: string | null; - mistralAPIKey: string | null; - githubModelsToken: string | null; - ollamaUrl: string | null; -} -export declare const ChatbotLanguageModelEntity: Type; -export interface ChatbotLanguageModelEntity extends Entities.Entity { - Type: "ChatbotLanguageModel"; - provider: LanguageModelProviderSymbol; - model: string; - temperature: number | null; - maxTokens: number | null; - isDefault: boolean; -} -export declare namespace ChatbotLanguageModelOperation { - const Save: Operations.ExecuteSymbol; - const MakeDefault: Operations.ExecuteSymbol; - const Delete: Operations.DeleteSymbol; -} -export declare namespace ChatbotMessage { - const OpenSession: MessageKey; - const NewSession: MessageKey; - const Send: MessageKey; - const TypeAMessage: MessageKey; - const InitialInstruction: MessageKey; -} -export declare namespace ChatbotPermission { - const UseChatbot: Basics.PermissionSymbol; -} -export declare const ChatbotUICommand: EnumType; -export type ChatbotUICommand = "System" | "SessionId" | "SessionTitle" | "QuestionId" | "AnswerId" | "AssistantAnswer" | "AssistantTool" | "Tool" | "Exception"; -export declare const ChatMessageEntity: Type; -export interface ChatMessageEntity extends Entities.Entity { - Type: "ChatMessage"; - chatSession: Entities.Lite; - creationDate: string; - role: ChatMessageRole; - content: string | null; - toolCalls: Entities.MList; - toolCallID: string | null; - toolID: string | null; - exception: Entities.Lite | null; -} -export declare namespace ChatMessageOperation { - const Save: Operations.ExecuteSymbol; - const Delete: Operations.DeleteSymbol; -} -export declare const ChatMessageRole: EnumType; -export type ChatMessageRole = "System" | "User" | "Assistant" | "Tool"; -export declare const ChatSessionEntity: Type; -export interface ChatSessionEntity extends Entities.Entity { - Type: "ChatSession"; - title: string | null; - languageModel: Entities.Lite; - user: Entities.Lite; - startDate: string; -} -export declare namespace ChatSessionOperation { - const Delete: Operations.DeleteSymbol; -} -export declare const EmbeddingsLanguageModelEntity: Type; -export interface EmbeddingsLanguageModelEntity extends Entities.Entity { - Type: "EmbeddingsLanguageModel"; - provider: LanguageModelProviderSymbol; - model: string; - dimensions: number | null; - isDefault: boolean; -} -export declare namespace EmbeddingsLanguageModelOperation { - const Save: Operations.ExecuteSymbol; - const MakeDefault: Operations.ExecuteSymbol; - const Delete: Operations.DeleteSymbol; -} -export declare namespace LanguageModelProviders { - const OpenAI: LanguageModelProviderSymbol; - const Gemini: LanguageModelProviderSymbol; - const Anthropic: LanguageModelProviderSymbol; - const Mistral: LanguageModelProviderSymbol; - const GithubModels: LanguageModelProviderSymbol; - const Ollama: LanguageModelProviderSymbol; -} -export declare const LanguageModelProviderSymbol: Type; -export interface LanguageModelProviderSymbol extends Basics.Symbol { - Type: "LanguageModelProvider"; -} -export declare const ToolCallEmbedded: Type; -export interface ToolCallEmbedded extends Entities.EmbeddedEntity { - Type: "ToolCallEmbedded"; - callId: string; - toolId: string; - arguments: string; -} -//# sourceMappingURL=Signum.Agent.d.ts.map \ No newline at end of file diff --git a/Extensions/Signum.Agent/Signum.Agent.ts b/Extensions/Signum.Agent/Signum.Agent.ts index f468d2cbb4..c037697a9c 100644 --- a/Extensions/Signum.Agent/Signum.Agent.ts +++ b/Extensions/Signum.Agent/Signum.Agent.ts @@ -12,6 +12,11 @@ export interface ToolCallEmbedded { _response?: ChatMessageEntity } +export const AgentSymbol: Type = new Type("Agent"); +export interface AgentSymbol extends Basics.Symbol { + Type: "Agent"; +} + export const ChatbotConfigurationEmbedded: Type = new Type("ChatbotConfigurationEmbedded"); export interface ChatbotConfigurationEmbedded extends Entities.EmbeddedEntity { Type: "ChatbotConfigurationEmbedded"; @@ -129,6 +134,12 @@ export namespace ChatSessionOperation { export const Delete : Operations.DeleteSymbol = registerSymbol("Operation", "ChatSessionOperation.Delete"); } +export namespace DefaultAgent { + export const Chatbot : AgentSymbol = registerSymbol("Agent", "DefaultAgent.Chatbot"); + export const QuestionSummarizer : AgentSymbol = registerSymbol("Agent", "DefaultAgent.QuestionSummarizer"); + export const ConversationSumarizer : AgentSymbol = registerSymbol("Agent", "DefaultAgent.ConversationSumarizer"); +} + export const EmbeddingsLanguageModelEntity: Type = new Type("EmbeddingsLanguageModel"); export interface EmbeddingsLanguageModelEntity extends Entities.Entity { Type: "EmbeddingsLanguageModel"; @@ -159,6 +170,48 @@ export interface LanguageModelProviderSymbol extends Basics.Symbol { Type: "LanguageModelProvider"; } +export const SkillActivation: EnumType = new EnumType("SkillActivation"); +export type SkillActivation = + "Eager" | + "Lazy"; + +export const SkillCodeEntity: Type = new Type("SkillCode"); +export interface SkillCodeEntity extends Entities.Entity { + Type: "SkillCode"; + className: string; +} + +export const SkillCustomizationEntity: Type = new Type("SkillCustomization"); +export interface SkillCustomizationEntity extends Entities.Entity { + Type: "SkillCustomization"; + skillCode: SkillCodeEntity; + agent: AgentSymbol | null; + shortDescription: string | null; + instructions: string | null; + properties: Entities.MList; + subSkills: Entities.MList; +} + +export namespace SkillCustomizationOperation { + export const Save : Operations.ExecuteSymbol = registerSymbol("Operation", "SkillCustomizationOperation.Save"); + export const Delete : Operations.DeleteSymbol = registerSymbol("Operation", "SkillCustomizationOperation.Delete"); + export const CreateFromAgent : Operations.ConstructSymbol_From = registerSymbol("Operation", "SkillCustomizationOperation.CreateFromAgent"); +} + +export const SkillPropertyEmbedded: Type = new Type("SkillPropertyEmbedded"); +export interface SkillPropertyEmbedded extends Entities.EmbeddedEntity { + Type: "SkillPropertyEmbedded"; + propertyName: string; + value: string | null; +} + +export const SubSkillEmbedded: Type = new Type("SubSkillEmbedded"); +export interface SubSkillEmbedded extends Entities.EmbeddedEntity { + Type: "SubSkillEmbedded"; + skill: Entities.Entity; + activation: SkillActivation; +} + export const ToolCallEmbedded: Type = new Type("ToolCallEmbedded"); export interface ToolCallEmbedded extends Entities.EmbeddedEntity { Type: "ToolCallEmbedded"; diff --git a/Extensions/Signum.Agent/SkillCode.cs b/Extensions/Signum.Agent/SkillCode.cs new file mode 100644 index 0000000000..50582f85aa --- /dev/null +++ b/Extensions/Signum.Agent/SkillCode.cs @@ -0,0 +1,194 @@ +using Microsoft.Extensions.AI; +using ModelContextProtocol.Server; +using Signum.API; +using System.ComponentModel; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace Signum.Agent; + +public abstract class SkillCode +{ + public SkillCode() + { + if (SkillCodeLogic.IsAutoRegister) + SkillCodeLogic.Register(this.GetType()); + else + { + if (!SkillCodeLogic.RegisteredCodes.ContainsKey(this.GetType().Name)) + throw new InvalidOperationException($"Type '{this.GetType().Name}' must be registered in SkillCodeLogic.Register<{this.GetType().TypeName()}>()"); + } + } + + public string Name => this.GetType().Name; + + public Lite? Customization { get; internal set; } + + public string ShortDescription { get; set; } = ""; + public Func IsAllowed { get; set; } = () => true; + public Dictionary>? Replacements; + + public static string SkillsDirectory = Path.Combine( + Path.GetDirectoryName(typeof(SkillCode).Assembly.Location)!, "Skills"); + + string? originalInstructions; + public string OriginalInstructions + { + get { return originalInstructions ??= File.ReadAllText(Path.Combine(SkillsDirectory, this.GetType().Name.Before("Skill") + ".md")); } + set { originalInstructions = value; } + } + public bool IsDefault() + { + if (SubSkills.Count > 0) return false; + + var defaultCode = (SkillCode)Activator.CreateInstance(GetType())!; + if (ShortDescription != defaultCode.ShortDescription) return false; + if (OriginalInstructions != defaultCode.OriginalInstructions) return false; + + foreach (var pi in GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var attr = pi.GetCustomAttribute(); + if (attr == null) continue; + var currentStr = attr.ConvertValueToString(pi.GetValue(this), pi.PropertyType); + var defaultStr = attr.ConvertValueToString(pi.GetValue(defaultCode), pi.PropertyType); + if (currentStr != defaultStr) return false; + } + + return true; + } + + // Populated from DB at resolve time, or from code when building a default tree for a factory. + public List<(SkillCode Code, SkillActivation Activation)> SubSkills { get; } = new(); + + public SkillCode WithSubSkill(SkillActivation activation, SkillCode sub) + { + SubSkills.Add((sub, activation)); + return this; + } + + public string GetInstruction(object? context) + { + var text = OriginalInstructions; + if (!Replacements.IsNullOrEmpty()) + text = text.Replace(Replacements.SelectDictionary(k => k, v => v(context))); + + if (SubSkills.Any()) + { + var sb = new StringBuilder(text); + foreach (var (sub, activation) in SubSkills) + { + sb.AppendLineLF("# Skill " + sub.Name); + sb.AppendLineLF("**Summary**: " + sub.ShortDescription); + sb.AppendLineLF(); + if (activation == SkillActivation.Eager) + sb.AppendLineLF(sub.GetInstruction(null)); + else + sb.AppendLineLF("Use the tool 'describe' to get more information about this skill and discover additional tools."); + } + return sb.ToString(); + } + + return text; + } + + public void ApplyPropertyOverrides(SkillCustomizationEntity entity) + { + foreach (var po in entity.Properties) + { + var pi = this.GetType() + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(p => p.Name == po.PropertyName + && p.GetCustomAttribute() != null); + + if (pi == null) continue; + + var attr = pi.GetCustomAttribute()!; + var value = attr.ConvertFromString(po.Value, pi.PropertyType); + pi.SetValue(this, value); + } + } + + public SkillCode? FindSkill(string name) + { + if (this.Name == name) return this; + foreach (var (sub, _) in SubSkills) + { + var found = sub.FindSkill(name); + if (found != null) return found; + } + return null; + } + + public AITool? FindTool(string toolName) + { + var tool = GetTools().FirstOrDefault(t => t.Name.Equals(toolName, StringComparison.InvariantCultureIgnoreCase)); + if (tool != null) return tool; + foreach (var (sub, _) in SubSkills) + { + var found = sub.FindTool(toolName); + if (found != null) return found; + } + return null; + } + + public IEnumerable GetSkillsRecursive() + { + yield return this; + foreach (var (sub, _) in SubSkills) + foreach (var s in sub.GetSkillsRecursive()) + yield return s; + } + + public IEnumerable GetEagerSkillsRecursive() + { + yield return this; + foreach (var (sub, activation) in SubSkills) + if (activation == SkillActivation.Eager) + foreach (var s in sub.GetEagerSkillsRecursive()) + yield return s; + } + + public IEnumerable GetToolsRecursive() + { + var list = GetTools().ToList(); + foreach (var (sub, activation) in SubSkills) + if (activation == SkillActivation.Eager) + list.AddRange(sub.GetToolsRecursive()); + return list; + } + + IEnumerable? cachedTools; + internal IEnumerable GetTools() + { + return (cachedTools ??= this.GetType() + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Where(m => m.GetCustomAttribute() != null) + .Select(m => + { + Type delType = Expression.GetDelegateType( + m.GetParameters().Select(a => a.ParameterType).And(m.ReturnType).ToArray()); + Delegate del = m.IsStatic + ? Delegate.CreateDelegate(delType, m) + : Delegate.CreateDelegate(delType, this, m); + string? description = m.GetCustomAttribute()?.Description; + return (AITool)AIFunctionFactory.Create(del, m.Name, description, GetJsonSerializerOptions()); + }) + .ToList()); + } + + internal IEnumerable GetMcpServerTools() => + GetTools().Select(t => McpServerTool.Create((AIFunction)t, new McpServerToolCreateOptions + { + SerializerOptions = GetJsonSerializerOptions(), + })); + + static JsonSerializerOptions JsonSerializationOptions = new JsonSerializerOptions + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }.AddSignumJsonConverters(); + + public virtual JsonSerializerOptions GetJsonSerializerOptions() => JsonSerializationOptions; +} diff --git a/Extensions/Signum.Agent/SkillCodeLogic.cs b/Extensions/Signum.Agent/SkillCodeLogic.cs new file mode 100644 index 0000000000..e809dc2743 --- /dev/null +++ b/Extensions/Signum.Agent/SkillCodeLogic.cs @@ -0,0 +1,148 @@ +using Signum.Engine.Sync; +using System.Collections.Frozen; + +namespace Signum.Agent; + +public static class SkillCodeLogic +{ + public static ResetLazy> TypeToEntity = null!; + public static ResetLazy> EntityToType = null!; + + public static Dictionary RegisteredCodes = new(); + + internal static void Register(Type type) + { + if (!typeof(SkillCode).IsAssignableFrom(type)) + throw new InvalidOperationException($"Type '{type.FullName}' must derive from SkillCode"); + + if(RegisteredCodes.TryGetValue(type.Name, out var already) && already != type) + throw new InvalidOperationException($"Type '{type.FullName}' is already registered with a different type."); + + RegisteredCodes[type.Name!] = type; + } + + public static void Register() + where T : SkillCode => Register(typeof(T)); + + public static void Start(SchemaBuilder sb) + { + if (sb.AlreadyDefined(MethodInfo.GetCurrentMethod())) + return; + + sb.Schema.Generating += Schema_Generating; + sb.Schema.Synchronizing += Schema_Synchronizing; + sb.Include() + .WithQuery(() => e => new + { + Entity = e, + e.Id, + e.ClassName + }); + + + TypeToEntity = sb.GlobalLazy(() => + { + var dbAtentCodes = Database.RetrieveAll(); + return EnumerableExtensions.JoinRelaxed( + dbAtentCodes, + RegisteredCodes.Values, + entity => entity.ClassName, + type => type!.Name!, + (entity, type) => KeyValuePair.Create(type, entity), + "caching " + nameof(SkillCodeEntity)) + .ToFrozenDictionaryEx(); + }, new InvalidateWith(typeof(SkillCodeEntity))); + + sb.Schema.Initializing += () => TypeToEntity.Load(); + + EntityToType = sb.GlobalLazy(() => TypeToEntity.Value.Inverse().ToFrozenDictionaryEx(), + new InvalidateWith(typeof(SkillCodeEntity))); + } + + public static Type ToType( this SkillCodeEntity codeEntity) + { + return EntityToType.Value.GetOrThrow(codeEntity); + } + + public static SkillCodeEntity ToSkillCodeEntity(Type type) + { + return TypeToEntity.Value.GetOrThrow(type); + } + + static SqlPreCommand? Schema_Generating() + { + var table = Schema.Current.Table(); + return GenerateCodeEntities() + .Select(e => table.InsertSqlSync(e)) + .Combine(Spacing.Simple); + } + + static SqlPreCommand? Schema_Synchronizing(Replacements replacements) + { + var table = Schema.Current.Table(); + var should = GenerateCodeEntities().ToDictionary(e => e.ClassName); + var current = Administrator.TryRetrieveAll(replacements) + .ToDictionary(e => e.ClassName); + + return Synchronizer.SynchronizeScript(Spacing.Double, should, current, + createNew: (_, s) => table.InsertSqlSync(s), + removeOld: (_, c) => table.DeleteSqlSync(c, e => e.ClassName == c.ClassName), + mergeBoth: (_, s, c) => table.UpdateSqlSync(c, e => e.ClassName == c.ClassName)); + } + + static List GenerateCodeEntities() => + RegisteredCodes.Values.Select(type => new SkillCodeEntity { ClassName = type.Name! }).ToList(); + + + public static DefaultSkillCodeInfo GetDefaultSkillCodeInfo(string skillCodeName) + { + if (!SkillCodeLogic.RegisteredCodes.TryGetValue(skillCodeName, out var type)) + throw new KeyNotFoundException($"AgentSkillCode type '{skillCodeName}' is not registered."); + + var instance = (SkillCode)Activator.CreateInstance(type)!; + + var properties = type + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(pi => new { pi, attr = pi.GetCustomAttribute() }) + .Where(x => x.attr != null) + .Select(x => new DefaultSkillCodeProperty + { + PropertyName = x.pi.Name, + AttributeName = x.attr!.GetType().Name.Before("Attribute"), + ValueHint = x.attr.ValueHint, + PropertyType = x.pi.PropertyType.TypeName(), + }) + .ToList(); + + return new DefaultSkillCodeInfo + { + DefaultShortDescription = instance.ShortDescription, + DefaultInstructions = instance.OriginalInstructions, + Properties = properties, + }; + } + + public static bool IsAutoRegister; + internal static IDisposable AutoRegister() + { + IsAutoRegister = true; + return new Disposable(() => IsAutoRegister = false); + } + + +} + +public class DefaultSkillCodeInfo +{ + public string DefaultShortDescription { get; set; } = null!; + public string DefaultInstructions { get; set; } = null!; + public List Properties { get; set; } = null!; +} + +public class DefaultSkillCodeProperty +{ + public string PropertyName { get; set; } = null!; + public string AttributeName { get; set; } = null!; + public string? ValueHint { get; set; } + public string PropertyType { get; set; } = null!; +} diff --git a/Extensions/Signum.Agent/SkillCustomizationEntity.cs b/Extensions/Signum.Agent/SkillCustomizationEntity.cs new file mode 100644 index 0000000000..9d66bb03d5 --- /dev/null +++ b/Extensions/Signum.Agent/SkillCustomizationEntity.cs @@ -0,0 +1,100 @@ +namespace Signum.Agent; + +[EntityKind(EntityKind.SystemString, EntityData.Master), TicksColumn(false)] +public class SkillCodeEntity : Entity +{ + [UniqueIndex] + public string ClassName { get; set; } + + [AutoExpressionField] + public override string ToString() => As.Expression(() => ClassName); +} + +[EntityKind(EntityKind.SystemString, EntityData.Master, IsLowPopulation = true)] +public class AgentSymbol : Symbol +{ + private AgentSymbol() { } + + public AgentSymbol(Type declaringType, string fieldName) : + base(declaringType, fieldName) + { + } +} + +[AutoInit] +public static class DefaultAgent +{ + public static AgentSymbol Chatbot; + public static AgentSymbol QuestionSummarizer; + public static AgentSymbol ConversationSumarizer; +} + +[EntityKind(EntityKind.Main, EntityData.Master)] +public class SkillCustomizationEntity : Entity +{ + public SkillCodeEntity SkillCode { get; set; } + + [UniqueIndex] + public AgentSymbol? Agent { get; set; } + + [StringLengthValidator(Min = 1, Max = 500)] + public string? ShortDescription { get; set; } + + [StringLengthValidator(MultiLine = true)] + public string? Instructions { get; set; } + + [BindParent] + public MList Properties { get; set; } = new MList(); + + [BindParent] + public MList SubSkills { get; set; } = new MList(); + + [AutoExpressionField] + public override string ToString() => As.Expression(() => IsNew ? this.BaseToString() : SkillCode.ToString()); + + protected override string? ChildPropertyValidation(ModifiableEntity sender, PropertyInfo pi) + { + if (sender is SkillPropertyEmbedded po + && pi.Name == nameof(SkillPropertyEmbedded.Value) + && SkillCode != null) + { + var propInfo = SkillCode.ToType().GetProperty(po.PropertyName, BindingFlags.Public | BindingFlags.Instance); + if (propInfo == null) + return $"Skill {SkillCode} has not property {po.PropertyName}"; + + var attr = propInfo?.GetCustomAttribute(); + if (propInfo == null || attr == null) + return $"Property {po.PropertyName} of type {SkillCode} has not AgentSkillProperty"; + + return attr.ValidateValue(po.Value, propInfo!.PropertyType); + } + + return base.ChildPropertyValidation(sender, pi); + } +} + +public class SkillPropertyEmbedded : EmbeddedEntity +{ + [StringLengthValidator(Min = 1, Max = 200)] + public string PropertyName { get; set; } + + [StringLengthValidator(MultiLine = true)] + public string? Value { get; set; } +} + +public class SubSkillEmbedded : EmbeddedEntity +{ + // Can reference either an AgentSkillEntity (customised) or AgentSkillCodeEntity (default, no DB entity needed) + [ImplementedBy(typeof(SkillCustomizationEntity), typeof(SkillCodeEntity))] + public Entity Skill { get; set; } + + public SkillActivation Activation { get; set; } +} + +[AutoInit] +public static class SkillCustomizationOperation +{ + public static ExecuteSymbol Save = null!; + public static DeleteSymbol Delete = null!; + public static ConstructSymbol.From CreateFromAgent = null!; +} diff --git a/Extensions/Signum.Agent/Skills/AutocompleteSkill.cs b/Extensions/Signum.Agent/Skills/AutocompleteSkill.cs index 52344d5d0d..41d97e6d94 100644 --- a/Extensions/Signum.Agent/Skills/AutocompleteSkill.cs +++ b/Extensions/Signum.Agent/Skills/AutocompleteSkill.cs @@ -3,7 +3,7 @@ namespace Signum.Agent.Skills; -public class AutocompleteSkill : AgentSkill +public class AutocompleteSkill : SkillCode { public AutocompleteSkill() { diff --git a/Extensions/Signum.Agent/Skills/ChartSkill.cs b/Extensions/Signum.Agent/Skills/ChartSkill.cs index be99eaff2a..f48d6795c3 100644 --- a/Extensions/Signum.Agent/Skills/ChartSkill.cs +++ b/Extensions/Signum.Agent/Skills/ChartSkill.cs @@ -11,7 +11,7 @@ namespace Signum.Agent.Skills; -public class ChartSkill : AgentSkill +public class ChartSkill : SkillCode { public ChartSkill() { diff --git a/Extensions/Signum.Agent/Skills/ConfirmUISkill.cs b/Extensions/Signum.Agent/Skills/ConfirmUISkill.cs index 4acc63a7b0..f08346805e 100644 --- a/Extensions/Signum.Agent/Skills/ConfirmUISkill.cs +++ b/Extensions/Signum.Agent/Skills/ConfirmUISkill.cs @@ -3,7 +3,7 @@ namespace Signum.Agent.Skills; -public class ConfirmUISkill : AgentSkill +public class ConfirmUISkill : SkillCode { public ConfirmUISkill() { diff --git a/Extensions/Signum.Agent/Skills/ConversationSumarizerSkill.cs b/Extensions/Signum.Agent/Skills/ConversationSumarizerSkill.cs index 13abd46271..db509bd8b6 100644 --- a/Extensions/Signum.Agent/Skills/ConversationSumarizerSkill.cs +++ b/Extensions/Signum.Agent/Skills/ConversationSumarizerSkill.cs @@ -2,7 +2,7 @@ namespace Signum.Agent.Skills; -public class ConversationSumarizerSkill : AgentSkill +public class ConversationSumarizerSkill : SkillCode { public ConversationSumarizerSkill() { diff --git a/Extensions/Signum.Agent/Skills/CurrentServerContextSkill.cs b/Extensions/Signum.Agent/Skills/CurrentServerContextSkill.cs index 3f38b540a5..bdefd33a5f 100644 --- a/Extensions/Signum.Agent/Skills/CurrentServerContextSkill.cs +++ b/Extensions/Signum.Agent/Skills/CurrentServerContextSkill.cs @@ -5,7 +5,7 @@ namespace Signum.Agent.Skills; -public class CurrentServerContextSkill : AgentSkill +public class CurrentServerContextSkill : SkillCode { public static Func? UrlLeft; diff --git a/Extensions/Signum.Agent/Skills/EntityUrlSkill.cs b/Extensions/Signum.Agent/Skills/EntityUrlSkill.cs index de4d19837a..58e7024f6f 100644 --- a/Extensions/Signum.Agent/Skills/EntityUrlSkill.cs +++ b/Extensions/Signum.Agent/Skills/EntityUrlSkill.cs @@ -1,6 +1,6 @@ namespace Signum.Agent.Skills; -public class EntityUrlSkill : AgentSkill +public class EntityUrlSkill : SkillCode { public EntityUrlSkill() { diff --git a/Extensions/Signum.Agent/Skills/GetUIContextSkill.cs b/Extensions/Signum.Agent/Skills/GetUIContextSkill.cs index 374610b53c..0e087ab056 100644 --- a/Extensions/Signum.Agent/Skills/GetUIContextSkill.cs +++ b/Extensions/Signum.Agent/Skills/GetUIContextSkill.cs @@ -3,7 +3,7 @@ namespace Signum.Agent.Skills; -public class GetUIContextSkill : AgentSkill +public class GetUIContextSkill : SkillCode { public GetUIContextSkill() { diff --git a/Extensions/Signum.Agent/Skills/IntroductionSkill.cs b/Extensions/Signum.Agent/Skills/IntroductionSkill.cs index a9a56f40b2..de3c69df5b 100644 --- a/Extensions/Signum.Agent/Skills/IntroductionSkill.cs +++ b/Extensions/Signum.Agent/Skills/IntroductionSkill.cs @@ -6,7 +6,7 @@ namespace Signum.Agent.Skills; -public class IntroductionSkill : AgentSkill +public class IntroductionSkill : SkillCode { public IntroductionSkill() { @@ -18,11 +18,9 @@ public IntroductionSkill() }; } - [McpServerTool, Description("Gets the introduction for an skill and discorver new tools")] + [McpServerTool, Description("Gets the instructions for a skill and discovers its tools")] public string Describe(string skillName) { - //throw new InvalidOperationException("bla"); - if (skillName.Contains("error")) throw new Exception(skillName + " has an error"); @@ -39,3 +37,4 @@ public Dictionary ListSkillNames() } } + diff --git a/Extensions/Signum.Agent/Skills/OperationSkill.cs b/Extensions/Signum.Agent/Skills/OperationSkill.cs index db4c6f9a01..0c651d8480 100644 --- a/Extensions/Signum.Agent/Skills/OperationSkill.cs +++ b/Extensions/Signum.Agent/Skills/OperationSkill.cs @@ -6,7 +6,7 @@ namespace Signum.Agent.Skills; -public class OperationSkill : AgentSkill +public class OperationSkill : SkillCode { public OperationSkill() { diff --git a/Extensions/Signum.Agent/Skills/QuestionSumarizerSkill.cs b/Extensions/Signum.Agent/Skills/QuestionSumarizerSkill.cs index 0bfd987b78..2b3e5256ed 100644 --- a/Extensions/Signum.Agent/Skills/QuestionSumarizerSkill.cs +++ b/Extensions/Signum.Agent/Skills/QuestionSumarizerSkill.cs @@ -3,7 +3,7 @@ namespace Signum.Agent.Skills; -public class QuestionSumarizerSkill : AgentSkill +public class QuestionSumarizerSkill : SkillCode { public QuestionSumarizerSkill() { diff --git a/Extensions/Signum.Agent/Skills/RetrieveSkill.cs b/Extensions/Signum.Agent/Skills/RetrieveSkill.cs index 7cecfc3ab3..3ef0e57f33 100644 --- a/Extensions/Signum.Agent/Skills/RetrieveSkill.cs +++ b/Extensions/Signum.Agent/Skills/RetrieveSkill.cs @@ -4,7 +4,7 @@ namespace Signum.Agent.Skills; -public class RetrieveSkill : AgentSkill +public class RetrieveSkill : SkillCode { public RetrieveSkill() { diff --git a/Extensions/Signum.Agent/Skills/Search.md b/Extensions/Signum.Agent/Skills/Search.md index 53dcee2fec..415b553bcc 100644 --- a/Extensions/Signum.Agent/Skills/Search.md +++ b/Extensions/Signum.Agent/Skills/Search.md @@ -203,7 +203,9 @@ IMPORTANT: Always set the appropiate `columnOptionsMode`; when grouping use `Rep Each column has: * `token`: the expression to use, can not use `Any`, `All`. * `displayName`: optional, if not specified the default name will be used. -* `summaryToken`: optional, only used to shown and aggregate in the header of the column. Can be used even if the `FindOptions` does not set `groupResults`. You can not aggregate twice (avoid `Count.Sum`, just use `Count`), but you can sum the number of elements in a collection (`Friends.Count.Sum`). +* `summaryToken`: optional, executes a separated query with the same filters to shown and aggregate in the header of the column. Can be used even if the `FindOptions` does not set `groupResults`. IMPORTANT: + * You can not aggregate twice, like `Count.Sum`, bacause `Count` is an aggegate, just use `Count`. + * But you can sum the number of elements in a collection, like `Friends.Count.Sum`, because `Friends.Count` is a property of the `Friends` collection. * `hiddenColumn`: optional, if true the column will not be shown, only usefull for hiding the real grouping key if `groupResults` is true. * `combineRows`: optional, if specified consecutive rows with the same value in this column will be combined in one row with rowspan in the html table. `EqualValue` compares similar values, `EqualEntity` compares the entity ids. diff --git a/Extensions/Signum.Agent/Skills/SearchSkill.cs b/Extensions/Signum.Agent/Skills/SearchSkill.cs index 6af7bc6570..0345b81145 100644 --- a/Extensions/Signum.Agent/Skills/SearchSkill.cs +++ b/Extensions/Signum.Agent/Skills/SearchSkill.cs @@ -14,16 +14,13 @@ namespace Signum.Agent.Skills; -public class SearchSkill : AgentSkill +public class SearchSkill : SkillCode { - public Func InlineQueryName = q => false; + [SkillProperty_QueryList] + public HashSet InlineQueryName { get; set; } = new HashSet(); - public SearchSkill(params HashSet queries) : this(q => queries.Contains(q)) + public SearchSkill() { - } - public SearchSkill(Func inlineQueryName) - { - InlineQueryName = inlineQueryName; ShortDescription = "Explores the database schema and queries any information in the database"; IsAllowed = () => true; Replacements = new Dictionary>() @@ -34,7 +31,7 @@ public SearchSkill(Func inlineQueryName) .GroupBy(a => a is Type t? t.Namespace : a is Enum e ? e.GetType().Namespace : "Unknown") .ToString(gr => { - var inlineQueries = gr.Where(InlineQueryName).ToString(qn => + var inlineQueries = gr.Where(InlineQueryName.Contains).ToString(qn => { var imp = QueryLogic.Queries.GetEntityImplementations(qn); var impStr = imp.Types.Only() == qn as Type ? "" : $" (ImplementedBy {imp.Types.ToString(t => t.Name, ", ")})"; diff --git a/Extensions/Signum.Agent/Templates/ChatMarkdown.tsx b/Extensions/Signum.Agent/Templates/ChatMarkdown.tsx index 791c5cc526..0e0de5d302 100644 --- a/Extensions/Signum.Agent/Templates/ChatMarkdown.tsx +++ b/Extensions/Signum.Agent/Templates/ChatMarkdown.tsx @@ -5,27 +5,27 @@ import remarkGfm from 'remark-gfm' import { toAbsoluteUrl } from '@framework/AppContext'; -export default function ChatMarkdown(p: { content: string }){ +export default function ChatMarkdown(p: { content: string }): JSX.Element { return {p.content}; } - function renderTable({ node, children, ...props }: React.PropsWithChildren> & { node?: any }): React.ReactNode { - return {children}
; - } +function renderTable({ node, children, ...props }: React.PropsWithChildren> & { node?: any }): React.ReactNode { + return {children}
; +} - export function renderLink({ node, href, children, ...props }: React.PropsWithChildren> & { node?: any }): React.ReactNode { - debugger; - if (href && href.startsWith("/")) - return {children}; +export function renderLink({ node, href, children, ...props }: React.PropsWithChildren> & { node?: any }): React.ReactNode { + debugger; + if (href && href.startsWith("/")) + return {children}; - var origin = document.location.origin + toAbsoluteUrl("~/"); - if (href && href.startsWith(origin)) - return {children}; + var origin = document.location.origin + toAbsoluteUrl("~/"); + if (href && href.startsWith(origin)) + return {children}; - return ( - - {children} - - ); - } + return ( + + {children} + + ); +} diff --git a/Extensions/Signum.Agent/Templates/ChatbotLanguageModel.tsx b/Extensions/Signum.Agent/Templates/ChatbotLanguageModel.tsx index 2ef25664fc..8d6ee2c38b 100644 --- a/Extensions/Signum.Agent/Templates/ChatbotLanguageModel.tsx +++ b/Extensions/Signum.Agent/Templates/ChatbotLanguageModel.tsx @@ -3,7 +3,7 @@ import { AutoLine, EntityCombo, EnumLine } from '@framework/Lines' import { TypeContext } from '@framework/TypeContext' import { ChatbotLanguageModelEntity } from '../Signum.Agent'; import { useAPI, useForceUpdate } from '@framework/Hooks'; -import { ChatbotClient } from '../ChatbotClient'; +import { LanguageModelClient } from '../LanguageModelClient'; export default function ChatbotConfiguration(p: { ctx: TypeContext }): React.JSX.Element { const ctx = p.ctx; @@ -12,7 +12,7 @@ export default function ChatbotConfiguration(p: { ctx: TypeContext provider && ChatbotClient.API.getModels(provider), [provider]); + const models = useAPI(() => provider && LanguageModelClient.API.getModels(provider), [provider]); return (
diff --git a/Extensions/Signum.Agent/Templates/EmbeddingsLanguageModel.tsx b/Extensions/Signum.Agent/Templates/EmbeddingsLanguageModel.tsx index 57281de37e..6dadadadd8 100644 --- a/Extensions/Signum.Agent/Templates/EmbeddingsLanguageModel.tsx +++ b/Extensions/Signum.Agent/Templates/EmbeddingsLanguageModel.tsx @@ -3,7 +3,7 @@ import { AutoLine, EntityCombo, EnumLine,NumberLine } from '@framework/Lines' import { TypeContext } from '@framework/TypeContext' import { EmbeddingsLanguageModelEntity } from '../Signum.Agent'; import { useAPI, useForceUpdate } from '@framework/Hooks'; -import { ChatbotClient } from '../ChatbotClient'; +import { LanguageModelClient } from '../LanguageModelClient'; export default function EmbeddingsLanguageModel(p: { ctx: TypeContext }): React.JSX.Element { const ctx = p.ctx; @@ -11,7 +11,7 @@ export default function EmbeddingsLanguageModel(p: { ctx: TypeContext provider && ChatbotClient.API.getEmbeddingModels(provider), [provider]); + const models = useAPI(() => provider && LanguageModelClient.API.getEmbeddingModels(provider), [provider]); return (
diff --git a/Extensions/Signum.Agent/Templates/SkillCustomization.tsx b/Extensions/Signum.Agent/Templates/SkillCustomization.tsx new file mode 100644 index 0000000000..80f1cf6040 --- /dev/null +++ b/Extensions/Signum.Agent/Templates/SkillCustomization.tsx @@ -0,0 +1,122 @@ +import * as React from 'react' +import { CheckboxLine, EntityCombo, EntityLine, EntityTable, EnumLine, TextBoxLine } from '@framework/Lines' +import { TypeContext } from '@framework/TypeContext' +import { SkillCustomizationEntity } from '../Signum.Agent' +import { useAPI, useForceUpdate } from '@framework/Hooks' +import { MarkdownLine } from '@framework/Lines/MarkdownLine' +import { DiffDocument } from '../../Signum.DiffLog/Templates/DiffDocument' +import { LinkButton } from '@framework/Basics/LinkButton' +import { AgentClient, SkillPropertyMeta } from '../AgentClient' + +export default function SkillCustomization(p: { ctx: TypeContext }): React.JSX.Element { + const ctx = p.ctx; + const ctx4 = ctx.subCtx({ labelColumns: 4 }); + const forceUpdate = useForceUpdate(); + + const skillCode = ctx.value.skillCode; + + const skillCodeInfo = useAPI( + () => skillCode ? AgentClient.API.getSkillCodeInfo(skillCode.className) : Promise.resolve(null), + [skillCode] + ); + + return ( +
+
+
+ e.skillCode)} + onChange={() => { + ctx.value.shortDescription = null; + ctx.value.instructions = null; + ctx.value.properties = []; + forceUpdate(); + }} /> + e.agent)} /> +
+
+ e.shortDescription)} + helpText={skillCodeInfo && ctx.value.shortDescription == null + ? `Default: ${skillCodeInfo.defaultShortDescription}` + : undefined} /> +
+
+ + + + e.subSkills)} avoidFieldSet="h5" columns={[ + { property: e => e.activation, template: ectx => e.activation)} /> }, + { property: e => e.skill, template: ectx => e.skill)} /> }, + ]} /> + + {skillCodeInfo && skillCodeInfo.properties.length > 0 && ( + e.properties)} avoidFieldSet="h5" columns={[ + { + property: e => e.propertyName, + template: ectx => ( + e.propertyName)} + optionItems={skillCodeInfo.properties.map(m => m.propertyName)} + onChange={() => { ectx.value.value = null; forceUpdate(); }} /> + ) + }, + { + property: e => e.value, + template: ectx => e.value)} + properties={skillCodeInfo.properties} + propertyName={ectx.value.propertyName} /> + }, + ]} /> + )} +
+ ); +} + +function InstructionsField(p: { + ctx: TypeContext, + info: { defaultInstructions: string } | null | undefined +}): React.JSX.Element { + const [showDiff, setShowDiff] = React.useState(false); + const ctx = p.ctx; + + const button = p.info && ( + setShowDiff(v => !v)}> + {showDiff ? "Show Editor" : "Show Diff"} + + ); + + return ( +
+
+ {button} +
+ {showDiff && p.info ?: + e.instructions)} /> } +
+ ); + +} + +function PropertyValueControl(p: { + ctx: TypeContext, + properties: SkillPropertyMeta[], + propertyName: string +}): React.JSX.Element { + const meta = p.properties.find(m => m.propertyName === p.propertyName); + + if (!meta) { + return ; + } + + const factory = AgentClient.getPropertyValueControl(meta.attributeName); + if (factory) { + return factory(p.ctx, meta); + } + + return ( + + ); +} diff --git a/Extensions/Signum.Agent/tsconfig.json b/Extensions/Signum.Agent/tsconfig.json index 169f2844b7..efdfe8dde4 100644 --- a/Extensions/Signum.Agent/tsconfig.json +++ b/Extensions/Signum.Agent/tsconfig.json @@ -9,6 +9,7 @@ "references": [ { "path": "../../Signum" }, { "path": "../Signum.Authorization" }, - { "path": "../Signum.HtmlEditor" } + { "path": "../Signum.HtmlEditor" }, + { "path": "../Signum.DiffLog" } ] } diff --git a/Extensions/Signum.Chart/GoogleMapScripts/Markermap.tsx b/Extensions/Signum.Chart/GoogleMapScripts/Markermap.tsx index 825d2e9406..80b46c0564 100644 --- a/Extensions/Signum.Chart/GoogleMapScripts/Markermap.tsx +++ b/Extensions/Signum.Chart/GoogleMapScripts/Markermap.tsx @@ -1,4 +1,3 @@ -/// import * as React from 'react' import * as d3 from 'd3' import { Navigator } from '@framework/Navigator'; diff --git a/Extensions/Signum.Dynamic/View/DynamicViewComponent.tsx b/Extensions/Signum.Dynamic/View/DynamicViewComponent.tsx index b80ef9711f..6c05340281 100644 --- a/Extensions/Signum.Dynamic/View/DynamicViewComponent.tsx +++ b/Extensions/Signum.Dynamic/View/DynamicViewComponent.tsx @@ -22,6 +22,7 @@ import { DynamicViewEntity, DynamicViewOperation } from '../Signum.Dynamic.Views export interface DynamicViewComponentProps { ctx: TypeContext; initialDynamicView: DynamicViewEntity; + ref?: React.Ref; //...extraProps } @@ -31,7 +32,6 @@ export interface DynamicViewComponentState { selectedNode: DesignerNode; dynamicView: DynamicViewEntity; viewOverrides?: ViewOverride[]; - } export default function DynamicViewComponent(p: DynamicViewComponentProps): React.JSX.Element | null { @@ -47,7 +47,7 @@ export default function DynamicViewComponent(p: DynamicViewComponentProps): Reac const viewOverrides = useAPI(() => Navigator.viewDispatcher.getViewOverrides(p.ctx.value.Type), []); function getZeroNode() { - var { ctx, initialDynamicView, ...extraProps } = p; + var { ctx, initialDynamicView, ref, ...extraProps } = p; var context: DesignerContext = { onClose: handleClose, diff --git a/Extensions/Signum.Mailing.MicrosoftGraph/RemoteEmails/MultiMessageProgressModal.tsx b/Extensions/Signum.Mailing.MicrosoftGraph/RemoteEmails/MultiMessageProgressModal.tsx index c941b63bb6..d9cb20ef54 100644 --- a/Extensions/Signum.Mailing.MicrosoftGraph/RemoteEmails/MultiMessageProgressModal.tsx +++ b/Extensions/Signum.Mailing.MicrosoftGraph/RemoteEmails/MultiMessageProgressModal.tsx @@ -55,7 +55,7 @@ export function MultiMessageProgressModal(p: MultiMessageProgressModalProps): Re } function handleOnExited() { - p.onExited!({ errors: messageResultRef.current.toObject(a => a.id, a => a.error) }); + p.onExited!({ errors: messageResultRef.current.toObject(a => a.id, a => a.error ?? null) }); } var errors = messageResultRef.current.filter(a => a.error != null); @@ -93,7 +93,7 @@ export namespace MultiMessageProgressModal { } else { return makeRequest().then(r => r.json()).then(obj => { var a = obj as EmailResult; - return softCast({ errors: { [a.id]: a.error } }); + return softCast({ errors: { [a.id]: a.error ?? null } }); }); } } diff --git a/Extensions/Signum.Workflow/Case/CaseFrameModal.tsx b/Extensions/Signum.Workflow/Case/CaseFrameModal.tsx index 651e1d6497..74a65f99fc 100644 --- a/Extensions/Signum.Workflow/Case/CaseFrameModal.tsx +++ b/Extensions/Signum.Workflow/Case/CaseFrameModal.tsx @@ -44,7 +44,7 @@ interface CaseFrameModalState { var modalCount = 0; -export function CaseFrameModal(p: CaseFrameModalProps) { +export function CaseFrameModal(p: CaseFrameModalProps): JSX.Element { const [state, setState] = useStateWithPromise(undefined); const [show, setShow] = React.useState(true); diff --git a/Extensions/Signum.Workflow/Workflow/Workflow.tsx b/Extensions/Signum.Workflow/Workflow/Workflow.tsx index de883a9dd6..247a00827e 100644 --- a/Extensions/Signum.Workflow/Workflow/Workflow.tsx +++ b/Extensions/Signum.Workflow/Workflow/Workflow.tsx @@ -28,7 +28,7 @@ export interface WorkflowHandle { getSvg(): Promise; } -export function Workflow(p: WorkflowProps) { +export function Workflow(p: WorkflowProps) : JSX.Element { const bpmnModelerComponentRef = React.useRef(null); diff --git a/Signum.Upgrade/Upgrades/Upgrade_20260321_SeleniumToPlaywright.cs b/Signum.Upgrade/Upgrades/Upgrade_20260321_SeleniumToPlaywright.cs index afee91a7cc..c4ee848364 100644 --- a/Signum.Upgrade/Upgrades/Upgrade_20260321_SeleniumToPlaywright.cs +++ b/Signum.Upgrade/Upgrades/Upgrade_20260321_SeleniumToPlaywright.cs @@ -95,7 +95,8 @@ private static void AssertClean200(HttpResponseMessage response) + content ); } - + + const int DebugChromePort = 9222; private static readonly Lazy> DefaultBrowser = new(async () => { var playwright = await Microsoft.Playwright.Playwright.CreateAsync(); diff --git a/tsconfig.base.json b/tsconfig.base.json index 6377f302c9..2e41278ef0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -14,6 +14,7 @@ "strict": true, "noImplicitOverride": true, "declarationMap": true, + "disableSourceOfProjectReferenceRedirect": true, "noUncheckedSideEffectImports": false, "lib": [ "ESNext",