CODEX_ADK/BACKEND/Codex.CQRS/Commands/SendMessageCommand.cs
Svrnty 229a0698a3 Initial commit: CODEX_ADK monorepo
Multi-agent AI laboratory with ASP.NET Core 8.0 backend and Flutter frontend.
Implements CQRS architecture, OpenAPI contract-first API design.

BACKEND: Agent management, conversations, executions with PostgreSQL + Ollama
FRONTEND: Cross-platform UI with strict typing and Result-based error handling

Co-Authored-By: Jean-Philippe Brule <jp@svrnty.io>
2025-10-26 23:12:32 -04:00

322 lines
10 KiB
C#

using Codex.Dal;
using Codex.Dal.Entities;
using Codex.Dal.Enums;
using Codex.Dal.Services;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using OpenHarbor.CQRS.Abstractions;
using System.Text;
namespace Codex.CQRS.Commands;
/// <summary>
/// Sends a user message to an agent and receives a response.
/// Creates a new conversation if ConversationId is not provided.
/// </summary>
public record SendMessageCommand
{
/// <summary>
/// ID of the agent to send the message to
/// </summary>
public Guid AgentId { get; init; }
/// <summary>
/// ID of existing conversation, or null to create a new conversation
/// </summary>
public Guid? ConversationId { get; init; }
/// <summary>
/// User's message content
/// </summary>
public string Message { get; init; } = string.Empty;
/// <summary>
/// Optional user identifier for future authentication support
/// </summary>
public string? UserId { get; init; }
}
/// <summary>
/// Result containing the user message, agent response, and conversation metadata
/// </summary>
public record SendMessageResult
{
/// <summary>
/// ID of the conversation (new or existing)
/// </summary>
public Guid ConversationId { get; init; }
/// <summary>
/// ID of the stored user message
/// </summary>
public Guid MessageId { get; init; }
/// <summary>
/// ID of the stored agent response message
/// </summary>
public Guid AgentResponseId { get; init; }
/// <summary>
/// The user's message that was sent
/// </summary>
public MessageDto UserMessage { get; init; } = null!;
/// <summary>
/// The agent's response
/// </summary>
public AgentResponseDto AgentResponse { get; init; } = null!;
}
/// <summary>
/// Simplified message data transfer object
/// </summary>
public record MessageDto
{
/// <summary>
/// Message content
/// </summary>
public string Content { get; init; } = string.Empty;
/// <summary>
/// When the message was created
/// </summary>
public DateTime Timestamp { get; init; }
}
/// <summary>
/// Agent response with token usage and cost information
/// </summary>
public record AgentResponseDto
{
/// <summary>
/// Response content from the agent
/// </summary>
public string Content { get; init; } = string.Empty;
/// <summary>
/// When the response was generated
/// </summary>
public DateTime Timestamp { get; init; }
/// <summary>
/// Number of input tokens processed
/// </summary>
public int? InputTokens { get; init; }
/// <summary>
/// Number of output tokens generated
/// </summary>
public int? OutputTokens { get; init; }
/// <summary>
/// Estimated cost of the request in USD
/// </summary>
public decimal? EstimatedCost { get; init; }
}
/// <summary>
/// Handles sending a message to an agent and storing the conversation
/// </summary>
public class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, SendMessageResult>
{
private readonly CodexDbContext _dbContext;
private readonly IOllamaService _ollamaService;
public SendMessageCommandHandler(CodexDbContext dbContext, IOllamaService ollamaService)
{
_dbContext = dbContext;
_ollamaService = ollamaService;
}
public async Task<SendMessageResult> HandleAsync(SendMessageCommand command, CancellationToken cancellationToken)
{
// A. Validate agent exists and is active
var agent = await _dbContext.Agents
.FirstOrDefaultAsync(a => a.Id == command.AgentId && !a.IsDeleted, cancellationToken);
if (agent == null)
{
throw new InvalidOperationException($"Agent with ID {command.AgentId} not found or has been deleted.");
}
if (agent.Status != AgentStatus.Active)
{
throw new InvalidOperationException($"Agent '{agent.Name}' is not active. Current status: {agent.Status}");
}
// B. Get or create conversation
Conversation conversation;
if (command.ConversationId.HasValue)
{
var existingConversation = await _dbContext.Conversations
.FirstOrDefaultAsync(c => c.Id == command.ConversationId.Value, cancellationToken);
if (existingConversation == null)
{
throw new InvalidOperationException($"Conversation with ID {command.ConversationId.Value} not found.");
}
conversation = existingConversation;
}
else
{
// Create new conversation with title from first message
var title = command.Message.Length > 50
? command.Message.Substring(0, 50) + "..."
: command.Message;
conversation = new Conversation
{
Id = Guid.NewGuid(),
Title = title,
StartedAt = DateTime.UtcNow,
LastMessageAt = DateTime.UtcNow,
MessageCount = 0,
IsActive = true
};
_dbContext.Conversations.Add(conversation);
await _dbContext.SaveChangesAsync(cancellationToken);
}
// C. Store user message
var userMessage = new ConversationMessage
{
Id = Guid.NewGuid(),
ConversationId = conversation.Id,
Role = MessageRole.User,
Content = command.Message,
MessageIndex = conversation.MessageCount,
IsInActiveWindow = true,
CreatedAt = DateTime.UtcNow
};
_dbContext.ConversationMessages.Add(userMessage);
conversation.MessageCount++;
conversation.LastMessageAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync(cancellationToken);
// D. Build conversation context (get messages in active window)
var contextMessages = await _dbContext.ConversationMessages
.AsNoTracking()
.Where(m => m.ConversationId == conversation.Id && m.IsInActiveWindow)
.OrderBy(m => m.MessageIndex)
.ToListAsync(cancellationToken);
// E. Create execution record
var execution = new AgentExecution
{
Id = Guid.NewGuid(),
AgentId = agent.Id,
ConversationId = conversation.Id,
UserPrompt = command.Message,
StartedAt = DateTime.UtcNow,
Status = ExecutionStatus.Running
};
_dbContext.AgentExecutions.Add(execution);
await _dbContext.SaveChangesAsync(cancellationToken);
// F. Execute agent via Ollama
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
OllamaResponse ollamaResponse;
try
{
ollamaResponse = await _ollamaService.GenerateAsync(
agent.ModelEndpoint ?? "http://localhost:11434",
agent.ModelName,
agent.SystemPrompt,
contextMessages,
command.Message,
agent.Temperature,
agent.MaxTokens,
cancellationToken
);
stopwatch.Stop();
}
catch (Exception ex)
{
stopwatch.Stop();
// Update execution to failed status
execution.Status = ExecutionStatus.Failed;
execution.ErrorMessage = ex.Message;
execution.CompletedAt = DateTime.UtcNow;
execution.ExecutionTimeMs = stopwatch.ElapsedMilliseconds;
await _dbContext.SaveChangesAsync(cancellationToken);
throw new InvalidOperationException($"Failed to get response from agent: {ex.Message}", ex);
}
// G. Store agent response
var agentMessage = new ConversationMessage
{
Id = Guid.NewGuid(),
ConversationId = conversation.Id,
Role = MessageRole.Assistant,
Content = ollamaResponse.Content,
MessageIndex = conversation.MessageCount,
IsInActiveWindow = true,
TokenCount = ollamaResponse.OutputTokens,
ExecutionId = execution.Id,
CreatedAt = DateTime.UtcNow
};
_dbContext.ConversationMessages.Add(agentMessage);
conversation.MessageCount++;
conversation.LastMessageAt = DateTime.UtcNow;
// H. Complete execution record
execution.Output = ollamaResponse.Content;
execution.CompletedAt = DateTime.UtcNow;
execution.ExecutionTimeMs = stopwatch.ElapsedMilliseconds;
execution.InputTokens = ollamaResponse.InputTokens;
execution.OutputTokens = ollamaResponse.OutputTokens;
execution.TotalTokens = (ollamaResponse.InputTokens ?? 0) + (ollamaResponse.OutputTokens ?? 0);
execution.Status = ExecutionStatus.Completed;
await _dbContext.SaveChangesAsync(cancellationToken);
// I. Return result
return new SendMessageResult
{
ConversationId = conversation.Id,
MessageId = userMessage.Id,
AgentResponseId = agentMessage.Id,
UserMessage = new MessageDto
{
Content = userMessage.Content,
Timestamp = userMessage.CreatedAt
},
AgentResponse = new AgentResponseDto
{
Content = agentMessage.Content,
Timestamp = agentMessage.CreatedAt,
InputTokens = execution.InputTokens,
OutputTokens = execution.OutputTokens,
EstimatedCost = execution.EstimatedCost
}
};
}
}
/// <summary>
/// Validates SendMessageCommand input
/// </summary>
public class SendMessageCommandValidator : AbstractValidator<SendMessageCommand>
{
public SendMessageCommandValidator()
{
RuleFor(x => x.AgentId)
.NotEmpty()
.WithMessage("Agent ID is required.");
RuleFor(x => x.Message)
.NotEmpty()
.WithMessage("Message is required.")
.MaximumLength(10000)
.WithMessage("Message must not exceed 10,000 characters.");
}
}