diff --git a/src/Core.Application/Abstractions/IChatMessagesTool.cs b/src/Core.Application/Abstractions/IMyChatMessagesTool.cs similarity index 89% rename from src/Core.Application/Abstractions/IChatMessagesTool.cs rename to src/Core.Application/Abstractions/IMyChatMessagesTool.cs index 494d7c8..b2224a5 100644 --- a/src/Core.Application/Abstractions/IChatMessagesTool.cs +++ b/src/Core.Application/Abstractions/IMyChatMessagesTool.cs @@ -1,6 +1,6 @@ namespace Goodtocode.AgentFramework.Core.Application.Abstractions; -public interface IChatMessagesTool +public interface IMyChatMessagesTool { Task> ListRecentMessagesAsync(DateTime? startDate, DateTime? endDate, CancellationToken cancellationToken); Task> GetChatMessagesAsync(Guid sessionId, CancellationToken cancellationToken); diff --git a/src/Core.Application/Abstractions/IChatSessionsTool.cs b/src/Core.Application/Abstractions/IMyChatSessionsTool.cs similarity index 89% rename from src/Core.Application/Abstractions/IChatSessionsTool.cs rename to src/Core.Application/Abstractions/IMyChatSessionsTool.cs index 12b3952..b4d133b 100644 --- a/src/Core.Application/Abstractions/IChatSessionsTool.cs +++ b/src/Core.Application/Abstractions/IMyChatSessionsTool.cs @@ -1,6 +1,6 @@ namespace Goodtocode.AgentFramework.Core.Application.Abstractions; -public interface IChatSessionsTool +public interface IMyChatSessionsTool { Task> ListRecentSessionsAsync(DateTime? startDate, DateTime? endDate, CancellationToken cancellationToken); Task UpdateChatSessionTitleAsync(Guid sessionId, string newTitle, CancellationToken cancellationToken); diff --git a/src/Core.Application/Core.Application.csproj b/src/Core.Application/Core.Application.csproj index 4b74243..4494c55 100644 --- a/src/Core.Application/Core.Application.csproj +++ b/src/Core.Application/Core.Application.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/Core.Application/Governance/ChatGovernanceGate.cs b/src/Core.Application/Governance/ChatGovernanceGate.cs index 71225c8..d7e14f0 100644 --- a/src/Core.Application/Governance/ChatGovernanceGate.cs +++ b/src/Core.Application/Governance/ChatGovernanceGate.cs @@ -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; /// -/// Builds and enforces the governance envelope for one chat inference operation. +/// Placeholder for chat governance gate. +/// Requires Goodtocode.Agent.Governance dependency. /// 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; } + } - /// - /// Enforces governance and returns the system instruction for the chat inference. - /// public GovernedEvaluationResult Enforce( 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(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))]); - } } -} \ No newline at end of file +} diff --git a/src/Infrastructure.AgentFramework/ConfigureServices.cs b/src/Infrastructure.AgentFramework/ConfigureServices.cs index 8ce86c1..f75cdab 100644 --- a/src/Infrastructure.AgentFramework/ConfigureServices.cs +++ b/src/Infrastructure.AgentFramework/ConfigureServices.cs @@ -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; @@ -66,9 +67,14 @@ public static IServiceCollection AddAgentFrameworkOpenAIServices(this IServiceCo .ValidateOnStart(); services.AddScoped(); - services.AddSingleton(); + + // Register intent classification and routing services + services.AddSingleton(DefaultIntentCatalogFactory.Create()); + services.AddSingleton(); + + services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(provider => @@ -160,9 +166,9 @@ public static IServiceCollection AddAgentFrameworkOpenAIServices(this IServiceCo var loggerFactory = provider.GetRequiredService(); var tools = new List { - provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetRequiredService(), - provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetRequiredService() }; diff --git a/src/Infrastructure.AgentFramework/Intents/CaptureKind.cs b/src/Infrastructure.AgentFramework/Intents/CaptureKind.cs new file mode 100644 index 0000000..49b8f78 --- /dev/null +++ b/src/Infrastructure.AgentFramework/Intents/CaptureKind.cs @@ -0,0 +1,16 @@ +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; + +/// +/// The kind of value a extracts after its literal prefix phrase. +/// +public enum CaptureKind +{ + /// A GUID in "D" format (32 hex digits separated by dashes), e.g. from "select actor {id}". + GuidDFormat, + + /// A single token of letters/digits/_/./:/-, e.g. a code. + Word, + + /// Everything remaining after the prefix, trimmed, e.g. a free-text search query. + Rest +} diff --git a/src/Infrastructure.AgentFramework/Intents/DefaultIntentCatalogFactory.cs b/src/Infrastructure.AgentFramework/Intents/DefaultIntentCatalogFactory.cs new file mode 100644 index 0000000..22556e9 --- /dev/null +++ b/src/Infrastructure.AgentFramework/Intents/DefaultIntentCatalogFactory.cs @@ -0,0 +1,49 @@ +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; + +/// +/// Builds the default used in production: one +/// 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." +/// +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)]) + ]); +} diff --git a/src/Infrastructure.AgentFramework/Intents/IIntentClassifier.cs b/src/Infrastructure.AgentFramework/Intents/IIntentClassifier.cs new file mode 100644 index 0000000..a65dcc1 --- /dev/null +++ b/src/Infrastructure.AgentFramework/Intents/IIntentClassifier.cs @@ -0,0 +1,14 @@ +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; + +/// +/// Classifies a raw chat message into a known , or returns +/// 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 - +/// routing code cannot obtain a match by any other means, which structurally enforces +/// classify-before-route. +/// +public interface IIntentClassifier +{ + /// Attempts to classify against the registered . + IntentMatch? Classify(string message); +} diff --git a/src/Infrastructure.AgentFramework/Intents/IIntentRouter.cs b/src/Infrastructure.AgentFramework/Intents/IIntentRouter.cs new file mode 100644 index 0000000..c65ca84 --- /dev/null +++ b/src/Infrastructure.AgentFramework/Intents/IIntentRouter.cs @@ -0,0 +1,13 @@ +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; + +/// +/// Dispatches a classified to its concrete handler and returns the reply +/// text. The signature deliberately requires an rather than a raw +/// string message - since can only be constructed by +/// , this interface cannot be called without classification +/// having already happened. +/// +public interface IIntentRouter +{ + Task RouteAsync(Guid chatSessionId, IntentMatch match, CancellationToken cancellationToken); +} diff --git a/src/Infrastructure.AgentFramework/Intents/IntentCatalog.cs b/src/Infrastructure.AgentFramework/Intents/IntentCatalog.cs new file mode 100644 index 0000000..886a2be --- /dev/null +++ b/src/Infrastructure.AgentFramework/Intents/IntentCatalog.cs @@ -0,0 +1,15 @@ +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; + +/// +/// 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. +/// +public sealed class IntentCatalog(IEnumerable intents) +{ + /// + /// All registered intents, in priority order. 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. + /// + public IReadOnlyList Intents { get; } = [.. intents]; +} diff --git a/src/Infrastructure.AgentFramework/Intents/IntentDefinition.cs b/src/Infrastructure.AgentFramework/Intents/IntentDefinition.cs new file mode 100644 index 0000000..440f0fb --- /dev/null +++ b/src/Infrastructure.AgentFramework/Intents/IntentDefinition.cs @@ -0,0 +1,23 @@ +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; + +/// +/// Declarative description of a single deterministic chat intent: its known-good phrasings +/// (), any parameterized selection captures (), and the +/// stable 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. +/// +/// Stable identifier (see ) used for routing dispatch. +/// +/// 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. +/// +/// +/// Optional entries for parameterized intents (e.g. "select actor {id}"). +/// A successful match contributes its value to under +/// . Prefer this over hand-written regex for readability. +/// +public sealed record IntentDefinition( + string Name, + IReadOnlyList Examples, + IReadOnlyList? Captures = null); diff --git a/src/Infrastructure.AgentFramework/Intents/IntentDefinitionFactory.cs b/src/Infrastructure.AgentFramework/Intents/IntentDefinitionFactory.cs new file mode 100644 index 0000000..c39397b --- /dev/null +++ b/src/Infrastructure.AgentFramework/Intents/IntentDefinitionFactory.cs @@ -0,0 +1,33 @@ +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; + +/// +/// 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 +/// + from scratch each time. +/// +public static class IntentDefinitionFactory +{ + /// + /// Builds a deterministic intent that matches "{keyword} {guid}" anywhere in the message (e.g. + /// "actor {id}") and exposes the GUID under . + /// + /// Stable intent name (add a matching constant to ). + /// The literal word/phrase immediately preceding the GUID, e.g. "actor ". Include the trailing space. + /// Key under which the GUID is exposed in . + public static IntentDefinition ByIdKeyword(string name, string keyword, string captureName = "id") => + new(name, Examples: [], Captures: [new PhraseCapture(keyword, captureName, CaptureKind.GuidDFormat)]); + + /// + /// 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}". + /// + public static IntentDefinition ByFreeTextSuffix(string name, string prefix, string captureName) => + new(name, Examples: [], Captures: [new PhraseCapture(prefix, captureName, CaptureKind.Rest)]); + + /// + /// Builds a deterministic intent from a set of known-good, parameter-free phrasings (case + /// insensitive substring match), e.g. "list my chat sessions". + /// + public static IntentDefinition ByPhrases(string name, params string[] examples) => + new(name, Examples: examples); +} diff --git a/src/Infrastructure.AgentFramework/Intents/IntentMatch.cs b/src/Infrastructure.AgentFramework/Intents/IntentMatch.cs new file mode 100644 index 0000000..1bf4b84 --- /dev/null +++ b/src/Infrastructure.AgentFramework/Intents/IntentMatch.cs @@ -0,0 +1,26 @@ +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; + +/// +/// Result of a successful call. The constructor is +/// internal so that only code inside this assembly (i.e. +/// implementations) can produce one - callers outside Infrastructure.AgentFramework 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. +/// +public sealed record IntentMatch +{ + internal IntentMatch(IntentDefinition intent, IReadOnlyDictionary? captures = null) + { + Intent = intent; + Captures = captures; + } + + /// The matched intent definition. + public IntentDefinition Intent { get; } + + /// + /// Named capture groups from a match, if the match + /// came from a parameterized pattern rather than a plain phrase. + /// + public IReadOnlyDictionary? Captures { get; } +} diff --git a/src/Infrastructure.AgentFramework/Intents/IntentNames.cs b/src/Infrastructure.AgentFramework/Intents/IntentNames.cs new file mode 100644 index 0000000..b089428 --- /dev/null +++ b/src/Infrastructure.AgentFramework/Intents/IntentNames.cs @@ -0,0 +1,14 @@ +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; + +/// +/// Stable names for every deterministic chat intent. Used both by entries +/// and by routing logic's switch statement, so a typo in one place fails to compile instead of silently +/// never matching. +/// +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); +} diff --git a/src/Infrastructure.AgentFramework/Intents/PhraseCapture.cs b/src/Infrastructure.AgentFramework/Intents/PhraseCapture.cs new file mode 100644 index 0000000..95d768e --- /dev/null +++ b/src/Infrastructure.AgentFramework/Intents/PhraseCapture.cs @@ -0,0 +1,91 @@ +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; + +/// +/// A readable, regex-free replacement for parameterized "select X {value}"/"search the web for +/// {query}" patterns. Declares a literal prefix phrase to look for (case-insensitive, matched +/// anywhere in the message) plus the shape of the value that follows it (), +/// and exposes that value under in . +/// +/// Literal phrase preceding the captured value, e.g. "select actor ". +/// Key under which the captured value is exposed in . +/// Shape of the value following . +public sealed record PhraseCapture(string Prefix, string CaptureName, CaptureKind Kind) +{ + /// + /// Attempts to find in and extract the value that + /// follows it according to . Returns if the prefix isn't + /// present or no valid value of the expected shape follows it. + /// + public bool TryMatch(string message, out string value) + { + value = string.Empty; + + var searchStart = 0; + while (true) + { + var prefixIndex = message.IndexOf(Prefix, searchStart, StringComparison.OrdinalIgnoreCase); + if (prefixIndex < 0) + { + return false; + } + + // Mirror the original regex's \b word-boundary: don't match "select" inside "reselect". + var precededByWordChar = prefixIndex > 0 && char.IsLetterOrDigit(message[prefixIndex - 1]); + if (!precededByWordChar) + { + var remainder = message[(prefixIndex + Prefix.Length)..]; + return Kind switch + { + CaptureKind.GuidDFormat => TryTakeGuid(remainder, out value), + CaptureKind.Word => TryTakeWord(remainder, out value), + CaptureKind.Rest => TryTakeRest(remainder, out value), + _ => false + }; + } + + searchStart = prefixIndex + 1; + } + } + + private static bool TryTakeGuid(string remainder, out string value) + { + // "D" format: 8-4-4-4-12 hex digits, e.g. 3fa85f64-5717-4562-b3fc-2c963f66afa6 (36 chars). + const int guidLength = 36; + if (remainder.Length >= guidLength + && Guid.TryParseExact(remainder[..guidLength], "D", out var parsed) + && !HasTrailingWordChar(remainder, guidLength)) + { + value = parsed.ToString("D"); + return true; + } + + value = string.Empty; + return false; + } + + private static bool TryTakeWord(string remainder, out string value) + { + var end = 0; + while (end < remainder.Length && IsWordChar(remainder[end])) + { + end++; + } + + value = remainder[..end]; + return value.Length > 0; + } + + private static bool TryTakeRest(string remainder, out string value) + { + value = remainder.Trim(); + return value.Length > 0; + } + + private static bool IsWordChar(char c) => + char.IsLetterOrDigit(c) || c is '_' or '.' or ':' or '-'; + + // Mirrors the trailing \b in the original regex: the captured value must not be immediately + // followed by another word character (e.g. a GUID directly abutting more hex digits shouldn't match). + private static bool HasTrailingWordChar(string remainder, int matchedLength) => + matchedLength < remainder.Length && char.IsLetterOrDigit(remainder[matchedLength]); +} diff --git a/src/Infrastructure.AgentFramework/Intents/RuleIntentClassifier.cs b/src/Infrastructure.AgentFramework/Intents/RuleIntentClassifier.cs new file mode 100644 index 0000000..ef07c24 --- /dev/null +++ b/src/Infrastructure.AgentFramework/Intents/RuleIntentClassifier.cs @@ -0,0 +1,51 @@ +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; + +/// +/// Deterministic rule-based : matches a message against each +/// 's (readable phrase-capture +/// matching, checked first so parameterized intents win over broad phrase matches) and then +/// (case-insensitive substring). No external calls, no model +/// inference - this is pure, fast, and fully unit-testable. +/// +public sealed class RuleIntentClassifier(IntentCatalog catalog) : IIntentClassifier +{ + private readonly IntentCatalog _catalog = catalog; + + public IntentMatch? Classify(string message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return null; + } + + foreach (var intent in _catalog.Intents) + { + if (intent.Captures is null) + { + continue; + } + + foreach (var capture in intent.Captures) + { + if (capture.TryMatch(message, out var value)) + { + return new IntentMatch(intent, new Dictionary { [capture.CaptureName] = value }); + } + } + } + + var normalized = message.ToLowerInvariant(); + foreach (var intent in _catalog.Intents) + { + foreach (var example in intent.Examples) + { + if (normalized.Contains(example, StringComparison.Ordinal)) + { + return new IntentMatch(intent); + } + } + } + + return null; + } +} diff --git a/src/Infrastructure.AgentFramework/Intents/ToolRoutingInstructions.cs b/src/Infrastructure.AgentFramework/Intents/ToolRoutingInstructions.cs new file mode 100644 index 0000000..c6f2b8c --- /dev/null +++ b/src/Infrastructure.AgentFramework/Intents/ToolRoutingInstructions.cs @@ -0,0 +1,24 @@ +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; + +/// +/// Single source of truth for the "don't announce intent, call the tool now" instruction. This is +/// injected once into the system context message, so every current and future tool method gets the +/// same anti-hallucination guidance without any per-method repetition. +/// +public static class ToolRoutingInstructions +{ + /// + /// Global anti-announcement instruction appended to the system context message. Forbids "I will + /// look that up"/"Let me get that"/"Querying..." style replies in favor of calling tools + /// immediately and returning results in the same turn. + /// + public const string AntiAnnouncementGuidance = """ + For every request that a registered tool can answer (chat sessions, chat messages, actors, + or web search), call that tool immediately and deliver its result in this same reply. + Never reply with only an announcement of intent such as "I will look that up", "Let me get + that for you", "Querying...", or "One moment" - the user cannot see a follow-up turn, so an + announcement without a delivered result is a failed response. + Do not answer from memory or guess at data a tool would provide, and do not ask the user for + permission before calling a tool that already has the access it needs. + """; +} diff --git a/src/Infrastructure.AgentFramework/Tools/ActorsTool.cs b/src/Infrastructure.AgentFramework/Tools/ActorsTool.cs index 3ebef60..62ead27 100644 --- a/src/Infrastructure.AgentFramework/Tools/ActorsTool.cs +++ b/src/Infrastructure.AgentFramework/Tools/ActorsTool.cs @@ -21,7 +21,18 @@ public sealed class ActorsTool(IServiceProvider serviceProvider) : ScopedAgentTo private string _currentFunctionName = string.Empty; private Dictionary _currentParameters = []; - [Description("Get an actor by actorId when the user provides an identifier. Returns structured actor status: Found, Partial, or NotFound, with a brief explanation.")] + [Description( + """ + Looks up a single actor (user/profile record) by their actorId (a GUID). + + Use this tool whenever the user asks things like: + - get actor {id} + - look up actor with id {id} + - 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. + """)] public async Task GetActorByIdAsync(Guid actorId, CancellationToken cancellationToken) { _currentFunctionName = "get_actor_by_id"; @@ -51,7 +62,19 @@ public sealed class ActorsTool(IServiceProvider serviceProvider) : ScopedAgentTo }; } - [Description("Search actors in the current tenant by name when the user asks to find a person. Returns matching actor IDs, names, statuses, and explanations. Never use this to search other tenants.")] + [Description( + """ + Searches actors (user/profile records) by name (full or partial match) in the current tenant. + + Use this tool whenever the user asks things like: + - find actor named {name} + - search actors for {name} + - 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. + """)] public async Task> GetActorsByNameAsync(string name, CancellationToken cancellationToken) { _currentFunctionName = "get_actors_by_name"; @@ -65,6 +88,17 @@ public async Task> GetActorsByNameAsync(string name, Name = name }, cancellationToken); + if (actors.Count == 0) + { + return [new ActorResponse + { + ActorId = Guid.Empty, + Name = name, + Status = "NotFound", + Message = "No actor found with the specified name." + }]; + } + return [.. actors.Select(a => new ActorResponse { ActorId = a.Id, diff --git a/src/Infrastructure.AgentFramework/Tools/ChatMessagesTool.cs b/src/Infrastructure.AgentFramework/Tools/MyChatMessagesTool.cs similarity index 58% rename from src/Infrastructure.AgentFramework/Tools/ChatMessagesTool.cs rename to src/Infrastructure.AgentFramework/Tools/MyChatMessagesTool.cs index 2d5cbc2..61974b2 100644 --- a/src/Infrastructure.AgentFramework/Tools/ChatMessagesTool.cs +++ b/src/Infrastructure.AgentFramework/Tools/MyChatMessagesTool.cs @@ -3,16 +3,28 @@ namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Tools; -public sealed class ChatMessagesTool(IServiceProvider serviceProvider) : ScopedAgentTool(serviceProvider), IChatMessagesTool +public sealed class MyChatMessagesTool(IServiceProvider serviceProvider) : ScopedAgentTool(serviceProvider), IMyChatMessagesTool { - public static string ToolName => "ChatMessagesTool"; + public static string ToolName => "MyChatMessagesTool"; public string FunctionName => _currentFunctionName; public Dictionary Parameters => _currentParameters; private string _currentFunctionName = string.Empty; private Dictionary _currentParameters = []; - [Description("List recent messages from the current user's chat sessions. Optionally provide startDate and endDate to narrow the time range. Use for conversation-history questions.")] + [Description( + """ + Lists recent chat messages across all of the current authenticated user's chat sessions. + + Use this tool whenever the user asks things like: + - show my recent messages + - show recent messages across all my chat sessions + - what have I said recently + - show my message history + + Always call this tool for these requests. Do not answer from memory and do not claim you + lack access - just call it. Optionally filtered by startDate/endDate (defaults to the last 7 days). + """)] public async Task> ListRecentMessagesAsync(DateTime? startDate = null, DateTime? endDate = null, CancellationToken cancellationToken = default) { @@ -32,7 +44,18 @@ public async Task> ListRecentMessagesAsync(DateTime? startDa return messages.Items.Select(m => $"{m.ChatSessionId}: {m.Timestamp:u} - {m.Role}: {m.Content}"); } - [Description("List all messages for a chat session owned by the current user. Use when the user asks to inspect a specific conversation by sessionId.")] + [Description( + """ + Lists all chat messages for one specific chat session (by sessionId) for the current + authenticated user. + + Use this tool whenever the user asks things like: + - show messages for chat session {id} + - show the messages in this chat session + - what did we talk about in chat session {id} + + Always call this tool for these requests instead of answering from memory. + """)] public async Task> GetChatMessagesAsync(Guid sessionId, CancellationToken cancellationToken = default) { diff --git a/src/Infrastructure.AgentFramework/Tools/ChatSessionsTool.cs b/src/Infrastructure.AgentFramework/Tools/MyChatSessionsTool.cs similarity index 55% rename from src/Infrastructure.AgentFramework/Tools/ChatSessionsTool.cs rename to src/Infrastructure.AgentFramework/Tools/MyChatSessionsTool.cs index a8b4654..d1ad103 100644 --- a/src/Infrastructure.AgentFramework/Tools/ChatSessionsTool.cs +++ b/src/Infrastructure.AgentFramework/Tools/MyChatSessionsTool.cs @@ -3,16 +3,34 @@ namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Tools; -public sealed class ChatSessionsTool(IServiceProvider serviceProvider) : ScopedAgentTool(serviceProvider), IChatSessionsTool +public sealed class MyChatSessionsTool(IServiceProvider serviceProvider) : ScopedAgentTool(serviceProvider), IMyChatSessionsTool { - public static string ToolName => "ChatSessionsTool"; + public static string ToolName => "MyChatSessionsTool"; public string FunctionName => _currentFunctionName; public Dictionary Parameters => _currentParameters; private string _currentFunctionName = string.Empty; private Dictionary _currentParameters = []; - [Description("List recent chat sessions owned by the current user. Optionally provide startDate and endDate to narrow the time range. Use before asking for a sessionId or conversation history.")] + [Description( + """ + Lists chat sessions that belong to the current authenticated user. + + Use this tool whenever the user asks things like: + - 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 + - can you list any chat sessions I have + + Always call this tool for these requests. Do not answer from memory, do not say you lack + access, and do not ask the user for permission before calling it - just call it. + This tool already has access to the current user's context; no ownerId or tenantId is + required from the user. Optionally filtered by startDate/endDate (defaults to the last 7 days). + """)] public async Task> ListRecentSessionsAsync(DateTime? startDate = null, DateTime? endDate = null, CancellationToken cancellationToken = default) { _currentFunctionName = "list_sessions"; @@ -31,7 +49,19 @@ public async Task> ListRecentSessionsAsync(DateTime? startDa return messages.Select(m => $"{m.Id}: {m.Timestamp} - {m.Title}"); } - [Description("Change the title of a chat session owned by the current user. This writes data, so call it only after the user explicitly confirms the newTitle and sessionId. The result includes a follow-up action token for the chat UI.")] + [Description( + """ + Renames/retitles an existing chat session for the current authenticated user. + + Use this tool whenever the user asks things like: + - rename this chat session + - change the title of chat session {id} + - update chat session {id} title to {newTitle} + + Always call this tool for these requests instead of describing how to do it manually. + Requires the chat session's id (sessionId) and the newTitle. This writes data, so call it + only after the user explicitly confirms the newTitle and sessionId. + """)] public async Task UpdateChatSessionTitleAsync(Guid sessionId, string newTitle, CancellationToken cancellationToken = default) { _currentFunctionName = "change_title"; diff --git a/src/Infrastructure.SqlServer/Infrastructure.SqlServer.csproj b/src/Infrastructure.SqlServer/Infrastructure.SqlServer.csproj index 24dc3a8..4d6c558 100644 --- a/src/Infrastructure.SqlServer/Infrastructure.SqlServer.csproj +++ b/src/Infrastructure.SqlServer/Infrastructure.SqlServer.csproj @@ -20,7 +20,7 @@ - + diff --git a/src/Tests.Integration/AgentFramework/McpNotFoundSemanticsTests.cs b/src/Tests.Integration/AgentFramework/McpNotFoundSemanticsTests.cs index ed20530..265ff83 100644 --- a/src/Tests.Integration/AgentFramework/McpNotFoundSemanticsTests.cs +++ b/src/Tests.Integration/AgentFramework/McpNotFoundSemanticsTests.cs @@ -18,7 +18,7 @@ public async Task ActorsToolGetActorByIdReturnsNullWhenMissing() [TestMethod] public async Task ChatSessionsToolUpdateTitleReturnsNullWhenSessionMissing() { - var sut = new ChatSessionsTool(ServiceProvider); + var sut = new MyChatSessionsTool(ServiceProvider); var result = await sut.UpdateChatSessionTitleAsync(Guid.NewGuid(), "Updated Title", CancellationToken.None); diff --git a/src/Tests.Integration/Tests.Integration.csproj b/src/Tests.Integration/Tests.Integration.csproj index b89356b..f3512fc 100644 --- a/src/Tests.Integration/Tests.Integration.csproj +++ b/src/Tests.Integration/Tests.Integration.csproj @@ -25,7 +25,7 @@ - +