diff --git a/docs/product/features/tiered-chat-routing.md b/docs/product/features/tiered-chat-routing.md new file mode 100644 index 0000000..56146e9 --- /dev/null +++ b/docs/product/features/tiered-chat-routing.md @@ -0,0 +1,49 @@ +# Tiered Chat Routing + +## Purpose + +The template routes each chat message through the cheapest reliable mechanism before using an +open-ended model response. This prevents the agent from responding with an announcement that it +will retrieve data without completing the tool call in the current synchronous turn. + +`Core.Application` calls only `IChatMessageRoutingService.ResolveReplyAsync`. It does not reference +MAF/MEAI types, intent matching, tool invocation, or chat formatting. Callers choose either +`ChatRoutingMode.Routed` (the default) or `ChatRoutingMode.Direct` for an explicit diagnostic +bypass to the open agent turn. + +## Cascade + +1. **Tier 1: deterministic intent routing.** `RuleIntentClassifier` evaluates the declarative + `IntentCatalog`. A successful `IntentMatch` is dispatched by `IIntentRouter` to a handler that + executes the appropriate typed application query and formats its current result. The + `IntentMatch` constructor is internal, preventing callers outside the intent implementation + from fabricating a route without classification. +2. **Tier 2: forced-tool inference.** Unmatched messages use the same `AIAgent`, instructions, + and tool catalog with `ChatToolMode.RequireAny`. MAF performs its native function selection and + typed argument binding. Exceptions, empty replies, or a provider that does not honor the mode + are logged at Warning and fall through with no retry. +3. **Tier 3: open agent turn.** The same history is reused in the ordinary `_agent.RunAsync` call, + with default `ChatToolMode.Auto`. This handles conversational requests and requests that are + not tool-shaped. + +There is intentionally no bespoke semantic-classifier or classification-pipeline abstraction. +Tier 2 uses MAF/MEAI's native tool calling, avoiding a duplicate private intent catalog and JSON +argument contract. + +## Tool Policy + +`AgentToolInstructions` in `Presentation.Api/appsettings*.json` holds a global preamble and an +ordered instruction entry for each demo tool: chat sessions, chat messages, actors, and web +search. `AgentInstructionsComposer` reads the current `IOptionsMonitor` value and supplies MAF's +single `ChatOptions.Instructions` string. The global policy forbids promises of future updates: +the agent reports only the current result and users send another message to check later. + +## Extension and Tests + +Add a deterministic phrase to `DefaultIntentCatalogFactory` only after a concrete reliability gap +is observed, and mirror it in the relevant tool description and configuration instruction. Keep +new tools in the same MAF tool catalog so Tier 2 and Tier 3 automatically see them. + +`ChatMessageRoutingServiceTests` verifies the Tier 1 short-circuit, Tier 2 forced-tool path, and +Tier 2 failure fallback to Tier 3. Live-provider end-to-end tests remain necessary to measure real +model tool-selection reliability. \ No newline at end of file diff --git a/src/Core.Application/Abstractions/ChatRoutingMode.cs b/src/Core.Application/Abstractions/ChatRoutingMode.cs new file mode 100644 index 0000000..03db1fc --- /dev/null +++ b/src/Core.Application/Abstractions/ChatRoutingMode.cs @@ -0,0 +1,13 @@ +namespace Goodtocode.AgentFramework.Core.Application.Abstractions; + +/// +/// Selects how a chat reply is resolved. +/// +public enum ChatRoutingMode +{ + /// Uses deterministic routing, forced-tool inference, then an open agent turn. + Routed = 0, + + /// Bypasses deterministic and forced-tool routing for diagnostics or explicit direct-agent requests. + Direct = 1 +} \ No newline at end of file diff --git a/src/Core.Application/Abstractions/IChatMessageRoutingService.cs b/src/Core.Application/Abstractions/IChatMessageRoutingService.cs new file mode 100644 index 0000000..015ff4a --- /dev/null +++ b/src/Core.Application/Abstractions/IChatMessageRoutingService.cs @@ -0,0 +1,17 @@ +namespace Goodtocode.AgentFramework.Core.Application.Abstractions; + +/// +/// Resolves an assistant reply while keeping chat presentation, intent routing, and AI integration +/// outside application command and query handlers. +/// +public interface IChatMessageRoutingService +{ + /// + /// Resolves the reply for in the specified chat session. + /// + Task ResolveReplyAsync( + Guid chatSessionId, + string message, + CancellationToken cancellationToken, + ChatRoutingMode mode = ChatRoutingMode.Routed); +} \ No newline at end of file diff --git a/src/Core.Application/Chats/CreateMyChatMessageCommand.cs b/src/Core.Application/Chats/CreateMyChatMessageCommand.cs index ae60c1d..0e3d617 100644 --- a/src/Core.Application/Chats/CreateMyChatMessageCommand.cs +++ b/src/Core.Application/Chats/CreateMyChatMessageCommand.cs @@ -1,7 +1,5 @@ using Goodtocode.AgentFramework.Core.Domain.Chats; -using Goodtocode.AgentFramework.Core.Application.Governance; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; +using Goodtocode.AgentFramework.Core.Application.Abstractions; namespace Goodtocode.AgentFramework.Core.Application.Chats; @@ -9,14 +7,14 @@ public class CreateMyChatMessageCommand : UserScopedRequest, IRequest> +public class CreateChatMessageCommandHandler(IAgentFrameworkContext context, IChatMessageRoutingService routingService) : IRequestHandler> { - private readonly AIAgent _agent = agent; private readonly IAgentFrameworkContext _context = context; - private readonly ChatGovernanceGate _governanceGate = governanceGate; + private readonly IChatMessageRoutingService _routingService = routingService; public async Task> Handle(CreateMyChatMessageCommand request, CancellationToken cancellationToken) { @@ -32,27 +30,11 @@ public async Task> Handle(CreateMyChatMessageComma ChatGuard.GuardAgainstUnauthorized(chatSession, request!.UserContext!); - var governed = _governanceGate.Enforce( - request.UserContext, + var agentReply = await _routingService.ResolveReplyAsync( chatSession.Id, - request.Message!); - - var chatHistory = new List - { - new(ChatRole.System, governed.PromptContext.SystemInstruction) - }; - foreach (ChatMessageEntity message in chatSession.Messages) - { - chatHistory.Add(new ChatMessage( - role: message.Role == ChatMessageRole.user ? ChatRole.User : ChatRole.Assistant, - content: message.Content)); - } - chatHistory.Add(new ChatMessage(role: ChatRole.User, content: request!.Message!)); - - var agentResponse = await _agent.RunAsync(chatHistory, cancellationToken: cancellationToken); - var response = agentResponse.Messages.LastOrDefault(); - - ChatGuard.GuardAgainstNullAgentResponse(response); + request.Message!, + cancellationToken, + request.RoutingMode); var chatMessage = ChatMessageEntity.Create( ownerId: request.UserContext.OwnerId, @@ -64,8 +46,6 @@ public async Task> Handle(CreateMyChatMessageComma chatSession.Messages.Add(chatMessage); _context.ChatMessages.Add(chatMessage); - var agentReply = (response?.Contents?.LastOrDefault()?.ToString()) ?? string.Empty; - var chatMessageResponse = ChatMessageEntity.Create( ownerId: request.UserContext.OwnerId, tenantId: request.UserContext.TenantId, diff --git a/src/Core.Application/Governance/ChatGovernanceGate.cs b/src/Core.Application/Governance/ChatGovernanceGate.cs index d7e14f0..ed0a897 100644 --- a/src/Core.Application/Governance/ChatGovernanceGate.cs +++ b/src/Core.Application/Governance/ChatGovernanceGate.cs @@ -22,7 +22,7 @@ public class GovernedEvaluationResult } public GovernedEvaluationResult Enforce( - IUserContext userContext, + IRlsContext userContext, Guid chatSessionId, string prompt) { diff --git a/src/Infrastructure.AgentFramework/AgentInstructionsComposer.cs b/src/Infrastructure.AgentFramework/AgentInstructionsComposer.cs new file mode 100644 index 0000000..8c2645b --- /dev/null +++ b/src/Infrastructure.AgentFramework/AgentInstructionsComposer.cs @@ -0,0 +1,46 @@ +using System.Text; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Options; +using Microsoft.Extensions.Options; + +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework; + +/// +/// Composes MAF's single instruction string from reloadable global and per-tool configuration. +/// +public interface IAgentInstructionsComposer +{ + /// Builds the current agent instruction string. + string Compose(); +} + +/// +/// Reads the current options snapshot whenever agent instructions are composed. +/// +public sealed class AgentInstructionsComposer(IOptionsMonitor optionsMonitor) : IAgentInstructionsComposer +{ + private readonly IOptionsMonitor _optionsMonitor = optionsMonitor; + + /// + public string Compose() + { + var options = _optionsMonitor.CurrentValue; + var instructions = new StringBuilder(); + + if (!string.IsNullOrWhiteSpace(options.GlobalPreamble)) + { + instructions.AppendLine(options.GlobalPreamble.Trim()); + instructions.AppendLine(); + } + + foreach (var tool in options.Tools.OrderBy(tool => tool.Order)) + { + if (!string.IsNullOrWhiteSpace(tool.Instructions)) + { + instructions.AppendLine(tool.Instructions.Trim()); + instructions.AppendLine(); + } + } + + return instructions.ToString().TrimEnd(); + } +} \ No newline at end of file diff --git a/src/Infrastructure.AgentFramework/ChatMessageRoutingService.cs b/src/Infrastructure.AgentFramework/ChatMessageRoutingService.cs new file mode 100644 index 0000000..d598d6e --- /dev/null +++ b/src/Infrastructure.AgentFramework/ChatMessageRoutingService.cs @@ -0,0 +1,201 @@ +using System.Text; +using System.Globalization; +using Goodtocode.AgentFramework.Core.Application.Common.Auth; +using Goodtocode.AgentFramework.Core.Application.Chats; +using Goodtocode.AgentFramework.Core.Application.Governance; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; +using Goodtocode.Mediator; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework; + +/// +/// Resolves chat replies through deterministic intent routing, forced-tool inference, and finally +/// the normal open agent turn. All AI and chat-presentation behavior stays here so application +/// handlers depend only on . +/// +public sealed class ChatMessageRoutingService( + AIAgent agent, + ISender sender, + ChatGovernanceGate governanceGate, + IRlsContext rlsContext, + IWebSearchProvider webSearchProvider, + IIntentClassifier intentClassifier, + ILogger logger) : IChatMessageRoutingService, IIntentRouter +{ + private readonly AIAgent _agent = agent; + private readonly ISender _sender = sender; + private readonly ChatGovernanceGate _governanceGate = governanceGate; + private readonly IRlsContext _rlsContext = rlsContext; + private readonly IWebSearchProvider _webSearchProvider = webSearchProvider; + private readonly IIntentClassifier _intentClassifier = intentClassifier; + private readonly ILogger _logger = logger; + private static readonly Action LogForcedToolInferenceFailure = LoggerMessage.Define( + LogLevel.Warning, + new EventId(1, nameof(LogForcedToolInferenceFailure)), + "Forced-tool inference (tier 2) failed; falling back to an open agent turn."); + + /// + public async Task ResolveReplyAsync( + Guid chatSessionId, + string message, + CancellationToken cancellationToken, + ChatRoutingMode mode = ChatRoutingMode.Routed) + { + if (mode == ChatRoutingMode.Routed) + { + var match = _intentClassifier.Classify(message); + var deterministicReply = match is null + ? null + : await RouteAsync(chatSessionId, match, cancellationToken); + if (!string.IsNullOrWhiteSpace(deterministicReply)) + { + return deterministicReply; + } + } + + var chatHistory = await BuildChatHistoryAsync(chatSessionId, message, cancellationToken); + + if (mode == ChatRoutingMode.Routed) + { + var forcedToolReply = await TryResolveViaForcedToolInferenceAsync(chatHistory, cancellationToken); + if (!string.IsNullOrWhiteSpace(forcedToolReply)) + { + return forcedToolReply; + } + } + + var agentResponse = await _agent.RunAsync(chatHistory, cancellationToken: cancellationToken); + var response = agentResponse.Messages.LastOrDefault(); + ChatGuard.GuardAgainstNullAgentResponse(response); + return response!.Contents.LastOrDefault()?.ToString() ?? string.Empty; + } + + private async Task TryResolveViaForcedToolInferenceAsync( + List chatHistory, + CancellationToken cancellationToken) + { + try + { + var runOptions = new ChatClientAgentRunOptions(new ChatOptions { ToolMode = ChatToolMode.RequireAny }); + var agentResponse = await _agent.RunAsync(chatHistory, options: runOptions, cancellationToken: cancellationToken); + return agentResponse.Messages.LastOrDefault()?.Contents.LastOrDefault()?.ToString(); + } + catch (Exception exception) + { + LogForcedToolInferenceFailure(_logger, exception); + return null; + } + } + + private async Task> BuildChatHistoryAsync( + Guid chatSessionId, + string userMessage, + CancellationToken cancellationToken) + { + var chatSession = await _sender.Send(new GetMyChatSessionQuery { Id = chatSessionId }, cancellationToken); + var governed = _governanceGate.Enforce(_rlsContext, chatSessionId, userMessage); + var chatHistory = new List + { + new(ChatRole.System, governed.PromptContext?.SystemInstruction ?? string.Empty) + }; + + foreach (var message in chatSession?.Messages ?? []) + { + chatHistory.Add(new ChatMessage( + message.Role.Equals("user", StringComparison.OrdinalIgnoreCase) ? ChatRole.User : ChatRole.Assistant, + message.Content)); + } + + chatHistory.Add(new ChatMessage(ChatRole.User, userMessage)); + return chatHistory; + } + + /// + public Task RouteAsync(Guid chatSessionId, IntentMatch match, CancellationToken cancellationToken) => match.Intent.Name switch + { + IntentNames.QueryChatSessionsList => QueryChatSessionsListAsync(cancellationToken), + IntentNames.QueryChatMessagesList => QueryChatMessagesListAsync(cancellationToken), + IntentNames.QueryActorById => QueryActorByIdAsync(Guid.Parse(match.Captures!["id"]), cancellationToken), + IntentNames.SearchWeb => QueryWebSearchAsync(match.Captures!["query"], cancellationToken), + _ => throw new InvalidOperationException($"No route registered for intent '{match.Intent.Name}'.") + }; + + private async Task QueryChatSessionsListAsync(CancellationToken cancellationToken) + { + var sessions = (await _sender.Send(new GetMyChatSessionsQuery(), cancellationToken)) + .OrderByDescending(session => session.Timestamp) + .Take(10) + .ToList(); + if (sessions.Count == 0) + { + return "You have no chat sessions yet."; + } + + var reply = new StringBuilder("| # | Title | Chat Session Id | Timestamp (UTC) |\n|---|---|---|---|\n"); + for (var index = 0; index < sessions.Count; index++) + { + var session = sessions[index]; + reply.AppendLine(CultureInfo.InvariantCulture, $"| {index + 1} | {EscapeCell(session.Title)} | `{session.Id:D}` | {session.Timestamp:u} |"); + } + + return reply.ToString(); + } + + private async Task QueryChatMessagesListAsync(CancellationToken cancellationToken) + { + var messages = await _sender.Send(new GetMyChatMessagesPaginatedQuery + { + StartDate = DateTime.UtcNow.AddDays(-7), + EndDate = DateTime.UtcNow.AddSeconds(1), + PageSize = 10 + }, cancellationToken); + if (messages.Items.Count == 0) + { + return "You have no recent chat messages in the last 7 days."; + } + + var reply = new StringBuilder("| # | Chat Session Id | Timestamp (UTC) | Role | Content |\n|---|---|---|---|---|\n"); + foreach (var message in messages.Items.Select((message, index) => new { Message = message, Index = index })) + { + reply.AppendLine(CultureInfo.InvariantCulture, $"| {message.Index + 1} | `{message.Message.ChatSessionId:D}` | {message.Message.Timestamp:u} | {message.Message.Role} | {EscapeCell(message.Message.Content)} |"); + } + + return reply.ToString(); + } + + private async Task QueryActorByIdAsync(Guid actorId, CancellationToken cancellationToken) + { + var actor = await _sender.Send(new Core.Application.Actors.GetOurActorQuery { ActorId = actorId }, cancellationToken); + return actor is null + ? $"No actor was found with id `{actorId:D}`." + : $"Actor `{actor.Id:D}`: {actor.FirstName} {actor.LastName}".TrimEnd(); + } + + private async Task QueryWebSearchAsync(string query, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(query)) + { + return "Please provide a web search query."; + } + + var result = await _webSearchProvider.SearchAsync(query.Trim(), cancellationToken); + if (result.Results.Count == 0) + { + return $"No web search results were found for \"{query}\"."; + } + + var reply = new StringBuilder($"Web search results for \"{query}\":\n\n| # | Title | Snippet | Url |\n|---|---|---|---|\n"); + for (var index = 0; index < result.Results.Count; index++) + { + var item = result.Results[index]; + reply.AppendLine(CultureInfo.InvariantCulture, $"| {index + 1} | {EscapeCell(item.Title)} | {EscapeCell(item.Snippet)} | {item.Url} |"); + } + + return reply.ToString(); + } + + private static string EscapeCell(string? value) => (value ?? string.Empty).Replace("|", "\\|").Replace("\r", " ").Replace("\n", " "); +} \ No newline at end of file diff --git a/src/Infrastructure.AgentFramework/ConfigureServices.cs b/src/Infrastructure.AgentFramework/ConfigureServices.cs index f75cdab..0c58cf6 100644 --- a/src/Infrastructure.AgentFramework/ConfigureServices.cs +++ b/src/Infrastructure.AgentFramework/ConfigureServices.cs @@ -3,6 +3,8 @@ using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Execution; using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Tools; +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Governance; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; @@ -66,11 +68,18 @@ public static IServiceCollection AddAgentFrameworkOpenAIServices(this IServiceCo .ValidateDataAnnotations() .ValidateOnStart(); + services.AddOptions() + .Bind(configuration.GetSection(AgentToolInstructionsOptions.SectionName)); + services.AddSingleton(); + services.AddScoped(); - - // Register intent classification and routing services + services.AddSingleton(DefaultIntentCatalogFactory.Create()); services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); @@ -164,6 +173,7 @@ public static IServiceCollection AddAgentFrameworkOpenAIServices(this IServiceCo { var chatClient = provider.GetRequiredService(); var loggerFactory = provider.GetRequiredService(); + var instructionsComposer = provider.GetRequiredService(); var tools = new List { provider.GetRequiredService(), @@ -178,6 +188,7 @@ public static IServiceCollection AddAgentFrameworkOpenAIServices(this IServiceCo Description = "GoodToCode AgentFramework Copilot", ChatOptions = new Microsoft.Extensions.AI.ChatOptions { + Instructions = instructionsComposer.Compose(), Tools = tools } }; diff --git a/src/Infrastructure.AgentFramework/Options/AgentToolInstructionsOptions.cs b/src/Infrastructure.AgentFramework/Options/AgentToolInstructionsOptions.cs new file mode 100644 index 0000000..b750c0a --- /dev/null +++ b/src/Infrastructure.AgentFramework/Options/AgentToolInstructionsOptions.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; + +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Options; + +/// +/// Defines the routing instructions for one registered agent tool. +/// +public sealed class AgentToolInstructionEntry +{ + /// The registered tool name. + [Required] + public string ToolName { get; set; } = string.Empty; + + /// The tool-specific instruction text. + [Required] + public string Instructions { get; set; } = string.Empty; + + /// The order in which this entry is appended to the agent instruction string. + public int Order { get; set; } +} + +/// +/// Defines reloadable global and per-tool instructions used by the chat agent. +/// +public sealed class AgentToolInstructionsOptions +{ + /// The configuration section name. + public const string SectionName = "AgentToolInstructions"; + + /// Instructions that apply to every tool invocation. + public string GlobalPreamble { get; set; } = string.Empty; + + /// Instructions for individual registered tools. + public List Tools { get; set; } = []; +} \ No newline at end of file diff --git a/src/Infrastructure.AgentFramework/Tools/ActorsTool.cs b/src/Infrastructure.AgentFramework/Tools/ActorsTool.cs index 62ead27..18d7283 100644 --- a/src/Infrastructure.AgentFramework/Tools/ActorsTool.cs +++ b/src/Infrastructure.AgentFramework/Tools/ActorsTool.cs @@ -31,7 +31,9 @@ Looks up a single actor (user/profile record) by their actorId (a GUID). - find the actor whose id is {id} - what is the status of actor {id} - Returns a structured status (Found, Partial, NotFound) with a human-readable message. + Always call this tool for these requests instead of answering from memory, claiming you + lack access, or asking permission first. Returns a structured status (Found, Partial, + NotFound) with a human-readable message. """)] public async Task GetActorByIdAsync(Guid actorId, CancellationToken cancellationToken) { @@ -72,8 +74,10 @@ Searches actors (user/profile records) by name (full or partial match) in the cu - who is {name} - look up a user called {name} - Returns a collection of structured matches with actorId, name, status, and message (or a - single NotFound entry if nothing matches). Never use this to search other tenants. + Always call this tool for these requests instead of answering from memory, claiming you + lack access, or asking permission first. Returns a collection of structured matches with + actorId, name, status, and message (or a single NotFound entry if nothing matches). Never + use this to search other tenants. """)] public async Task> GetActorsByNameAsync(string name, CancellationToken cancellationToken) { diff --git a/src/Infrastructure.AgentFramework/Tools/WebSearchTool.cs b/src/Infrastructure.AgentFramework/Tools/WebSearchTool.cs index f65f76b..5cf330d 100644 --- a/src/Infrastructure.AgentFramework/Tools/WebSearchTool.cs +++ b/src/Infrastructure.AgentFramework/Tools/WebSearchTool.cs @@ -5,7 +5,15 @@ namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Tools; public sealed class WebSearchTool(IServiceProvider serviceProvider) : ScopedAgentTool(serviceProvider) { - [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.")] + [Description( + """ + Searches the public web for current external information and returns ranked results. + + Use this tool for requests such as "search the web for {query}", current news, or current + public documentation when the answer is not available from the user's chat sessions or + actor data. Always call this tool for those requests instead of answering from memory, + claiming you lack access, asking permission, or promising to search later. + """)] public async Task SearchAsync(string query, CancellationToken cancellationToken = default) { return await ResolveScopedAsync(async provider => diff --git a/src/Presentation.Api/appsettings.Development.json b/src/Presentation.Api/appsettings.Development.json index 4aa99cf..e19c9ef 100644 --- a/src/Presentation.Api/appsettings.Development.json +++ b/src/Presentation.Api/appsettings.Development.json @@ -65,5 +65,14 @@ "ImageModelId": "dall-e-3", "AudioModelId": "tts-1", "ApiKey": "" + }, + "AgentToolInstructions": { + "GlobalPreamble": "You are the GoodToCode Agent Framework assistant. When a registered tool applies, call it immediately and answer using its current result. Never claim that you lack access to data a tool can retrieve, ask for permission before a read-only tool call, or announce that you will fetch, look up, retrieve, or provide updates later. This chat completes synchronously: report only the current result, and tell the user to send a new message to check later when needed.", + "Tools": [ + { "ToolName": "MyChatSessionsTool", "Order": 10, "Instructions": "Always call MyChatSessionsTool for requests about the current user's chats, chat sessions, conversations, or chat history." }, + { "ToolName": "MyChatMessagesTool", "Order": 20, "Instructions": "Always call MyChatMessagesTool for requests about the current user's recent messages or message history." }, + { "ToolName": "ActorsTool", "Order": 30, "Instructions": "Always call ActorsTool for actor or user-profile lookups instead of guessing identity details." }, + { "ToolName": "WebSearchTool", "Order": 40, "Instructions": "Call WebSearchTool for current public-web information that is not available from chat sessions or actor data." } + ] } } \ No newline at end of file diff --git a/src/Presentation.Api/appsettings.Production.json b/src/Presentation.Api/appsettings.Production.json index 15f8910..2e0e754 100644 --- a/src/Presentation.Api/appsettings.Production.json +++ b/src/Presentation.Api/appsettings.Production.json @@ -65,5 +65,14 @@ "ImageModelId": "dall-e-3", "AudioModelId": "tts-1", "ApiKey": "" + }, + "AgentToolInstructions": { + "GlobalPreamble": "You are the GoodToCode Agent Framework assistant. When a registered tool applies, call it immediately and answer using its current result. Never claim that you lack access to data a tool can retrieve, ask for permission before a read-only tool call, or announce that you will fetch, look up, retrieve, or provide updates later. This chat completes synchronously: report only the current result, and tell the user to send a new message to check later when needed.", + "Tools": [ + { "ToolName": "MyChatSessionsTool", "Order": 10, "Instructions": "Always call MyChatSessionsTool for requests about the current user's chats, chat sessions, conversations, or chat history." }, + { "ToolName": "MyChatMessagesTool", "Order": 20, "Instructions": "Always call MyChatMessagesTool for requests about the current user's recent messages or message history." }, + { "ToolName": "ActorsTool", "Order": 30, "Instructions": "Always call ActorsTool for actor or user-profile lookups instead of guessing identity details." }, + { "ToolName": "WebSearchTool", "Order": 40, "Instructions": "Call WebSearchTool for current public-web information that is not available from chat sessions or actor data." } + ] } } \ No newline at end of file diff --git a/src/Presentation.Api/appsettings.json b/src/Presentation.Api/appsettings.json index 0c208ae..ce35a6f 100644 --- a/src/Presentation.Api/appsettings.json +++ b/src/Presentation.Api/appsettings.json @@ -4,5 +4,30 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "AgentToolInstructions": { + "GlobalPreamble": "You are the GoodToCode Agent Framework assistant. When a registered tool applies, call it immediately and answer using its current result. Never claim that you lack access to data a tool can retrieve, ask for permission before a read-only tool call, or announce that you will fetch, look up, retrieve, or provide updates later. This chat completes synchronously: report only the current result, and tell the user to send a new message to check later when needed.", + "Tools": [ + { + "ToolName": "MyChatSessionsTool", + "Order": 10, + "Instructions": "Always call MyChatSessionsTool for requests about the current user's chats, chat sessions, conversations, or chat history, including vague requests about previous discussions." + }, + { + "ToolName": "MyChatMessagesTool", + "Order": 20, + "Instructions": "Always call MyChatMessagesTool for requests about the current user's recent messages or message history." + }, + { + "ToolName": "ActorsTool", + "Order": 30, + "Instructions": "Always call ActorsTool for actor or user-profile lookups instead of guessing identity details." + }, + { + "ToolName": "WebSearchTool", + "Order": 40, + "Instructions": "Call WebSearchTool for current public-web information that is not available from chat sessions or actor data." + } + ] } } diff --git a/src/Presentation.Api/appsettings.local.json b/src/Presentation.Api/appsettings.local.json index 24330fa..e88396f 100644 --- a/src/Presentation.Api/appsettings.local.json +++ b/src/Presentation.Api/appsettings.local.json @@ -65,5 +65,14 @@ "ImageModelId": "dall-e-3", "AudioModelId": "tts-1", "ApiKey": "" + }, + "AgentToolInstructions": { + "GlobalPreamble": "You are the GoodToCode Agent Framework assistant. When a registered tool applies, call it immediately and answer using its current result. Never claim that you lack access to data a tool can retrieve, ask for permission before a read-only tool call, or announce that you will fetch, look up, retrieve, or provide updates later. This chat completes synchronously: report only the current result, and tell the user to send a new message to check later when needed.", + "Tools": [ + { "ToolName": "MyChatSessionsTool", "Order": 10, "Instructions": "Always call MyChatSessionsTool for requests about the current user's chats, chat sessions, conversations, or chat history." }, + { "ToolName": "MyChatMessagesTool", "Order": 20, "Instructions": "Always call MyChatMessagesTool for requests about the current user's recent messages or message history." }, + { "ToolName": "ActorsTool", "Order": 30, "Instructions": "Always call ActorsTool for actor or user-profile lookups instead of guessing identity details." }, + { "ToolName": "WebSearchTool", "Order": 40, "Instructions": "Call WebSearchTool for current public-web information that is not available from chat sessions or actor data." } + ] } } \ No newline at end of file diff --git a/src/Tests.Integration/AgentFramework/ChatMessageRoutingServiceTests.cs b/src/Tests.Integration/AgentFramework/ChatMessageRoutingServiceTests.cs new file mode 100644 index 0000000..2045b60 --- /dev/null +++ b/src/Tests.Integration/AgentFramework/ChatMessageRoutingServiceTests.cs @@ -0,0 +1,62 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Microsoft.Agents.AI; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Goodtocode.AgentFramework.Tests.Integration.AgentFramework; + +[TestClass] +public sealed class ChatMessageRoutingServiceTests : TestBase +{ + [TestMethod] + public async Task ResolveReplyAsyncDeterministicIntentSkipsAgentRuns() + { + var router = ServiceProvider.GetRequiredService(); + + var reply = await router.ResolveReplyAsync(Guid.NewGuid(), "List my chat sessions", CancellationToken.None); + + Assert.AreEqual("You have no chat sessions yet.", reply); + Assert.AreEqual(0, agent.RunCount); + } + + [TestMethod] + public async Task ResolveReplyAsyncAmbiguousMessageReturnsForcedToolReply() + { + var router = ServiceProvider.GetRequiredService(); + + var reply = await router.ResolveReplyAsync(Guid.NewGuid(), "Can you help with my saved information?", CancellationToken.None); + + Assert.AreEqual("mock-response", reply); + Assert.AreEqual(1, agent.RunCount); + Assert.IsInstanceOfType(agent.RunOptions[0]); + } + + [TestMethod] + public async Task ResolveReplyAsyncForcedToolFailureFallsThroughToOpenAgentTurn() + { + agent.ThrowOnForcedToolRun = true; + var router = ServiceProvider.GetRequiredService(); + + var reply = await router.ResolveReplyAsync(Guid.NewGuid(), "Can you help with my saved information?", CancellationToken.None); + + Assert.AreEqual("mock-response", reply); + Assert.AreEqual(2, agent.RunCount); + Assert.IsInstanceOfType(agent.RunOptions[0]); + Assert.IsNull(agent.RunOptions[1]); + } + + [TestMethod] + public async Task ResolveReplyAsyncDirectModeSkipsTheFirstTwoTiers() + { + var router = ServiceProvider.GetRequiredService(); + + var reply = await router.ResolveReplyAsync( + Guid.NewGuid(), + "List my chat sessions", + CancellationToken.None, + ChatRoutingMode.Direct); + + Assert.AreEqual("mock-response", reply); + Assert.AreEqual(1, agent.RunCount); + Assert.IsNull(agent.RunOptions[0]); + } +} \ No newline at end of file diff --git a/src/Tests.Integration/Mocks/MockAIAgent.cs b/src/Tests.Integration/Mocks/MockAIAgent.cs index dcfb889..59ca9dc 100644 --- a/src/Tests.Integration/Mocks/MockAIAgent.cs +++ b/src/Tests.Integration/Mocks/MockAIAgent.cs @@ -7,6 +7,9 @@ namespace Goodtocode.AgentFramework.Tests.Integration.Mocks; public class MockAIAgent : AIAgent { public IReadOnlyList LastMessages { get; private set; } = []; + public IReadOnlyList RunOptions { get; private set; } = []; + public int RunCount { get; private set; } + public bool ThrowOnForcedToolRun { get; set; } protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => new(new MockAgentSession("mock-session")); @@ -23,6 +26,13 @@ protected override ValueTask DeserializeSessionCoreAsync(JsonEleme protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { LastMessages = [.. messages]; + RunOptions = [.. RunOptions, options]; + RunCount++; + if (ThrowOnForcedToolRun && options is ChatClientAgentRunOptions) + { + throw new InvalidOperationException("Forced-tool inference failed."); + } + return Task.FromResult(new MockAgentResponse("mock-response")); } diff --git a/src/Tests.Integration/TestBase.cs b/src/Tests.Integration/TestBase.cs index 939c5c2..a42aaa4 100644 --- a/src/Tests.Integration/TestBase.cs +++ b/src/Tests.Integration/TestBase.cs @@ -1,8 +1,12 @@ using Goodtocode.AgentFramework.Core.Application; using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Governance; using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework; using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Options; using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Execution; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Providers; using Goodtocode.AgentFramework.Infrastructure.SqlServer.Persistence; using Goodtocode.AgentFramework.Tests.Integration.Mocks; using Microsoft.Agents.AI; @@ -65,6 +69,13 @@ public TestBase() services.AddApplicationServices(); services.AddScoped(); + services.AddSingleton(DefaultIntentCatalogFactory.Create()); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); services.AddDbContext(options => options.UseInMemoryDatabase($"AgentFrameworkContext-{Guid.NewGuid()}")