Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/Core.Application/Actor/GetOurActorsByNameQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace Goodtocode.AgentFramework.Core.Application.Actor;

public class GetOurActorsByNameQuery : UserScopedRequest, IRequest<ICollection<ActorDto>>
{
public string Name { get; set; } = string.Empty;
}

public class GetOurActorsByNameQueryHandler(IAgentFrameworkContext context) : IRequestHandler<GetOurActorsByNameQuery, ICollection<ActorDto>>
{
private readonly IAgentFrameworkContext _context = context;

public async Task<ICollection<ActorDto>> 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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Goodtocode.AgentFramework.Core.Application.Actor;

public class GetOurActorsByNameQueryValidator : SecuredValidator<GetOurActorsByNameQuery>
{
public GetOurActorsByNameQueryValidator()
{
RuleFor(x => x.Name).NotEmpty();
}
}
26 changes: 26 additions & 0 deletions src/Core.Application/Chat/GetMyChatSessionMessagesQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace Goodtocode.AgentFramework.Core.Application.Chat;

public class GetMyChatSessionMessagesQuery : UserScopedRequest, IRequest<ICollection<ChatMessageDto>>
{
public Guid ChatSessionId { get; set; }
}

public class GetMyChatSessionMessagesQueryHandler(IAgentFrameworkContext context) : IRequestHandler<GetMyChatSessionMessagesQuery, ICollection<ChatMessageDto>>
{
private readonly IAgentFrameworkContext _context = context;

public async Task<ICollection<ChatMessageDto>> 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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Goodtocode.AgentFramework.Core.Application.Chat;

public class GetMyChatSessionMessagesQueryValidator : SecuredValidator<GetMyChatSessionMessagesQuery>
{
public GetMyChatSessionMessagesQueryValidator()
{
RuleFor(x => x.ChatSessionId).NotEmpty();
}
}
2 changes: 2 additions & 0 deletions src/Infrastructure.AgentFramework/ConfigureServices.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -64,6 +65,7 @@
.ValidateDataAnnotations()
.ValidateOnStart();

services.AddScoped<IToolApplicationExecutor, ToolApplicationExecutor>();
services.AddSingleton<ChatSessionsTool>();
services.AddSingleton<ActorsTool>();
services.AddSingleton<ChatMessagesTool>();
Expand All @@ -77,7 +79,7 @@
if (providerOptions.Kind.Equals("GitHubCopilotSDK", StringComparison.OrdinalIgnoreCase))
{
var options = provider.GetRequiredService<IOptions<GitHubCopilotSdkOptions>>().Value;
logger.LogInformation("Agent provider resolved to {Provider}. Endpoint: {Endpoint}. ChatCompletionModelId: {ChatCompletionModelId}",

Check warning on line 82 in src/Infrastructure.AgentFramework/ConfigureServices.cs

View workflow job for this annotation

GitHub Actions / Web, API and SQL CI (10.x)

Evaluation of this argument may be expensive and unnecessary if logging is disabled (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1873)
providerOptions.Kind,
options.Endpoint,
options.ChatCompletionModelId);
Expand All @@ -95,7 +97,7 @@
{
var options = provider.GetRequiredService<IOptions<AzureOpenAIOptions>>().Value;
var normalizedEndpoint = NormalizeAzureOpenAIEndpoint(options.Endpoint);
logger.LogInformation("Agent provider resolved to {Provider}. Endpoint: {Endpoint}. NormalizedEndpoint: {NormalizedEndpoint}. ChatDeploymentName: {ChatDeploymentName}",

Check warning on line 100 in src/Infrastructure.AgentFramework/ConfigureServices.cs

View workflow job for this annotation

GitHub Actions / Web, API and SQL CI (10.x)

Evaluation of this argument may be expensive and unnecessary if logging is disabled (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1873)
providerOptions.Kind,
options.Endpoint,
normalizedEndpoint,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Goodtocode.Mediator;

namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Execution;

/// <summary>
/// Executes application requests for AI tools through the mediator pipeline.
/// </summary>
public interface IToolApplicationExecutor
{
/// <summary>
/// Sends a request with a response through the application mediator pipeline.
/// </summary>
Task<TResponse> SendAsync<TResponse>(IRequest<TResponse> request, CancellationToken cancellationToken);

/// <summary>
/// Sends a request without a response through the application mediator pipeline.
/// </summary>
Task SendAsync(IRequest request, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Goodtocode.Mediator;

namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Execution;

/// <summary>
/// Default mediator-backed executor used by AI tools for application commands and queries.
/// </summary>
public sealed class ToolApplicationExecutor(ISender sender) : IToolApplicationExecutor
{
private readonly ISender _sender = sender;

/// <inheritdoc />
public Task<TResponse> SendAsync<TResponse>(IRequest<TResponse> request, CancellationToken cancellationToken)
=> _sender.Send(request, cancellationToken);

/// <inheritdoc />
public Task SendAsync(IRequest request, CancellationToken cancellationToken)
=> _sender.Send(request, cancellationToken);
}
48 changes: 12 additions & 36 deletions src/Infrastructure.AgentFramework/Tools/ActorsTool.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -14,18 +12,16 @@ 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<string, object> Parameters => _currentParameters;

private string _currentFunctionName = string.Empty;
private Dictionary<string, object> _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<IActorResponse?> GetActorByIdAsync(Guid actorId, CancellationToken cancellationToken)
{
_currentFunctionName = "get_actor_by_id";
Expand All @@ -34,9 +30,10 @@ public sealed class ActorsTool(IServiceProvider serviceProvider) : AITool, IActo
{ "actorId", actorId }
};

using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<IAgentFrameworkContext>();
var actor = await context.Actors.FindAsync([actorId, cancellationToken], cancellationToken: cancellationToken);
var actor = await SendAsync(new GetOurActorQuery
{
ActorId = actorId
}, cancellationToken);

if (actor == null)
{
Expand All @@ -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<ICollection<IActorResponse>> GetActorsByNameAsync(string name, CancellationToken cancellationToken)
{
_currentFunctionName = "get_actors_by_name";
Expand All @@ -63,31 +60,10 @@ public async Task<ICollection<IActorResponse>> GetActorsByNameAsync(string name,
{ "name", name }
};

using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<IAgentFrameworkContext>();
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
{
Expand Down
38 changes: 15 additions & 23 deletions src/Infrastructure.AgentFramework/Tools/ChatMessagesTool.cs
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
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<string, object> Parameters => _currentParameters;

private string _currentFunctionName = string.Empty;
private Dictionary<string, object> _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<IEnumerable<string>> ListRecentMessagesAsync(DateTime? startDate = null, DateTime? endDate = null,
CancellationToken cancellationToken = default)
{
Expand All @@ -27,18 +23,16 @@ public async Task<IEnumerable<string>> ListRecentMessagesAsync(DateTime? startDa
{ "endDate", endDate ?? DateTime.UtcNow.AddSeconds(1)}
};

using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<IAgentFrameworkContext>();

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<IEnumerable<string>> GetChatMessagesAsync(Guid sessionId,
CancellationToken cancellationToken = default)
{
Expand All @@ -48,12 +42,10 @@ public async Task<IEnumerable<string>> GetChatMessagesAsync(Guid sessionId,
{ "sessionId", sessionId }
};

using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<IAgentFrameworkContext>();

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}");
}
Expand Down
53 changes: 22 additions & 31 deletions src/Infrastructure.AgentFramework/Tools/ChatSessionsTool.cs
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
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<string, object> Parameters => _currentParameters;

private string _currentFunctionName = string.Empty;
private Dictionary<string, object> _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<IEnumerable<string>> ListRecentSessionsAsync(DateTime? startDate = null, DateTime? endDate = null, CancellationToken cancellationToken = default)
{
_currentFunctionName = "list_sessions";
Expand All @@ -26,24 +22,16 @@ public async Task<IEnumerable<string>> ListRecentSessionsAsync(DateTime? startDa
{ "endDate", endDate ?? DateTime.UtcNow.AddSeconds(1)}
};

using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<IAgentFrameworkContext>();

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<string?> UpdateChatSessionTitleAsync(Guid sessionId, string newTitle, CancellationToken cancellationToken = default)
{
_currentFunctionName = "change_title";
Expand All @@ -53,21 +41,24 @@ public async Task<IEnumerable<string>> ListRecentSessionsAsync(DateTime? startDa
{ "newTitle", newTitle }
};

using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<IAgentFrameworkContext>();

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]";
}
}
Loading
Loading