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()}")