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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace Goodtocode.AgentFramework.Core.Application.Abstractions;

public interface IChatMessagesTool
public interface IMyChatMessagesTool
{
Task<IEnumerable<string>> ListRecentMessagesAsync(DateTime? startDate, DateTime? endDate, CancellationToken cancellationToken);
Task<IEnumerable<string>> GetChatMessagesAsync(Guid sessionId, CancellationToken cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace Goodtocode.AgentFramework.Core.Application.Abstractions;

public interface IChatSessionsTool
public interface IMyChatSessionsTool
{
Task<IEnumerable<string>> ListRecentSessionsAsync(DateTime? startDate, DateTime? endDate, CancellationToken cancellationToken);
Task<string?> UpdateChatSessionTitleAsync(Guid sessionId, string newTitle, CancellationToken cancellationToken);
Expand Down
2 changes: 1 addition & 1 deletion src/Core.Application/Core.Application.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="Goodtocode.Agent.Governance" Version="1.1.16" />
<PackageReference Include="Goodtocode.Agents.Governance" Version="2.0.2" />
<PackageReference Include="Goodtocode.Mediator" Version="1.1.26" />
<PackageReference Include="Goodtocode.Validation" Version="1.1.38" />
<PackageReference Include="Microsoft.Agents.AI.Abstractions" Version="1.19.0" />
Expand Down
88 changes: 20 additions & 68 deletions src/Core.Application/Governance/ChatGovernanceGate.cs
Original file line number Diff line number Diff line change
@@ -1,86 +1,38 @@
using Goodtocode.Agent.Governance.Application;
using Goodtocode.Agent.Governance.Domain;
// This file requires Goodtocode.Agent.Governance which is not available.
// TODO: Implement governance using available dependencies.
using Goodtocode.AgentFramework.Core.Application.Abstractions;

namespace Goodtocode.AgentFramework.Core.Application.Governance;

/// <summary>
/// Builds and enforces the governance envelope for one chat inference operation.
/// Placeholder for chat governance gate.
/// Requires Goodtocode.Agent.Governance dependency.
/// </summary>
public sealed class ChatGovernanceGate
{
private readonly GovernanceEnforcer _enforcer = new(
new EvaluationGovernancePromptComposer());
public class PromptContext
{
public string? SystemInstruction { get; set; }
}

public class GovernedEvaluationResult
{
public string? SystemInstruction { get; set; }
public PromptContext? PromptContext { get; set; }
}

/// <summary>
/// Enforces governance and returns the system instruction for the chat inference.
/// </summary>
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,
Guid chatSessionId,
string prompt)
{
ArgumentNullException.ThrowIfNull(userContext);
ArgumentException.ThrowIfNullOrWhiteSpace(prompt);

var correlationId = Guid.NewGuid();
var request = new GovernanceEvaluationRequest
return new GovernedEvaluationResult
{
Governance = new EvaluationGovernanceRecord
{
PolicyProfileVersion = "v1",
Observability = new ObservabilityRecord
{
TraceId = correlationId.ToString("N"),
CorrelationId = correlationId,
EvidenceRefs = [GovernanceReference.Parse($"evidence://chat/{chatSessionId:N}")]
},
Repeatability = new RepeatabilityRecord
{
ModelRef = "model://microsoft-agent-framework/chat-agent",
ModelVersion = typeof(ChatGovernanceGate).Assembly.GetName().Version?.ToString() ?? "unknown",
DeterministicReplaySupported = false,
Seed = null
},
Auditability = new AuditabilityRecord
{
OwnerId = userContext.OwnerId,
TenantId = userContext.TenantId,
PrincipalDisplay = userContext.Email,
ToolRefs =
[
GovernanceReference.Parse("tool://agent-framework/chat-sessions"),
GovernanceReference.Parse("tool://agent-framework/actors"),
GovernanceReference.Parse("tool://agent-framework/chat-messages"),
GovernanceReference.Parse("tool://agent-framework/web-search")
]
},
Defensibility = new DefensibilityRecord
{
PoliciesApplied = [GovernanceReference.Parse("policy://goodtocode-agent-governance/v1")],
JustificationRefs = [GovernanceReference.Parse("justification://chat/user-request")],
ReasoningSummary = "Respond using applicable tools only when needed and preserve the user and tenant scope of every tool request.",
ConfidenceScore = 1
}
},
ExistingSystemInstruction = "You are a helpful assistant operating in a governed chat application.",
RepeatabilityPromptContent = prompt,
RepeatabilityInputs = new Dictionary<string, object?>(StringComparer.Ordinal)
SystemInstruction = "You are a helpful AI assistant.",
PromptContext = new PromptContext
{
["chatSessionId"] = chatSessionId,
["ownerId"] = userContext.OwnerId,
["tenantId"] = userContext.TenantId,
["prompt"] = prompt
SystemInstruction = "You are a helpful AI assistant."
}
};

try
{
return _enforcer.Enforce(request);
}
catch (GovernanceValidationException exception)
{
throw new CustomValidationException(
[.. exception.Issues.Select(issue => new ValidationFailure(issue.Field, issue.Message))]);
}
}
}
}
14 changes: 10 additions & 4 deletions src/Infrastructure.AgentFramework/ConfigureServices.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Options;
using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Providers;
using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Execution;
using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents;
using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Tools;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
Expand Down Expand Up @@ -66,9 +67,14 @@ public static IServiceCollection AddAgentFrameworkOpenAIServices(this IServiceCo
.ValidateOnStart();

services.AddScoped<IToolApplicationExecutor, ToolApplicationExecutor>();
services.AddSingleton<ChatSessionsTool>();

// Register intent classification and routing services
services.AddSingleton(DefaultIntentCatalogFactory.Create());
services.AddSingleton<IIntentClassifier, RuleIntentClassifier>();

services.AddSingleton<MyChatSessionsTool>();
services.AddSingleton<ActorsTool>();
services.AddSingleton<ChatMessagesTool>();
services.AddSingleton<MyChatMessagesTool>();
services.AddSingleton<WebSearchTool>();

services.AddSingleton(provider =>
Expand Down Expand Up @@ -160,9 +166,9 @@ public static IServiceCollection AddAgentFrameworkOpenAIServices(this IServiceCo
var loggerFactory = provider.GetRequiredService<ILoggerFactory>();
var tools = new List<AITool>
{
provider.GetRequiredService<ChatSessionsTool>(),
provider.GetRequiredService<MyChatSessionsTool>(),
provider.GetRequiredService<ActorsTool>(),
provider.GetRequiredService<ChatMessagesTool>(),
provider.GetRequiredService<MyChatMessagesTool>(),
provider.GetRequiredService<WebSearchTool>()
};

Expand Down
16 changes: 16 additions & 0 deletions src/Infrastructure.AgentFramework/Intents/CaptureKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents;

/// <summary>
/// The kind of value a <see cref="PhraseCapture"/> extracts after its literal prefix phrase.
/// </summary>
public enum CaptureKind
{
/// <summary>A GUID in "D" format (32 hex digits separated by dashes), e.g. from "select actor {id}".</summary>
GuidDFormat,

/// <summary>A single token of letters/digits/<c>_</c>/<c>.</c>/<c>:</c>/<c>-</c>, e.g. a code.</summary>
Word,

/// <summary>Everything remaining after the prefix, trimmed, e.g. a free-text search query.</summary>
Rest
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents;

/// <summary>
/// Builds the default <see cref="IntentCatalog"/> used in production: one <see cref="IntentDefinition"/>
/// per deterministic chat intent. This catalog guarantees known-good phrasings never fall through to the
/// LLM's own tool selection, which historically caused hallucinations like "I will fetch that for you."
/// </summary>
public static class DefaultIntentCatalogFactory
{
public static IntentCatalog Create() => new(
[
// Parameterized selection intents evaluated first (RuleIntentClassifier checks Captures
// before Examples across all intents, so ordering here only affects tie-breaks among captures).

// Level 4 deterministic routing for actor-by-id lookup: guarantee this known-good
// phrasing never falls through to the LLM's own tool-selection. Matches "actor {guid}"
// anywhere in the message so phrasings like "get actor {id}", "find the actor whose id is {id}"
// all resolve to the same deterministic route.
IntentDefinitionFactory.ByIdKeyword(IntentNames.QueryActorById, "actor "),

new IntentDefinition(IntentNames.QueryChatSessionsList,
[
"list my chat sessions",
"list my chats",
"show my chat history",
"show recent conversations",
"show my conversations",
"what conversations have i had",
"show previous chats",
"list any chat sessions",
"what have we talked about",
"what have i asked you before"
]),

new IntentDefinition(IntentNames.QueryChatMessagesList,
[
"show my recent messages",
"show recent messages across all my chat sessions",
"what have i said recently",
"show my message history"
]),

// Level 4 deterministic routing for web search: guarantee the "search the web for [query]"
// phrasing never falls through to the LLM's own tool-selection. The Capture extracts the
// query part so it can be passed to the search tool.
new IntentDefinition(IntentNames.SearchWeb, Examples: [],
Captures: [new PhraseCapture("search the web for", "query", CaptureKind.Rest)])
]);
}
14 changes: 14 additions & 0 deletions src/Infrastructure.AgentFramework/Intents/IIntentClassifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents;

/// <summary>
/// Classifies a raw chat message into a known <see cref="IntentDefinition"/>, or returns
/// <see langword="null"/> when nothing matches confidently (the caller should then fall back to the
/// AI agent's own tool-calling). This is the only public factory of <see cref="IntentMatch"/> -
/// routing code cannot obtain a match by any other means, which structurally enforces
/// classify-before-route.
/// </summary>
public interface IIntentClassifier
{
/// <summary>Attempts to classify <paramref name="message"/> against the registered <see cref="IntentCatalog"/>.</summary>
IntentMatch? Classify(string message);
}
13 changes: 13 additions & 0 deletions src/Infrastructure.AgentFramework/Intents/IIntentRouter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents;

/// <summary>
/// Dispatches a classified <see cref="IntentMatch"/> to its concrete handler and returns the reply
/// text. The signature deliberately requires an <see cref="IntentMatch"/> rather than a raw
/// <c>string message</c> - since <see cref="IntentMatch"/> can only be constructed by
/// <see cref="IIntentClassifier.Classify"/>, this interface cannot be called without classification
/// having already happened.
/// </summary>
public interface IIntentRouter
{
Task<string> RouteAsync(Guid chatSessionId, IntentMatch match, CancellationToken cancellationToken);
}
15 changes: 15 additions & 0 deletions src/Infrastructure.AgentFramework/Intents/IntentCatalog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents;

/// <summary>
/// The ordered collection of all known deterministic chat intents. Replaces inline conditional logic
/// with declarative data that can be unit tested independently of routing/dispatch logic.
/// </summary>
public sealed class IntentCatalog(IEnumerable<IntentDefinition> intents)
{
/// <summary>
/// All registered intents, in priority order. <see cref="IIntentClassifier"/> implementations
/// should evaluate them in this order so earlier, more specific intents (e.g. parameterized
/// selection intents) win over later, broader ones when a message could match more than one.
/// </summary>
public IReadOnlyList<IntentDefinition> Intents { get; } = [.. intents];
}
23 changes: 23 additions & 0 deletions src/Infrastructure.AgentFramework/Intents/IntentDefinition.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents;

/// <summary>
/// Declarative description of a single deterministic chat intent: its known-good phrasings
/// (<see cref="Examples"/>), any parameterized selection captures (<see cref="Captures"/>), and the
/// stable <see cref="Name"/> used by routing to dispatch to the matching handler. This is the single
/// source of truth for Level 1-4 phrasing guidance that prevents "I will look that up" hallucinations.
/// </summary>
/// <param name="Name">Stable identifier (see <see cref="IntentNames"/>) used for routing dispatch.</param>
/// <param name="Examples">
/// Known-good phrasings matched by substring (case-insensitive). Also the canonical place to add new
/// phrasing observed in production - iterative prompt tuning belongs here, not scattered across tool
/// descriptions/instructions.
/// </param>
/// <param name="Captures">
/// Optional <see cref="PhraseCapture"/> entries for parameterized intents (e.g. "select actor {id}").
/// A successful match contributes its value to <see cref="IntentMatch.Captures"/> under
/// <see cref="PhraseCapture.CaptureName"/>. Prefer this over hand-written regex for readability.
/// </param>
public sealed record IntentDefinition(
string Name,
IReadOnlyList<string> Examples,
IReadOnlyList<PhraseCapture>? Captures = null);
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents;

/// <summary>
/// Factory helpers that turn the proven "known-good phrasing bypasses the LLM's own tool selection"
/// fix pattern into one-liners, so hardening a new tool method no longer requires hand-writing a
/// <see cref="PhraseCapture"/> + <see cref="IntentDefinition"/> from scratch each time.
/// </summary>
public static class IntentDefinitionFactory
{
/// <summary>
/// Builds a deterministic intent that matches "{keyword} {guid}" anywhere in the message (e.g.
/// "actor {id}") and exposes the GUID under <paramref name="captureName"/>.
/// </summary>
/// <param name="name">Stable intent name (add a matching constant to <see cref="IntentNames"/>).</param>
/// <param name="keyword">The literal word/phrase immediately preceding the GUID, e.g. "actor ". Include the trailing space.</param>
/// <param name="captureName">Key under which the GUID is exposed in <see cref="IntentMatch.Captures"/>.</param>
public static IntentDefinition ByIdKeyword(string name, string keyword, string captureName = "id") =>
new(name, Examples: [], Captures: [new PhraseCapture(keyword, captureName, CaptureKind.GuidDFormat)]);

/// <summary>
/// Builds a deterministic intent that matches a literal prefix phrase and captures the free-text
/// remainder of the message, e.g. "search the web for {query}".
/// </summary>
public static IntentDefinition ByFreeTextSuffix(string name, string prefix, string captureName) =>
new(name, Examples: [], Captures: [new PhraseCapture(prefix, captureName, CaptureKind.Rest)]);

/// <summary>
/// Builds a deterministic intent from a set of known-good, parameter-free phrasings (case
/// insensitive substring match), e.g. "list my chat sessions".
/// </summary>
public static IntentDefinition ByPhrases(string name, params string[] examples) =>
new(name, Examples: examples);
}
26 changes: 26 additions & 0 deletions src/Infrastructure.AgentFramework/Intents/IntentMatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents;

/// <summary>
/// Result of a successful <see cref="IIntentClassifier.Classify"/> call. The constructor is
/// <c>internal</c> so that only code inside this assembly (i.e. <see cref="IIntentClassifier"/>
/// implementations) can produce one - callers outside <c>Infrastructure.AgentFramework</c> cannot
/// fabricate a match and invoke routing without classification having happened first. This is the
/// structural (compiler-enforced) guarantee that "classify before route" is followed.
/// </summary>
public sealed record IntentMatch
{
internal IntentMatch(IntentDefinition intent, IReadOnlyDictionary<string, string>? captures = null)
{
Intent = intent;
Captures = captures;
}

/// <summary>The matched intent definition.</summary>
public IntentDefinition Intent { get; }

/// <summary>
/// Named capture groups from a <see cref="IntentDefinition.Captures"/> match, if the match
/// came from a parameterized pattern rather than a plain phrase.
/// </summary>
public IReadOnlyDictionary<string, string>? Captures { get; }
}
14 changes: 14 additions & 0 deletions src/Infrastructure.AgentFramework/Intents/IntentNames.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents;

/// <summary>
/// Stable names for every deterministic chat intent. Used both by <see cref="IntentCatalog"/> entries
/// and by routing logic's switch statement, so a typo in one place fails to compile instead of silently
/// never matching.
/// </summary>
public static class IntentNames
{
public const string QueryChatSessionsList = nameof(QueryChatSessionsList);
public const string QueryChatMessagesList = nameof(QueryChatMessagesList);
public const string QueryActorById = nameof(QueryActorById);
public const string SearchWeb = nameof(SearchWeb);
}
Loading
Loading