Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/product/features/tiered-chat-routing.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions src/Core.Application/Abstractions/ChatRoutingMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Goodtocode.AgentFramework.Core.Application.Abstractions;

/// <summary>
/// Selects how a chat reply is resolved.
/// </summary>
public enum ChatRoutingMode
{
/// <summary>Uses deterministic routing, forced-tool inference, then an open agent turn.</summary>
Routed = 0,

/// <summary>Bypasses deterministic and forced-tool routing for diagnostics or explicit direct-agent requests.</summary>
Direct = 1
}
17 changes: 17 additions & 0 deletions src/Core.Application/Abstractions/IChatMessageRoutingService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Goodtocode.AgentFramework.Core.Application.Abstractions;

/// <summary>
/// Resolves an assistant reply while keeping chat presentation, intent routing, and AI integration
/// outside application command and query handlers.
/// </summary>
public interface IChatMessageRoutingService
{
/// <summary>
/// Resolves the reply for <paramref name="message"/> in the specified chat session.
/// </summary>
Task<string> ResolveReplyAsync(
Guid chatSessionId,
string message,
CancellationToken cancellationToken,
ChatRoutingMode mode = ChatRoutingMode.Routed);
}
36 changes: 8 additions & 28 deletions src/Core.Application/Chats/CreateMyChatMessageCommand.cs
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
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;

public class CreateMyChatMessageCommand : UserScopedRequest, IRequest<CommandResult<ChatMessageDto>>
{
public Guid ChatSessionId { get; set; }
public string? Message { get; set; }
public ChatRoutingMode RoutingMode { get; set; } = ChatRoutingMode.Routed;

}

public class CreateChatMessageCommandHandler(AIAgent agent, IAgentFrameworkContext context, ChatGovernanceGate governanceGate) : IRequestHandler<CreateMyChatMessageCommand, CommandResult<ChatMessageDto>>
public class CreateChatMessageCommandHandler(IAgentFrameworkContext context, IChatMessageRoutingService routingService) : IRequestHandler<CreateMyChatMessageCommand, CommandResult<ChatMessageDto>>
{
private readonly AIAgent _agent = agent;
private readonly IAgentFrameworkContext _context = context;
private readonly ChatGovernanceGate _governanceGate = governanceGate;
private readonly IChatMessageRoutingService _routingService = routingService;

public async Task<CommandResult<ChatMessageDto>> Handle(CreateMyChatMessageCommand request, CancellationToken cancellationToken)
{
Expand All @@ -32,27 +30,11 @@ public async Task<CommandResult<ChatMessageDto>> 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<ChatMessage>
{
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,
Expand All @@ -64,8 +46,6 @@ public async Task<CommandResult<ChatMessageDto>> 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,
Expand Down
2 changes: 1 addition & 1 deletion src/Core.Application/Governance/ChatGovernanceGate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
public PromptContext? PromptContext { get; set; }
}

public GovernedEvaluationResult Enforce(

Check warning on line 24 in src/Core.Application/Governance/ChatGovernanceGate.cs

View workflow job for this annotation

GitHub Actions / Web, API and SQL CI (10.x)

Member 'Enforce' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
IUserContext userContext,
IRlsContext userContext,
Guid chatSessionId,
string prompt)
{
Expand Down
46 changes: 46 additions & 0 deletions src/Infrastructure.AgentFramework/AgentInstructionsComposer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System.Text;
using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Options;
using Microsoft.Extensions.Options;

namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework;

/// <summary>
/// Composes MAF's single instruction string from reloadable global and per-tool configuration.
/// </summary>
public interface IAgentInstructionsComposer
{
/// <summary>Builds the current agent instruction string.</summary>
string Compose();
}

/// <summary>
/// Reads the current options snapshot whenever agent instructions are composed.
/// </summary>
public sealed class AgentInstructionsComposer(IOptionsMonitor<AgentToolInstructionsOptions> optionsMonitor) : IAgentInstructionsComposer
{
private readonly IOptionsMonitor<AgentToolInstructionsOptions> _optionsMonitor = optionsMonitor;

/// <inheritdoc />
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();
}
}
Loading
Loading