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
+
+