diff --git a/src/Core.Application/Actor/GetOurActorsByNameQuery.cs b/src/Core.Application/Actor/GetOurActorsByNameQuery.cs new file mode 100644 index 0000000..414be5e --- /dev/null +++ b/src/Core.Application/Actor/GetOurActorsByNameQuery.cs @@ -0,0 +1,25 @@ +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class GetOurActorsByNameQuery : UserScopedRequest, IRequest> +{ + public string Name { get; set; } = string.Empty; +} + +public class GetOurActorsByNameQueryHandler(IAgentFrameworkContext context) : IRequestHandler> +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task> Handle(GetOurActorsByNameQuery request, CancellationToken cancellationToken) + { + var tenantId = request.UserContext.TenantId; + var normalizedInput = request.Name.Trim(); + + return await _context.Actors + .Where(x => x.TenantId == tenantId) + .Where(x => + (x.FirstName != null && x.FirstName.Contains(normalizedInput)) + || (x.LastName != null && x.LastName.Contains(normalizedInput))) + .Select(x => ActorDto.CreateFrom(x)) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/GetOurActorsByNameQueryValidator.cs b/src/Core.Application/Actor/GetOurActorsByNameQueryValidator.cs new file mode 100644 index 0000000..93ffebf --- /dev/null +++ b/src/Core.Application/Actor/GetOurActorsByNameQueryValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class GetOurActorsByNameQueryValidator : SecuredValidator +{ + public GetOurActorsByNameQueryValidator() + { + RuleFor(x => x.Name).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/Chat/GetMyChatSessionMessagesQuery.cs b/src/Core.Application/Chat/GetMyChatSessionMessagesQuery.cs new file mode 100644 index 0000000..c942052 --- /dev/null +++ b/src/Core.Application/Chat/GetMyChatSessionMessagesQuery.cs @@ -0,0 +1,26 @@ +namespace Goodtocode.AgentFramework.Core.Application.Chat; + +public class GetMyChatSessionMessagesQuery : UserScopedRequest, IRequest> +{ + public Guid ChatSessionId { get; set; } +} + +public class GetMyChatSessionMessagesQueryHandler(IAgentFrameworkContext context) : IRequestHandler> +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task> Handle(GetMyChatSessionMessagesQuery request, CancellationToken cancellationToken) + { + ChatGuard.GuardAgainstEmptyUserForQuery(request.UserContext); + ChatGuard.GuardAgainstEmptyId(request.ChatSessionId); + + var userContext = request.UserContext; + return await _context.ChatMessages + .Where(x => x.ChatSessionId == request.ChatSessionId + && x.OwnerId == userContext.OwnerId + && x.TenantId == userContext.TenantId) + .OrderBy(x => x.Timestamp) + .Select(x => ChatMessageDto.CreateFrom(x)) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Core.Application/Chat/GetMyChatSessionMessagesQueryValidator.cs b/src/Core.Application/Chat/GetMyChatSessionMessagesQueryValidator.cs new file mode 100644 index 0000000..7dc4d90 --- /dev/null +++ b/src/Core.Application/Chat/GetMyChatSessionMessagesQueryValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.Chat; + +public class GetMyChatSessionMessagesQueryValidator : SecuredValidator +{ + public GetMyChatSessionMessagesQueryValidator() + { + RuleFor(x => x.ChatSessionId).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Infrastructure.AgentFramework/ConfigureServices.cs b/src/Infrastructure.AgentFramework/ConfigureServices.cs index 2c37a7c..8ce86c1 100644 --- a/src/Infrastructure.AgentFramework/ConfigureServices.cs +++ b/src/Infrastructure.AgentFramework/ConfigureServices.cs @@ -1,5 +1,6 @@ using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Options; using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Providers; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Execution; using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Tools; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -64,6 +65,7 @@ public static IServiceCollection AddAgentFrameworkOpenAIServices(this IServiceCo .ValidateDataAnnotations() .ValidateOnStart(); + services.AddScoped(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Infrastructure.AgentFramework/Execution/IToolApplicationExecutor.cs b/src/Infrastructure.AgentFramework/Execution/IToolApplicationExecutor.cs new file mode 100644 index 0000000..f7716cc --- /dev/null +++ b/src/Infrastructure.AgentFramework/Execution/IToolApplicationExecutor.cs @@ -0,0 +1,19 @@ +using Goodtocode.Mediator; + +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Execution; + +/// +/// Executes application requests for AI tools through the mediator pipeline. +/// +public interface IToolApplicationExecutor +{ + /// + /// Sends a request with a response through the application mediator pipeline. + /// + Task SendAsync(IRequest request, CancellationToken cancellationToken); + + /// + /// Sends a request without a response through the application mediator pipeline. + /// + Task SendAsync(IRequest request, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Infrastructure.AgentFramework/Execution/ToolApplicationExecutor.cs b/src/Infrastructure.AgentFramework/Execution/ToolApplicationExecutor.cs new file mode 100644 index 0000000..09bd9e2 --- /dev/null +++ b/src/Infrastructure.AgentFramework/Execution/ToolApplicationExecutor.cs @@ -0,0 +1,19 @@ +using Goodtocode.Mediator; + +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Execution; + +/// +/// Default mediator-backed executor used by AI tools for application commands and queries. +/// +public sealed class ToolApplicationExecutor(ISender sender) : IToolApplicationExecutor +{ + private readonly ISender _sender = sender; + + /// + public Task SendAsync(IRequest request, CancellationToken cancellationToken) + => _sender.Send(request, cancellationToken); + + /// + public Task SendAsync(IRequest request, CancellationToken cancellationToken) + => _sender.Send(request, cancellationToken); +} \ No newline at end of file diff --git a/src/Infrastructure.AgentFramework/Tools/ActorsTool.cs b/src/Infrastructure.AgentFramework/Tools/ActorsTool.cs index 452f268..8a8e38b 100644 --- a/src/Infrastructure.AgentFramework/Tools/ActorsTool.cs +++ b/src/Infrastructure.AgentFramework/Tools/ActorsTool.cs @@ -1,7 +1,5 @@ using System.ComponentModel; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; +using Goodtocode.AgentFramework.Core.Application.Actor; namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Tools; @@ -14,10 +12,8 @@ public class ActorResponse : IActorResponse } -public sealed class ActorsTool(IServiceProvider serviceProvider) : AITool, IActorsTool +public sealed class ActorsTool(IServiceProvider serviceProvider) : ScopedAgentTool(serviceProvider), IActorsTool { - private readonly IServiceProvider _serviceProvider = serviceProvider; - public static string ToolName => "ActorsTool"; public string FunctionName => _currentFunctionName; public Dictionary Parameters => _currentParameters; @@ -25,7 +21,7 @@ public sealed class ActorsTool(IServiceProvider serviceProvider) : AITool, IActo private string _currentFunctionName = string.Empty; private Dictionary _currentParameters = []; - [Description("Returns structured actor info by ID including name, status, and explanation.")] + [Description("Get an actor by actorId when the user provides an identifier. Returns structured actor status: Found, Partial, or NotFound, with a brief explanation.")] public async Task GetActorByIdAsync(Guid actorId, CancellationToken cancellationToken) { _currentFunctionName = "get_actor_by_id"; @@ -34,9 +30,10 @@ public sealed class ActorsTool(IServiceProvider serviceProvider) : AITool, IActo { "actorId", actorId } }; - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - var actor = await context.Actors.FindAsync([actorId, cancellationToken], cancellationToken: cancellationToken); + var actor = await SendAsync(new GetOurActorQuery + { + ActorId = actorId + }, cancellationToken); if (actor == null) { @@ -54,7 +51,7 @@ public sealed class ActorsTool(IServiceProvider serviceProvider) : AITool, IActo }; } - [Description("Returns structured actor info by name including ID, status, and explanation.")] + [Description("Search actors in the current tenant by name when the user asks to find a person. Returns matching actor IDs, names, statuses, and explanations. Never use this to search other tenants.")] public async Task> GetActorsByNameAsync(string name, CancellationToken cancellationToken) { _currentFunctionName = "get_actors_by_name"; @@ -63,31 +60,10 @@ public async Task> GetActorsByNameAsync(string name, { "name", name } }; - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - var nameTokens = name?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; - var normalizedInput = name?.Trim() ?? string.Empty; - - var actors = await context.Actors - .Where(a => - nameTokens.Any(token => - EF.Functions.Like(a.FirstName, $"%{token}%") || - EF.Functions.Like(a.LastName, $"%{token}%") - ) - || EF.Functions.Like( - (a.FirstName + " " + a.LastName).Trim(), $"%{normalizedInput}%" - ) - || EF.Functions.Like( - (a.LastName + " " + a.FirstName).Trim(), $"%{normalizedInput}%" - ) - || nameTokens.Any(token => - EF.Functions.Like(a.FirstName, $"{token}%") || - EF.Functions.Like(a.FirstName, $"%{token}") || - EF.Functions.Like(a.LastName, $"{token}%") || - EF.Functions.Like(a.LastName, $"%{token}") - ) - ) - .ToListAsync(cancellationToken); + var actors = await SendAsync(new GetOurActorsByNameQuery + { + Name = name + }, cancellationToken); return [.. actors.Select(a => new ActorResponse { diff --git a/src/Infrastructure.AgentFramework/Tools/ChatMessagesTool.cs b/src/Infrastructure.AgentFramework/Tools/ChatMessagesTool.cs index 0501407..03d91f7 100644 --- a/src/Infrastructure.AgentFramework/Tools/ChatMessagesTool.cs +++ b/src/Infrastructure.AgentFramework/Tools/ChatMessagesTool.cs @@ -1,14 +1,10 @@ using System.ComponentModel; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; +using Goodtocode.AgentFramework.Core.Application.Chat; namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Tools; -public sealed class ChatMessagesTool(IServiceProvider serviceProvider) : AITool, IChatMessagesTool +public sealed class ChatMessagesTool(IServiceProvider serviceProvider) : ScopedAgentTool(serviceProvider), IChatMessagesTool { - private readonly IServiceProvider _serviceProvider = serviceProvider; - public static string ToolName => "ChatMessagesTool"; public string FunctionName => _currentFunctionName; public Dictionary Parameters => _currentParameters; @@ -16,7 +12,7 @@ public sealed class ChatMessagesTool(IServiceProvider serviceProvider) : AITool, private string _currentFunctionName = string.Empty; private Dictionary _currentParameters = []; - [Description("Retrieves the most recent messages from all chat sessions.")] + [Description("List recent messages from the current user's chat sessions. Optionally provide startDate and endDate to narrow the time range. Use for conversation-history questions.")] public async Task> ListRecentMessagesAsync(DateTime? startDate = null, DateTime? endDate = null, CancellationToken cancellationToken = default) { @@ -27,18 +23,16 @@ public async Task> ListRecentMessagesAsync(DateTime? startDa { "endDate", endDate ?? DateTime.UtcNow.AddSeconds(1)} }; - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - var query = context.ChatMessages.AsQueryable(); - if (startDate.HasValue) query = query.Where(x => x.Timestamp >= startDate.Value); - if (endDate.HasValue) query = query.Where(x => x.Timestamp <= endDate.Value); - - var messages = await query.OrderByDescending(x => x.Timestamp).ToListAsync(cancellationToken); - return messages.Select(m => $"{m.ChatSessionId}: {m.Timestamp:u} - {m.Role}: {m.Content}"); + var messages = await SendAsync(new GetMyChatMessagesPaginatedQuery + { + StartDate = startDate, + EndDate = endDate, + PageSize = 100 + }, cancellationToken); + return messages.Items.Select(m => $"{m.ChatSessionId}: {m.Timestamp:u} - {m.Role}: {m.Content}"); } - [Description("Retrieves all messages from a specific chat session.")] + [Description("List all messages for a chat session owned by the current user. Use when the user asks to inspect a specific conversation by sessionId.")] public async Task> GetChatMessagesAsync(Guid sessionId, CancellationToken cancellationToken = default) { @@ -48,12 +42,10 @@ public async Task> GetChatMessagesAsync(Guid sessionId, { "sessionId", sessionId } }; - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - var messages = await context.ChatMessages - .Where(x => x.ChatSessionId == sessionId) - .ToListAsync(cancellationToken); + var messages = await SendAsync(new GetMyChatSessionMessagesQuery + { + ChatSessionId = sessionId + }, cancellationToken); return messages.Select(m => $"{m.ChatSessionId}: {m.Timestamp:u} - {m.Role}: {m.Content}"); } diff --git a/src/Infrastructure.AgentFramework/Tools/ChatSessionsTool.cs b/src/Infrastructure.AgentFramework/Tools/ChatSessionsTool.cs index 124a065..17e0894 100644 --- a/src/Infrastructure.AgentFramework/Tools/ChatSessionsTool.cs +++ b/src/Infrastructure.AgentFramework/Tools/ChatSessionsTool.cs @@ -1,14 +1,10 @@ using System.ComponentModel; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; +using Goodtocode.AgentFramework.Core.Application.Chat; namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Tools; -public sealed class ChatSessionsTool(IServiceProvider serviceProvider) : AITool, IChatSessionsTool +public sealed class ChatSessionsTool(IServiceProvider serviceProvider) : ScopedAgentTool(serviceProvider), IChatSessionsTool { - private readonly IServiceProvider _serviceProvider = serviceProvider; - public static string ToolName => "ChatSessionsTool"; public string FunctionName => _currentFunctionName; public Dictionary Parameters => _currentParameters; @@ -16,7 +12,7 @@ public sealed class ChatSessionsTool(IServiceProvider serviceProvider) : AITool, private string _currentFunctionName = string.Empty; private Dictionary _currentParameters = []; - [Description("Retrieves a list of recent chat sessions. Optionally, filter results by start and/or end date to narrow the search.")] + [Description("List recent chat sessions owned by the current user. Optionally provide startDate and endDate to narrow the time range. Use before asking for a sessionId or conversation history.")] public async Task> ListRecentSessionsAsync(DateTime? startDate = null, DateTime? endDate = null, CancellationToken cancellationToken = default) { _currentFunctionName = "list_sessions"; @@ -26,24 +22,16 @@ public async Task> ListRecentSessionsAsync(DateTime? startDa { "endDate", endDate ?? DateTime.UtcNow.AddSeconds(1)} }; - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - var query = context.ChatSessions.AsQueryable(); - - if (startDate.HasValue) - query = query.Where(x => x.Timestamp > startDate.Value); - if (endDate.HasValue) - query = query.Where(x => x.Timestamp < endDate.Value); - - var messages = await query - .OrderByDescending(x => x.Timestamp) - .ToListAsync(cancellationToken); + var messages = await SendAsync(new GetMyChatSessionsQuery + { + StartDate = startDate, + EndDate = endDate + }, cancellationToken); return messages.Select(m => $"{m.Id}: {m.Timestamp} - {m.Title}"); } - [Description("Changes the title on this chat session.")] + [Description("Change the title of a chat session owned by the current user. This writes data, so call it only after the user explicitly confirms the newTitle and sessionId. The result includes a follow-up action token for the chat UI.")] public async Task UpdateChatSessionTitleAsync(Guid sessionId, string newTitle, CancellationToken cancellationToken = default) { _currentFunctionName = "change_title"; @@ -53,21 +41,24 @@ public async Task> ListRecentSessionsAsync(DateTime? startDa { "newTitle", newTitle } }; - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - var chatSession = await context.ChatSessions - .FirstOrDefaultAsync(x => x.Id == sessionId, cancellationToken: cancellationToken); + var result = await SendAsync(new PatchMyChatSessionCommand + { + Id = sessionId, + Title = newTitle + }, cancellationToken); - if (chatSession == null) + if (!result.IsSuccess) { return null; } - chatSession.Update(newTitle); - context.ChatSessions.Update(chatSession); - await context.SaveChangesAsync(cancellationToken); + var chatSession = await SendAsync(new GetMyChatSessionQuery + { + Id = sessionId + }, cancellationToken); - return $"{chatSession.Id}: {chatSession.Timestamp} - {chatSession.Title}"; + return chatSession is null + ? null + : $"{chatSession.Id}: {chatSession.Timestamp} - {chatSession.Title}\n[action|Review chat sessions|List my recent chat sessions]"; } } \ No newline at end of file diff --git a/src/Infrastructure.AgentFramework/Tools/ScopedAgentTool.cs b/src/Infrastructure.AgentFramework/Tools/ScopedAgentTool.cs new file mode 100644 index 0000000..efa9370 --- /dev/null +++ b/src/Infrastructure.AgentFramework/Tools/ScopedAgentTool.cs @@ -0,0 +1,43 @@ +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Execution; +using Goodtocode.Mediator; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Tools; + +/// +/// Shared base class for AI tools that execute application requests in an isolated scope. +/// +public abstract class ScopedAgentTool(IServiceProvider serviceProvider) : AITool +{ + private readonly IServiceProvider _serviceProvider = serviceProvider; + + /// + /// Sends an application request with a response through a new dependency-injection scope. + /// + protected async Task SendAsync(IRequest request, CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + return await executor.SendAsync(request, cancellationToken); + } + + /// + /// Sends an application request without a response through a new dependency-injection scope. + /// + protected async Task SendAsync(IRequest request, CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + await executor.SendAsync(request, cancellationToken); + } + + /// + /// Executes a scoped infrastructure operation that is not an application request. + /// + protected async Task ResolveScopedAsync(Func> action) + { + using var scope = _serviceProvider.CreateScope(); + return await action(scope.ServiceProvider); + } +} \ No newline at end of file diff --git a/src/Infrastructure.AgentFramework/Tools/WebSearchTool.cs b/src/Infrastructure.AgentFramework/Tools/WebSearchTool.cs index 88a2ef4..f65f76b 100644 --- a/src/Infrastructure.AgentFramework/Tools/WebSearchTool.cs +++ b/src/Infrastructure.AgentFramework/Tools/WebSearchTool.cs @@ -1,18 +1,17 @@ using System.ComponentModel; -using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Tools; -public sealed class WebSearchTool(IServiceProvider serviceProvider) : AITool +public sealed class WebSearchTool(IServiceProvider serviceProvider) : ScopedAgentTool(serviceProvider) { - private readonly IServiceProvider _serviceProvider = serviceProvider; - - [Description("Search the public web and return ranked results.")] + [Description("Search the public web for current external information and return ranked results. Use only when the answer is not available from the user's chat sessions or actor data.")] public async Task SearchAsync(string query, CancellationToken cancellationToken = default) { - using var scope = _serviceProvider.CreateScope(); - var webSearchProvider = scope.ServiceProvider.GetRequiredService(); - return await webSearchProvider.SearchAsync(query, cancellationToken); + return await ResolveScopedAsync(async provider => + { + var webSearchProvider = provider.GetRequiredService(); + return await webSearchProvider.SearchAsync(query, cancellationToken); + }); } } \ No newline at end of file diff --git a/src/Presentation.Web/Features/Chat/ChatPage.razor b/src/Presentation.Web/Features/Chat/ChatPage.razor index e2aeb60..6a6d07b 100644 --- a/src/Presentation.Web/Features/Chat/ChatPage.razor +++ b/src/Presentation.Web/Features/Chat/ChatPage.razor @@ -7,6 +7,7 @@ @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @using Microsoft.JSInterop +@using System.Text.RegularExpressions @attribute [Authorize] @implements IAsyncDisposable @@ -45,10 +46,13 @@ + + } else @@ -80,10 +84,13 @@ + + } @@ -95,9 +102,19 @@ private ChatSessionsModel chatSessions = new ChatSessionsModel(); private ChatSessionList? chatSessionListRef; private ChatSessionStrip? chatSessionStripRef; + private NewChatMessageCard? chatMessageCardRef; + private NewChatMessageInput? chatMessageInputRef; private bool isMobileLayout; private bool shouldScrollToBottom; private IJSObjectReference? chatPageJsModule; + private List pendingActions = []; + private static readonly IReadOnlyList SuggestedPrompts = + [ + "List my recent chat sessions", + "Find an actor by name", + "Search the web for current information" + ]; + private static readonly Regex ActionTokenRegex = new(@"\[action\|(?