diff --git a/Svrnty.Sample/AI/Commands/ExecuteAgentCommand.cs b/Svrnty.Sample/AI/Commands/ExecuteAgentCommand.cs new file mode 100644 index 0000000..a8e72a8 --- /dev/null +++ b/Svrnty.Sample/AI/Commands/ExecuteAgentCommand.cs @@ -0,0 +1,17 @@ +using Svrnty.CQRS.Grpc.Abstractions.Attributes; + +namespace Svrnty.Sample.AI.Commands; + +/// +/// Command to execute an AI agent with a user prompt +/// +/// The user's input prompt for the AI agent +[GrpcIgnore] // MVP: HTTP-only endpoint, gRPC support can be added later +public record ExecuteAgentCommand(string Prompt); + +/// +/// Response from the AI agent execution +/// +/// The AI agent's response content +/// Unique identifier for this conversation +public record AgentResponse(string Content, Guid ConversationId); diff --git a/Svrnty.Sample/AI/Commands/ExecuteAgentCommandHandler.cs b/Svrnty.Sample/AI/Commands/ExecuteAgentCommandHandler.cs new file mode 100644 index 0000000..d874593 --- /dev/null +++ b/Svrnty.Sample/AI/Commands/ExecuteAgentCommandHandler.cs @@ -0,0 +1,107 @@ +using Microsoft.Extensions.AI; +using Svrnty.CQRS.Abstractions; +using Svrnty.Sample.AI.Tools; + +namespace Svrnty.Sample.AI.Commands; + +/// +/// Handler for executing AI agent commands with function calling support +/// +public class ExecuteAgentCommandHandler(IChatClient chatClient) : ICommandHandler +{ + // In-memory conversation store (replace with proper persistence in production) + private static readonly Dictionary> ConversationStore = new(); + private const int MaxFunctionCallIterations = 10; // Prevent infinite loops + + public async Task HandleAsync( + ExecuteAgentCommand command, + CancellationToken cancellationToken = default) + { + var conversationId = Guid.NewGuid(); + var messages = new List + { + new(ChatRole.User, command.Prompt) + }; + + // Register available tools + var mathTool = new MathTool(); + var dbTool = new DatabaseQueryTool(); + var tools = new List + { + // Math tools + AIFunctionFactory.Create(mathTool.Add), + AIFunctionFactory.Create(mathTool.Multiply), + + // Business tools + AIFunctionFactory.Create(dbTool.GetMonthlyRevenue), + AIFunctionFactory.Create(dbTool.GetRevenueRange), + AIFunctionFactory.Create(dbTool.CountCustomersByState), + AIFunctionFactory.Create(dbTool.CountCustomersByTier), + AIFunctionFactory.Create(dbTool.GetCustomers) + }; + + var options = new ChatOptions + { + ModelId = "qwen2.5-coder:7b", + Tools = tools.Cast().ToList() + }; + + // Create function lookup by name for invocation + var functionLookup = tools.ToDictionary( + f => f.Metadata.Name, + f => f, + StringComparer.OrdinalIgnoreCase + ); + + // Initial AI completion + var completion = await chatClient.CompleteAsync(messages, options, cancellationToken); + messages.Add(completion.Message); + + // Function calling loop - continue until no more function calls or max iterations + var iterations = 0; + while (completion.Message.Contents.OfType().Any() && iterations < MaxFunctionCallIterations) + { + iterations++; + + // Execute all function calls from the response + foreach (var functionCall in completion.Message.Contents.OfType()) + { + try + { + // Look up the actual function + if (!functionLookup.TryGetValue(functionCall.Name, out var function)) + { + throw new InvalidOperationException($"Function '{functionCall.Name}' not found"); + } + + // Invoke the function with arguments + var result = await function.InvokeAsync(functionCall.Arguments, cancellationToken); + + // Add function result to conversation as a tool message + var toolMessage = new ChatMessage(ChatRole.Tool, result?.ToString() ?? "null"); + toolMessage.Contents.Add(new FunctionResultContent(functionCall.CallId, functionCall.Name, result)); + messages.Add(toolMessage); + } + catch (Exception ex) + { + // Handle function call errors gracefully + var errorMessage = new ChatMessage(ChatRole.Tool, $"Error executing {functionCall.Name}: {ex.Message}"); + errorMessage.Contents.Add(new FunctionResultContent(functionCall.CallId, functionCall.Name, $"Error: {ex.Message}")); + messages.Add(errorMessage); + } + } + + // Get next completion with function results + completion = await chatClient.CompleteAsync(messages, options, cancellationToken); + messages.Add(completion.Message); + } + + // Store conversation for potential future use + ConversationStore[conversationId] = messages; + + return new AgentResponse( + Content: completion.Message.Text ?? "No response", + ConversationId: conversationId + ); + } +} diff --git a/Svrnty.Sample/AI/OllamaClient.cs b/Svrnty.Sample/AI/OllamaClient.cs new file mode 100644 index 0000000..d55f8c9 --- /dev/null +++ b/Svrnty.Sample/AI/OllamaClient.cs @@ -0,0 +1,170 @@ +using Microsoft.Extensions.AI; +using System.Text.Json; + +namespace Svrnty.Sample.AI; + +public sealed class OllamaClient(HttpClient http) : IChatClient +{ + public ChatClientMetadata Metadata => new("ollama", new Uri("http://localhost:11434")); + + public async Task CompleteAsync( + IList messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + // Build messages array including tool results + var ollamaMessages = messages.Select(m => new + { + role = m.Role.ToString().ToLower(), + content = m.Text ?? string.Empty, + tool_call_id = m.Contents.OfType().FirstOrDefault()?.CallId + }).ToList(); + + // Build payload with optional tools + var payload = new Dictionary + { + ["model"] = options?.ModelId ?? "qwen2.5-coder:7b", + ["messages"] = ollamaMessages, + ["stream"] = false + }; + + // Add tools if provided + if (options?.Tools is { Count: > 0 }) + { + payload["tools"] = options.Tools.Select(BuildToolDefinition).ToArray(); + } + + var response = await http.PostAsJsonAsync("/api/chat", payload, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(cancellationToken); + var messageElement = json!.RootElement.GetProperty("message"); + + var content = messageElement.TryGetProperty("content", out var contentProp) + ? contentProp.GetString() ?? "" + : ""; + + var chatMessage = new ChatMessage(ChatRole.Assistant, content); + + // Parse tool calls - handle both OpenAI format and text-based format + if (messageElement.TryGetProperty("tool_calls", out var toolCallsElement)) + { + // OpenAI-style tool_calls format + foreach (var toolCall in toolCallsElement.EnumerateArray()) + { + var function = toolCall.GetProperty("function"); + var functionName = function.GetProperty("name").GetString()!; + var argumentsJson = function.GetProperty("arguments"); + + var arguments = ParseArguments(argumentsJson); + + chatMessage.Contents.Add(new FunctionCallContent( + callId: Guid.NewGuid().ToString(), + name: functionName, + arguments: arguments + )); + } + } + else if (!string.IsNullOrWhiteSpace(content) && content.TrimStart().StartsWith("{")) + { + // Text-based function call format (some models like qwen2.5-coder return this) + try + { + var functionCallJson = JsonDocument.Parse(content); + if (functionCallJson.RootElement.TryGetProperty("name", out var nameProp) && + functionCallJson.RootElement.TryGetProperty("arguments", out var argsProp)) + { + var functionName = nameProp.GetString()!; + var arguments = ParseArguments(argsProp); + + chatMessage.Contents.Add(new FunctionCallContent( + callId: Guid.NewGuid().ToString(), + name: functionName, + arguments: arguments + )); + } + } + catch + { + // Not a function call, just regular content + } + } + + return new ChatCompletion(chatMessage); + } + + private static Dictionary ParseArguments(JsonElement argumentsJson) + { + var arguments = new Dictionary(); + foreach (var prop in argumentsJson.EnumerateObject()) + { + arguments[prop.Name] = prop.Value.ValueKind switch + { + JsonValueKind.Number => prop.Value.GetDouble(), + JsonValueKind.String => prop.Value.GetString(), + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => prop.Value.ToString() + }; + } + return arguments; + } + + private static object BuildToolDefinition(AITool tool) + { + var functionInfo = tool.GetType().GetProperty("Metadata")?.GetValue(tool) as AIFunctionMetadata + ?? throw new InvalidOperationException("Tool must have Metadata property"); + + var parameters = new Dictionary + { + ["type"] = "object", + ["properties"] = functionInfo.Parameters.ToDictionary( + p => p.Name, + p => new Dictionary + { + ["type"] = GetJsonType(p.ParameterType), + ["description"] = p.Description ?? "" + } + ), + ["required"] = functionInfo.Parameters + .Where(p => p.IsRequired) + .Select(p => p.Name) + .ToArray() + }; + + return new + { + type = "function", + function = new + { + name = functionInfo.Name, + description = functionInfo.Description ?? "", + parameters + } + }; + } + + private static string GetJsonType(Type type) + { + if (type == typeof(int) || type == typeof(long) || type == typeof(short)) + return "integer"; + if (type == typeof(float) || type == typeof(double) || type == typeof(decimal)) + return "number"; + if (type == typeof(bool)) + return "boolean"; + if (type == typeof(string)) + return "string"; + return "object"; + } + + public IAsyncEnumerable CompleteStreamingAsync( + IList messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotImplementedException("Streaming not supported in MVP"); + + public TService? GetService(object? key = null) where TService : class + => this as TService; + + public void Dispose() { } +} diff --git a/Svrnty.Sample/AI/Tools/DatabaseQueryTool.cs b/Svrnty.Sample/AI/Tools/DatabaseQueryTool.cs new file mode 100644 index 0000000..03fc232 --- /dev/null +++ b/Svrnty.Sample/AI/Tools/DatabaseQueryTool.cs @@ -0,0 +1,88 @@ +using System.ComponentModel; + +namespace Svrnty.Sample.AI.Tools; + +/// +/// Business tool for querying database and business metrics +/// +public class DatabaseQueryTool +{ + // Simulated data - replace with actual database queries via CQRS + private static readonly Dictionary MonthlyRevenue = new() + { + ["2025-01"] = 50000m, + ["2025-02"] = 45000m, + ["2025-03"] = 55000m, + ["2025-04"] = 62000m, + ["2025-05"] = 58000m, + ["2025-06"] = 67000m + }; + + private static readonly List<(string Name, string State, string Tier)> Customers = new() + { + ("Acme Corp", "California", "Enterprise"), + ("TechStart Inc", "California", "Startup"), + ("BigRetail LLC", "Texas", "Enterprise"), + ("SmallShop", "New York", "SMB"), + ("MegaCorp", "California", "Enterprise") + }; + + [Description("Get revenue for a specific month in YYYY-MM format")] + public decimal GetMonthlyRevenue( + [Description("Month in YYYY-MM format, e.g., 2025-01")] string month) + { + return MonthlyRevenue.TryGetValue(month, out var revenue) ? revenue : 0m; + } + + [Description("Calculate total revenue between two months (inclusive)")] + public decimal GetRevenueRange( + [Description("Start month in YYYY-MM format")] string startMonth, + [Description("End month in YYYY-MM format")] string endMonth) + { + var total = 0m; + foreach (var kvp in MonthlyRevenue) + { + if (string.Compare(kvp.Key, startMonth, StringComparison.Ordinal) >= 0 && + string.Compare(kvp.Key, endMonth, StringComparison.Ordinal) <= 0) + { + total += kvp.Value; + } + } + return total; + } + + [Description("Count customers by state")] + public int CountCustomersByState( + [Description("US state name, e.g., California")] string state) + { + return Customers.Count(c => c.State.Equals(state, StringComparison.OrdinalIgnoreCase)); + } + + [Description("Count customers by tier (Enterprise, SMB, Startup)")] + public int CountCustomersByTier( + [Description("Customer tier: Enterprise, SMB, or Startup")] string tier) + { + return Customers.Count(c => c.Tier.Equals(tier, StringComparison.OrdinalIgnoreCase)); + } + + [Description("Get list of customer names by state and tier")] + public string GetCustomers( + [Description("US state name, optional")] string? state = null, + [Description("Customer tier, optional")] string? tier = null) + { + var filtered = Customers.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(state)) + { + filtered = filtered.Where(c => c.State.Equals(state, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(tier)) + { + filtered = filtered.Where(c => c.Tier.Equals(tier, StringComparison.OrdinalIgnoreCase)); + } + + var names = filtered.Select(c => c.Name).ToList(); + return names.Any() ? string.Join(", ", names) : "No customers found"; + } +} diff --git a/Svrnty.Sample/AI/Tools/MathTool.cs b/Svrnty.Sample/AI/Tools/MathTool.cs new file mode 100644 index 0000000..74ca7db --- /dev/null +++ b/Svrnty.Sample/AI/Tools/MathTool.cs @@ -0,0 +1,12 @@ +using System.ComponentModel; + +namespace Svrnty.Sample.AI.Tools; + +public class MathTool +{ + [Description("Add two numbers together")] + public int Add(int a, int b) => a + b; + + [Description("Multiply two numbers together")] + public int Multiply(int a, int b) => a * b; +} diff --git a/Svrnty.Sample/Program.cs b/Svrnty.Sample/Program.cs index dda0ff4..759c616 100644 --- a/Svrnty.Sample/Program.cs +++ b/Svrnty.Sample/Program.cs @@ -1,8 +1,11 @@ using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.AI; using Svrnty.CQRS; using Svrnty.CQRS.FluentValidation; using Svrnty.CQRS.Grpc; using Svrnty.Sample; +using Svrnty.Sample.AI; +using Svrnty.Sample.AI.Commands; using Svrnty.CQRS.MinimalApi; using Svrnty.CQRS.DynamicQuery; using Svrnty.CQRS.Abstractions; @@ -24,11 +27,20 @@ builder.Services.AddTransient(); builder.Services.AddDynamicQueryWithProvider(); +// Register Ollama AI client +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("http://localhost:11434"); +}); + // Register commands and queries with validators builder.Services.AddCommand(); builder.Services.AddCommand(); builder.Services.AddQuery(); +// Register AI agent command +builder.Services.AddCommand(); + // Configure CQRS with fluent API builder.Services.AddSvrntyCqrs(cqrs => { @@ -55,6 +67,34 @@ app.UseSvrntyCqrs(); app.UseSwagger(); app.UseSwaggerUI(); +// Health check endpoints +app.MapGet("/health", () => Results.Ok(new { status = "healthy" })) + .WithTags("Health"); + +app.MapGet("/health/ready", async (IChatClient client) => +{ + try + { + var testMessages = new List { new(ChatRole.User, "ping") }; + var response = await client.CompleteAsync(testMessages); + return Results.Ok(new + { + status = "ready", + ollama = "connected", + responseTime = response != null ? "ok" : "slow" + }); + } + catch (Exception ex) + { + return Results.Json(new + { + status = "not_ready", + ollama = "disconnected", + error = ex.Message + }, statusCode: 503); + } +}) +.WithTags("Health"); Console.WriteLine("Auto-Generated gRPC Server with Reflection, Validation, MinimalApi and Swagger"); Console.WriteLine("gRPC (HTTP/2): http://localhost:6000"); diff --git a/Svrnty.Sample/Svrnty.Sample.csproj b/Svrnty.Sample/Svrnty.Sample.csproj index 2410a08..34bca67 100644 --- a/Svrnty.Sample/Svrnty.Sample.csproj +++ b/Svrnty.Sample/Svrnty.Sample.csproj @@ -20,6 +20,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + +